diff --git a/src/api.rs b/src/api.rs index 3b772dc..0e8f733 100644 --- a/src/api.rs +++ b/src/api.rs @@ -895,17 +895,19 @@ impl Worker { )); } }; - if !matches!( - input.kind.as_str(), - "click" - | "focus" - | "scroll" - | "tab-activate" - | "tab-close" - | "tab-detach" - | "tab-drop" - | "window-close" - ) { + if !input.kind.starts_with("developer-open:") + && !matches!( + input.kind.as_str(), + "click" + | "focus" + | "scroll" + | "tab-activate" + | "tab-close" + | "tab-detach" + | "tab-drop" + | "window-close" + ) + { return RequestProgress::Complete(error_response( StatusCode(400), "unsupported event kind", @@ -985,8 +987,8 @@ impl Worker { || frame.session != input.session || (input.kind != "window-close" && frame.layout_version != input.layout_version) || frame.fault.is_some() - || if input.kind == "window-close" { - false + || if input.kind == "window-close" || input.kind.starts_with("developer-open:") { + input.mount != 0 } else if matches!( input.kind.as_str(), "tab-activate" | "tab-close" | "tab-detach" | "tab-drop" @@ -1797,487 +1799,4 @@ fn add_header(response: &mut Response>>, name: &str, val } #[cfg(test)] -mod tests { - use super::*; - use std::{ - io::Write, - net::{SocketAddr, TcpStream}, - sync::atomic::{AtomicUsize, Ordering}, - time::Duration, - }; - - #[test] - fn preserved_picture_close_does_not_retire_same_number_in_fresh_console_vm() { - let (_, mut picture) = endor_host::ReplSession::load( - include_str!("../examples/windows.endor"), - "app.main", - 1, - 150, - ) - .unwrap(); - picture.frame.session = 0; - for view in picture.windows.values_mut() { - view.frame.session = 0; - } - let mut fresh = endor_host::ReplSession::new(151).unwrap(); - assert!( - fresh - .execute("pending=Window()\nui.window().resize(0,600)", "app.main") - .is_err() - ); - assert!(fresh.accepts_window(2)); - let namespace = Arc::new(RwLock::new(NamespaceState { - document: picture, - submission: 1, - runtime_session: 151, - ..NamespaceState::default() - })); - let mut worker = Worker { - repl: Some((1, fresh)), - next_session: 151, - }; - let notify: Notify = Arc::new(|| {}); - namespace.write().unwrap().window_viewports.insert( - 2, - crate::layout::Size { - width: 800.0, - height: 600.0, - }, - ); - worker.refresh_viewport(&namespace, ¬ify); - assert_eq!( - namespace.read().unwrap().document.windows[&2].frame.session, - 0 - ); - let response = worker.close_inactive_window( - &crate::events::Input { - window: 2, - session: 0, - layout_version: 0, - mount: 0, - kind: "window-close".into(), - point: None, - delta: None, - press_point: None, - tab_drop: None, - screen_position: None, - }, - 1, - &namespace, - ¬ify, - ); - assert_eq!(response.status_code(), StatusCode(200)); - assert!(namespace.read().unwrap().document.windows.is_empty()); - let runtime = &mut worker.repl.as_mut().unwrap().1; - assert_eq!(runtime.id, 151); - assert!(runtime.accepts_window(2)); - assert_eq!( - runtime.execute("pending.id()", "app.main").unwrap().value, - "2" - ); - } - - struct TestServer { - server: Arc, - address: SocketAddr, - api: Option>, - } - - impl TestServer { - fn request(&self, method: &str, path: &str, body: &str) -> (u16, String, String) { - let mut stream = TcpStream::connect(self.address).unwrap(); - stream - .set_read_timeout(Some(Duration::from_secs(10))) - .unwrap(); - stream - .set_write_timeout(Some(Duration::from_secs(10))) - .unwrap(); - write!(stream, "{method} {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\n\r\n{body}", body.len()).unwrap(); - let mut response = String::new(); - stream.read_to_string(&mut response).unwrap(); - let (headers, body) = response.split_once("\r\n\r\n").unwrap(); - let status = headers.split_whitespace().nth(1).unwrap().parse().unwrap(); - (status, headers.to_owned(), body.to_owned()) - } - } - - impl Drop for TestServer { - fn drop(&mut self) { - self.server.unblock(); - if let Some(api) = self.api.take() { - let _ = api.join(); - } - } - } - - #[test] - fn native_tab_drop_route_reorders_live_pages_and_rejects_reused_frames() { - let server = Arc::new(Server::http("127.0.0.1:0").unwrap()); - let namespace = Arc::new(RwLock::new(NamespaceState::default())); - let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap(); - let client = TestServer { - address: server.server_addr().to_ip().unwrap(), - server, - api: Some(api), - }; - let source = r#"class Tracked: Page { - calls: Object - function moveTab(self: Tracked, position: Number): Page { self.calls.increment() return Page.moveTab(self,position) } - } - calls=ui.state(0) - first=Tracked(calls) - first.title="first" - first.render(TextBlock("first")) - second=Page() - second.title="second" - second.render(TextBlock("second")) - first.activate()"#; - let result = client.request("PUT", "/v1/namespaces/app.main/code", source); - assert_eq!(result.0, 200, "{}", result.2); - let input = { - let current = namespace.read().unwrap(); - let frame = ¤t.document.frame; - crate::events::Input { - window: 1, - session: frame.session, - layout_version: frame.layout_version, - mount: frame.tabs[0].id, - kind: "tab-drop".into(), - point: None, - press_point: None, - delta: None, - screen_position: None, - tab_drop: Some(crate::events::TabDrop { - window: 1, - layout_version: frame.layout_version, - index: 2, - }), - } - }; - let body = serde_json::to_string(&input).unwrap(); - let response = client.request("POST", "/v1/namespaces/app.main/events", &body); - assert_eq!(response.0, 200, "{}", response.2); - let response = client.request("POST", "/v1/namespaces/app.main/repl", "calls.value"); - let value: serde_json::Value = serde_json::from_str(&response.2).unwrap(); - assert_eq!(value["value"], "1"); - assert_eq!( - namespace.read().unwrap().document.frame.tabs[1].title, - "first" - ); - assert_eq!( - client - .request("POST", "/v1/namespaces/app.main/events", &body) - .0, - 409 - ); - assert!(namespace.read().unwrap().document.frame.fault.is_none()); - } - - #[test] - fn background_failure_preserves_its_cause_after_rejected_console_input() { - let server = Arc::new(Server::http("127.0.0.1:0").unwrap()); - let namespace = Arc::new(RwLock::new(NamespaceState::default())); - let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap(); - let client = TestServer { - address: server.server_addr().to_ip().unwrap(), - server, - api: Some(api), - }; - let source = r#"class Failure { - function stop(self: Failure): Unit { ui.window().resize(0,600) } - } - failure=Failure() - page=Page() - page.render(TextBlock("preserved")) - task=ui.after(ui,"failure",0,failure.stop)"#; - let result = client.request("PUT", "/v1/namespaces/app.main/code", source); - assert_eq!(result.0, 200, "{}", result.2); - let repl = "/v1/namespaces/app.main/repl"; - assert_eq!(client.request("POST", repl, "42").0, 422); - let fault = namespace - .read() - .unwrap() - .document - .frame - .fault - .clone() - .unwrap(); - assert!(fault.contains("window size"), "{fault}"); - assert!(fault.contains("stop"), "{fault}"); - assert_eq!(client.request("POST", repl, "42").0, 422); - assert_eq!( - namespace.read().unwrap().document.frame.fault.as_ref(), - Some(&fault) - ); - } - - #[test] - fn console_keeps_memory_until_reset_or_complete_put_without_erasing_the_page() { - let server = Arc::new(Server::http("127.0.0.1:0").unwrap()); - let namespace = Arc::new(RwLock::new(NamespaceState::default())); - let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap(); - let client = TestServer { - address: server.server_addr().to_ip().unwrap(), - server, - api: Some(api), - }; - let repl = "/v1/namespaces/app.main/repl"; - let code = "/v1/namespaces/app.main/code"; - let created = client.request("POST", repl, "title = ui.text(\"中文\")"); - assert_eq!(created.0, 200, "{}", created.2); - let created: serde_json::Value = serde_json::from_str(&created.2).unwrap(); - assert_eq!(created["rendered"], false); - assert_eq!(created["revision"], 0); - let read: serde_json::Value = - serde_json::from_str(&client.request("POST", repl, "title").2).unwrap(); - assert_eq!(read["value"], created["value"]); - assert_eq!( - client.request("POST", repl, "ui.page().render(title)").0, - 200 - ); - let snapshot = client.request("GET", code, ""); - assert!( - snapshot - .1 - .to_lowercase() - .contains("x-tessera-source-kind: repl-transcript") - ); - assert_eq!(namespace.read().unwrap().revision, 1); - assert_eq!(client.request("DELETE", repl, "").0, 200); - assert_eq!(client.request("GET", code, "").2, snapshot.2); - assert_eq!(client.request("POST", repl, "title").0, 422); - assert_eq!(namespace.read().unwrap().revision, 1); - assert_eq!(client.request("POST", repl, "retained = 42").0, 200); - assert_eq!( - client - .request("PUT", code, include_str!("../examples/page.endor")) - .0, - 200 - ); - assert_eq!(client.request("POST", repl, "retained").0, 422); - assert_eq!(namespace.read().unwrap().revision, 2); - } - - #[test] - fn http_executes_real_endor_preserves_failed_updates_and_supports_delete() { - let server = Arc::new(Server::http("127.0.0.1:0").unwrap()); - let namespace = Arc::new(RwLock::new(NamespaceState::default())); - let notifications = Arc::new(AtomicUsize::new(0)); - let counter = notifications.clone(); - let notify: Notify = Arc::new(move || { - counter.fetch_add(1, Ordering::SeqCst); - }); - let api = start_server(server.clone(), namespace.clone(), notify).unwrap(); - let client = TestServer { - address: server.server_addr().to_ip().unwrap(), - server, - api: Some(api), - }; - let route = "/v1/namespaces/app.main/code"; - let example = include_str!("../examples/page.endor"); - let (status, _, guide) = client.request("GET", "/agent", ""); - assert_eq!(status, 200); - assert!(guide.contains("let myui = ui.page()")); - let (status, _, body) = client.request("PUT", route, example); - assert_eq!(status, 200, "{body}"); - assert_eq!( - serde_json::from_str::(&body).unwrap()["revision"], - 1 - ); - let (status, headers, source) = client.request("GET", route, ""); - assert_eq!(status, 200); - assert!(headers.to_lowercase().contains("x-tessera-revision: 1")); - assert_eq!(source, example); - let (status, _, body) = - client.request("PUT", route, "function main(): View { return missing }"); - assert_eq!(status, 422, "{body}"); - assert_eq!( - serde_json::from_str::(&body).unwrap()["diagnostics"][0]["line"], - 1 - ); - assert_eq!(client.request("GET", route, "").2, example); - let second = example.replace("Hello Endor", "Second"); - assert_eq!(client.request("PUT", route, &second).0, 200); - assert_eq!(namespace.read().unwrap().revision, 2); - assert_eq!(client.request("DELETE", route, "").0, 200); - assert_eq!(client.request("GET", route, "").2, ""); - assert!(namespace.read().unwrap().document.frame.commands.is_empty()); - assert_eq!(client.request("PUT", route, example).0, 200); - assert_eq!(namespace.read().unwrap().revision, 4); - assert_eq!( - client - .request("PUT", "/v1/namespaces/other/code", example) - .0, - 404 - ); - assert_eq!(notifications.load(Ordering::SeqCst), 4); - } - - #[test] - fn queued_candidate_keeps_live_owner_until_delete_revokes_it() { - let (runtime, document) = endor_host::ReplSession::load( - "retained = ui.state(9)\npage = Page()\npage.render(ui.textOf(retained,\"value\"))", - "app.main", - 1, - 277, - ) - .unwrap(); - let namespace = Arc::new(RwLock::new(NamespaceState { - document, - submission: 2, - runtime_session: runtime.id, - ..NamespaceState::default() - })); - let mut worker = Worker { - repl: Some((1, runtime)), - next_session: 277, - }; - let notify: Notify = Arc::new(|| {}); - worker.refresh_viewport(&namespace, ¬ify); - assert_eq!(worker.repl.as_ref().unwrap().0, 2); - assert_eq!( - worker - .repl - .as_mut() - .unwrap() - .1 - .execute("retained.value", "app.main") - .unwrap() - .value, - "9" - ); - { - let mut current = namespace.write().unwrap(); - current.submission = 3; - worker.preserve_live_owner(¤t); - } - assert_eq!(worker.repl.as_ref().unwrap().0, 3); - namespace.write().unwrap().clear().unwrap(); - worker.refresh_viewport(&namespace, ¬ify); - assert!(worker.repl.is_none()); - } - - #[test] - fn http_delete_remains_available_and_invalidates_queued_put() { - use std::{ - sync::{Mutex, mpsc}, - time::Instant, - }; - let server = Arc::new(Server::http("127.0.0.1:0").unwrap()); - let namespace = Arc::new(RwLock::new(NamespaceState::default())); - let (entered_tx, entered_rx) = mpsc::channel(); - let (release_tx, release_rx) = mpsc::channel(); - let release_rx = Mutex::new(release_rx); - let calls = AtomicUsize::new(0); - let notify: Notify = Arc::new(move || { - if calls.fetch_add(1, Ordering::SeqCst) == 0 { - entered_tx.send(()).unwrap(); - release_rx.lock().unwrap().recv().unwrap(); - } - }); - let api = start_server(server.clone(), namespace.clone(), notify).unwrap(); - let client = TestServer { - address: server.server_addr().to_ip().unwrap(), - server, - api: Some(api), - }; - // Unblock even if a test assertion fails, before scoped threads join. - struct Release(mpsc::Sender<()>); - impl Drop for Release { - fn drop(&mut self) { - let _ = self.0.send(()); - } - } - thread::scope(|scope| { - let release = Release(release_tx); - let client = &client; - let route = "/v1/namespaces/app.main/code"; - let example = include_str!("../examples/page.endor"); - let first = scope.spawn(move || client.request("PUT", route, example)); - entered_rx.recv_timeout(Duration::from_secs(10)).unwrap(); - let pending = scope.spawn(move || client.request("PUT", route, example)); - let deadline = Instant::now() + Duration::from_secs(10); - while namespace.read().unwrap().submission < 2 { - assert!(Instant::now() < deadline, "second PUT was not admitted"); - thread::sleep(Duration::from_millis(5)); - } - assert_eq!(client.request("GET", route, "").2, example); - assert_eq!(client.request("DELETE", route, "").0, 200); - drop(release); - assert_eq!(first.join().unwrap().0, 200); - assert_eq!(pending.join().unwrap().0, 409); - assert_eq!(client.request("GET", route, "").2, ""); - assert_eq!(namespace.read().unwrap().revision, 2); - }); - } - - #[test] - fn extracts_namespace_only_from_code_routes() { - assert_eq!( - namespace_from_path("/v1/namespaces/app.main/code"), - Some("app.main") - ); - assert_eq!(namespace_from_path("/v1/namespaces/app.main"), None); - assert_eq!(namespace_from_path("/v1/namespaces/a/b/code"), None); - } - - #[test] - fn guide_contains_only_code_crud_routes() { - let guide = agent_guide(&NamespaceState::default()); - assert!(guide.contains("Code CRUD")); - assert!(guide.contains("let myui = ui.page()")); - assert!(!guide.contains("export scene")); - assert!(guide.contains("PUT http://127.0.0.1:4737")); - assert!(!guide.contains("display_list")); - } - - #[test] - fn foreground_refresh_does_not_deliver_new_callbacks_ahead_of_input() { - let source = r#" -class Action { - count: Object - function fire(self: Action): Unit { self.count.increment() } -} -count=ui.state(0) -action=Action(count) -ui.after(ui,"ready",0,action.fire) -page=Page() -page.render(TextBlock("input priority")) -"#; - let (runtime, document) = - endor_host::ReplSession::load(source, "app.main", 1, 801).unwrap(); - let namespace = Arc::new(RwLock::new(NamespaceState { - document, - submission: 1, - runtime_session: 801, - ..NamespaceState::default() - })); - let mut worker = Worker { - repl: Some((1, runtime)), - next_session: 801, - }; - let notify: Notify = Arc::new(|| {}); - worker.refresh_viewport_mode(&namespace, ¬ify, true); - let value = worker - .repl - .as_mut() - .unwrap() - .1 - .execute("count.value", "app.main") - .unwrap() - .value; - assert_eq!(value, "0"); - worker.refresh_viewport(&namespace, ¬ify); - let runtime = &mut worker.repl.as_mut().unwrap().1; - for _ in 0..64 { - if !runtime.has_pending_refresh() { - break; - } - runtime - .resize_windows_incremental(&std::collections::BTreeMap::new(), false) - .unwrap(); - } - let value = runtime.execute("count.value", "app.main").unwrap().value; - assert_eq!(value, "1"); - } -} +mod tests; diff --git a/src/api/tests.rs b/src/api/tests.rs new file mode 100644 index 0000000..70a3ec7 --- /dev/null +++ b/src/api/tests.rs @@ -0,0 +1,481 @@ +use super::*; +use std::{ + io::Write, + net::{SocketAddr, TcpStream}, + sync::atomic::{AtomicUsize, Ordering}, + time::Duration, +}; + +#[test] +fn preserved_picture_close_does_not_retire_same_number_in_fresh_console_vm() { + let (_, mut picture) = endor_host::ReplSession::load( + include_str!("../../examples/windows.endor"), + "app.main", + 1, + 150, + ) + .unwrap(); + picture.frame.session = 0; + for view in picture.windows.values_mut() { + view.frame.session = 0; + } + let mut fresh = endor_host::ReplSession::new(151).unwrap(); + assert!( + fresh + .execute("pending=Window()\nui.window().resize(0,600)", "app.main") + .is_err() + ); + assert!(fresh.accepts_window(2)); + let namespace = Arc::new(RwLock::new(NamespaceState { + document: picture, + submission: 1, + runtime_session: 151, + ..NamespaceState::default() + })); + let mut worker = Worker { + repl: Some((1, fresh)), + next_session: 151, + }; + let notify: Notify = Arc::new(|| {}); + namespace.write().unwrap().window_viewports.insert( + 2, + crate::layout::Size { + width: 800.0, + height: 600.0, + }, + ); + worker.refresh_viewport(&namespace, ¬ify); + assert_eq!( + namespace.read().unwrap().document.windows[&2].frame.session, + 0 + ); + let response = worker.close_inactive_window( + &crate::events::Input { + window: 2, + session: 0, + layout_version: 0, + mount: 0, + kind: "window-close".into(), + point: None, + delta: None, + press_point: None, + tab_drop: None, + screen_position: None, + }, + 1, + &namespace, + ¬ify, + ); + assert_eq!(response.status_code(), StatusCode(200)); + assert!(namespace.read().unwrap().document.windows.is_empty()); + let runtime = &mut worker.repl.as_mut().unwrap().1; + assert_eq!(runtime.id, 151); + assert!(runtime.accepts_window(2)); + assert_eq!( + runtime.execute("pending.id()", "app.main").unwrap().value, + "2" + ); +} + +struct TestServer { + server: Arc, + address: SocketAddr, + api: Option>, +} + +impl TestServer { + fn request(&self, method: &str, path: &str, body: &str) -> (u16, String, String) { + let mut stream = TcpStream::connect(self.address).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(10))) + .unwrap(); + stream + .set_write_timeout(Some(Duration::from_secs(10))) + .unwrap(); + write!(stream, "{method} {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\n\r\n{body}", body.len()).unwrap(); + let mut response = String::new(); + stream.read_to_string(&mut response).unwrap(); + let (headers, body) = response.split_once("\r\n\r\n").unwrap(); + let status = headers.split_whitespace().nth(1).unwrap().parse().unwrap(); + (status, headers.to_owned(), body.to_owned()) + } +} + +impl Drop for TestServer { + fn drop(&mut self) { + self.server.unblock(); + if let Some(api) = self.api.take() { + let _ = api.join(); + } + } +} + +#[test] +fn native_tab_drop_route_reorders_live_pages_and_rejects_reused_frames() { + let server = Arc::new(Server::http("127.0.0.1:0").unwrap()); + let namespace = Arc::new(RwLock::new(NamespaceState::default())); + let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap(); + let client = TestServer { + address: server.server_addr().to_ip().unwrap(), + server, + api: Some(api), + }; + let source = r#"class Tracked: Page { + calls: Object + function moveTab(self: Tracked, position: Number): Page { self.calls.increment() return Page.moveTab(self,position) } + } + calls=ui.state(0) + first=Tracked(calls) + first.title="first" + first.render(TextBlock("first")) + second=Page() + second.title="second" + second.render(TextBlock("second")) + first.activate()"#; + let result = client.request("PUT", "/v1/namespaces/app.main/code", source); + assert_eq!(result.0, 200, "{}", result.2); + let input = { + let current = namespace.read().unwrap(); + let frame = ¤t.document.frame; + crate::events::Input { + window: 1, + session: frame.session, + layout_version: frame.layout_version, + mount: frame.tabs[0].id, + kind: "tab-drop".into(), + point: None, + press_point: None, + delta: None, + screen_position: None, + tab_drop: Some(crate::events::TabDrop { + window: 1, + layout_version: frame.layout_version, + index: 2, + }), + } + }; + let body = serde_json::to_string(&input).unwrap(); + let response = client.request("POST", "/v1/namespaces/app.main/events", &body); + assert_eq!(response.0, 200, "{}", response.2); + let response = client.request("POST", "/v1/namespaces/app.main/repl", "calls.value"); + let value: serde_json::Value = serde_json::from_str(&response.2).unwrap(); + assert_eq!(value["value"], "1"); + assert_eq!( + namespace.read().unwrap().document.frame.tabs[1].title, + "first" + ); + assert_eq!( + client + .request("POST", "/v1/namespaces/app.main/events", &body) + .0, + 409 + ); + assert!(namespace.read().unwrap().document.frame.fault.is_none()); +} + +#[test] +fn background_failure_preserves_its_cause_after_rejected_console_input() { + let server = Arc::new(Server::http("127.0.0.1:0").unwrap()); + let namespace = Arc::new(RwLock::new(NamespaceState::default())); + let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap(); + let client = TestServer { + address: server.server_addr().to_ip().unwrap(), + server, + api: Some(api), + }; + let source = r#"class Failure { + function stop(self: Failure): Unit { ui.window().resize(0,600) } + } + failure=Failure() + page=Page() + page.render(TextBlock("preserved")) + task=ui.after(ui,"failure",0,failure.stop)"#; + let result = client.request("PUT", "/v1/namespaces/app.main/code", source); + assert_eq!(result.0, 200, "{}", result.2); + let repl = "/v1/namespaces/app.main/repl"; + assert_eq!(client.request("POST", repl, "42").0, 422); + let fault = namespace + .read() + .unwrap() + .document + .frame + .fault + .clone() + .unwrap(); + assert!(fault.contains("window size"), "{fault}"); + assert!(fault.contains("stop"), "{fault}"); + assert_eq!(client.request("POST", repl, "42").0, 422); + assert_eq!( + namespace.read().unwrap().document.frame.fault.as_ref(), + Some(&fault) + ); +} + +#[test] +fn console_keeps_memory_until_reset_or_complete_put_without_erasing_the_page() { + let server = Arc::new(Server::http("127.0.0.1:0").unwrap()); + let namespace = Arc::new(RwLock::new(NamespaceState::default())); + let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap(); + let client = TestServer { + address: server.server_addr().to_ip().unwrap(), + server, + api: Some(api), + }; + let repl = "/v1/namespaces/app.main/repl"; + let code = "/v1/namespaces/app.main/code"; + let created = client.request("POST", repl, "title = ui.text(\"中文\")"); + assert_eq!(created.0, 200, "{}", created.2); + let created: serde_json::Value = serde_json::from_str(&created.2).unwrap(); + assert_eq!(created["rendered"], false); + assert_eq!(created["revision"], 0); + let read: serde_json::Value = + serde_json::from_str(&client.request("POST", repl, "title").2).unwrap(); + assert_eq!(read["value"], created["value"]); + assert_eq!( + client.request("POST", repl, "ui.page().render(title)").0, + 200 + ); + let snapshot = client.request("GET", code, ""); + assert!( + snapshot + .1 + .to_lowercase() + .contains("x-tessera-source-kind: repl-transcript") + ); + assert_eq!(namespace.read().unwrap().revision, 1); + assert_eq!(client.request("DELETE", repl, "").0, 200); + assert_eq!(client.request("GET", code, "").2, snapshot.2); + assert_eq!(client.request("POST", repl, "title").0, 422); + assert_eq!(namespace.read().unwrap().revision, 1); + assert_eq!(client.request("POST", repl, "retained = 42").0, 200); + assert_eq!( + client + .request("PUT", code, include_str!("../../examples/page.endor")) + .0, + 200 + ); + assert_eq!(client.request("POST", repl, "retained").0, 422); + assert_eq!(namespace.read().unwrap().revision, 2); +} + +#[test] +fn http_executes_real_endor_preserves_failed_updates_and_supports_delete() { + let server = Arc::new(Server::http("127.0.0.1:0").unwrap()); + let namespace = Arc::new(RwLock::new(NamespaceState::default())); + let notifications = Arc::new(AtomicUsize::new(0)); + let counter = notifications.clone(); + let notify: Notify = Arc::new(move || { + counter.fetch_add(1, Ordering::SeqCst); + }); + let api = start_server(server.clone(), namespace.clone(), notify).unwrap(); + let client = TestServer { + address: server.server_addr().to_ip().unwrap(), + server, + api: Some(api), + }; + let route = "/v1/namespaces/app.main/code"; + let example = include_str!("../../examples/page.endor"); + let (status, _, guide) = client.request("GET", "/agent", ""); + assert_eq!(status, 200); + assert!(guide.contains("let myui = ui.page()")); + let (status, _, body) = client.request("PUT", route, example); + assert_eq!(status, 200, "{body}"); + assert_eq!( + serde_json::from_str::(&body).unwrap()["revision"], + 1 + ); + let (status, headers, source) = client.request("GET", route, ""); + assert_eq!(status, 200); + assert!(headers.to_lowercase().contains("x-tessera-revision: 1")); + assert_eq!(source, example); + let (status, _, body) = + client.request("PUT", route, "function main(): View { return missing }"); + assert_eq!(status, 422, "{body}"); + assert_eq!( + serde_json::from_str::(&body).unwrap()["diagnostics"][0]["line"], + 1 + ); + assert_eq!(client.request("GET", route, "").2, example); + let second = example.replace("Hello Endor", "Second"); + assert_eq!(client.request("PUT", route, &second).0, 200); + assert_eq!(namespace.read().unwrap().revision, 2); + assert_eq!(client.request("DELETE", route, "").0, 200); + assert_eq!(client.request("GET", route, "").2, ""); + assert!(namespace.read().unwrap().document.frame.commands.is_empty()); + assert_eq!(client.request("PUT", route, example).0, 200); + assert_eq!(namespace.read().unwrap().revision, 4); + assert_eq!( + client + .request("PUT", "/v1/namespaces/other/code", example) + .0, + 404 + ); + assert_eq!(notifications.load(Ordering::SeqCst), 4); +} + +#[test] +fn queued_candidate_keeps_live_owner_until_delete_revokes_it() { + let (runtime, document) = endor_host::ReplSession::load( + "retained = ui.state(9)\npage = Page()\npage.render(ui.textOf(retained,\"value\"))", + "app.main", + 1, + 277, + ) + .unwrap(); + let namespace = Arc::new(RwLock::new(NamespaceState { + document, + submission: 2, + runtime_session: runtime.id, + ..NamespaceState::default() + })); + let mut worker = Worker { + repl: Some((1, runtime)), + next_session: 277, + }; + let notify: Notify = Arc::new(|| {}); + worker.refresh_viewport(&namespace, ¬ify); + assert_eq!(worker.repl.as_ref().unwrap().0, 2); + assert_eq!( + worker + .repl + .as_mut() + .unwrap() + .1 + .execute("retained.value", "app.main") + .unwrap() + .value, + "9" + ); + { + let mut current = namespace.write().unwrap(); + current.submission = 3; + worker.preserve_live_owner(¤t); + } + assert_eq!(worker.repl.as_ref().unwrap().0, 3); + namespace.write().unwrap().clear().unwrap(); + worker.refresh_viewport(&namespace, ¬ify); + assert!(worker.repl.is_none()); +} + +#[test] +fn http_delete_remains_available_and_invalidates_queued_put() { + use std::{ + sync::{Mutex, mpsc}, + time::Instant, + }; + let server = Arc::new(Server::http("127.0.0.1:0").unwrap()); + let namespace = Arc::new(RwLock::new(NamespaceState::default())); + let (entered_tx, entered_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let release_rx = Mutex::new(release_rx); + let calls = AtomicUsize::new(0); + let notify: Notify = Arc::new(move || { + if calls.fetch_add(1, Ordering::SeqCst) == 0 { + entered_tx.send(()).unwrap(); + release_rx.lock().unwrap().recv().unwrap(); + } + }); + let api = start_server(server.clone(), namespace.clone(), notify).unwrap(); + let client = TestServer { + address: server.server_addr().to_ip().unwrap(), + server, + api: Some(api), + }; + // Unblock even if a test assertion fails, before scoped threads join. + struct Release(mpsc::Sender<()>); + impl Drop for Release { + fn drop(&mut self) { + let _ = self.0.send(()); + } + } + thread::scope(|scope| { + let release = Release(release_tx); + let client = &client; + let route = "/v1/namespaces/app.main/code"; + let example = include_str!("../../examples/page.endor"); + let first = scope.spawn(move || client.request("PUT", route, example)); + entered_rx.recv_timeout(Duration::from_secs(10)).unwrap(); + let pending = scope.spawn(move || client.request("PUT", route, example)); + let deadline = Instant::now() + Duration::from_secs(10); + while namespace.read().unwrap().submission < 2 { + assert!(Instant::now() < deadline, "second PUT was not admitted"); + thread::sleep(Duration::from_millis(5)); + } + assert_eq!(client.request("GET", route, "").2, example); + assert_eq!(client.request("DELETE", route, "").0, 200); + drop(release); + assert_eq!(first.join().unwrap().0, 200); + assert_eq!(pending.join().unwrap().0, 409); + assert_eq!(client.request("GET", route, "").2, ""); + assert_eq!(namespace.read().unwrap().revision, 2); + }); +} + +#[test] +fn extracts_namespace_only_from_code_routes() { + assert_eq!( + namespace_from_path("/v1/namespaces/app.main/code"), + Some("app.main") + ); + assert_eq!(namespace_from_path("/v1/namespaces/app.main"), None); + assert_eq!(namespace_from_path("/v1/namespaces/a/b/code"), None); +} + +#[test] +fn guide_contains_only_code_crud_routes() { + let guide = agent_guide(&NamespaceState::default()); + assert!(guide.contains("Code CRUD")); + assert!(guide.contains("let myui = ui.page()")); + assert!(!guide.contains("export scene")); + assert!(guide.contains("PUT http://127.0.0.1:4737")); + assert!(!guide.contains("display_list")); +} + +#[test] +fn foreground_refresh_does_not_deliver_new_callbacks_ahead_of_input() { + let source = r#" +class Action { + count: Object + function fire(self: Action): Unit { self.count.increment() } +} +count=ui.state(0) +action=Action(count) +ui.after(ui,"ready",0,action.fire) +page=Page() +page.render(TextBlock("input priority")) +"#; + let (runtime, document) = endor_host::ReplSession::load(source, "app.main", 1, 801).unwrap(); + let namespace = Arc::new(RwLock::new(NamespaceState { + document, + submission: 1, + runtime_session: 801, + ..NamespaceState::default() + })); + let mut worker = Worker { + repl: Some((1, runtime)), + next_session: 801, + }; + let notify: Notify = Arc::new(|| {}); + worker.refresh_viewport_mode(&namespace, ¬ify, true); + let value = worker + .repl + .as_mut() + .unwrap() + .1 + .execute("count.value", "app.main") + .unwrap() + .value; + assert_eq!(value, "0"); + worker.refresh_viewport(&namespace, ¬ify); + let runtime = &mut worker.repl.as_mut().unwrap().1; + for _ in 0..64 { + if !runtime.has_pending_refresh() { + break; + } + runtime + .resize_windows_incremental(&std::collections::BTreeMap::new(), false) + .unwrap(); + } + let value = runtime.execute("count.value", "app.main").unwrap().value; + assert_eq!(value, "1"); +} diff --git a/src/app.rs b/src/app.rs index eb50797..2124752 100644 --- a/src/app.rs +++ b/src/app.rs @@ -16,7 +16,7 @@ use winit::{ use crate::{ activity::Activity, - developer_tools::DeveloperToolsState, + developer_tools::Workspace as DeveloperToolsState, document::NamespaceState, geometry::Point, scene::{self, SceneLayout}, @@ -44,6 +44,7 @@ pub struct TesseraApp { control: crate::api::RuntimeControl, exit_requested: bool, native_session: u64, + startup_page_requested: bool, native_error: Option, children: std::collections::BTreeMap, proxy: EventLoopProxy, @@ -54,6 +55,8 @@ pub struct TesseraApp { developer_tools: DeveloperToolsState, layout: SceneLayout, pointer: Point, + pointer_inside: bool, + new_tab_pressed: bool, modifiers: ModifiersState, activity: Activity, event_client: crate::events::Client, @@ -63,6 +66,8 @@ pub struct TesseraApp { tab_drag_grab: Option, tab_drop_index: Option, tab_first: Option, + tab_motion: crate::tabs::Motion, + tab_visual: crate::tabs::Visual, tab_scroll_hover: Option, window_order: Vec, guest_focus: Option, @@ -83,6 +88,7 @@ impl TesseraApp { control, exit_requested: false, native_session: 0, + startup_page_requested: false, native_error: None, children: std::collections::BTreeMap::new(), proxy: proxy.clone(), @@ -90,13 +96,11 @@ impl TesseraApp { context: None, surface: None, namespace, - developer_tools: { - let mut state = DeveloperToolsState::default(); - state.proxy = Some(proxy); - state - }, + developer_tools: DeveloperToolsState::new(proxy), layout: SceneLayout::default(), pointer: Point::default(), + pointer_inside: false, + new_tab_pressed: false, modifiers: ModifiersState::default(), activity, event_client, @@ -106,6 +110,8 @@ impl TesseraApp { tab_drag_grab: None, tab_drop_index: None, tab_first: None, + tab_motion: crate::tabs::Motion::default(), + tab_visual: crate::tabs::Visual::default(), tab_scroll_hover: None, window_order: vec![crate::window::MAIN_WINDOW], guest_focus: None, @@ -286,11 +292,30 @@ impl TesseraApp { } } - fn toggle_developer_tools(&mut self) { - if let Ok(namespace) = self.namespace.read() { - self.developer_tools.toggle(&namespace.document); + fn open_developer_tool(&mut self, name: &str) { + let input = self.namespace.read().ok().map(|namespace| { + let frame = &namespace.document.frame; + crate::events::Input { + window: self.resource, + session: frame.session, + layout_version: frame.layout_version, + mount: 0, + kind: format!("developer-open:{name}"), + point: None, + delta: None, + press_point: None, + tab_drop: None, + screen_position: None, + } + }); + if let Some(input) = input { + if input.session == 0 { + self.developer_tools.initialize_tools(name); + } else { + self.send_guest(input); + } } - self.guest_keyboard = !self.developer_tools.open; + self.guest_keyboard = false; self.request_redraw(); } @@ -333,7 +358,16 @@ impl TesseraApp { } fn handle_keyboard(&mut self, event: &winit::event::KeyEvent) { - if self.guest_keyboard && event.state == ElementState::Pressed && !event.repeat { + if event.state == ElementState::Pressed + && !event.repeat + && (self.modifiers.super_key() || self.modifiers.control_key()) + && matches!(&event.logical_key, Key::Character(key) if key.eq_ignore_ascii_case("t")) + { + self.open_developer_tool("New tab"); + return; + } + + if event.state == ElementState::Pressed && !event.repeat { let close = (self.modifiers.super_key() || self.modifiers.control_key()) && matches!(&event.logical_key, Key::Character(key) if key.eq_ignore_ascii_case("w")); let cycle = self.modifiers.control_key() @@ -361,11 +395,13 @@ impl TesseraApp { let is_f12 = matches!(event.logical_key, Key::Named(NamedKey::F12)); let is_i = matches!(&event.logical_key, Key::Character(value) if value.eq_ignore_ascii_case("i")); - let mac_shortcut = self.modifiers.super_key() && self.modifiers.alt_key() && is_i; + let mac_shortcut = self.modifiers.super_key() + && (self.modifiers.alt_key() || self.modifiers.shift_key()) + && is_i; let other_shortcut = self.modifiers.control_key() && self.modifiers.shift_key() && is_i; if is_f12 || mac_shortcut || other_shortcut { if !event.repeat { - self.toggle_developer_tools(); + self.open_developer_tool("Console"); } return; } @@ -445,7 +481,7 @@ impl TesseraApp { if self.pointer.y < offset { return None; } - let point = Point::new(self.pointer.x, self.pointer.y - offset); + let point = Point::new(self.pointer.x - crate::tabs::RAIL, self.pointer.y - offset); let mount = frame.hit_test(point)?; Some(crate::events::Input { window: self.resource, @@ -501,6 +537,24 @@ impl TesseraApp { } } + fn new_tab_hit(&self) -> bool { + let Some(window) = &self.window else { + return false; + }; + self.namespace.read().is_ok_and(|n| { + (if self.tab_visual.plus.width > 0.0 { + self.tab_visual.plus + } else { + crate::tabs::plus_rect( + self.tab_first, + &n.document.frame.tabs, + window.inner_size().width as f32 / window.scale_factor() as f32, + ) + }) + .contains(self.pointer) + }) + } + fn tab_hit(&self) -> Option { if self.guest_covered(self.pointer) { return None; @@ -512,7 +566,16 @@ impl TesseraApp { return None; } let width = window.inner_size().width as f32 / window.scale_factor() as f32; - let (id, close) = crate::tabs::hit(self.tab_first, &frame.tabs, width, self.pointer)?; + let (id, close) = if self.tab_visual.rects.is_empty() { + crate::tabs::hit(self.tab_first, &frame.tabs, width, self.pointer) + } else { + self.tab_visual.hit(self.pointer).or_else(|| { + crate::tabs::navigation_hit(self.tab_first, &frame.tabs, width, self.pointer) + }) + }?; + if !frame.tabs.iter().any(|tab| tab.id == id) { + return None; + } Some(crate::events::Input { window: self.resource, session: frame.session, @@ -610,6 +673,14 @@ impl TesseraApp { fn drag_anchor(&self, input: &crate::events::Input) -> Option { let window = self.window.as_ref()?; let current = self.namespace.read().ok()?; + if let Some((_, rect)) = self + .tab_visual + .rects + .iter() + .find(|(id, _)| *id == input.mount) + { + return Some(Point::new(self.pointer.x - rect.x, self.pointer.y)); + } crate::tabs::drag_anchor( self.tab_first, ¤t.document.frame.tabs, @@ -988,6 +1059,14 @@ impl TesseraApp { crate::window::MAX_WINDOW_DIMENSION, crate::window::MAX_WINDOW_DIMENSION, )); + #[cfg(target_os = "macos")] + let attributes = { + use winit::platform::macos::WindowAttributesExtMacOS; + attributes + .with_title_hidden(true) + .with_titlebar_transparent(true) + .with_fullsize_content_view(true) + }; let window = Arc::new(event_loop.create_window(attributes)?); let context = Context::new(window.clone()).map_err(|error| anyhow::anyhow!("{error}"))?; let surface = @@ -1046,7 +1125,9 @@ impl TesseraApp { } fn try_redraw(&mut self) -> anyhow::Result<()> { - let guest_cursor = self.guest_hit().is_some() || self.tab_hit().is_some(); + let pointer = self.pointer_inside.then_some(self.pointer); + let hover = pointer.and_then(|_| self.tab_hit().map(|input| input.mount)); + let guest_cursor = self.guest_hit().is_some() || hover.is_some() || self.new_tab_hit(); let (Some(window), Some(surface)) = (self.window.as_ref(), self.surface.as_mut()) else { return Ok(()); }; @@ -1060,6 +1141,11 @@ impl TesseraApp { let Ok(namespace) = self.namespace.read() else { return Ok(()); }; + self.tab_visual = self.tab_motion.sample( + &namespace.document.frame.tabs, + self.tab_first, + size.width as f32 / window.scale_factor() as f32, + ); let scene = scene::build_scene( size.width, size.height, @@ -1070,6 +1156,8 @@ impl TesseraApp { crate::tabs::Chrome { first: self.tab_first, insertion: self.tab_drop_index, + pointer, + visual: self.tab_visual.clone(), }, ); drop(namespace); @@ -1127,9 +1215,37 @@ impl ApplicationHandler for TesseraApp { fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { event_loop.set_control_flow(ControlFlow::Wait); self.tick_tab_scroll(event_loop); + let now = Instant::now(); + let mut next = match event_loop.control_flow() { + ControlFlow::WaitUntil(deadline) => Some(deadline), + _ => None, + }; + for app in std::iter::once(&*self).chain(self.children.values()) { + if let Some(deadline) = app.tab_motion.next_frame { + if now >= deadline { + app.request_redraw(); + } + let deadline = deadline.max(now + Duration::from_millis(1)); + next = Some(next.map_or(deadline, |previous| previous.min(deadline))); + } + } + if let Some(deadline) = next { + event_loop.set_control_flow(ControlFlow::WaitUntil(deadline)); + } } fn resumed(&mut self, event_loop: &ActiveEventLoop) { + if self.resource == crate::window::MAIN_WINDOW + && !self.startup_page_requested + && self + .namespace + .read() + .is_ok_and(|n| n.runtime_session == 0 && n.document.frame.tabs.is_empty()) + { + self.startup_page_requested = true; + self.developer_tools.initialize_tools("New tab"); + } + if let Err(error) = self.create_native(event_loop) { self.native_error = Some(format!( "Window {} initialization failed: {error:#}", @@ -1235,6 +1351,7 @@ impl ApplicationHandler for TesseraApp { let scale = window.scale_factor() as f32; let next = Point::new(x as f32 / scale, y as f32 / scale); let delta = Point::new(next.x - self.pointer.x, next.y - self.pointer.y); + self.pointer_inside = true; self.pointer = next; self.developer_tools.move_pointer( next, @@ -1242,15 +1359,19 @@ impl ApplicationHandler for TesseraApp { &self.layout.developer_tools, window.inner_size().width as f32 / scale, ); - window.set_cursor(if self.guest_hit().is_some() || self.tab_hit().is_some() { - winit::window::CursorIcon::Pointer - } else { - self.developer_tools - .cursor_kind(&self.layout.developer_tools) - }); + window.set_cursor( + if self.guest_hit().is_some() || self.tab_hit().is_some() || self.new_tab_hit() + { + winit::window::CursorIcon::Pointer + } else { + self.developer_tools + .cursor_kind(&self.layout.developer_tools) + }, + ); window.request_redraw(); } WindowEvent::CursorLeft { .. } => { + self.pointer_inside = false; self.guest_pressed = None; if self.tab_drag_origin.is_none() { self.tab_pressed = None; @@ -1265,6 +1386,17 @@ impl ApplicationHandler for TesseraApp { .. } => { if state == ElementState::Pressed { + if self.new_tab_hit() { + self.new_tab_pressed = true; + window.request_redraw(); + return; + } + if self.pointer.y < crate::tabs::HEIGHT + && self.pointer.x >= crate::tabs::LEADING + && self.tab_hit().is_none() + { + let _ = window.drag_window(); + } self.guest_keyboard = !self.guest_covered(self.pointer); self.tab_pressed = self.tab_hit(); self.tab_drag_grab = self @@ -1283,6 +1415,14 @@ impl ApplicationHandler for TesseraApp { &self.activity, ); } else { + if self.new_tab_pressed { + self.new_tab_pressed = false; + if self.new_tab_hit() { + self.open_developer_tool("New tab"); + } + window.request_redraw(); + return; + } if let Some(mut pressed) = self.tab_pressed.take() { let width = window.inner_size().width as f32 / window.scale_factor() as f32; let dragged = self.tab_drag_origin.take().is_some_and(|origin| { @@ -1347,7 +1487,8 @@ impl ApplicationHandler for TesseraApp { { return None; } - let point = Point::new(self.pointer.x, self.pointer.y - offset); + let point = + Point::new(self.pointer.x - crate::tabs::RAIL, self.pointer.y - offset); let delta = [-dx, -dy]; let mount = crate::scroll::wheel_target(frame, point, delta)?; Some(crate::events::Input { diff --git a/src/developer_tools.rs b/src/developer_tools.rs index 755d513..8d46e25 100644 --- a/src/developer_tools.rs +++ b/src/developer_tools.rs @@ -20,6 +20,15 @@ use std::{ }; use unicode_segmentation::UnicodeSegmentation; +mod console; +mod network; +mod painter; +mod selection; +mod settings; +mod sources; +use painter::*; +use selection::SourceSelection; + const ROW: f32 = 19.0; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] @@ -45,14 +54,13 @@ struct Scroll { } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Focus { + Source, Input, Filter, Custom, } #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub enum Action { - Tab(Tab), - Close, ClearConsole, ClearNetwork, Follow, @@ -68,7 +76,6 @@ pub enum Action { } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Split { - Dock, Tree, Network, } @@ -78,9 +85,56 @@ enum Drag { Scroll(Region, bool, f32), } +/// Each workspace keeps its own input, focus, selection, scroll and request state. +/// Only appearance and the window-level launcher are shared. +pub struct Workspace { + states: [DeveloperToolsState; 4], + active: usize, +} +impl Workspace { + pub fn new(proxy: winit::event_loop::EventLoopProxy) -> Self { + let states = [Tab::Console, Tab::Sources, Tab::Network, Tab::Settings].map(|tab| { + DeveloperToolsState { + tab, + proxy: Some(proxy.clone()), + ..Default::default() + } + }); + Self { states, active: 0 } + } + pub fn select(&mut self, name: Option<&str>) { + let next = match name { + Some("Sources") => 1, + Some("Network") => 2, + Some("Settings") => 3, + _ => 0, + }; + let accent = self.states[self.active].accent; + if next != self.active { + self.states[self.active].blur(); + } + self.active = next; + self.states[next].accent = accent; + self.states[next].open = name.is_some(); + } +} +impl std::ops::Deref for Workspace { + type Target = DeveloperToolsState; + fn deref(&self) -> &Self::Target { + &self.states[self.active] + } +} +impl std::ops::DerefMut for Workspace { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.states[self.active] + } +} + #[derive(Debug)] pub struct DeveloperToolsState { pub open: bool, + pointer: Point, + source_selection: SourceSelection, pub interaction: Interaction, pub repaint: bool, pub proxy: Option>, @@ -96,7 +150,6 @@ pub struct DeveloperToolsState { pub custom: String, pub accent: Color, pub input_error: String, - panel_width: f32, tree_width: f32, network_fraction: f32, scroll: [Scroll; 5], @@ -117,6 +170,8 @@ impl Default for DeveloperToolsState { fn default() -> Self { Self { open: false, + pointer: Point::default(), + source_selection: SourceSelection::default(), interaction: Interaction::default(), repaint: false, proxy: None, @@ -132,7 +187,6 @@ impl Default for DeveloperToolsState { custom: "#1671D5".into(), accent: PALETTE[0], input_error: String::new(), - panel_width: 660.0, tree_width: 190.0, network_fraction: 0.42, scroll: [Scroll::default(); 5], @@ -159,6 +213,41 @@ pub struct DeveloperToolsLayout { regions: Vec<(Rect, Region, f32, f32)>, thumbs: Vec<(Rect, Region, bool, f32)>, input: Option, + source: Option<(Rect, Point, Vec)>, +} +impl DeveloperToolsLayout { + pub fn translate(&mut self, dx: f32, dy: f32) { + self.panel.x += dx; + self.panel.y += dy; + for target in &mut self.widgets { + target.rect.x += dx; + target.rect.y += dy; + } + for (rect, _) in &mut self.splits { + rect.x += dx; + rect.y += dy; + } + for (rect, _, _, _) in &mut self.regions { + rect.x += dx; + rect.y += dy; + } + for (rect, _, _, _) in &mut self.thumbs { + rect.x += dx; + rect.y += dy; + } + if let Some(input) = &mut self.input { + input.viewport.x += dx; + input.viewport.y += dy; + input.origin.x += dx; + input.origin.y += dy; + } + if let Some((area, origin, _)) = &mut self.source { + area.x += dx; + area.y += dy; + origin.x += dx; + origin.y += dy; + } + } } #[derive(Clone, Copy, Debug)] struct ConsoleInputLayout { @@ -166,13 +255,6 @@ struct ConsoleInputLayout { origin: Point, } impl DeveloperToolsState { - pub fn toggle(&mut self, doc: &RenderDocument) { - self.open = !self.open; - self.interaction.blur(); - self.focus = None; - self.drag = None; - self.document_updated(doc); - } pub fn document_updated(&mut self, doc: &RenderDocument) { if self .selected_node_id @@ -187,6 +269,13 @@ impl DeveloperToolsState { if !self.open { return CursorIcon::Default; } + if layout + .source + .as_ref() + .is_some_and(|(area, _, _)| area.contains(self.pointer)) + { + return CursorIcon::Text; + } match self.interaction.cursor(&layout.widgets) { Cursor::Default => CursorIcon::Default, Cursor::Pointer => CursorIcon::Pointer, @@ -211,9 +300,18 @@ impl DeveloperToolsState { return; } self.preedit.clear(); + self.pointer = p; self.interaction.pointer_moved(p, &layout.widgets); let target = self.interaction.press(&layout.widgets); self.focus = None; + if let Some((area, origin, lines)) = &layout.source + && area.contains(p) + && target.is_none() + { + self.focus = Some(Focus::Source); + self.selection = false; + self.source_selection.begin(p, *origin, lines); + } if let Some(target) = target { match target.role { Role::Splitter(_) => { @@ -244,12 +342,6 @@ impl DeveloperToolsState { ) { self.focus = None; match action { - Some(Action::Tab(t)) => { - self.tab = t; - } - Some(Action::Close) => { - self.open = false; - } Some(Action::Focus(f)) => { self.focus = Some(f); self.selection = false; @@ -332,9 +424,11 @@ impl DeveloperToolsState { } } pub fn release(&mut self, layout: &DeveloperToolsLayout, p: Point, activity: &Activity) { + self.pointer = p; self.interaction.pointer_moved(p, &layout.widgets); let action = self.interaction.release(&layout.widgets); self.drag = None; + self.source_selection.dragging = false; if action.is_some() { self.activate(action, layout, p, activity); } @@ -402,21 +496,30 @@ impl DeveloperToolsState { p: Point, delta: Point, layout: &DeveloperToolsLayout, - window_width: f32, + _window_width: f32, ) { + self.pointer = p; self.interaction.pointer_moved(p, &layout.widgets); - match self.drag { - Some(Drag::Split(Split::Dock)) => { - self.panel_width = - (window_width - p.x).clamp(360.0, (window_width - 100.0).max(360.0)) + if self.source_selection.dragging + && let Some((area, origin, lines)) = &layout.source + { + if p.y < area.y || p.y > area.y + area.height - 9.0 { + let direction = if p.y < area.y { -ROW } else { ROW }; + self.scroll[Region::Source as usize].y = + (self.scroll[Region::Source as usize].y + direction).max(0.0); } + self.source_selection.extend(p, *origin, lines); + self.repaint = true; + } + match self.drag { Some(Drag::Split(Split::Tree)) => { self.tree_width = (p.x - layout.panel.x).clamp(120.0, (layout.panel.width - 180.0).max(120.0)) } Some(Drag::Split(Split::Network)) => { - self.network_fraction = - ((p.y - 92.0) / (layout.panel.height - 114.0).max(1.0)).clamp(0.2, 0.8) + self.network_fraction = ((p.y - layout.panel.y - 92.0) + / (layout.panel.height - 114.0).max(1.0)) + .clamp(0.2, 0.8) } Some(Drag::Scroll(r, h, ratio)) => { let s = &mut self.scroll[r as usize]; @@ -485,7 +588,7 @@ impl DeveloperToolsState { .take(7), ); } - None => {} + Some(Focus::Source) | None => {} } } pub(crate) fn reveal_cursor(&mut self) { @@ -506,6 +609,9 @@ impl DeveloperToolsState { Point::new(layout.panel.x + 20.0, 60.0) } pub fn selected_text(&self) -> String { + if self.focus == Some(Focus::Source) { + return self.source_selection.text(self.selection); + } if !self.selection { return String::new(); } @@ -513,7 +619,7 @@ impl DeveloperToolsState { Some(Focus::Input) => self.input.clone(), Some(Focus::Filter) => self.filter.clone(), Some(Focus::Custom) => self.custom.clone(), - None => String::new(), + Some(Focus::Source) | None => String::new(), } } pub fn key(&mut self, key: &winit::keyboard::NamedKey, shift: bool, command: bool) { @@ -524,6 +630,14 @@ impl DeveloperToolsState { self.interaction.blur(); return; } + if self.focus == Some(Focus::Source) { + if let Some(row) = self.source_selection.navigate(key, shift, command) { + self.selection = false; + self.scroll[Region::Source as usize].y = (row as f32 * ROW - ROW * 4.0).max(0.0); + self.repaint = true; + } + return; + } if self.focus == Some(Focus::Custom) && *key == Enter { let hex = self.custom.trim_start_matches('#'); if hex.len() == 6 @@ -678,6 +792,37 @@ impl DeveloperToolsState { } self.reveal_cursor(); } + pub fn initialize_tools(&mut self, name: &str) { + let name = name.to_owned(); + let proxy = self.proxy.clone(); + thread::spawn(move || { + let source = format!("ui.developerTools({name:?})"); + let send = || -> std::io::Result<()> { + let mut stream = TcpStream::connect_timeout( + &"127.0.0.1:4737".parse().unwrap(), + Duration::from_secs(2), + )?; + stream.set_read_timeout(Some(Duration::from_secs(30)))?; + write!( + stream, + "POST /v1/namespaces/app.main/repl HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\nContent-Type: text/plain\r\nContent-Length: {}\r\n\r\n{}", + source.len(), + source + )?; + let mut response = String::new(); + stream.read_to_string(&mut response)?; + Ok(()) + }; + if let Err(error) = send() + && let Some(proxy) = proxy + { + let _ = proxy.send_event(AppEvent::ConsoleFailed { + message: error.to_string(), + source: String::new(), + }); + } + }); + } fn submit(&mut self) { self.submit_console(false); } @@ -722,242 +867,6 @@ impl DeveloperToolsState { } } -struct Painter { - ui: Ui, - layout: DeveloperToolsLayout, -} -impl Painter { - fn fill(&mut self, r: Rect, c: Color) { - self.ui.fill(r, c); - } - fn label(&mut self, x: f32, y: f32, value: &str, c: Color, code: bool) { - self.ui.label(x, y, value, c, code); - } - fn button(&mut self, r: Rect, label: &str, action: Action, active: bool) { - let role = match action { - Action::Tab(_) => Role::Tab, - Action::Follow | Action::Snapshot | Action::Cloud => Role::Toggle, - _ => Role::Button, - }; - self.ui - .button(r, action, Button::new(label).selected(active).role(role)); - } - fn enabled_button(&mut self, r: Rect, label: &str, action: Action, enabled: bool) { - self.ui - .button(r, action, Button::new(label).enabled(enabled)); - } - fn split(&mut self, r: Rect, kind: Split) { - // Paint and register splitters after all pane content, in one overlay pass. - self.layout.splits.push((r, kind)); - } - fn lines( - &mut self, - area: Rect, - region: Region, - lines: &[Line], - state: &mut DeveloperToolsState, - follow: bool, - ) { - let content_h = lines.len() as f32 * ROW + 12.0; - use std::hash::{Hash, Hasher}; - let mut hash = std::collections::hash_map::DefaultHasher::new(); - for line in lines { - line.value.hash(&mut hash); - } - let fingerprint = hash.finish(); - let cache = &mut state.widths[region as usize]; - let content_w = match *cache { - Some((previous, width)) if previous == fingerprint => width, - _ => { - let width = lines - .iter() - .map(|line| text::measure(&line.value, TextStyle::code()).width + 20.0) - .fold(0.0_f32, f32::max); - *cache = Some((fingerprint, width)); - width - } - }; - let s = &mut state.scroll[region as usize]; - s.y = if follow { - (content_h - area.height + 12.0).max(0.0) - } else { - s.y.clamp(0.0, (content_h - area.height + 12.0).max(0.0)) - }; - s.x = s.x.clamp(0.0, (content_w - area.width + 12.0).max(0.0)); - self.ui.list.push(DrawCommand::PushClip(Rect::new( - area.x, - area.y, - (area.width - 9.0).max(0.0), - (area.height - 9.0).max(0.0), - ))); - let first = (s.y / ROW).floor() as usize; - let count = (area.height / ROW).ceil() as usize + 2; - for (i, line) in lines.iter().enumerate().skip(first).take(count) { - let y = area.y + 5.0 + i as f32 * ROW - s.y; - if let Some(divider) = line.divider { - self.fill( - Rect::new(area.x, y, area.width, 1.0), - match divider { - Divider::Accent => self.ui.theme.accent, - Divider::Neutral => BORDER, - }, - ); - } - let paint = Rect::new(area.x, y, area.width, ROW); - if let Some(action) = &line.action { - let top = y.max(area.y); - let bottom = (y + ROW).min(area.y + area.height - 9.0); - if bottom > top { - self.ui.row( - paint, - Rect::new(area.x, top, area.width - 9.0, bottom - top), - action.clone(), - line.selected, - ); - } - } else if line.selected { - self.fill(paint, self.ui.theme.soft()); - } - self.label(area.x + 8.0 - s.x, y, &line.value, line.color, true); - } - self.ui.list.push(DrawCommand::PopClip); - self.layout - .regions - .push((area, region, content_w, content_h)); - self.scrollbar(area, region, false, s.y, content_h); - self.scrollbar(area, region, true, s.x, content_w); - } - fn scrollbar(&mut self, r: Rect, region: Region, h: bool, offset: f32, content: f32) { - let viewport = if h { r.width } else { r.height } - 10.0; - if content <= viewport || viewport <= 0.0 { - return; - } - let length = (viewport * viewport / content).max(24.0).min(viewport); - let travel = (viewport - length).max(1.0); - let maximum = (content - viewport).max(1.0); - let position = (offset / maximum).clamp(0.0, 1.0) * travel; - let track = if h { - Rect::new(r.x, r.y + r.height - 8.0, viewport, 8.0) - } else { - Rect::new(r.x + r.width - 8.0, r.y, 8.0, viewport) - }; - self.fill(track, BAR); - let thumb = if h { - Rect::new(track.x + position, track.y + 1.0, length, 6.0) - } else { - Rect::new(track.x + 1.0, track.y + position, 6.0, length) - }; - self.ui.scrollbar( - thumb, - WidgetId::new(("scroll", region, h)), - if h { Axis::Horizontal } else { Axis::Vertical }, - ); - self.layout - .thumbs - .push((thumb, region, h, maximum / travel)); - } -} -#[derive(Clone)] -struct Line { - value: String, - color: Color, - divider: Option, - selected: bool, - action: Option, -} -#[derive(Clone, Copy)] -enum Divider { - Accent, - Neutral, -} -impl Line { - fn new(value: impl Into, color: Color) -> Self { - Self { - value: value.into(), - color, - divider: None, - selected: false, - action: None, - } - } -} -fn multiline(out: &mut Vec, value: &str, color: Color) { - out.extend( - value - .replace('\t', " ") - .split('\n') - .map(|s| Line::new(s, color)), - ); -} -fn console_result(out: &mut Vec, body: &str, color: Color) { - if let Ok(value) = serde_json::from_str::(body) { - if value["execution"] == "repl" { - out.push(Line::new( - if value["rendered"] == true { - format!( - "Rendered revision {} · session {}", - value["revision"], value["session"] - ) - } else { - format!( - "Session {} · retained in memory; page unchanged", - value["session"] - ) - }, - MUTED, - )); - } - if value["session_reset"] == true { - multiline( - out, - value["message"] - .as_str() - .or(value["error"].as_str()) - .unwrap_or("Session reset"), - color, - ); - return; - } - if let Some(result) = value["value"].as_str() { - multiline(out, result, color); - return; - } - if let Some(diagnostics) = value["diagnostics"].as_array() - && !diagnostics.is_empty() - { - for d in diagnostics { - if let Some(line) = d["line"].as_u64() { - out.push(Line::new( - format!("Error · line {line}, column {}", d["column"]), - color, - )); - } - multiline( - out, - d["message"].as_str().unwrap_or("Execution failed"), - color, - ); - } - return; - } - if let Some(error) = value["error"].as_str() { - multiline(out, error, color); - return; - } - if let Some(revision) = value["revision"].as_u64() { - out.push(Line::new(format!("Committed revision {revision}"), color)); - return; - } - } - multiline(out, body, color); -} -fn flatten<'a>(node: &'a CodeNode, depth: usize, out: &mut Vec<(usize, &'a CodeNode)>) { - out.push((depth, node)); - for child in &node.children { - flatten(child, depth + 1, out); - } -} - pub fn build( width: u32, height: u32, @@ -971,11 +880,8 @@ pub fn build( } let width = width as f32 / scale; let height = height as f32 / scale; - let w = state - .panel_width - .clamp(360.0, (width - 100.0).max(360.0)) - .min(width); - let x = width - w; + let w = width; + let x = 0.0; let panel = Rect::new(x, 0.0, w, height); let mut p = Painter { ui: Ui::new(Theme::new(state.accent), state.interaction.clone()), @@ -987,23 +893,17 @@ pub fn build( p.fill(panel, BG); p.ui.list.push(DrawCommand::PushClip(panel)); p.fill(Rect::new(x, 0.0, w, 31.0), BAR); - p.split(Rect::new(x + 1.0, 0.0, 1.0, height), Split::Dock); - for (i, (label, tab)) in [ - ("Console", Tab::Console), - ("Sources", Tab::Sources), - ("Network", Tab::Network), - ("Settings", Tab::Settings), - ] - .iter() - .enumerate() - { - let r = Rect::new(x + 5.0 + i as f32 * 75.0, 0.0, 75.0, 30.0); - p.button(r, label, Action::Tab(*tab), state.tab == *tab); - } - p.button( - Rect::new(width - 32.0, 0.0, 31.0, 30.0), - "×", - Action::Close, + + p.label( + 12.0, + 8.0, + match state.tab { + Tab::Console => "Console · app.main", + Tab::Sources => "Sources · read only", + Tab::Network => "Network · captured requests", + Tab::Settings => "Settings · appearance", + }, + MUTED, false, ); let records = activity @@ -1015,505 +915,28 @@ pub fn build( .collect::>(); let bottom = height - 22.0; match state.tab { - Tab::Console => { - p.fill(Rect::new(x, 32.0, w, 30.0), BAR); - p.enabled_button( - Rect::new(x + 3.0, 32.0, 62.0, 29.0), - "Clear", - Action::ClearConsole, - records.iter().any(|r| { - r.id > state.console_after - && matches!(r.method.as_str(), "PUT" | "POST" | "DELETE") - && !r.path.ends_with("/events") - }), - ); - p.button( - Rect::new(x + 68.0, 32.0, 106.0, 29.0), - if state.follow { - "Follow: on" - } else { - "Follow: off" - }, - Action::Follow, - state.follow, - ); - p.button( - Rect::new(x + 181.0, 32.0, 119.0, 29.0), - "Reset session", - Action::ResetSession, - false, - ); - if w > 520.0 { - p.label(x + 309.0, 39.0, "app.main · live objects", MUTED, false); - } - let mut lines = Vec::new(); - for r in records.iter().filter(|r| { - r.id > state.console_after - && (r.method == "PUT" || r.method == "POST" || r.method == "DELETE") - && !r.path.ends_with("/events") - }) { - let kind = if r.path.ends_with("/repl") { - if r.method == "DELETE" { - "RESET SESSION" - } else { - "REPL · live objects" - } - } else if r.path.ends_with("/evaluate") { - "ISOLATED API EVALUATION" - } else if r.method == "PUT" { - "FULL SOURCE · live program" - } else { - "CLEAR DOCUMENT" - }; - let local = - r.origin == "Console" && r.path.ends_with("/repl") && r.method == "POST"; - if !local { - let mut title = Line::new( - format!("#{} {} {} {}", r.id, r.timestamp, r.origin, kind), - MUTED, - ); - title.divider = Some(Divider::Accent); - title.action = Some(Action::Request(r.id)); - lines.push(title); - } - for (i, line) in r.request.split('\n').enumerate() { - lines.push(Line::new( - format!("{}{line}", if i == 0 { "> " } else { " " }), - TEXT, - )); - } - let color = if r.status.is_some_and(|s| s >= 400) { - ERROR - } else { - OK - }; - if let Some(status) = r.status { - if !local { - lines.push(Line::new( - format!("‹ {status} · {} ms", r.elapsed_ms), - color, - )); - } - console_result(&mut lines, &r.response, color); - } else { - lines.push(Line::new(" Executing…", MUTED)); - } - let mut separator = Line::new("", MUTED); - separator.divider = Some(Divider::Neutral); - lines.push(separator); - } - if lines.is_empty() { - multiline( - &mut lines, - "Endor · live session\nCreate objects here; call page.render(...) to display.\n", - MUTED, - ); - } - use std::hash::{Hash, Hasher}; - let mut hash = std::collections::hash_map::DefaultHasher::new(); - for line in &lines { - line.value.hash(&mut hash); - } - let stamp = hash.finish(); - let output_changed = stamp != state.console_stamp; - state.console_stamp = stamp; - let first_input = lines.len(); - let area = Rect::new(x + 4.0, 63.0, w - 5.0, (bottom - 88.0).max(1.0)); - p.ui.inline_input( - Rect::new( - area.x, - area.y, - (area.width - 9.0).max(0.0), - (area.height - 9.0).max(0.0), - ), - Action::Focus(Focus::Input), - ); - let mut input = state.input.clone(); - if state.focus == Some(Focus::Input) { - input.insert_str(state.cursor, &state.preedit); - } - for (i, line) in input.split('\n').enumerate() { - let mut line = - Line::new(format!("{}{line}", if i == 0 { "> " } else { " " }), TEXT); - line.selected = state.selection && state.focus == Some(Focus::Input); - lines.push(line); - } - let prefix = text::measure("> ", TextStyle::code()).width; - let before = &state.input[..state.cursor]; - let row = before.matches('\n').count() as f32; - let column = before.rsplit('\n').next().unwrap_or(""); - let caret_x = prefix - + text::measure(&format!("{column}{}", state.preedit), TextStyle::code()).width; - let caret_y = (first_input as f32 + row) * ROW; - let follow = state.follow && output_changed && !state.reveal_input; - if state.reveal_input { - let s = &mut state.scroll[Region::Transcript as usize]; - s.y = s.y.clamp( - (caret_y + ROW + 16.0 - area.height).max(0.0).min(caret_y), - caret_y, - ); - s.x = s.x.clamp((caret_x + 24.0 - area.width).max(0.0), caret_x); - state.reveal_input = false; - } - p.lines(area, Region::Transcript, &lines, state, follow); - let scroll = state.scroll[Region::Transcript as usize]; - let origin = Point::new( - area.x + 8.0 + prefix - scroll.x, - area.y + 5.0 + first_input as f32 * ROW - scroll.y, - ); - p.layout.input = Some(ConsoleInputLayout { - viewport: area, - origin, - }); - if state.focus == Some(Focus::Input) { - p.ui.list.push(DrawCommand::PushClip(Rect::new( - area.x, - area.y, - (area.width - 9.0).max(0.0), - (area.height - 9.0).max(0.0), - ))); - p.fill( - Rect::new( - area.x + 8.0 + caret_x - scroll.x, - origin.y + row * ROW + 1.0, - 1.0, - ROW - 2.0, - ), - TEXT, - ); - p.ui.list.push(DrawCommand::PopClip); - } - p.label( - x + 10.0, - bottom - 23.0, - if state.input_error.is_empty() { - "Enter: run · Shift+Enter: newline · ⌘/Ctrl+↑↓: history" - } else { - &state.input_error - }, - if state.input_error.is_empty() { - MUTED - } else { - ERROR - }, - false, - ); - } - Tab::Sources => { - p.fill(Rect::new(x, 32.0, w, 30.0), BAR); - p.button( - Rect::new(x + 3.0, 32.0, 139.0, 29.0), - "Current snapshot", - Action::Snapshot, - !state.cloud, - ); - p.button( - Rect::new(x + 145.0, 32.0, 107.0, 29.0), - "Cloud files", - Action::Cloud, - state.cloud, - ); - if state.cloud { - p.button( - Rect::new(width - 92.0, 32.0, 88.0, 29.0), - "Refresh", - Action::CloudRefresh, - false, - ); - let tree = state.tree_width.clamp(120.0, (w - 180.0).max(120.0)); - let code_x = x + tree + 2.0; - p.label(x + 10.0, 69.0, "CLOUD FILES", MUTED, false); - let file_lines = state - .cloud_sources - .files - .iter() - .map(|file| { - let mut l = Line::new(&file.path, TEXT); - l.action = Some(Action::CloudOpen(file.id.clone())); - l - }) - .collect::>(); - p.lines( - Rect::new(x + 1.0, 92.0, tree - 1.0, bottom - 92.0), - Region::Tree, - &file_lines, - state, - false, - ); - p.split(Rect::new(x + tree, 63.0, 1.0, bottom - 63.0), Split::Tree); - let mut source_lines = Vec::new(); - if let Some(source) = &state.cloud_sources.opened { - p.label(code_x + 10.0, 69.0, &source.path, MUTED, false); - for (i, line) in source.code.split('\n').enumerate() { - source_lines.push(Line::new(format!("{:>5} {}", i + 1, line), TEXT)); - } - } else { - multiline( - &mut source_lines, - "No cloud file open\n\nNo provider connected.\nCloud files will open here as full source.\nCurrent snapshot contains your last successful script.", - MUTED, - ); - } - if !state.cloud_sources.error.is_empty() { - source_lines.push(Line::new(&state.cloud_sources.error, ERROR)); - } - p.lines( - Rect::new(code_x, 92.0, width - code_x, bottom - 92.0), - Region::Source, - &source_lines, - state, - false, - ); - } else { - let tree = state.tree_width.clamp(120.0, (w - 180.0).max(120.0)); - let code_x = x + tree + 2.0; - p.label(x + 10.0, 69.0, "ASTER AST", MUTED, false); - p.label( - code_x + 10.0, - 69.0, - &format!( - "{} · {} lines · read only", - if doc - .root - .as_ref() - .is_some_and(|root| root.kind == "repl-session") - { - "Console transcript" - } else { - "app.main.endor" - }, - doc.source.split('\n').count() - ), - MUTED, - false, - ); - let mut tree_lines = Vec::new(); - if let Some(root) = &doc.root { - let mut flat = Vec::new(); - flatten(root, 0, &mut flat); - for (depth, n) in flat { - let mut l = - Line::new(format!("{}{} {}", " ".repeat(depth), n.kind, n.name), TEXT); - l.selected = state.selected_node_id.as_deref() == Some(&n.id); - l.action = Some(Action::Node(n.id.clone(), n.start_line)); - tree_lines.push(l); - } - } else { - tree_lines.push(Line::new("No submitted source", MUTED)); - } - p.lines( - Rect::new(x + 1.0, 92.0, tree - 1.0, bottom - 92.0), - Region::Tree, - &tree_lines, - state, - false, - ); - p.split(Rect::new(x + tree, 63.0, 1.0, bottom - 63.0), Split::Tree); - let selected = state - .selected_node_id - .as_deref() - .and_then(|id| doc.node(id)); - let code_lines = doc - .source - .replace("\r\n", "\n") - .split('\n') - .enumerate() - .map(|(i, s)| { - let mut l = - Line::new(format!("{:>5} {}", i + 1, s.replace('\t', " ")), TEXT); - l.selected = - selected.is_some_and(|n| i + 1 >= n.start_line && i < n.end_line); - l - }) - .collect::>(); - p.lines( - Rect::new(code_x, 92.0, width - code_x, bottom - 92.0), - Region::Source, - &code_lines, - state, - false, - ); - } - } - Tab::Network => { - p.fill(Rect::new(x, 32.0, w, 30.0), BAR); - p.enabled_button( - Rect::new(x + 3.0, 32.0, 62.0, 29.0), - "Clear", - Action::ClearNetwork, - records.iter().any(|r| r.id > state.network_after), - ); - let filter_area = Rect::new(x + 72.0, 35.0, (w - 85.0).max(100.0), 24.0); - p.ui.text_field( - filter_area, - Action::Focus(Focus::Filter), - state.focus == Some(Focus::Filter), - ); - p.label( - filter_area.x + 7.0, - filter_area.y + 3.0, - &format!( - "Filter: {}{}", - state.filter, - if state.focus == Some(Focus::Filter) { - "│" - } else { - "" - } - ), - MUTED, - false, - ); - - p.label( - x + 10.0, - 69.0, - "METHOD STATUS TIME PATH", - MUTED, - true, - ); - let divider = 92.0 + (bottom - 92.0) * state.network_fraction; - let filter = state.filter.to_lowercase(); - let visible = records - .iter() - .filter(|r| r.id > state.network_after) - .filter(|r| { - format!( - "{} {} {}", - r.method, - r.path, - r.status.map_or("pending".into(), |s| s.to_string()) - ) - .to_lowercase() - .contains(&filter) - }) - .collect::>(); - let mut lines = Vec::new(); - for r in &visible { - let mut l = Line::new( - format!( - "{:<7} {:<6} {:>5} ms {}", - r.method, - r.status.map_or("…".into(), |s| s.to_string()), - r.elapsed_ms, - r.path - ), - if r.status.is_some_and(|s| s >= 400) { - ERROR - } else { - TEXT - }, - ); - l.selected = state.selected_request == Some(r.id); - l.action = Some(Action::Request(r.id)); - lines.push(l); - } - if lines.is_empty() { - lines.push(Line::new( - "No matching requests. Listening to Tessera's local API.", - MUTED, - )); - } - p.lines( - Rect::new(x + 1.0, 92.0, w - 2.0, divider - 92.0), - Region::Requests, - &lines, - state, - false, - ); - p.split(Rect::new(x + 1.0, divider, w - 2.0, 1.0), Split::Network); - let selected = records - .iter() - .find(|r| Some(r.id) == state.selected_request); - let mut details = Vec::new(); - if let Some(r) = selected { - request_details(&mut details, r); - } else { - multiline( - &mut details, - "Select a request to inspect headers and bodies.\n\nCapture scope: 127.0.0.1:4737 in this Tessera process.\n80 most recent requests; body previews up to 32 KiB.\nOnly Content-Type and Content-Length request headers are recorded.\nExternal browser / OS traffic is not captured.", - MUTED, - ); - } - p.lines( - Rect::new(x + 1.0, divider + 5.0, w - 2.0, bottom - divider - 5.0), - Region::Details, - &details, - state, - false, - ); - } - Tab::Settings => { - p.label(x + 24.0, 62.0, "Appearance", TEXT, false); - p.label( - x + 24.0, - 90.0, - "Accent color · tabs, focus and execution separators", - MUTED, - false, - ); - for (i, color) in PALETTE.iter().enumerate() { - let r = Rect::new(x + 24.0 + i as f32 * 44.0, 122.0, 32.0, 32.0); - p.ui.swatch(r, Action::Theme(i), *color, *color == state.accent); - } - p.label( - x + 24.0, - 176.0, - "Custom hex · Enter to apply (bright colors adjusted)", - MUTED, - false, - ); - let custom = Rect::new(x + 24.0, 204.0, 200.0, 32.0); - p.ui.text_field( - custom, - Action::Focus(Focus::Custom), - state.focus == Some(Focus::Custom), - ); - p.label( - custom.x + 10.0, - custom.y + 6.0, - &format!( - "{}{}", - state.custom, - if state.focus == Some(Focus::Custom) { - "│" - } else { - "" - } - ), - TEXT, - true, - ); - - if !state.input_error.is_empty() { - p.label(x + 24.0, 245.0, &state.input_error, ERROR, false); - } - p.fill(Rect::new(x + 24.0, 285.0, w - 48.0, 1.0), BORDER); - p.label(x + 24.0, 305.0, "Theme preview", TEXT, false); - p.fill(Rect::new(x + 24.0, 340.0, w - 48.0, 2.0), state.accent); - p.label(x + 32.0, 353.0, "› \"Hello\"", TEXT, true); - p.label(x + 32.0, 380.0, "‹ \"Hello\"", OK, true); - p.label( - x + 24.0, - 430.0, - "Settings last for this window session. Source is not persisted.", - MUTED, - false, - ); - } + Tab::Console => console::build(&mut p, state, &records, panel), + Tab::Sources => sources::build(&mut p, state, doc, panel), + Tab::Network => network::build(&mut p, state, &records, panel), + Tab::Settings => settings::build(&mut p, state, panel), } p.fill(Rect::new(x + 1.0, bottom, w - 1.0, 22.0), BAR); p.label( x + 9.0, bottom + 3.0, &text::fit( - &format!( - "● 127.0.0.1:4737 · {} captured · F12 / ⌘⌥I", - records - .iter() - .filter(|r| r.id > state.network_after) - .count() - ), + &match state.tab { + Tab::Console => "Live session · Enter to run".into(), + Tab::Sources => "Read only · drag to select · ⌘/Ctrl+C to copy".into(), + Tab::Network => format!( + "{} captured requests", + records + .iter() + .filter(|r| r.id > state.network_after) + .count() + ), + Tab::Settings => "Appearance · current window session".into(), + }, w - 20.0, TextStyle::code(), ), @@ -1527,7 +950,7 @@ pub fn build( rect, WidgetId::new(("split", kind)), match kind { - Split::Dock | Split::Tree => Axis::Horizontal, + Split::Tree => Axis::Horizontal, Split::Network => Axis::Vertical, }, ); @@ -1577,39 +1000,3 @@ pub fn build( state.repaint = state.interaction.reconcile(&p.layout.widgets); (scaled, p.layout) } -fn request_details(out: &mut Vec, r: &Exchange) { - multiline( - out, - &format!( - "#{} {} {}\n{} · {} · {} ms\nStatus: {}", - r.id, - r.method, - r.path, - r.timestamp, - r.origin, - r.elapsed_ms, - r.status.map_or("pending".into(), |s| s.to_string()) - ), - TEXT, - ); - for (title, body) in [ - ("REQUEST HEADERS", &r.request_headers), - ("REQUEST BODY", &r.request), - ("RESPONSE HEADERS", &r.response_headers), - ("RESPONSE BODY", &r.response), - ] { - out.push(Line::new("", MUTED)); - let mut l = Line::new(title, MUTED); - l.divider = Some(Divider::Accent); - out.push(l); - multiline( - out, - if body.is_empty() { "(empty)" } else { body }, - if title == "RESPONSE BODY" && r.status.is_some_and(|s| s >= 400) { - ERROR - } else { - TEXT - }, - ); - } -} diff --git a/src/developer_tools/console.rs b/src/developer_tools/console.rs new file mode 100644 index 0000000..b79b08f --- /dev/null +++ b/src/developer_tools/console.rs @@ -0,0 +1,198 @@ +use super::*; + +pub(super) fn build( + p: &mut Painter, + state: &mut DeveloperToolsState, + records: &[Exchange], + viewport: Rect, +) { + let x = viewport.x; + let w = viewport.width; + let bottom = viewport.height - 22.0; + + p.fill(Rect::new(x, 32.0, w, 30.0), BAR); + p.enabled_button( + Rect::new(x + 3.0, 32.0, 62.0, 29.0), + "Clear", + Action::ClearConsole, + records.iter().any(|r| { + r.id > state.console_after + && matches!(r.method.as_str(), "PUT" | "POST" | "DELETE") + && !r.path.ends_with("/events") + }), + ); + p.button( + Rect::new(x + 68.0, 32.0, 106.0, 29.0), + if state.follow { + "Follow: on" + } else { + "Follow: off" + }, + Action::Follow, + state.follow, + ); + p.button( + Rect::new(x + 181.0, 32.0, 119.0, 29.0), + "Reset session", + Action::ResetSession, + false, + ); + if w > 520.0 { + p.label(x + 309.0, 39.0, "app.main · live objects", MUTED, false); + } + let mut lines = Vec::new(); + for r in records.iter().filter(|r| { + r.id > state.console_after + && (r.method == "PUT" || r.method == "POST" || r.method == "DELETE") + && !r.path.ends_with("/events") + }) { + let kind = if r.path.ends_with("/repl") { + if r.method == "DELETE" { + "RESET SESSION" + } else { + "REPL · live objects" + } + } else if r.path.ends_with("/evaluate") { + "ISOLATED API EVALUATION" + } else if r.method == "PUT" { + "FULL SOURCE · live program" + } else { + "CLEAR DOCUMENT" + }; + let local = r.origin == "Console" && r.path.ends_with("/repl") && r.method == "POST"; + if !local { + let mut title = Line::new( + format!("#{} {} {} {}", r.id, r.timestamp, r.origin, kind), + MUTED, + ); + title.divider = Some(Divider::Accent); + title.action = Some(Action::Request(r.id)); + lines.push(title); + } + for (i, line) in r.request.split('\n').enumerate() { + lines.push(Line::new( + format!("{}{line}", if i == 0 { "> " } else { " " }), + TEXT, + )); + } + let color = if r.status.is_some_and(|s| s >= 400) { + ERROR + } else { + OK + }; + if let Some(status) = r.status { + if !local { + lines.push(Line::new( + format!("‹ {status} · {} ms", r.elapsed_ms), + color, + )); + } + console_result(&mut lines, &r.response, color); + } else { + lines.push(Line::new(" Executing…", MUTED)); + } + let mut separator = Line::new("", MUTED); + separator.divider = Some(Divider::Neutral); + lines.push(separator); + } + if lines.is_empty() { + multiline( + &mut lines, + "Endor · live session\nCreate objects here; call page.render(...) to display.\n", + MUTED, + ); + } + use std::hash::{Hash, Hasher}; + let mut hash = std::collections::hash_map::DefaultHasher::new(); + for line in &lines { + line.value.hash(&mut hash); + } + let stamp = hash.finish(); + let output_changed = stamp != state.console_stamp; + state.console_stamp = stamp; + let first_input = lines.len(); + let area = Rect::new(x + 4.0, 63.0, w - 5.0, (bottom - 88.0).max(1.0)); + let mut input = state.input.clone(); + if state.focus == Some(Focus::Input) { + input.insert_str(state.cursor, &state.preedit); + } + for (i, line) in input.split('\n').enumerate() { + let mut line = Line::new(format!("{}{line}", if i == 0 { "> " } else { " " }), TEXT); + line.selected = state.selection && state.focus == Some(Focus::Input); + lines.push(line); + } + let prefix = text::measure("> ", TextStyle::code()).width; + let before = &state.input[..state.cursor]; + let row = before.matches('\n').count() as f32; + let column = before.rsplit('\n').next().unwrap_or(""); + let caret_x = + prefix + text::measure(&format!("{column}{}", state.preedit), TextStyle::code()).width; + let caret_y = (first_input as f32 + row) * ROW; + let follow = state.follow && output_changed && !state.reveal_input; + if state.reveal_input { + let s = &mut state.scroll[Region::Transcript as usize]; + s.y = s.y.clamp( + (caret_y + ROW + 16.0 - area.height).max(0.0).min(caret_y), + caret_y, + ); + s.x = s.x.clamp((caret_x + 24.0 - area.width).max(0.0), caret_x); + state.reveal_input = false; + } + p.lines(area, Region::Transcript, &lines, state, follow); + let scroll = state.scroll[Region::Transcript as usize]; + let origin = Point::new( + area.x + 8.0 + prefix - scroll.x, + area.y + 5.0 + first_input as f32 * ROW - scroll.y, + ); + p.layout.input = Some(ConsoleInputLayout { + viewport: area, + origin, + }); + let input_top = origin.y.max(area.y); + let input_bottom = + (origin.y + state.input.split('\n').count() as f32 * ROW).min(area.y + area.height - 9.0); + if input_bottom > input_top { + p.ui.inline_input( + Rect::new( + area.x, + input_top, + (area.width - 9.0).max(0.0), + input_bottom - input_top, + ), + Action::Focus(Focus::Input), + ); + } + if state.focus == Some(Focus::Input) { + p.ui.list.push(DrawCommand::PushClip(Rect::new( + area.x, + area.y, + (area.width - 9.0).max(0.0), + (area.height - 9.0).max(0.0), + ))); + p.fill( + Rect::new( + area.x + 8.0 + caret_x - scroll.x, + origin.y + row * ROW + 1.0, + 1.0, + ROW - 2.0, + ), + TEXT, + ); + p.ui.list.push(DrawCommand::PopClip); + } + p.label( + x + 10.0, + bottom - 23.0, + if state.input_error.is_empty() { + "Enter: run · Shift+Enter: newline · ⌘/Ctrl+↑↓: history" + } else { + &state.input_error + }, + if state.input_error.is_empty() { + MUTED + } else { + ERROR + }, + false, + ); +} diff --git a/src/developer_tools/network.rs b/src/developer_tools/network.rs new file mode 100644 index 0000000..7636693 --- /dev/null +++ b/src/developer_tools/network.rs @@ -0,0 +1,119 @@ +use super::*; + +pub(super) fn build( + p: &mut Painter, + state: &mut DeveloperToolsState, + records: &[Exchange], + viewport: Rect, +) { + let x = viewport.x; + let w = viewport.width; + let bottom = viewport.height - 22.0; + + p.fill(Rect::new(x, 32.0, w, 30.0), BAR); + p.enabled_button( + Rect::new(x + 3.0, 32.0, 62.0, 29.0), + "Clear", + Action::ClearNetwork, + records.iter().any(|r| r.id > state.network_after), + ); + let filter_area = Rect::new(x + 72.0, 35.0, (w - 85.0).max(100.0), 24.0); + p.ui.text_field( + filter_area, + Action::Focus(Focus::Filter), + state.focus == Some(Focus::Filter), + ); + p.label( + filter_area.x + 7.0, + filter_area.y + 3.0, + &format!( + "Filter: {}{}", + state.filter, + if state.focus == Some(Focus::Filter) { + "│" + } else { + "" + } + ), + MUTED, + false, + ); + + p.label( + x + 10.0, + 69.0, + "METHOD STATUS TIME PATH", + MUTED, + true, + ); + let divider = 92.0 + (bottom - 92.0) * state.network_fraction; + let filter = state.filter.to_lowercase(); + let visible = records + .iter() + .filter(|r| r.id > state.network_after) + .filter(|r| { + format!( + "{} {} {}", + r.method, + r.path, + r.status.map_or("pending".into(), |s| s.to_string()) + ) + .to_lowercase() + .contains(&filter) + }) + .collect::>(); + let mut lines = Vec::new(); + for r in &visible { + let mut l = Line::new( + format!( + "{:<7} {:<6} {:>5} ms {}", + r.method, + r.status.map_or("…".into(), |s| s.to_string()), + r.elapsed_ms, + r.path + ), + if r.status.is_some_and(|s| s >= 400) { + ERROR + } else { + TEXT + }, + ); + l.selected = state.selected_request == Some(r.id); + l.action = Some(Action::Request(r.id)); + lines.push(l); + } + if lines.is_empty() { + lines.push(Line::new( + "No matching requests. Listening to Tessera's local API.", + MUTED, + )); + } + p.lines( + Rect::new(x + 1.0, 92.0, w - 2.0, divider - 92.0), + Region::Requests, + &lines, + state, + false, + ); + p.split(Rect::new(x + 1.0, divider, w - 2.0, 1.0), Split::Network); + let selected = records + .iter() + .find(|r| Some(r.id) == state.selected_request); + let mut details = Vec::new(); + if let Some(r) = selected { + request_details(&mut details, r); + } else { + multiline( + &mut details, + "Select a request to inspect headers and bodies.\n\nCapture scope: 127.0.0.1:4737 in this Tessera process.\n80 most recent requests; body previews up to 32 KiB.\nOnly Content-Type and Content-Length request headers are recorded.\nExternal browser / OS traffic is not captured.", + MUTED, + ); + } + p.lines( + Rect::new(x + 1.0, divider + 5.0, w - 2.0, bottom - divider - 5.0), + Region::Details, + &details, + state, + false, + ); +} diff --git a/src/developer_tools/painter.rs b/src/developer_tools/painter.rs new file mode 100644 index 0000000..a118412 --- /dev/null +++ b/src/developer_tools/painter.rs @@ -0,0 +1,308 @@ +use super::*; + +pub(super) struct Painter { + pub(super) ui: Ui, + pub(super) layout: DeveloperToolsLayout, +} +impl Painter { + pub(super) fn fill(&mut self, r: Rect, c: Color) { + self.ui.fill(r, c); + } + pub(super) fn label(&mut self, x: f32, y: f32, value: &str, c: Color, code: bool) { + self.ui.label(x, y, value, c, code); + } + pub(super) fn button(&mut self, r: Rect, label: &str, action: Action, active: bool) { + let role = match action { + Action::Follow | Action::Snapshot | Action::Cloud => Role::Toggle, + _ => Role::Button, + }; + self.ui + .button(r, action, Button::new(label).selected(active).role(role)); + } + pub(super) fn enabled_button(&mut self, r: Rect, label: &str, action: Action, enabled: bool) { + self.ui + .button(r, action, Button::new(label).enabled(enabled)); + } + pub(super) fn split(&mut self, r: Rect, kind: Split) { + // Paint and register splitters after all pane content, in one overlay pass. + self.layout.splits.push((r, kind)); + } + pub(super) fn lines( + &mut self, + area: Rect, + region: Region, + lines: &[Line], + state: &mut DeveloperToolsState, + follow: bool, + ) { + let content_h = lines.len() as f32 * ROW + 12.0; + use std::hash::{Hash, Hasher}; + let mut hash = std::collections::hash_map::DefaultHasher::new(); + for line in lines { + line.value.hash(&mut hash); + } + let fingerprint = hash.finish(); + let cache = &mut state.widths[region as usize]; + let content_w = match *cache { + Some((previous, width)) if previous == fingerprint => width, + _ => { + let width = lines + .iter() + .map(|line| text::measure(&line.value, TextStyle::code()).width + 20.0) + .fold(0.0_f32, f32::max); + *cache = Some((fingerprint, width)); + width + } + }; + let s = &mut state.scroll[region as usize]; + s.y = if follow { + (content_h - area.height + 12.0).max(0.0) + } else { + s.y.clamp(0.0, (content_h - area.height + 12.0).max(0.0)) + }; + s.x = s.x.clamp(0.0, (content_w - area.width + 12.0).max(0.0)); + self.ui.list.push(DrawCommand::PushClip(Rect::new( + area.x, + area.y, + (area.width - 9.0).max(0.0), + (area.height - 9.0).max(0.0), + ))); + if region == Region::Source { + let values = lines + .iter() + .map(|line| line.value.chars().skip(7).collect::()) + .collect::>(); + state.source_selection.set_lines(&values); + self.layout.source = Some(( + area, + Point::new( + area.x + 8.0 - s.x + text::measure(" ", TextStyle::code()).width, + area.y + 5.0 - s.y, + ), + values, + )); + } + let first = (s.y / ROW).floor() as usize; + let count = (area.height / ROW).ceil() as usize + 2; + for (i, line) in lines.iter().enumerate().skip(first).take(count) { + let y = area.y + 5.0 + i as f32 * ROW - s.y; + if let Some(divider) = line.divider { + self.fill( + Rect::new(area.x, y, area.width, 1.0), + match divider { + Divider::Accent => self.ui.theme.accent, + Divider::Neutral => BORDER, + }, + ); + } + let paint = Rect::new(area.x, y, area.width, ROW); + if let Some(action) = &line.action { + let top = y.max(area.y); + let bottom = (y + ROW).min(area.y + area.height - 9.0); + if bottom > top { + self.ui.row( + paint, + Rect::new(area.x, top, area.width - 9.0, bottom - top), + action.clone(), + line.selected, + ); + } + } else if line.selected { + self.fill(paint, self.ui.theme.soft()); + } + if region == Region::Source + && let Some((_, origin, values)) = &self.layout.source + && let Some((left, right)) = state.source_selection.span( + i, + state.selection && state.focus == Some(Focus::Source), + values, + ) + { + self.fill( + Rect::new(origin.x + left, y, right - left, ROW), + self.ui.theme.hover(), + ); + } + self.label(area.x + 8.0 - s.x, y, &line.value, line.color, true); + } + self.ui.list.push(DrawCommand::PopClip); + self.layout + .regions + .push((area, region, content_w, content_h)); + self.scrollbar(area, region, false, s.y, content_h); + self.scrollbar(area, region, true, s.x, content_w); + } + pub(super) fn scrollbar( + &mut self, + r: Rect, + region: Region, + h: bool, + offset: f32, + content: f32, + ) { + let viewport = if h { r.width } else { r.height } - 10.0; + if content <= viewport || viewport <= 0.0 { + return; + } + let length = (viewport * viewport / content).max(24.0).min(viewport); + let travel = (viewport - length).max(1.0); + let maximum = (content - viewport).max(1.0); + let position = (offset / maximum).clamp(0.0, 1.0) * travel; + let track = if h { + Rect::new(r.x, r.y + r.height - 8.0, viewport, 8.0) + } else { + Rect::new(r.x + r.width - 8.0, r.y, 8.0, viewport) + }; + self.fill(track, BAR); + let thumb = if h { + Rect::new(track.x + position, track.y + 1.0, length, 6.0) + } else { + Rect::new(track.x + 1.0, track.y + position, 6.0, length) + }; + self.ui.scrollbar( + thumb, + WidgetId::new(("scroll", region, h)), + if h { Axis::Horizontal } else { Axis::Vertical }, + ); + self.layout + .thumbs + .push((thumb, region, h, maximum / travel)); + } +} +#[derive(Clone)] +pub(super) struct Line { + pub(super) value: String, + pub(super) color: Color, + pub(super) divider: Option, + pub(super) selected: bool, + pub(super) action: Option, +} +#[derive(Clone, Copy)] +pub(super) enum Divider { + Accent, + Neutral, +} +impl Line { + pub(super) fn new(value: impl Into, color: Color) -> Self { + Self { + value: value.into(), + color, + divider: None, + selected: false, + action: None, + } + } +} +pub(super) fn multiline(out: &mut Vec, value: &str, color: Color) { + out.extend( + value + .replace('\t', " ") + .split('\n') + .map(|s| Line::new(s, color)), + ); +} +pub(super) fn console_result(out: &mut Vec, body: &str, color: Color) { + if let Ok(value) = serde_json::from_str::(body) { + if value["execution"] == "repl" { + out.push(Line::new( + if value["rendered"] == true { + format!( + "Rendered revision {} · session {}", + value["revision"], value["session"] + ) + } else { + format!( + "Session {} · retained in memory; page unchanged", + value["session"] + ) + }, + MUTED, + )); + } + if value["session_reset"] == true { + multiline( + out, + value["message"] + .as_str() + .or(value["error"].as_str()) + .unwrap_or("Session reset"), + color, + ); + return; + } + if let Some(result) = value["value"].as_str() { + multiline(out, result, color); + return; + } + if let Some(diagnostics) = value["diagnostics"].as_array() + && !diagnostics.is_empty() + { + for d in diagnostics { + if let Some(line) = d["line"].as_u64() { + out.push(Line::new( + format!("Error · line {line}, column {}", d["column"]), + color, + )); + } + multiline( + out, + d["message"].as_str().unwrap_or("Execution failed"), + color, + ); + } + return; + } + if let Some(error) = value["error"].as_str() { + multiline(out, error, color); + return; + } + if let Some(revision) = value["revision"].as_u64() { + out.push(Line::new(format!("Committed revision {revision}"), color)); + return; + } + } + multiline(out, body, color); +} +pub(super) fn flatten<'a>(node: &'a CodeNode, depth: usize, out: &mut Vec<(usize, &'a CodeNode)>) { + out.push((depth, node)); + for child in &node.children { + flatten(child, depth + 1, out); + } +} + +pub(super) fn request_details(out: &mut Vec, r: &Exchange) { + multiline( + out, + &format!( + "#{} {} {}\n{} · {} · {} ms\nStatus: {}", + r.id, + r.method, + r.path, + r.timestamp, + r.origin, + r.elapsed_ms, + r.status.map_or("pending".into(), |s| s.to_string()) + ), + TEXT, + ); + for (title, body) in [ + ("REQUEST HEADERS", &r.request_headers), + ("REQUEST BODY", &r.request), + ("RESPONSE HEADERS", &r.response_headers), + ("RESPONSE BODY", &r.response), + ] { + out.push(Line::new("", MUTED)); + let mut l = Line::new(title, MUTED); + l.divider = Some(Divider::Accent); + out.push(l); + multiline( + out, + if body.is_empty() { "(empty)" } else { body }, + if title == "RESPONSE BODY" && r.status.is_some_and(|s| s >= 400) { + ERROR + } else { + TEXT + }, + ); + } +} diff --git a/src/developer_tools/selection.rs b/src/developer_tools/selection.rs new file mode 100644 index 0000000..784bd9f --- /dev/null +++ b/src/developer_tools/selection.rs @@ -0,0 +1,151 @@ +//! Read-only, grapheme-aware source selection, independent of console editing. +use crate::{ + geometry::Point, + text::{self, TextStyle}, +}; +use unicode_segmentation::UnicodeSegmentation; + +#[derive(Debug, Default)] +pub(super) struct SourceSelection { + lines: Vec, + anchor: (usize, usize), + end: (usize, usize), + pub dragging: bool, +} +impl SourceSelection { + pub fn set_lines(&mut self, lines: &[String]) { + if self.lines != lines { + self.lines = lines.to_vec(); + self.anchor = (0, 0); + self.end = (0, 0); + self.dragging = false; + } + } + fn locate(p: Point, origin: Point, lines: &[String]) -> (usize, usize) { + let row = (((p.y - origin.y) / super::ROW).floor().max(0.0) as usize) + .min(lines.len().saturating_sub(1)); + let Some(line) = lines.get(row) else { + return (0, 0); + }; + let x = (p.x - origin.x).max(0.0); + let mut byte = line.len(); + for (i, g) in line.grapheme_indices(true) { + let left = text::measure(&line[..i], TextStyle::code()).width; + let right = text::measure(&line[..i + g.len()], TextStyle::code()).width; + if x < (left + right) * 0.5 { + byte = i; + break; + } + } + (row, byte) + } + pub fn begin(&mut self, p: Point, origin: Point, lines: &[String]) { + self.anchor = Self::locate(p, origin, lines); + self.end = self.anchor; + self.dragging = true; + } + pub fn extend(&mut self, p: Point, origin: Point, lines: &[String]) { + self.end = Self::locate(p, origin, lines); + } + pub fn navigate( + &mut self, + key: &winit::keyboard::NamedKey, + shift: bool, + command: bool, + ) -> Option { + use winit::keyboard::NamedKey::*; + let (mut row, mut byte) = self.end; + let line = self.lines.get(row)?; + match key { + ArrowLeft => { + if byte > 0 { + byte = line[..byte] + .grapheme_indices(true) + .next_back() + .map_or(0, |(i, _)| i); + } else if row > 0 { + row -= 1; + byte = self.lines[row].len(); + } + } + ArrowRight => { + if byte < line.len() { + byte += line[byte..].graphemes(true).next().map_or(0, str::len); + } else if row + 1 < self.lines.len() { + row += 1; + byte = 0; + } + } + ArrowUp | ArrowDown => { + let col = line[..byte].graphemes(true).count(); + row = if *key == ArrowUp { + row.saturating_sub(1) + } else { + (row + 1).min(self.lines.len() - 1) + }; + byte = self.lines[row] + .graphemes(true) + .take(col) + .map(str::len) + .sum(); + } + Home => { + if command { + row = 0; + } + byte = 0; + } + End => { + if command { + row = self.lines.len() - 1; + } + byte = self.lines[row].len(); + } + _ => return None, + } + self.end = (row, byte); + if !shift { + self.anchor = self.end; + } + Some(row) + } + fn range(&self, all: bool) -> ((usize, usize), (usize, usize)) { + if all { + return ( + (0, 0), + ( + self.lines.len().saturating_sub(1), + self.lines.last().map_or(0, String::len), + ), + ); + } + (self.anchor.min(self.end), self.anchor.max(self.end)) + } + pub fn text(&self, all: bool) -> String { + let (a, b) = self.range(all); + self.lines + .iter() + .enumerate() + .filter(|(i, _)| *i >= a.0 && *i <= b.0) + .map(|(i, line)| { + let start = if i == a.0 { a.1 } else { 0 }; + let end = if i == b.0 { b.1 } else { line.len() }; + line.get(start..end).unwrap_or("").to_owned() + }) + .collect::>() + .join("\n") + } + pub fn span(&self, row: usize, all: bool, lines: &[String]) -> Option<(f32, f32)> { + let (a, b) = self.range(all); + if row < a.0 || row > b.0 || a == b { + return None; + } + let line = lines.get(row)?; + let start = if row == a.0 { a.1 } else { 0 }; + let end = if row == b.0 { b.1 } else { line.len() }; + Some(( + text::measure(line.get(..start)?, TextStyle::code()).width, + text::measure(line.get(..end)?, TextStyle::code()).width, + )) + } +} diff --git a/src/developer_tools/settings.rs b/src/developer_tools/settings.rs new file mode 100644 index 0000000..724df03 --- /dev/null +++ b/src/developer_tools/settings.rs @@ -0,0 +1,63 @@ +use super::*; + +pub(super) fn build(p: &mut Painter, state: &mut DeveloperToolsState, viewport: Rect) { + let x = viewport.x; + let w = viewport.width; + + p.label(x + 24.0, 62.0, "Appearance", TEXT, false); + p.label( + x + 24.0, + 90.0, + "Accent color · tabs, focus and execution separators", + MUTED, + false, + ); + for (i, color) in PALETTE.iter().enumerate() { + let r = Rect::new(x + 24.0 + i as f32 * 44.0, 122.0, 32.0, 32.0); + p.ui.swatch(r, Action::Theme(i), *color, *color == state.accent); + } + p.label( + x + 24.0, + 176.0, + "Custom hex · Enter to apply (bright colors adjusted)", + MUTED, + false, + ); + let custom = Rect::new(x + 24.0, 204.0, 200.0, 32.0); + p.ui.text_field( + custom, + Action::Focus(Focus::Custom), + state.focus == Some(Focus::Custom), + ); + p.label( + custom.x + 10.0, + custom.y + 6.0, + &format!( + "{}{}", + state.custom, + if state.focus == Some(Focus::Custom) { + "│" + } else { + "" + } + ), + TEXT, + true, + ); + + if !state.input_error.is_empty() { + p.label(x + 24.0, 245.0, &state.input_error, ERROR, false); + } + p.fill(Rect::new(x + 24.0, 285.0, w - 48.0, 1.0), BORDER); + p.label(x + 24.0, 305.0, "Theme preview", TEXT, false); + p.fill(Rect::new(x + 24.0, 340.0, w - 48.0, 2.0), state.accent); + p.label(x + 32.0, 353.0, "› \"Hello\"", TEXT, true); + p.label(x + 32.0, 380.0, "‹ \"Hello\"", OK, true); + p.label( + x + 24.0, + 430.0, + "Settings last for this window session. Source is not persisted.", + MUTED, + false, + ); +} diff --git a/src/developer_tools/sources.rs b/src/developer_tools/sources.rs new file mode 100644 index 0000000..eb6bc42 --- /dev/null +++ b/src/developer_tools/sources.rs @@ -0,0 +1,145 @@ +use super::*; + +pub(super) fn build( + p: &mut Painter, + state: &mut DeveloperToolsState, + doc: &RenderDocument, + viewport: Rect, +) { + let x = viewport.x; + let w = viewport.width; + let width = viewport.x + viewport.width; + let bottom = viewport.height - 22.0; + + p.fill(Rect::new(x, 32.0, w, 30.0), BAR); + p.button( + Rect::new(x + 3.0, 32.0, 139.0, 29.0), + "Current snapshot", + Action::Snapshot, + !state.cloud, + ); + p.button( + Rect::new(x + 145.0, 32.0, 107.0, 29.0), + "Cloud files", + Action::Cloud, + state.cloud, + ); + if state.cloud { + p.button( + Rect::new(width - 92.0, 32.0, 88.0, 29.0), + "Refresh", + Action::CloudRefresh, + false, + ); + let tree = state.tree_width.clamp(120.0, (w - 180.0).max(120.0)); + let code_x = x + tree + 2.0; + p.label(x + 10.0, 69.0, "CLOUD FILES", MUTED, false); + let file_lines = state + .cloud_sources + .files + .iter() + .map(|file| { + let mut l = Line::new(&file.path, TEXT); + l.action = Some(Action::CloudOpen(file.id.clone())); + l + }) + .collect::>(); + p.lines( + Rect::new(x + 1.0, 92.0, tree - 1.0, bottom - 92.0), + Region::Tree, + &file_lines, + state, + false, + ); + p.split(Rect::new(x + tree, 63.0, 1.0, bottom - 63.0), Split::Tree); + let mut source_lines = Vec::new(); + if let Some(source) = &state.cloud_sources.opened { + p.label(code_x + 10.0, 69.0, &source.path, MUTED, false); + for (i, line) in source.code.split('\n').enumerate() { + source_lines.push(Line::new(format!("{:>5} {}", i + 1, line), TEXT)); + } + } else { + multiline( + &mut source_lines, + "No cloud file open\n\nNo provider connected.\nCloud files will open here as full source.\nCurrent snapshot contains your last successful script.", + MUTED, + ); + } + if !state.cloud_sources.error.is_empty() { + source_lines.push(Line::new(&state.cloud_sources.error, ERROR)); + } + p.lines( + Rect::new(code_x, 92.0, width - code_x, bottom - 92.0), + Region::Source, + &source_lines, + state, + false, + ); + } else { + let tree = state.tree_width.clamp(120.0, (w - 180.0).max(120.0)); + let code_x = x + tree + 2.0; + p.label(x + 10.0, 69.0, "ASTER AST", MUTED, false); + p.label( + code_x + 10.0, + 69.0, + &format!( + "{} · {} lines · read only", + if doc + .root + .as_ref() + .is_some_and(|root| root.kind == "repl-session") + { + "Console transcript" + } else { + "app.main.endor" + }, + doc.source.split('\n').count() + ), + MUTED, + false, + ); + let mut tree_lines = Vec::new(); + if let Some(root) = &doc.root { + let mut flat = Vec::new(); + flatten(root, 0, &mut flat); + for (depth, n) in flat { + let mut l = Line::new(format!("{}{} {}", " ".repeat(depth), n.kind, n.name), TEXT); + l.selected = state.selected_node_id.as_deref() == Some(&n.id); + l.action = Some(Action::Node(n.id.clone(), n.start_line)); + tree_lines.push(l); + } + } else { + tree_lines.push(Line::new("No submitted source", MUTED)); + } + p.lines( + Rect::new(x + 1.0, 92.0, tree - 1.0, bottom - 92.0), + Region::Tree, + &tree_lines, + state, + false, + ); + p.split(Rect::new(x + tree, 63.0, 1.0, bottom - 63.0), Split::Tree); + let selected = state + .selected_node_id + .as_deref() + .and_then(|id| doc.node(id)); + let code_lines = doc + .source + .replace("\r\n", "\n") + .split('\n') + .enumerate() + .map(|(i, s)| { + let mut l = Line::new(format!("{:>5} {}", i + 1, s.replace('\t', " ")), TEXT); + l.selected = selected.is_some_and(|n| i + 1 >= n.start_line && i < n.end_line); + l + }) + .collect::>(); + p.lines( + Rect::new(code_x, 92.0, width - code_x, bottom - 92.0), + Region::Source, + &code_lines, + state, + false, + ); + } +} diff --git a/src/scene.rs b/src/scene.rs index 46637bb..1a70570 100644 --- a/src/scene.rs +++ b/src/scene.rs @@ -1,6 +1,6 @@ use crate::{ activity::Activity, - developer_tools::{self, DeveloperToolsLayout, DeveloperToolsState}, + developer_tools::{self, DeveloperToolsLayout, Workspace as DeveloperToolsState}, display_list::DisplayList, document::RenderDocument, }; @@ -19,19 +19,54 @@ pub fn build_scene( activity: &Activity, tab_chrome: crate::tabs::Chrome, ) -> DisplayScene { + let tool = document + .frame + .tabs + .iter() + .find(|tab| tab.active) + .and_then(|tab| tab.developer_tool.as_deref()); + developer_tools_state.select(tool); + developer_tools_state.document_updated(document); let mut display_list = document.display_list(scale).unwrap_or_else(|_| { RenderDocument::default() .display_list(scale) .expect("default document is valid") }); let offset = crate::tabs::height(&document.frame.tabs); - display_list.translate(crate::geometry::Point::new(0.0, offset * scale)); + let inset = crate::tabs::INSET; + let left = crate::tabs::RAIL; + let content = crate::geometry::Rect::new( + left * scale, + offset * scale, + (width as f32 - (left + inset) * scale).max(0.0), + (height as f32 - (offset + inset) * scale).max(0.0), + ); + display_list.translate(crate::geometry::Point::new(left * scale, offset * scale)); + let mut framed = DisplayList::default(); + use crate::display_list::DrawCommand; + framed.push(DrawCommand::Clear(crate::system_ui::theme::BAR)); + framed.push(DrawCommand::PushClip(content)); + for command in display_list.commands() { + if let DrawCommand::Clear(color) = command { + framed.push(DrawCommand::FillRoundedRect { + rect: content, + radius: 10.0 * scale, + color: *color, + }); + } else { + framed.push(command.clone()); + } + } + framed.push(DrawCommand::PopClip); + let mut display_list = framed; crate::tabs::draw( tab_chrome.first, &mut display_list, &document.frame.tabs, width as f32 / scale, scale, + tab_chrome.pointer, + &tab_chrome.visual, ); if let Some(index) = tab_chrome.insertion { crate::tabs::draw_insertion( @@ -43,15 +78,22 @@ pub fn build_scene( scale, ); } - let (developer_list, developer_layout) = developer_tools::build( - width, - height, + let (mut developer_list, mut developer_layout) = developer_tools::build( + width.saturating_sub(((left + inset) * scale) as u32), + height.saturating_sub(((offset + inset) * scale) as u32), scale, document, developer_tools_state, activity, ); - display_list.extend(developer_list); + developer_list.translate(crate::geometry::Point::new(left * scale, offset * scale)); + developer_layout.translate(left, offset); + if tool == Some("New tab") { + developer_layout = DeveloperToolsLayout::default(); + crate::system_ui::start_page::draw(&mut display_list, content, scale); + } else { + display_list.extend(developer_list); + } DisplayScene { display_list, diff --git a/src/system_ui/mod.rs b/src/system_ui/mod.rs index 9f75629..7e2eaf2 100644 --- a/src/system_ui/mod.rs +++ b/src/system_ui/mod.rs @@ -5,3 +5,5 @@ pub mod theme; mod widgets; pub use interaction::{Axis, Cursor, Interaction, Role, Target, VisualState, WidgetId}; pub use widgets::{Button, Ui}; + +pub mod start_page; diff --git a/src/system_ui/start_page.rs b/src/system_ui/start_page.rs new file mode 100644 index 0000000..9e36c93 --- /dev/null +++ b/src/system_ui/start_page.rs @@ -0,0 +1,51 @@ +//! The start page introduces Endor without advertising unimplemented controls. +use super::theme; +use crate::{ + display_list::{DisplayList, DrawCommand}, + geometry::{Point, Rect}, + text::{self, TextStyle}, +}; +pub fn draw(list: &mut DisplayList, content: Rect, scale: f32) { + list.push(DrawCommand::FillRoundedRect { + rect: content, + radius: 12.0 * scale, + color: theme::BG, + }); + let width = content.width / scale; + let left = content.x / scale + ((width - 620.0) / 2.0).max(32.0); + let top = content.y / scale + (content.height / scale * 0.20).clamp(40.0, 150.0); + list.push(DrawCommand::PushClip(content)); + for (dy, value, size, color) in [ + (0.0, "Endor", 3, theme::TEXT), + (82.0, "对象、代码与界面,在同一个世界里。", 1, theme::TEXT), + (118.0, "构建可交互的页面、工具与工作流。", 1, theme::MUTED), + (210.0, "对象拥有身份", 1, theme::TEXT), + ( + 240.0, + "代码与可视界面共享同一份对象,改变对象,也改变它的展示。", + 1, + theme::MUTED, + ), + (310.0, "从这里开始", 1, theme::TEXT), + ( + 340.0, + "这是一个新的页面。你的程序可以在这里组织和呈现对象。", + 1, + theme::MUTED, + ), + ( + 430.0, + "正在构建:持久对象与多模态模型连接。", + 1, + theme::MUTED, + ), + ] { + list.push(DrawCommand::Text { + origin: Point::new(left * scale, (top + dy) * scale), + value: text::fit(value, (width - 64.0).min(620.0), TextStyle::ui(size)), + style: TextStyle::ui(size).scaled(scale), + color, + }); + } + list.push(DrawCommand::PopClip); +} diff --git a/src/system_ui/theme.rs b/src/system_ui/theme.rs index 7c710aa..096d444 100644 --- a/src/system_ui/theme.rs +++ b/src/system_ui/theme.rs @@ -1,7 +1,7 @@ use crate::display_list::Color; pub const BG: Color = Color::rgb(255, 255, 255); -pub const BAR: Color = Color::rgb(243, 245, 248); -pub const BORDER: Color = Color::rgb(214, 220, 229); +pub const BAR: Color = Color::rgb(245, 245, 245); +pub const BORDER: Color = Color::rgb(226, 226, 226); pub const TEXT: Color = Color::rgb(35, 42, 53); pub const MUTED: Color = Color::rgb(103, 114, 130); pub const ERROR: Color = Color::rgb(175, 42, 49); diff --git a/src/tabs.rs b/src/tabs.rs index 67a2564..a6492c0 100644 --- a/src/tabs.rs +++ b/src/tabs.rs @@ -6,26 +6,42 @@ use crate::{ }; use serde::{Deserialize, Serialize}; -pub const HEIGHT: f32 = 36.0; -#[derive(Clone, Copy, Default)] +mod motion; +pub use motion::{Motion, Visual}; + +pub const HEIGHT: f32 = 44.0; +pub const INSET: f32 = 8.0; +const NEW_TAB_SPACE: f32 = 44.0; +pub const RAIL: f32 = 56.0; +pub const CONTROLS: f32 = if cfg!(target_os = "macos") { 86.0 } else { 0.0 }; +pub const LEADING: f32 = CONTROLS + 12.0; +#[derive(Clone, Default)] pub struct Chrome { pub first: Option, pub insertion: Option, + pub pointer: Option, + pub visual: Visual, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct Tab { pub id: u64, pub title: String, pub active: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub developer_tool: Option, } pub fn height(tabs: &[Tab]) -> f32 { - if tabs.is_empty() { 0.0 } else { HEIGHT } + let _ = tabs; + HEIGHT + INSET } /// A drag viewport is presentation state only: it never activates a Page. fn visible_range(tabs: &[Tab], width: f32, start: Option) -> std::ops::Range { - let count = (((width - 64.0) / 160.0).floor().max(1.0) as usize).min(tabs.len()); + let count = (((width - LEADING - NEW_TAB_SPACE - 64.0) / 160.0) + .floor() + .max(1.0) as usize) + .min(tabs.len()); if tabs.len() <= count { return 0..tabs.len(); } @@ -37,13 +53,13 @@ fn visible_range(tabs: &[Tab], width: f32, start: Option) -> std::ops::Ra } pub fn edge_direction(tabs: &[Tab], width: f32, start: Option, point: Point) -> i8 { - if point.x < 0.0 || point.x >= width || point.y < 0.0 || point.y >= HEIGHT { + if point.x < LEADING || point.x >= width - NEW_TAB_SPACE || point.y < 0.0 || point.y >= HEIGHT { return 0; } let visible = visible_range(tabs, width, start); - if point.x < 32.0 && visible.start > 0 { + if point.x >= LEADING && point.x < LEADING + 32.0 && visible.start > 0 { -1 - } else if point.x >= width - 32.0 && visible.end < tabs.len() { + } else if point.x >= width - NEW_TAB_SPACE - 32.0 && visible.end < tabs.len() { 1 } else { 0 @@ -80,19 +96,25 @@ fn items(start: Option, tabs: &[Tab], width: f32) -> Vec> { let count = visible.len(); let overflow = count < tabs.len(); let margin = if overflow { 32.0 } else { 0.0 }; - let tab_width = ((width - margin * 2.0) / count as f32).min(220.0); + let width = width - NEW_TAB_SPACE; + let tab_width = ((width - LEADING - margin * 2.0) / count as f32).min(240.0); let mut result = Vec::new(); if overflow && start > 0 { result.push(Item { tab: &tabs[start - 1], - rect: Rect::new(0.0, 0.0, 32.0, HEIGHT), + rect: Rect::new(LEADING, 0.0, 32.0, HEIGHT), navigation: Some("‹"), }); } for (index, tab) in tabs[start..start + count].iter().enumerate() { result.push(Item { tab, - rect: Rect::new(margin + index as f32 * tab_width, 0.0, tab_width, HEIGHT), + rect: Rect::new( + LEADING + margin + index as f32 * tab_width, + 0.0, + tab_width, + HEIGHT, + ), navigation: None, }); } @@ -105,17 +127,29 @@ fn items(start: Option, tabs: &[Tab], width: f32) -> Vec> { } result } +fn pill_rect(rect: Rect) -> Rect { + Rect::new(rect.x + 2.0, 4.0, (rect.width - 4.0).max(0.0), HEIGHT - 8.0) +} fn close_rect(rect: Rect) -> Rect { - Rect::new(rect.x + rect.width - 27.0, 5.0, 24.0, 26.0) + Rect::new( + rect.x + rect.width - 28.0, + (HEIGHT - 20.0) / 2.0, + 20.0, + 20.0, + ) } pub fn hit(start: Option, tabs: &[Tab], width: f32, point: Point) -> Option<(u64, bool)> { - if point.x < 0.0 || point.x >= width || point.y < 0.0 || point.y >= height(tabs) { + if point.x < LEADING + || point.x >= width - NEW_TAB_SPACE + || point.y < 0.0 + || point.y >= height(tabs) + { return None; } items(start, tabs, width) .into_iter() - .find(|item| item.rect.contains(point)) + .find(|item| pill_rect(item.rect).contains(point)) .map(|item| { ( item.tab.id, @@ -124,6 +158,18 @@ pub fn hit(start: Option, tabs: &[Tab], width: f32, point: Point) -> Opti }) } +pub fn navigation_hit( + start: Option, + tabs: &[Tab], + width: f32, + point: Point, +) -> Option<(u64, bool)> { + items(start, tabs, width) + .iter() + .find(|item| item.navigation.is_some() && pill_rect(item.rect).contains(point)) + .map(|item| (item.tab.id, false)) +} + /// Gap index in the complete published order, including overflow-hidden tabs. pub fn insertion_index( start: Option, @@ -131,7 +177,7 @@ pub fn insertion_index( width: f32, point: Point, ) -> Option { - if point.x < 0.0 || point.x >= width || point.y < 0.0 || point.y >= HEIGHT { + if point.x < LEADING || point.x >= width - NEW_TAB_SPACE || point.y < 0.0 || point.y >= HEIGHT { return None; } if tabs.is_empty() { @@ -159,9 +205,9 @@ pub fn drag_anchor( id: u64, point: Point, ) -> Option { - let item = items(start, tabs, width) - .into_iter() - .find(|item| item.tab.id == id && item.navigation.is_none() && item.rect.contains(point))?; + let item = items(start, tabs, width).into_iter().find(|item| { + item.tab.id == id && item.navigation.is_none() && pill_rect(item.rect).contains(point) + })?; (!close_rect(item.rect).contains(point)).then(|| Point::new(point.x - item.rect.x, point.y)) } @@ -178,7 +224,7 @@ pub fn draw_insertion( .filter(|item| item.navigation.is_none()) .collect(); let x = if tabs.is_empty() { - Some(8.0) + Some(LEADING + 8.0) } else { visible .iter() @@ -207,10 +253,23 @@ pub fn draw_insertion( } } -pub fn draw(start: Option, list: &mut DisplayList, tabs: &[Tab], width: f32, scale: f32) { - if tabs.is_empty() { - return; - } +pub fn plus_rect(start: Option, tabs: &[Tab], width: f32) -> Rect { + let end = items(start, tabs, width) + .iter() + .map(|item| item.rect.x + item.rect.width) + .fold(LEADING, f32::max); + Rect::new((end + 4.0).min(width - 38.0), 4.0, 32.0, HEIGHT - 8.0) +} + +pub fn draw( + start: Option, + list: &mut DisplayList, + tabs: &[Tab], + width: f32, + scale: f32, + pointer: Option, + visual: &Visual, +) { let scaled = |rect: Rect| { Rect::new( rect.x * scale, @@ -222,48 +281,99 @@ pub fn draw(start: Option, list: &mut DisplayList, tabs: &[Tab], width: f list.push(DrawCommand::FillRoundedRect { rect: scaled(Rect::new(0.0, 0.0, width, HEIGHT)), radius: 0.0, - color: Color::rgb(230, 232, 236), + color: crate::system_ui::theme::BAR, }); - for item in items(start, tabs, width) { - if item.tab.active && item.navigation.is_none() { + let plus = visual.plus; + if pointer.is_some_and(|point| plus.contains(point)) { + list.push(DrawCommand::FillRoundedRect { + rect: scaled(plus), + radius: 8.0 * scale, + color: crate::system_ui::theme::mix( + crate::system_ui::theme::BAR, + crate::system_ui::theme::TEXT, + 0.06, + ), + }); + } + list.push(DrawCommand::Text { + origin: Point::new( + (plus.x + (plus.width - crate::text::measure("+", TextStyle::ui(1)).width) / 2.0) + * scale, + ((HEIGHT - 20.0) / 2.0) * scale, + ), + value: "+".into(), + style: TextStyle::ui(1).scaled(scale), + color: crate::system_ui::theme::MUTED, + }); + for mut item in items(start, tabs, width) { + if item.navigation.is_none() { + item.rect = visual.rect(item.tab.id, item.rect); + } + let pill = scaled(pill_rect(item.rect)); + let active = item.tab.active && item.navigation.is_none(); + if active || pointer.is_some_and(|point| pill_rect(item.rect).contains(point)) { list.push(DrawCommand::FillRoundedRect { - rect: scaled(item.rect), - radius: 0.0, - color: Color::rgb(250, 250, 249), - }); - list.push(DrawCommand::FillRoundedRect { - rect: scaled(Rect::new(item.rect.x, HEIGHT - 2.0, item.rect.width, 2.0)), - radius: 0.0, - color: Color::rgb(79, 111, 219), + rect: pill, + radius: 8.0 * scale, + color: if active { + crate::system_ui::theme::BG + } else { + crate::system_ui::theme::mix( + crate::system_ui::theme::BAR, + crate::system_ui::theme::TEXT, + 0.06, + ) + }, }); } let title = item.navigation.unwrap_or(&item.tab.title); let text_rect = Rect::new( - item.rect.x + 10.0, + item.rect.x + 14.0, 4.0, (item.rect.width - if item.navigation.is_some() { 12.0 } else { - 42.0 + 46.0 }) .max(0.0), 28.0, ); list.push(DrawCommand::PushClip(scaled(text_rect))); list.push(DrawCommand::Text { - origin: Point::new((item.rect.x + 10.0) * scale, 8.0 * scale), + origin: Point::new( + (item.rect.x + 14.0) * scale, + ((HEIGHT - 20.0) / 2.0) * scale, + ), value: title.into(), style: TextStyle::ui(1).scaled(scale), - color: Color::rgb(36, 36, 40), + color: crate::system_ui::theme::TEXT, }); list.push(DrawCommand::PopClip); - if item.navigation.is_none() { + if item.navigation.is_none() && item.rect.width >= 44.0 { + if pointer.is_some_and(|point| close_rect(item.rect).contains(point)) { + list.push(DrawCommand::FillRoundedRect { + rect: scaled(close_rect(item.rect)), + radius: 5.0 * scale, + color: crate::system_ui::theme::mix( + crate::system_ui::theme::BAR, + crate::system_ui::theme::TEXT, + 0.12, + ), + }); + } list.push(DrawCommand::Text { - origin: Point::new((item.rect.x + item.rect.width - 22.0) * scale, 8.0 * scale), + origin: Point::new( + (close_rect(item.rect).x + + (close_rect(item.rect).width + - crate::text::measure("×", TextStyle::ui(1)).width) + / 2.0) + * scale, + ((HEIGHT - 20.0) / 2.0) * scale, + ), value: "×".into(), style: TextStyle::ui(1).scaled(scale), - color: Color::rgb(80, 82, 88), + color: crate::system_ui::theme::MUTED, }); } } @@ -278,30 +388,83 @@ mod tests { .map(|id| Tab { id, title: id.to_string(), + developer_tool: None, active: id == 1, }) .collect(); assert_eq!( - insertion_index(None, &tabs, 1000.0, Point::new(10.0, 18.0)), + insertion_index( + None, + &tabs, + LEADING + NEW_TAB_SPACE + 1000.0, + Point::new(LEADING + 10.0, 18.0) + ), Some(0) ); assert_eq!( - insertion_index(None, &tabs, 1000.0, Point::new(215.0, 18.0)), + insertion_index( + None, + &tabs, + LEADING + NEW_TAB_SPACE + 1000.0, + Point::new(LEADING + 215.0, 18.0) + ), Some(1) ); assert_eq!( - insertion_index(None, &tabs, 1000.0, Point::new(990.0, 18.0)), + insertion_index( + None, + &tabs, + LEADING + NEW_TAB_SPACE + 1000.0, + Point::new(LEADING + 990.0, 18.0) + ), Some(4) ); - assert!(drag_anchor(None, &tabs, 1000.0, 1, Point::new(200.0, 18.0)).is_none()); - assert!(drag_anchor(None, &tabs, 1000.0, 1, Point::new(25.0, 18.0)).is_some()); + assert!( + drag_anchor( + None, + &tabs, + LEADING + NEW_TAB_SPACE + 1000.0, + 1, + Point::new(LEADING + 220.0, 18.0) + ) + .is_none() + ); + assert!( + drag_anchor( + None, + &tabs, + LEADING + NEW_TAB_SPACE + 1000.0, + 1, + Point::new(LEADING + 25.0, 18.0) + ) + .is_some() + ); assert_eq!( - insertion_index(None, &tabs, 1000.0, Point::new(20.0, HEIGHT)), + insertion_index( + None, + &tabs, + LEADING + NEW_TAB_SPACE + 1000.0, + Point::new(LEADING + 20.0, HEIGHT) + ), None ); - assert!(drag_anchor(None, &tabs, 400.0, 3, Point::new(390.0, 18.0)).is_none()); + assert!( + drag_anchor( + None, + &tabs, + LEADING + NEW_TAB_SPACE + 400.0, + 3, + Point::new(LEADING + 390.0, 18.0) + ) + .is_none() + ); assert_eq!( - insertion_index(None, &[], 720.0, Point::new(20.0, 18.0)), + insertion_index( + None, + &[], + LEADING + NEW_TAB_SPACE + 720.0, + Point::new(LEADING + 20.0, 18.0) + ), Some(0) ); } @@ -312,10 +475,11 @@ mod tests { .map(|id| Tab { id, title: format!("Page {id}"), + developer_tool: None, active: id == 32, }) .collect(); - let visible = items(None, &tabs, 720.0); + let visible = items(None, &tabs, LEADING + NEW_TAB_SPACE + 720.0); assert!( visible .iter() @@ -323,19 +487,30 @@ mod tests { ); for item in &visible { let point = Point::new(item.rect.x + 12.0, 18.0); - assert_eq!(hit(None, &tabs, 720.0, point), Some((item.tab.id, false))); + assert_eq!( + hit(None, &tabs, LEADING + NEW_TAB_SPACE + 720.0, point), + Some((item.tab.id, false)) + ); } let tab = visible.iter().find(|item| item.tab.active).unwrap(); assert_eq!( hit( None, &tabs, - 720.0, + LEADING + NEW_TAB_SPACE + 720.0, Point::new(tab.rect.x + tab.rect.width - 15.0, 18.0) ), Some((32, true)) ); - assert_eq!(hit(None, &tabs, 720.0, Point::new(50.0, HEIGHT)), None); + assert_eq!( + hit( + None, + &tabs, + LEADING + NEW_TAB_SPACE + 720.0, + Point::new(LEADING + 50.0, HEIGHT) + ), + None + ); } #[test] @@ -344,21 +519,22 @@ mod tests { .map(|id| Tab { id, title: id.to_string(), + developer_tool: None, active: id == 32, }) .collect(); - let width = 720.0; + let width = LEADING + NEW_TAB_SPACE + 720.0; let mut start = None; while let Some(next) = scroll_start(&tabs, width, start, 1) { start = Some(next); } assert_eq!(visible_range(&tabs, width, start), 60..64); assert_eq!( - edge_direction(&tabs, width, start, Point::new(710.0, 18.0)), + edge_direction(&tabs, width, start, Point::new(LEADING + 710.0, 18.0)), 0 ); assert_eq!( - insertion_index(start, &tabs, width, Point::new(710.0, 18.0)), + insertion_index(start, &tabs, width, Point::new(LEADING + 710.0, 18.0)), Some(64) ); while let Some(next) = scroll_start(&tabs, width, start, -1) { @@ -366,11 +542,11 @@ mod tests { } assert_eq!(visible_range(&tabs, width, start), 0..4); assert_eq!( - edge_direction(&tabs, width, start, Point::new(10.0, 18.0)), + edge_direction(&tabs, width, start, Point::new(LEADING + 10.0, 18.0)), 0 ); assert_eq!( - insertion_index(start, &tabs, width, Point::new(10.0, 18.0)), + insertion_index(start, &tabs, width, Point::new(LEADING + 10.0, 18.0)), Some(0) ); assert_eq!( @@ -393,26 +569,53 @@ mod tests { .map(|id| Tab { id, title: id.to_string(), + developer_tool: None, active: id == 1, }) .collect(); let start = Some(4); assert_eq!( - hit(start, &tabs, 720.0, Point::new(40.0, 18.0)), + hit( + start, + &tabs, + LEADING + NEW_TAB_SPACE + 720.0, + Point::new(LEADING + 40.0, 18.0) + ), Some((5, false)) ); assert_eq!( - insertion_index(start, &tabs, 720.0, Point::new(40.0, 18.0)), + insertion_index( + start, + &tabs, + LEADING + NEW_TAB_SPACE + 720.0, + Point::new(LEADING + 40.0, 18.0) + ), Some(4) ); - assert_eq!(visible_range(&tabs[..3], 720.0, start), 0..3); - assert_eq!(visible_range(&tabs, 1920.0, start), 0..8); assert_eq!( - edge_direction(&tabs, 720.0, start, Point::new(710.0, HEIGHT)), + visible_range(&tabs[..3], LEADING + NEW_TAB_SPACE + 720.0, start), + 0..3 + ); + assert_eq!( + visible_range(&tabs, LEADING + NEW_TAB_SPACE + 1920.0, start), + 0..8 + ); + assert_eq!( + edge_direction( + &tabs, + LEADING + NEW_TAB_SPACE + 720.0, + start, + Point::new(LEADING + 710.0, HEIGHT) + ), 0 ); assert_eq!( - edge_direction(&tabs, 720.0, start, Point::new(725.0, 18.0)), + edge_direction( + &tabs, + LEADING + NEW_TAB_SPACE + 720.0, + start, + Point::new(LEADING + 725.0, 18.0) + ), 0 ); } diff --git a/src/tabs/motion.rs b/src/tabs/motion.rs new file mode 100644 index 0000000..3e63512 --- /dev/null +++ b/src/tabs/motion.rs @@ -0,0 +1,82 @@ +//! Interpolated chrome geometry is shared by painting and pointer hit testing. +use super::*; +use std::time::{Duration, Instant}; + +#[derive(Clone, Default)] +pub struct Visual { + pub rects: Vec<(u64, Rect)>, + pub plus: Rect, +} +impl Visual { + pub fn rect(&self, id: u64, fallback: Rect) -> Rect { + self.rects + .iter() + .find(|(key, _)| *key == id) + .map_or(fallback, |(_, rect)| *rect) + } + pub fn hit(&self, point: Point) -> Option<(u64, bool)> { + self.rects + .iter() + .rev() + .find(|(_, rect)| pill_rect(*rect).contains(point)) + .map(|(id, rect)| (*id, close_rect(*rect).contains(point))) + } +} +#[derive(Default)] +pub struct Motion { + target: Visual, + from: Visual, + current: Visual, + started: Option, + pub next_frame: Option, +} +fn blend(a: Rect, b: Rect, t: f32) -> Rect { + Rect::new( + a.x + (b.x - a.x) * t, + a.y + (b.y - a.y) * t, + a.width + (b.width - a.width) * t, + a.height + (b.height - a.height) * t, + ) +} +impl Motion { + pub fn sample(&mut self, tabs: &[Tab], first: Option, width: f32) -> Visual { + let now = Instant::now(); + let target = Visual { + rects: items(first, tabs, width) + .iter() + .filter(|item| item.navigation.is_none()) + .map(|item| (item.tab.id, item.rect)) + .collect(), + plus: plus_rect(first, tabs, width), + }; + if self.target.rects != target.rects || self.target.plus != target.plus { + self.from = self.current.clone(); + self.target = target; + self.started = Some(now); + } + let t = self.started.map_or(1.0, |start| { + (now.duration_since(start).as_secs_f32() / 0.20).min(1.0) + }); + let eased = 1.0 - (1.0 - t).powi(3); + let rects = self + .target + .rects + .iter() + .map(|(id, target)| { + let from = self.from.rects.iter().find(|(key, _)| key == id).map_or( + Rect::new(target.x, target.y, 0.0, target.height), + |(_, rect)| *rect, + ); + (*id, blend(from, *target, eased)) + }) + .collect(); + let plus = if self.from.plus.width > 0.0 { + blend(self.from.plus, self.target.plus, eased) + } else { + self.target.plus + }; + self.current = Visual { rects, plus }; + self.next_frame = (t < 1.0).then(|| now + Duration::from_millis(16)); + self.current.clone() + } +} diff --git a/src/ui_bridge.rs b/src/ui_bridge.rs index 678009b..3641ae2 100644 --- a/src/ui_bridge.rs +++ b/src/ui_bridge.rs @@ -1,4 +1,5 @@ //! Tessera's native visual protocols over Aster's managed object graph. +mod developer_pages; use std::sync::{Arc, Mutex}; use anyhow::{Result, bail}; @@ -1832,6 +1833,10 @@ fn project_tabs( tabs.push(crate::tabs::Tab { id, title, + developer_tool: match vm.get_attr(page, "_tesseraTool") { + Ok(Value::Text(name)) => Some(name), + _ => None, + }, active: active == Some(page), }); } @@ -2276,6 +2281,12 @@ impl UiBridge { vm.release_value(result?)?; return Ok(()); } + if let Some(name) = input.kind.strip_prefix("developer-open:") { + let function = object(Some(&vm.get_attr(self.ui, "developerTools")?))?; + let page = vm.call(function, &[Value::Text(name.into())])?; + vm.release_value(page)?; + return Ok(()); + } if matches!( input.kind.as_str(), "tab-activate" | "tab-close" | "tab-detach" | "tab-drop" @@ -2299,8 +2310,11 @@ impl UiBridge { state.desktop, state.mounts.ordered(), state.layout.viewport.map(|size| crate::layout::Size { - width: size.width, - height: (size.height - crate::tabs::height(&state.last_tabs)).max(0.0), + width: (size.width - crate::tabs::RAIL - crate::tabs::INSET).max(0.0), + height: (size.height + - crate::tabs::INSET + - crate::tabs::height(&state.last_tabs)) + .max(0.0), }), ) }; @@ -2734,7 +2748,8 @@ impl UiBridge { }; let full_viewport = layout.viewport; if let Some(size) = &mut layout.viewport { - size.height = (size.height - crate::tabs::height(&tabs)).max(0.0); + size.width = (size.width - crate::tabs::RAIL - crate::tabs::INSET).max(0.0); + size.height = (size.height - crate::tabs::height(&tabs) - crate::tabs::INSET).max(0.0); } { let state = self @@ -3391,6 +3406,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { crate::event_dispatch::install(vm, roots, event_type, token_type, function_type)?; crate::protocols::install(vm, roots, desktop, ui)?; let tasks = crate::tasks::install(vm, roots, ui, desktop)?; + developer_pages::install(vm, ui, &session)?; Ok(UiBridge { ui, session, tasks }) } diff --git a/src/ui_bridge/developer_pages.rs b/src/ui_bridge/developer_pages.rs new file mode 100644 index 0000000..0b0c686 --- /dev/null +++ b/src/ui_bridge/developer_pages.rs @@ -0,0 +1,72 @@ +//! Developer workspaces are managed Pages, using the same roots and placement API. +use super::*; + +#[derive(Debug)] +struct OpenTool { + session: Shared, +} +impl NativeCallable for OpenTool { + fn arity(&self) -> Option { + Some(endor_core::CallArity::exact(1)) + } + fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result { + let [Value::Text(name)] = args else { + return Err(invalid("developerTools requires a tool name")); + }; + if !["Console", "Sources", "Network", "Settings", "New tab"].contains(&name.as_str()) { + return Err(invalid("unknown developer tool")); + } + let nodes = self + .session + .lock() + .map_err(|_| invalid("UI session poisoned"))? + .nodes + .clone(); + for weak in nodes { + if name == "New tab" { + break; + } + if let Some(page) = cx.upgrade(weak)? + && cx.store().get_attr(page, "_tesseraTool").ok() == Some(Value::Text(name.clone())) + { + show(cx, page, &self.session)?; + let window = { + let state = self + .session + .lock() + .map_err(|_| invalid("UI session poisoned"))?; + let id = page_window(cx.store(), &state, page)? + .ok_or(invalid("tool is not open"))?; + object(Some( + &cx.store() + .get_attr(state.presentations[&id].desktop, "window")?, + ))? + }; + crate::window::enqueue(cx, window, crate::window::Action::Focus)?; + return Ok(NativeResult::Return(Value::Object(page))); + } + } + let page = new_typed_block( + cx, + Data::new(Kind::Page, vec![], 0.0, 0.0), + &self.session, + None, + )?; + cx.set_attr(page, "title", Value::Text(name.clone()))?; + cx.set_attr(page, "_tesseraTool", Value::Text(name.clone()))?; + show(cx, page, &self.session)?; + Ok(NativeResult::Return(Value::Object(page))) + } +} +pub(super) fn install(vm: &mut Vm, ui: ObjectRef, session: &Shared) -> Result<()> { + let function = vm.allocate(ContextNativeFunction::new( + "developerTools", + OpenTool { + session: session.clone(), + }, + vec![], + ))?; + vm.set_attr(ui, "developerTools", Value::Object(function))?; + vm.release(function)?; + Ok(()) +}