feat: unify window chrome and managed workspace tabs
This commit is contained in:
+16
-497
@@ -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, ¬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<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 = ¤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::<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, ¬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;
|
||||
|
||||
@@ -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<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 = ¤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::<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, ¬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");
|
||||
}
|
||||
+164
-23
@@ -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,
|
||||
¤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<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
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user