feat: unify window chrome and managed workspace tabs

This commit is contained in:
2026-10-24 19:36:01 +08:00
parent 3220544777
commit 656522edfe
18 changed files with 2389 additions and 1409 deletions
+16 -497
View File
@@ -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<std::io::Cursor<Vec<u8>>>, 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, &notify);
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,
&notify,
);
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<Server>,
address: SocketAddr,
api: Option<thread::JoinHandle<()>>,
}
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 = &current.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::<serde_json::Value>(&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::<serde_json::Value>(&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, &notify);
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(&current);
}
assert_eq!(worker.repl.as_ref().unwrap().0, 3);
namespace.write().unwrap().clear().unwrap();
worker.refresh_viewport(&namespace, &notify);
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, &notify, true);
let value = worker
.repl
.as_mut()
.unwrap()
.1
.execute("count.value", "app.main")
.unwrap()
.value;
assert_eq!(value, "0");
worker.refresh_viewport(&namespace, &notify);
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;
+481
View File
@@ -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, &notify);
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,
&notify,
);
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<Server>,
address: SocketAddr,
api: Option<thread::JoinHandle<()>>,
}
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 = &current.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::<serde_json::Value>(&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::<serde_json::Value>(&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, &notify);
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(&current);
}
assert_eq!(worker.repl.as_ref().unwrap().0, 3);
namespace.write().unwrap().clear().unwrap();
worker.refresh_viewport(&namespace, &notify);
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, &notify, true);
let value = worker
.repl
.as_mut()
.unwrap()
.1
.execute("count.value", "app.main")
.unwrap()
.value;
assert_eq!(value, "0");
worker.refresh_viewport(&namespace, &notify);
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");
}
+164 -23
View File
@@ -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<String>,
children: std::collections::BTreeMap<u64, TesseraApp>,
proxy: EventLoopProxy<AppEvent>,
@@ -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<Point>,
tab_drop_index: Option<usize>,
tab_first: Option<usize>,
tab_motion: crate::tabs::Motion,
tab_visual: crate::tabs::Visual,
tab_scroll_hover: Option<TabScrollHover>,
window_order: Vec<u64>,
guest_focus: Option<crate::events::Input>,
@@ -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<crate::events::Input> {
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<Point> {
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,
&current.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<AppEvent> 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<AppEvent> 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<AppEvent> 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<AppEvent> 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<AppEvent> 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<AppEvent> 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 {
+202 -815
View File
File diff suppressed because it is too large Load Diff
+198
View File
@@ -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,
);
}
+119
View File
@@ -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::<Vec<_>>();
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,
);
}
+308
View File
@@ -0,0 +1,308 @@
use super::*;
pub(super) struct Painter {
pub(super) ui: Ui<Action>,
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::<String>())
.collect::<Vec<_>>();
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<Divider>,
pub(super) selected: bool,
pub(super) action: Option<Action>,
}
#[derive(Clone, Copy)]
pub(super) enum Divider {
Accent,
Neutral,
}
impl Line {
pub(super) fn new(value: impl Into<String>, color: Color) -> Self {
Self {
value: value.into(),
color,
divider: None,
selected: false,
action: None,
}
}
}
pub(super) fn multiline(out: &mut Vec<Line>, 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<Line>, body: &str, color: Color) {
if let Ok(value) = serde_json::from_str::<serde_json::Value>(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<Line>, 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
},
);
}
}
+151
View File
@@ -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<String>,
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<usize> {
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::<Vec<_>>()
.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,
))
}
}
+63
View File
@@ -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,
);
}
+145
View File
@@ -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::<Vec<_>>();
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::<Vec<_>>();
p.lines(
Rect::new(code_x, 92.0, width - code_x, bottom - 92.0),
Region::Source,
&code_lines,
state,
false,
);
}
}
+48 -6
View File
@@ -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,
+2
View File
@@ -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;
+51
View File
@@ -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);
}
+2 -2
View File
@@ -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);
+266 -63
View File
@@ -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<usize>,
pub insertion: Option<usize>,
pub pointer: Option<Point>,
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<String>,
}
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<usize>) -> std::ops::Range<usize> {
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<usize>) -> std::ops::Ra
}
pub fn edge_direction(tabs: &[Tab], width: f32, start: Option<usize>, 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<usize>, tabs: &[Tab], width: f32) -> Vec<Item<'_>> {
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<usize>, tabs: &[Tab], width: f32) -> Vec<Item<'_>> {
}
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<usize>, 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<usize>, tabs: &[Tab], width: f32, point: Point) -> Opti
})
}
pub fn navigation_hit(
start: Option<usize>,
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<usize>,
@@ -131,7 +177,7 @@ pub fn insertion_index(
width: f32,
point: Point,
) -> Option<usize> {
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<Point> {
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<usize>, list: &mut DisplayList, tabs: &[Tab], width: f32, scale: f32) {
if tabs.is_empty() {
return;
}
pub fn plus_rect(start: Option<usize>, 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<usize>,
list: &mut DisplayList,
tabs: &[Tab],
width: f32,
scale: f32,
pointer: Option<Point>,
visual: &Visual,
) {
let scaled = |rect: Rect| {
Rect::new(
rect.x * scale,
@@ -222,48 +281,99 @@ pub fn draw(start: Option<usize>, 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
);
}
+82
View File
@@ -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<Instant>,
pub next_frame: Option<Instant>,
}
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<usize>, 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()
}
}
+19 -3
View File
@@ -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<ObjectRef>) -> Result<UiBridge> {
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 })
}
+72
View File
@@ -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<endor_core::CallArity> {
Some(endor_core::CallArity::exact(1))
}
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
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(())
}