From 76f640bcd9afc70147a57782d69d97bd164264d5 Mon Sep 17 00:00:00 2001 From: Miguel Lopez Date: Tue, 14 Jul 2026 02:17:21 -0400 Subject: [PATCH] Refresh Rust tooling baseline --- native/deny.toml | 1 - native/unterm/examples/diff_smoke.rs | 109 ++++- native/unterm/examples/dump_debug.rs | 37 +- native/unterm/examples/dump_selection.rs | 27 +- native/unterm/examples/dump_term.rs | 13 +- native/unterm/src/agentview.rs | 67 ++- native/unterm/src/browser.rs | 194 ++++++-- native/unterm/src/clock.rs | 17 +- native/unterm/src/control.rs | 87 +++- native/unterm/src/debugger.rs | 555 ++++++++++++++++------- native/unterm/src/diff.rs | 238 ++++++++-- native/unterm/src/editops.rs | 53 ++- native/unterm/src/editorview.rs | 38 +- native/unterm/src/gpu.rs | 21 +- native/unterm/src/highlight.rs | 164 +++++-- native/unterm/src/input.rs | 531 +++++++++++++++++----- native/unterm/src/iosurface.rs | 4 +- native/unterm/src/lib.rs | 231 ++++++---- native/unterm/src/markdown.rs | 12 +- native/unterm/src/palette.rs | 18 +- native/unterm/src/panel.rs | 343 +++++++++++--- native/unterm/src/popup.rs | 316 +++++++++++-- native/unterm/src/quads.rs | 34 +- native/unterm/src/renderer.rs | 148 +++++- native/unterm/src/sdb/mod.rs | 37 +- native/unterm/src/sdb/value.rs | 5 +- native/unterm/src/sdb/wire.rs | 13 +- native/unterm/src/sessions.rs | 27 +- native/unterm/src/surface/d3d.rs | 15 +- native/unterm/src/term.rs | 28 +- native/unterm/src/unity.rs | 8 +- 31 files changed, 2649 insertions(+), 742 deletions(-) diff --git a/native/deny.toml b/native/deny.toml index 9fa6235..09d1767 100644 --- a/native/deny.toml +++ b/native/deny.toml @@ -2,7 +2,6 @@ version = 2 yanked = "deny" unmaintained = "workspace" -unsound = "all" ignore = [] [bans] diff --git a/native/unterm/examples/diff_smoke.rs b/native/unterm/examples/diff_smoke.rs index 4dbd8d3..15d26b6 100644 --- a/native/unterm/examples/diff_smoke.rs +++ b/native/unterm/examples/diff_smoke.rs @@ -47,7 +47,8 @@ fn main() { index.write().unwrap(); let tree = repo.find_tree(index.write_tree().unwrap()).unwrap(); let sig = git2::Signature::now("t", "t@t").unwrap(); - repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]).unwrap(); + repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]) + .unwrap(); let id = unterm_editor_create(900, 500, 2.0); assert!(id != 0, "editor create failed"); @@ -85,13 +86,22 @@ fn main() { // then render — exercises line_at_y / hunk lookup / tooltip overlay without panic. // scale 2.0 → line_height 40, pad 12: line 1 sits around y≈52..92. let shown = unterm_editor_hover(id, 6.0, 72.0); - assert!(shown, "hover over a modified line's gutter marker should show the tooltip"); + assert!( + shown, + "hover over a modified line's gutter marker should show the tooltip" + ); unterm_editor_render(id); println!("gutter-marker hover + tooltip render OK"); // Moving off the marker hides the tooltip: the first away-hover returns true (a // repaint is needed to clear it), and a second one returns false (nothing shown). - assert!(unterm_editor_hover(id, 400.0, 300.0), "away-hover should request a clear repaint"); - assert!(!unterm_editor_hover(id, 400.0, 300.0), "tooltip should now be hidden"); + assert!( + unterm_editor_hover(id, 400.0, 300.0), + "away-hover should request a clear repaint" + ); + assert!( + !unterm_editor_hover(id, 400.0, 300.0), + "tooltip should now be hidden" + ); unterm_editor_render(id); println!("tooltip hide (hover away) + render OK"); @@ -101,7 +111,10 @@ fn main() { unterm_editor_refresh_diff(id); let deadline = Instant::now() + Duration::from_millis(1500); while Instant::now() < deadline { - assert!(!unterm_editor_poll_diff(id), "unchanged refresh must not report a change"); + assert!( + !unterm_editor_poll_diff(id), + "unchanged refresh must not report a change" + ); std::thread::sleep(Duration::from_millis(10)); } unterm_editor_render(id); @@ -109,15 +122,31 @@ fn main() { // --- hunk_at + STAGE (HEAD base: the marker must SURVIVE staging, hollow) --- let hi = unterm_editor_hunk_at(id, 6.0, 72.0); - assert!(hi >= 0, "hunk_at should find the modified hunk in the gutter"); - assert!(!unterm_editor_hunk_staged(id, hi as u32), "hunk should start unstaged"); + assert!( + hi >= 0, + "hunk_at should find the modified hunk in the gutter" + ); + assert!( + !unterm_editor_hunk_staged(id, hi as u32), + "hunk should start unstaged" + ); println!("hunk_at found hunk {hi} (unstaged)"); - assert!(unterm_editor_stage_hunk(id, hi as u32), "stage_hunk should succeed"); + assert!( + unterm_editor_stage_hunk(id, hi as u32), + "stage_hunk should succeed" + ); // Only hunk here == the whole change, so the index blob should now equal the buffer. let repo2 = git2::Repository::discover(&dir).unwrap(); - let entry = repo2.index().unwrap().get_path(Path::new("foo.cs"), 0).unwrap(); + let entry = repo2 + .index() + .unwrap() + .get_path(Path::new("foo.cs"), 0) + .unwrap(); let staged = String::from_utf8(repo2.find_blob(entry.id).unwrap().content().to_vec()).unwrap(); - assert_eq!(staged, "class A {\n int x = 1;\n int z;\n}\n", "index updated by stage_hunk"); + assert_eq!( + staged, "class A {\n int x = 1;\n int z;\n}\n", + "index updated by stage_hunk" + ); println!("stage_hunk updated the index OK"); // Pick up the refreshed git texts: the hunk is still there (buffer != HEAD) but @@ -126,41 +155,73 @@ fn main() { unterm_editor_render(id); let hi2 = unterm_editor_hunk_at(id, 6.0, 72.0); assert!(hi2 >= 0, "marker must survive staging (HEAD base)"); - assert!(unterm_editor_hunk_staged(id, hi2 as u32), "hunk should now read staged"); + assert!( + unterm_editor_hunk_staged(id, hi2 as u32), + "hunk should now read staged" + ); println!("marker survives staging and reads staged OK"); // --- UNSTAGE: the index goes back to HEAD, the hunk reads unstaged again --- - assert!(unterm_editor_unstage_hunk(id, hi2 as u32), "unstage_hunk should succeed"); + assert!( + unterm_editor_unstage_hunk(id, hi2 as u32), + "unstage_hunk should succeed" + ); let entry = { let repo3 = git2::Repository::discover(&dir).unwrap(); - repo3.index().unwrap().get_path(Path::new("foo.cs"), 0).unwrap() + repo3 + .index() + .unwrap() + .get_path(Path::new("foo.cs"), 0) + .unwrap() }; let repo3 = git2::Repository::discover(&dir).unwrap(); - let unstaged = String::from_utf8(repo3.find_blob(entry.id).unwrap().content().to_vec()).unwrap(); - assert_eq!(unstaged, "class A {\n int x;\n int y;\n}\n", "index restored to HEAD"); + let unstaged = + String::from_utf8(repo3.find_blob(entry.id).unwrap().content().to_vec()).unwrap(); + assert_eq!( + unstaged, "class A {\n int x;\n int y;\n}\n", + "index restored to HEAD" + ); wait_diff(id); unterm_editor_render(id); let hi3 = unterm_editor_hunk_at(id, 6.0, 72.0); - assert!(hi3 >= 0 && !unterm_editor_hunk_staged(id, hi3 as u32), "hunk reads unstaged again"); + assert!( + hi3 >= 0 && !unterm_editor_hunk_staged(id, hi3 as u32), + "hunk reads unstaged again" + ); println!("unstage_hunk restored the index OK"); // --- STAGED-ONLY: stage, then revert the buffer back to HEAD. The change now // lives only in the index (`git diff --cached` shows it) — the editor must keep // showing a (hollow, staged) hunk there and allow unstaging it. - assert!(unterm_editor_stage_hunk(id, hi3 as u32), "re-stage should succeed"); + assert!( + unterm_editor_stage_hunk(id, hi3 as u32), + "re-stage should succeed" + ); wait_diff(id); let head_buf = CString::new("class A {\n int x;\n int y;\n}\n").unwrap(); unsafe { unterm_editor_set_text(id, head_buf.as_ptr()) }; // buffer back at HEAD unterm_editor_render(id); let so = unterm_editor_hunk_at(id, 6.0, 72.0); assert!(so >= 0, "staged-only hunk must still show a marker"); - assert!(unterm_editor_hunk_staged(id, so as u32), "staged-only hunk reads staged"); - assert!(unterm_editor_hover(id, 6.0, 72.0), "staged-only hunk is peekable"); + assert!( + unterm_editor_hunk_staged(id, so as u32), + "staged-only hunk reads staged" + ); + assert!( + unterm_editor_hover(id, 6.0, 72.0), + "staged-only hunk is peekable" + ); unterm_editor_render(id); - assert!(unterm_editor_unstage_hunk(id, so as u32), "unstage staged-only should succeed"); + assert!( + unterm_editor_unstage_hunk(id, so as u32), + "unstage staged-only should succeed" + ); wait_diff(id); unterm_editor_render(id); - assert!(unterm_editor_hunk_at(id, 6.0, 72.0) < 0, "everything clean → no markers"); + assert!( + unterm_editor_hunk_at(id, 6.0, 72.0) < 0, + "everything clean → no markers" + ); println!("staged-only hunk shown, peeked, and unstaged OK"); unterm_editor_destroy(id); @@ -189,7 +250,11 @@ fn main() { let hi2 = unterm_editor_hunk_at(id2, 6.0, 72.0); assert!(hi2 >= 0, "hunk_at should find the modified hunk (revert)"); unterm_editor_revert_hunk(id2, hi2 as u32); - assert_eq!(editor_text(id2), "class B {\n int p;\n}\n", "revert restored the base content"); + assert_eq!( + editor_text(id2), + "class B {\n int p;\n}\n", + "revert restored the base content" + ); println!("revert_hunk restored the base content OK"); // --- pure ADDITION is peekable (VS Code parity: its peek shows the + lines) --- diff --git a/native/unterm/examples/dump_debug.rs b/native/unterm/examples/dump_debug.rs index 8c9800c..cff4e2b 100644 --- a/native/unterm/examples/dump_debug.rs +++ b/native/unterm/examples/dump_debug.rs @@ -24,7 +24,10 @@ fn main() { let mut conn = match sdb::connect_editor(&root) { Ok(c) => c, Err(e) => { - eprintln!("connect failed: {e} (is the Unity editor open under {}?)", root.display()); + eprintln!( + "connect failed: {e} (is the Unity editor open under {}?)", + root.display() + ); std::process::exit(1); } }; @@ -36,7 +39,9 @@ fn main() { // Arm: subscribe to TYPE_LOAD for the target file. Types already loaded resolve // immediately; everything else resolves when the play-mode domain loads them. - let watch = conn.watch_source_files(&[file.clone()]).expect("watch source"); + let watch = conn + .watch_source_files(&[file.clone()]) + .expect("watch source"); println!("watching TYPE_LOAD for {file} (request {watch})"); let mut armed = false; if let Ok(types) = conn.types_for_source_file(&file, true) { @@ -113,7 +118,9 @@ fn try_arm(conn: &mut sdb::Connection, types: &[u32], file: &str, line: i32) -> let name = conn.method_name(method).unwrap_or_default(); match conn.set_breakpoint(method, il) { Ok(req) => { - println!("armed breakpoint at {file}:{line} -> {name}+0x{il:x} (request {req})"); + println!( + "armed breakpoint at {file}:{line} -> {name}+0x{il:x} (request {req})" + ); true } Err(e) => { @@ -123,7 +130,10 @@ fn try_arm(conn: &mut sdb::Connection, types: &[u32], file: &str, line: i32) -> } } None => { - println!("could not resolve {file}:{line} in {} method(s) yet", candidates.len()); + println!( + "could not resolve {file}:{line} in {} method(s) yet", + candidates.len() + ); false } } @@ -170,7 +180,12 @@ fn dump_stop(conn: &mut sdb::Connection, thread: u32) { .enumerate() .filter(|(_, l)| top.il_offset >= l.live_start && top.il_offset < l.live_end) .collect(); - println!("locals ({}/{} in scope at il 0x{:x}):", in_scope.len(), locals.len(), top.il_offset); + println!( + "locals ({}/{} in scope at il 0x{:x}):", + in_scope.len(), + locals.len(), + top.il_offset + ); if in_scope.is_empty() { return; } @@ -193,8 +208,16 @@ fn il_to_source(info: &DebugInfo, il: i32) -> Option { .iter() .filter(|s| !s.is_hidden() && s.il_offset <= il) .max_by_key(|s| s.il_offset)?; - let src = info.sources.get(sp.source_idx as usize).cloned().unwrap_or_default(); - Some(format!("{}:{}", src.rsplit(['/', '\\']).next().unwrap_or(&src), sp.line)) + let src = info + .sources + .get(sp.source_idx as usize) + .cloned() + .unwrap_or_default(); + Some(format!( + "{}:{}", + src.rsplit(['/', '\\']).next().unwrap_or(&src), + sp.line + )) } /// Sanity-check the GET_DEBUG_INFO decoder against a loaded method (mscorlib is diff --git a/native/unterm/examples/dump_selection.rs b/native/unterm/examples/dump_selection.rs index b97d566..27057c6 100644 --- a/native/unterm/examples/dump_selection.rs +++ b/native/unterm/examples/dump_selection.rs @@ -12,7 +12,13 @@ use unterm::*; fn main() { env_logger::try_init().ok(); - let cwd = CString::new(std::env::current_dir().unwrap().to_string_lossy().to_string()).unwrap(); + let cwd = CString::new( + std::env::current_dir() + .unwrap() + .to_string_lossy() + .to_string(), + ) + .unwrap(); let id = unsafe { unterm_create(1000, 600, 2.0, cwd.as_ptr()) }; assert!(id != 0, "create failed"); @@ -34,8 +40,13 @@ fn main() { let mut len = 0usize; let ptr = unsafe { unterm_selection_text(id, &mut len as *mut usize) }; assert!(!ptr.is_null() && len > 0, "no selection text"); - let text = unsafe { CStr::from_ptr(ptr) }.to_string_lossy().into_owned(); - assert!(text.contains("SELECTME"), "selection missing token; got:\n{text}"); + let text = unsafe { CStr::from_ptr(ptr) } + .to_string_lossy() + .into_owned(); + assert!( + text.contains("SELECTME"), + "selection missing token; got:\n{text}" + ); println!("selection round-trip OK ({len} bytes), contains SELECTME"); // Render the highlighted frame for visual inspection. @@ -47,8 +58,14 @@ fn main() { let mut h = 0u32; unsafe { unterm_size(id, &mut w as *mut u32, &mut h as *mut u32) }; let data = unsafe { std::slice::from_raw_parts(px, plen) }; - image::save_buffer("unterm_sel.png", data, w, h, image::ExtendedColorType::Rgba8) - .expect("png save"); + image::save_buffer( + "unterm_sel.png", + data, + w, + h, + image::ExtendedColorType::Rgba8, + ) + .expect("png save"); println!("wrote unterm_sel.png ({w}x{h})"); // Clearing drops the highlight (selection text becomes empty). diff --git a/native/unterm/examples/dump_term.rs b/native/unterm/examples/dump_term.rs index d26e9d3..aa39513 100644 --- a/native/unterm/examples/dump_term.rs +++ b/native/unterm/examples/dump_term.rs @@ -12,7 +12,13 @@ use unterm::*; fn main() { env_logger::try_init().ok(); - let cwd = CString::new(std::env::current_dir().unwrap().to_string_lossy().to_string()).unwrap(); + let cwd = CString::new( + std::env::current_dir() + .unwrap() + .to_string_lossy() + .to_string(), + ) + .unwrap(); let id = unsafe { unterm_create(1000, 600, 2.0, cwd.as_ptr()) }; assert!(id != 0, "create failed"); @@ -44,7 +50,10 @@ fn main() { unsafe { unterm_size(id, &mut w as *mut u32, &mut h as *mut u32) }; let raw = unsafe { unterm_raw_texture(id) }; println!("rendered {w}x{h}; IOSurface MTLTexture ptr = {raw:?}"); - assert!(!raw.is_null(), "IOSurface texture was null (zero-copy target failed)"); + assert!( + !raw.is_null(), + "IOSurface texture was null (zero-copy target failed)" + ); unterm_destroy(id); println!("OK: render pipeline ran on wgpu 29 without panicking"); diff --git a/native/unterm/src/agentview.rs b/native/unterm/src/agentview.rs index d3135ea..c5400b6 100644 --- a/native/unterm/src/agentview.rs +++ b/native/unterm/src/agentview.rs @@ -193,7 +193,11 @@ impl AgentView { /// event, otherwise the id we resumed with (so the title/current-highlight are /// right immediately on resume instead of after init lands). fn effective_id(&self) -> String { - let live = self.driver.as_ref().map(|d| d.session_id()).unwrap_or_default(); + let live = self + .driver + .as_ref() + .map(|d| d.session_id()) + .unwrap_or_default(); if live.is_empty() { self.resume_id.clone() } else { @@ -228,7 +232,12 @@ impl AgentView { let (w, h) = self.panel_size; let scale = self.panel_scale; let bg = self.theme_bg; - let clear = wgpu::Color { r: bg[0], g: bg[1], b: bg[2], a: bg[3] }; + let clear = wgpu::Color { + r: bg[0], + g: bg[1], + b: bg[2], + a: bg[3], + }; let fg = glyphon::Color::rgb(self.theme_fg[0], self.theme_fg[1], self.theme_fg[2]); let family = resolve_family(&self.ui_font); let current = self.effective_id(); @@ -273,7 +282,10 @@ impl AgentView { /// How many of the browser's listed sessions are archived (for the host's /// "Archived" toggle visibility). pub fn browse_archived_count(&self) -> u64 { - self.browser.as_ref().map(|b| b.archived_count() as u64).unwrap_or(0) + self.browser + .as_ref() + .map(|b| b.archived_count() as u64) + .unwrap_or(0) } /// Open a browser row as a host command: the host owns view lifetimes (it @@ -290,7 +302,11 @@ impl AgentView { // Reported in every mode — the session browser raises host commands too // (opening a session from it routes through one). The host drains the // string only on ticks this bit is set. - let host_flag = if self.pending_host_cmd.is_some() { FLAG_HOST_CMD } else { 0 }; + let host_flag = if self.pending_host_cmd.is_some() { + FLAG_HOST_CMD + } else { + 0 + }; // Browser mode: the composer text is the live search query. if self.browsing { let query = self.input.text(); @@ -350,7 +366,11 @@ impl AgentView { self.last_sid = sid; flags |= FLAG_META; } - let mode = self.driver.as_ref().map(|d| d.permission_mode()).unwrap_or_default(); + let mode = self + .driver + .as_ref() + .map(|d| d.permission_mode()) + .unwrap_or_default(); if mode != self.last_mode { self.last_mode = mode; flags |= FLAG_META; @@ -361,7 +381,11 @@ impl AgentView { let now = crate::clock::now_secs(); if now / 60 != self.last_minute { self.last_minute = now / 60; - if self.driver.as_ref().is_some_and(|d| d.has_relative_stamp(now)) { + if self + .driver + .as_ref() + .is_some_and(|d| d.has_relative_stamp(now)) + { flags |= FLAG_DIRTY; } } @@ -484,7 +508,12 @@ impl AgentView { self.theme_fg = [fr, fg, fb]; if let Some(b) = &mut self.browser { b.set_theme( - wgpu::Color { r: br, g: bg, b: bb, a: ba }, + wgpu::Color { + r: br, + g: bg, + b: bb, + a: ba, + }, glyphon::Color::rgb(fr, fg, fb), ); } @@ -605,7 +634,11 @@ impl AgentView { } } pub fn permission_mode(&mut self) -> &CString { - let s = self.driver.as_ref().map(|d| d.permission_mode()).unwrap_or_default(); + let s = self + .driver + .as_ref() + .map(|d| d.permission_mode()) + .unwrap_or_default(); self.mode_snap = clean(s); &self.mode_snap } @@ -625,7 +658,11 @@ impl AgentView { &self.models_snap } pub fn commands(&mut self) -> &CString { - let s = self.driver.as_ref().map(|d| d.commands()).unwrap_or_default(); + let s = self + .driver + .as_ref() + .map(|d| d.commands()) + .unwrap_or_default(); self.commands_snap = clean(s); &self.commands_snap } @@ -789,7 +826,11 @@ impl AgentView { // --- Identity (host owns the picker / persistence) ---------------------- pub fn session_id(&mut self) -> &CString { - let s = self.driver.as_ref().map(|d| d.session_id()).unwrap_or_default(); + let s = self + .driver + .as_ref() + .map(|d| d.session_id()) + .unwrap_or_default(); self.session_id_snap = clean(s); &self.session_id_snap } @@ -799,7 +840,11 @@ impl AgentView { let title = if !self.ai_title.is_empty() { self.ai_title.clone() } else { - let t = self.driver.as_ref().map(|d| d.transcript()).unwrap_or_default(); + let t = self + .driver + .as_ref() + .map(|d| d.transcript()) + .unwrap_or_default(); first_user_line(&t) }; self.title_snap = clean(title); diff --git a/native/unterm/src/browser.rs b/native/unterm/src/browser.rs index 2d6f9af..797f8bd 100644 --- a/native/unterm/src/browser.rs +++ b/native/unterm/src/browser.rs @@ -52,7 +52,7 @@ pub struct BrowserView { /// The query the in-flight/last request was issued for. sent_query: Option, last_gen: u64, // sessions-dir generation the current list reflects - serial: u64, // in-flight sessions request (0 = idle) + serial: u64, // in-flight sessions request (0 = idle) rows: Arc>, loading: bool, show_archived: bool, @@ -92,8 +92,12 @@ impl BrowserView { let swash_cache = SwashCache::new(); let viewport = Viewport::new(&g.device, &g.cache); let mut atlas = TextAtlas::new(&g.device, &g.queue, &g.cache, FORMAT); - let text_renderer = - TextRenderer::new(&mut atlas, &g.device, wgpu::MultisampleState::default(), None); + let text_renderer = TextRenderer::new( + &mut atlas, + &g.device, + wgpu::MultisampleState::default(), + None, + ); let quads = QuadRenderer::new(&g.device, FORMAT); let mesh = MeshRenderer::new(&g.device, FORMAT); Self { @@ -101,7 +105,12 @@ impl BrowserView { height, shared, scale: 1.0, - clear: wgpu::Color { r: 0.05, g: 0.05, b: 0.05, a: 1.0 }, + clear: wgpu::Color { + r: 0.05, + g: 0.05, + b: 0.05, + a: 1.0, + }, text_color: Color::rgb(210, 210, 214), font_family: None, cwd, @@ -252,7 +261,11 @@ impl BrowserView { for r in &self.row_rects { if y >= r.y && y < r.y + r.h && x >= 0.0 && x <= self.width as f32 { // A busy (open-elsewhere) row is disabled: no hover, no icon. - if self.visible.get(r.index).is_some_and(|&i| self.is_busy(&self.rows[i].id)) { + if self + .visible + .get(r.index) + .is_some_and(|&i| self.is_busy(&self.rows[i].id)) + { break; } let on_icon = x >= r.icon[0] @@ -398,12 +411,33 @@ impl BrowserView { let busy = self.is_busy(&sess.id); let hovered = !busy && self.hover.map(|(i, _)| i) == Some(index); if is_current { - quads.push(Quad { x: 0.0, y, w: width, h: row_h, color: accent_bg, radius: 0.0 }); + quads.push(Quad { + x: 0.0, + y, + w: width, + h: row_h, + color: accent_bg, + radius: 0.0, + }); } else if hovered { - quads.push(Quad { x: 0.0, y, w: width, h: row_h, color: hover_bg, radius: 0.0 }); + quads.push(Quad { + x: 0.0, + y, + w: width, + h: row_h, + color: hover_bg, + radius: 0.0, + }); } // Hairline separator. - quads.push(Quad { x: 0.0, y: y + row_h - 1.0, w: width, h: 1.0, color: sep, radius: 0.0 }); + quads.push(Quad { + x: 0.0, + y: y + row_h - 1.0, + w: width, + h: 1.0, + color: sep, + radius: 0.0, + }); // Title (clipped left of the time slot) + right-aligned time. // Archived rows keep full-strength text — dimming them would @@ -412,9 +446,21 @@ impl BrowserView { // disabled, so it's the one thing that greys out. let title_color = if busy { faint } else { t }; let meta_color = if busy { faint } else { dim }; - let title = if sess.title.is_empty() { "(untitled)" } else { &sess.title }; + let title = if sess.title.is_empty() { + "(untitled)" + } else { + &sess.title + }; let title_w = (width - pad_h * 2.0 - time_w - icon_w - 16.0 * s).max(1.0); - let b = shape(fs, title, family, title_size, title_lh, title_color, title_w); + let b = shape( + fs, + title, + family, + title_size, + title_lh, + title_color, + title_w, + ); let clip = [pad_h, y, title_w, row_h]; buffers.push((b, pad_h, y + pad_v, Some(clip), title_color)); @@ -422,7 +468,13 @@ impl BrowserView { let label = crate::clock::format_relative(sess.updated, now); let b = shape(fs, &label, family, meta_size, title_lh, meta_color, time_w); let tx = width - pad_h - icon_w - 8.0 * s - measure_w(&b); - buffers.push((b, tx, y + pad_v + (title_lh - meta_lh) / 2.0, None, meta_color)); + buffers.push(( + b, + tx, + y + pad_v + (title_lh - meta_lh) / 2.0, + None, + meta_color, + )); } if has_snippet { @@ -451,7 +503,12 @@ impl BrowserView { } else { 0.55 }; - let c = [t.r() as f32 / 255.0, t.g() as f32 / 255.0, t.b() as f32 / 255.0, alpha]; + let c = [ + t.r() as f32 / 255.0, + t.g() as f32 / 255.0, + t.b() as f32 / 255.0, + alpha, + ]; push_archive_icon(&mut quads, &mut verts, icon_rect, c, s); } } @@ -487,10 +544,15 @@ impl BrowserView { self.viewport.update( &g.queue, - Resolution { width: self.width, height: self.height }, + Resolution { + width: self.width, + height: self.height, + }, ); - self.quads.prepare(&g.device, &g.queue, (width, height), &quads); - self.mesh.prepare(&g.device, &g.queue, (width, height), &verts); + self.quads + .prepare(&g.device, &g.queue, (width, height), &quads); + self.mesh + .prepare(&g.device, &g.queue, (width, height), &verts); if let Err(e) = self.text_renderer.prepare( &g.device, &g.queue, @@ -528,7 +590,10 @@ impl BrowserView { }); self.quads.render(&mut pass); self.mesh.render(&mut pass); - if let Err(e) = self.text_renderer.render(&self.atlas, &self.viewport, &mut pass) { + if let Err(e) = self + .text_renderer + .render(&self.atlas, &self.viewport, &mut pass) + { log::error!("unterm: browser glyphon render failed: {e}"); } } @@ -552,7 +617,13 @@ fn shape( let mut b = Buffer::new(fs, Metrics::new(size, line_h)); b.set_size(fs, Some(width.max(1.0)), None); b.set_wrap(fs, Wrap::None); - b.set_text(fs, text, &Attrs::new().family(family).color(color), Shaping::Advanced, None); + b.set_text( + fs, + text, + &Attrs::new().family(family).color(color), + Shaping::Advanced, + None, + ); b.shape_until_scroll(fs, false); b } @@ -581,10 +652,24 @@ fn push_archive_icon( let vx = |v: f32| x + v * ux; let vy = |v: f32| y + v * uy; let hbar = |q: &mut Vec, x0: f32, x1: f32, yc: f32| { - q.push(Quad { x: vx(x0) - t / 2.0, y: vy(yc) - t / 2.0, w: (x1 - x0) * ux + t, h: t, color, radius: t / 2.0 }); + q.push(Quad { + x: vx(x0) - t / 2.0, + y: vy(yc) - t / 2.0, + w: (x1 - x0) * ux + t, + h: t, + color, + radius: t / 2.0, + }); }; let vbar = |q: &mut Vec, xc: f32, y0: f32, y1: f32| { - q.push(Quad { x: vx(xc) - t / 2.0, y: vy(y0), w: t, h: (y1 - y0) * uy, color, radius: t / 2.0 }); + q.push(Quad { + x: vx(xc) - t / 2.0, + y: vy(y0), + w: t, + h: (y1 - y0) * uy, + color, + radius: t / 2.0, + }); }; // Lid: a hollow rounded rectangle across the top (design: x3..13, y3..5.6). @@ -601,8 +686,26 @@ fn push_archive_icon( hbar(quads, 3.6 + cr, 12.4 - cr, 13.0); // Bottom-left / bottom-right rounded corners (y is down: 90°=down, 180°=left). let r_px = cr * ux; - push_arc(verts, vx(3.6 + cr), vy(13.0 - cr), r_px, t, 90.0, 180.0, color); - push_arc(verts, vx(12.4 - cr), vy(13.0 - cr), r_px, t, 0.0, 90.0, color); + push_arc( + verts, + vx(3.6 + cr), + vy(13.0 - cr), + r_px, + t, + 90.0, + 180.0, + color, + ); + push_arc( + verts, + vx(12.4 - cr), + vy(13.0 - cr), + r_px, + t, + 0.0, + 90.0, + color, + ); // Handle: a short horizontal stroke centred in the box (design: x6..10, y8.3). hbar(quads, 6.0, 10.0, 8.3); @@ -618,7 +721,16 @@ fn push_tri(verts: &mut Vec, a: [f32; 2], b: [f32; 2], c: [f32; 2], /// Append a stroked circular arc (a quarter-annulus of mid-radius `r`, stroke /// width `thick`) from `a0` to `a1` degrees, centred at (`cx`, `cy`). Angles use /// the screen's y-down convention: 0°=right, 90°=down, 180°=left. -fn push_arc(verts: &mut Vec, cx: f32, cy: f32, r: f32, thick: f32, a0: f32, a1: f32, color: [f32; 4]) { +fn push_arc( + verts: &mut Vec, + cx: f32, + cy: f32, + r: f32, + thick: f32, + a0: f32, + a1: f32, + color: [f32; 4], +) { let ro = r + thick / 2.0; let ri = (r - thick / 2.0).max(0.0); let steps = 8; @@ -648,7 +760,11 @@ mod tests { let (w, h) = (200u32, 56u32); let tex = g.device.create_texture(&wgpu::TextureDescriptor { label: Some("icon-test"), - size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, @@ -689,7 +805,12 @@ mod tests { depth_slice: None, resolve_target: None, ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color { r: 0.14, g: 0.14, b: 0.15, a: 1.0 }), + load: wgpu::LoadOp::Clear(wgpu::Color { + r: 0.14, + g: 0.14, + b: 0.15, + a: 1.0, + }), store: wgpu::StoreOp::Store, }, })], @@ -702,12 +823,25 @@ mod tests { mr.render(&mut pass); } enc.copy_texture_to_buffer( - wgpu::TexelCopyTextureInfo { texture: &tex, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, + wgpu::TexelCopyTextureInfo { + texture: &tex, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, wgpu::TexelCopyBufferInfo { buffer: &buf, - layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(bpr), rows_per_image: Some(h) }, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(bpr), + rows_per_image: Some(h), + }, + }, + wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, }, - wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, ); g.queue.submit([enc.finish()]); @@ -718,7 +852,11 @@ mod tests { for row in 0..h { for col_ in 0..w { let o = (row * bpr + col_ * 4) as usize; - img.put_pixel(col_, row, image::Rgba([data[o], data[o + 1], data[o + 2], data[o + 3]])); + img.put_pixel( + col_, + row, + image::Rgba([data[o], data[o + 1], data[o + 2], data[o + 3]]), + ); } } let path = std::env::temp_dir().join("unterm-archive-icon.png"); diff --git a/native/unterm/src/clock.rs b/native/unterm/src/clock.rs index 608bab3..f2a0189 100644 --- a/native/unterm/src/clock.rs +++ b/native/unterm/src/clock.rs @@ -60,7 +60,12 @@ pub fn format_relative(stamp: u64, now: u64) -> String { /// needs no date library just for this one field. pub fn parse_iso8601_secs(s: &str) -> u64 { let b = s.as_bytes(); - if b.len() < 19 || b[4] != b'-' || b[7] != b'-' || b[10] != b'T' || b[13] != b':' || b[16] != b':' + if b.len() < 19 + || b[4] != b'-' + || b[7] != b'-' + || b[10] != b'T' + || b[13] != b':' + || b[16] != b':' { return 0; } @@ -102,8 +107,14 @@ mod tests { let midnight = parse_iso8601_secs("2026-07-02T00:00:00Z"); assert_ne!(midnight, 0); let intraday = 8 * 3600 + 12 * 60 + 34; - assert_eq!(parse_iso8601_secs("2026-07-02T08:12:34Z"), midnight + intraday); - assert_eq!(parse_iso8601_secs("2026-07-02T08:12:34.567Z"), midnight + intraday); + assert_eq!( + parse_iso8601_secs("2026-07-02T08:12:34Z"), + midnight + intraday + ); + assert_eq!( + parse_iso8601_secs("2026-07-02T08:12:34.567Z"), + midnight + intraday + ); // Consecutive civil days are exactly 86_400 s apart, across a leap day too. let next = parse_iso8601_secs("2026-07-03T00:00:00Z"); assert_eq!(next - midnight, 86_400); diff --git a/native/unterm/src/control.rs b/native/unterm/src/control.rs index e57e0a2..6dec8a8 100644 --- a/native/unterm/src/control.rs +++ b/native/unterm/src/control.rs @@ -159,12 +159,18 @@ impl Conv { if let Some(name) = tag_inner(t, "command-name") { let args = tag_inner(t, "command-args").unwrap_or_default(); let (name, args) = (name.trim(), args.trim()); - let line = if args.is_empty() { name.to_string() } else { format!("{name} {args}") }; + let line = if args.is_empty() { + name.to_string() + } else { + format!("{name} {args}") + }; self.push_block('u', line); return; } // A command's stdout → a plain result line (agent role: no user bubble). - if let Some(out) = tag_inner(t, "local-command-stdout").or_else(|| tag_inner(t, "local-command-stderr")) { + if let Some(out) = + tag_inner(t, "local-command-stdout").or_else(|| tag_inner(t, "local-command-stderr")) + { let out = out.trim(); if !out.is_empty() { self.push_block('a', out.to_string()); @@ -269,10 +275,20 @@ impl Conv { } else { let idx = self.blocks.len(); self.push_block('x', String::new()); - let title = if name.is_empty() { "(tool)".to_string() } else { name.to_string() }; + let title = if name.is_empty() { + "(tool)".to_string() + } else { + name.to_string() + }; self.tools.insert( id.to_string(), - ToolEntry { idx, title, glyph: "▸", input, output: String::new() }, + ToolEntry { + idx, + title, + glyph: "▸", + input, + output: String::new(), + }, ); } self.rebuild_tool(id); @@ -291,7 +307,13 @@ impl Conv { self.push_block('x', String::new()); self.tools.insert( id.to_string(), - ToolEntry { idx, title: "(tool)".to_string(), glyph, input: String::new(), output }, + ToolEntry { + idx, + title: "(tool)".to_string(), + glyph, + input: String::new(), + output, + }, ); } self.rebuild_tool(id); @@ -679,7 +701,6 @@ impl State { "request": { "subtype": subtype, key: value } })); } - } /// A live control-protocol session: the spawned `claude` child plus its reader @@ -729,7 +750,11 @@ impl Driver { args.push("--effort".into()); args.push(effort); } - let workdir: std::path::PathBuf = if cwd.is_empty() { ".".into() } else { cwd.into() }; + let workdir: std::path::PathBuf = if cwd.is_empty() { + ".".into() + } else { + cwd.into() + }; // Ground the agent in its host up front: it runs embedded in the Unity // Editor, so name the editor version / project and point it at the // unterm-unity MCP tools. Spawn-time only — live editor state (scene, @@ -979,7 +1004,8 @@ impl Driver { } *self.state.permission_mode.lock_recover() = safe_mode.to_string(); if self.state.ready.load(Ordering::Relaxed) { - self.state.send_control("set_permission_mode", "mode", safe_mode); + self.state + .send_control("set_permission_mode", "mode", safe_mode); } } pub fn permission_mode(&self) -> String { @@ -1049,7 +1075,6 @@ impl Driver { self.state.conv.lock_recover().has_relative_stamp(now) } - pub fn status(&self) -> String { self.state.status.lock_recover().clone() } @@ -1106,7 +1131,11 @@ impl Driver { .iter() .map(|o| (o.label.clone(), o.label.clone(), "answer".to_string())) .collect(); - opts.push(("__skip__".to_string(), "Skip".to_string(), "skip".to_string())); + opts.push(( + "__skip__".to_string(), + "Skip".to_string(), + "skip".to_string(), + )); Some((title, opts)) } Pending::Plan { .. } => { @@ -1222,7 +1251,9 @@ fn describe_tool(input: &Value) -> String { } } match input { - Value::Null | Value::Object(_) if input.as_object().map(|m| m.is_empty()).unwrap_or(true) => { + Value::Null | Value::Object(_) + if input.as_object().map(|m| m.is_empty()).unwrap_or(true) => + { String::new() } _ => truncate(&input.to_string(), 400), @@ -1301,7 +1332,10 @@ fn parse_questions(input: &Value) -> Vec { os.iter() .map(|o| QOption { label: o["label"].as_str().unwrap_or("").to_string(), - description: o["description"].as_str().unwrap_or("").to_string(), + description: o["description"] + .as_str() + .unwrap_or("") + .to_string(), }) .collect() }) @@ -1330,8 +1364,7 @@ fn unity_context(project: &std::path::Path) -> Option { // The real project name is PlayerSettings.productName, not the folder name // (they diverge once the repo is cloned/renamed); the agent already knows the // folder from its cwd, so name the product. Fall back to "this project". - let name = - unity_product_name(project).unwrap_or_else(|| "this project".to_string()); + let name = unity_product_name(project).unwrap_or_else(|| "this project".to_string()); Some(format!( "You are running embedded inside the Unity Editor (Unity {version}) in the Unity project {name}; \ the working directory is the project root. Editor operations and live editor state \ @@ -1345,9 +1378,12 @@ fn unity_context(project: &std::path::Path) -> Option { /// Unity project's display name (line-scanned; the field is a top-level scalar /// that appears once). `None` when the file is missing or the name is empty. fn unity_product_name(project: &std::path::Path) -> Option { - let text = - std::fs::read_to_string(project.join("ProjectSettings").join("ProjectSettings.asset")) - .ok()?; + let text = std::fs::read_to_string( + project + .join("ProjectSettings") + .join("ProjectSettings.asset"), + ) + .ok()?; text.lines() .find_map(|l| l.trim_start().strip_prefix("productName:")) .map(str::trim) @@ -1530,10 +1566,7 @@ fn handle_control_request(state: &Arc, v: &Value) { // "Ready to code?" approval (accept → also set the next permission mode) // rather than a generic allow/deny. if tool_name == "ExitPlanMode" { - *state.pending.lock_recover() = Some(Pending::Plan { - request_id, - input, - }); + *state.pending.lock_recover() = Some(Pending::Plan { request_id, input }); return; } @@ -1628,7 +1661,10 @@ mod tests { "AWS_SECRET_ACCESS_KEY", "UNITY_PASSWORD", ] { - assert!(!SAFE_CHILD_ENVIRONMENT.contains(&key), "{key} must not cross the child boundary"); + assert!( + !SAFE_CHILD_ENVIRONMENT.contains(&key), + "{key} must not cross the child boundary" + ); } } @@ -1647,7 +1683,12 @@ mod tests { // reply in between carries none. → [s, first, reply, s, after lunch]. assert_eq!(blocks.len(), 5, "opening + lull separators: {s:?}"); // Separators carry the raw stamp; the panel formats it at layout time. - assert_eq!(blocks[0], &format!("s{US}{}", 1_000_000), "opening: {:?}", blocks[0]); + assert_eq!( + blocks[0], + &format!("s{US}{}", 1_000_000), + "opening: {:?}", + blocks[0] + ); let lull = format!("s{US}{}", 1_000_000 + 60 + TIME_GAP_SECS); assert_eq!(blocks[3], &lull, "lull separator: {:?}", blocks[3]); // The lull is fresh relative to "now" just after it → minute ticks on; diff --git a/native/unterm/src/debugger.rs b/native/unterm/src/debugger.rs index 6a7d97d..27472d3 100644 --- a/native/unterm/src/debugger.rs +++ b/native/unterm/src/debugger.rs @@ -46,7 +46,10 @@ enum Cmd { /// Inspect a different call-stack frame (0 = innermost). SelectFrame(usize), /// Lazily fetch the children (fields or elements) of an expandable value. - ExpandVar { id: u32, array: bool }, + ExpandVar { + id: u32, + array: bool, + }, /// Attach to a different target (the editor, or a discovered player). SelectTarget(Target), } @@ -84,7 +87,11 @@ struct SourcePaneOut { impl Default for SourcePaneOut { fn default() -> Self { - Self { rect: egui::Rect::NOTHING, acts: Vec::new(), hover: None } + Self { + rect: egui::Rect::NOTHING, + acts: Vec::new(), + hover: None, + } } } @@ -322,7 +329,11 @@ impl ApplicationHandler for App { width: size.width.max(1), height: size.height.max(1), present_mode: wgpu::PresentMode::Fifo, - alpha_mode: caps.alpha_modes.first().copied().unwrap_or(wgpu::CompositeAlphaMode::Auto), + alpha_mode: caps + .alpha_modes + .first() + .copied() + .unwrap_or(wgpu::CompositeAlphaMode::Auto), view_formats: vec![], desired_maximum_frame_latency: 2, }; @@ -507,29 +518,37 @@ impl App { } } } - gfx.egui_state.handle_platform_output(&gfx.window, full.platform_output); + gfx.egui_state + .handle_platform_output(&gfx.window, full.platform_output); let tris = gfx.egui_ctx.tessellate(full.shapes, full.pixels_per_point); let screen = egui_wgpu::ScreenDescriptor { size_in_pixels: [gfx.config.width, gfx.config.height], pixels_per_point: full.pixels_per_point, }; for (id, delta) in &full.textures_delta.set { - gfx.egui_renderer.update_texture(&g.device, &g.queue, *id, delta); + gfx.egui_renderer + .update_texture(&g.device, &g.queue, *id, delta); } let mut encoder = g .device - .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("egui") }); + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("egui"), + }); let user_cmds = - gfx.egui_renderer.update_buffers(&g.device, &g.queue, &mut encoder, &tris, &screen); + gfx.egui_renderer + .update_buffers(&g.device, &g.queue, &mut encoder, &tris, &screen); let frame = match gfx.surface.get_current_texture() { - wgpu::CurrentSurfaceTexture::Success(t) | wgpu::CurrentSurfaceTexture::Suboptimal(t) => t, + wgpu::CurrentSurfaceTexture::Success(t) + | wgpu::CurrentSurfaceTexture::Suboptimal(t) => t, _ => { gfx.surface.configure(&g.device, &gfx.config); return; } }; - let view = frame.texture.create_view(&wgpu::TextureViewDescriptor::default()); + let view = frame + .texture + .create_view(&wgpu::TextureViewDescriptor::default()); { let mut pass = encoder .begin_render_pass(&wgpu::RenderPassDescriptor { @@ -539,7 +558,12 @@ impl App { depth_slice: None, resolve_target: None, ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color { r: 0.13, g: 0.13, b: 0.13, a: 1.0 }), + load: wgpu::LoadOp::Clear(wgpu::Color { + r: 0.13, + g: 0.13, + b: 0.13, + a: 1.0, + }), store: wgpu::StoreOp::Store, }, })], @@ -551,7 +575,8 @@ impl App { .forget_lifetime(); gfx.egui_renderer.render(&mut pass, &tris, &screen); } - g.queue.submit(user_cmds.into_iter().chain([encoder.finish()])); + g.queue + .submit(user_cmds.into_iter().chain([encoder.finish()])); frame.present(); for id in &full.textures_delta.free { gfx.egui_renderer.free_texture(id); @@ -691,8 +716,7 @@ impl App { .map(|&l| (l - 1) as u32) .collect(); ed.set_breakpoints(&bp0); - let viewing_stopped = - snap.stopped && basename(&self.view_file) == basename(&snap.cur_file); + let viewing_stopped = snap.stopped && basename(&self.view_file) == basename(&snap.cur_file); ed.set_exec_line(if viewing_stopped && snap.cur_line > 0 { (snap.cur_line - 1) as usize } else { @@ -713,7 +737,8 @@ impl App { /// Absolute path to a Unity-bundled font (`Contents/Resources/Fonts/`), if present. fn unity_font_path(name: &str) -> Option { let inst = sdb::find_editor_instance(&std::env::current_dir().ok()?)?; - let json: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(inst).ok()?).ok()?; + let json: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(inst).ok()?).ok()?; let contents = json.get("app_contents_path")?.as_str()?; let p = std::path::Path::new(contents) .join("Resources") @@ -734,7 +759,9 @@ fn setup_theme(ctx: &egui::Context) { // Fonts: Unity's Inter (UI) + RobotoMono (code), if available. let mut fonts = egui::FontDefinitions::default(); if let Some(b) = unity_font_bytes("Inter-Regular.ttf") { - fonts.font_data.insert("inter".into(), Arc::new(egui::FontData::from_owned(b))); + fonts + .font_data + .insert("inter".into(), Arc::new(egui::FontData::from_owned(b))); fonts .families .entry(egui::FontFamily::Proportional) @@ -742,7 +769,9 @@ fn setup_theme(ctx: &egui::Context) { .insert(0, "inter".into()); } if let Some(b) = unity_font_bytes("RobotoMono-Regular.ttf") { - fonts.font_data.insert("robotomono".into(), Arc::new(egui::FontData::from_owned(b))); + fonts + .font_data + .insert("robotomono".into(), Arc::new(egui::FontData::from_owned(b))); fonts .families .entry(egui::FontFamily::Monospace) @@ -765,7 +794,9 @@ fn setup_theme(ctx: &egui::Context) { "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", ] { if let Ok(b) = std::fs::read(jp) { - fonts.font_data.insert("jp".into(), Arc::new(egui::FontData::from_owned(b))); + fonts + .font_data + .insert("jp".into(), Arc::new(egui::FontData::from_owned(b))); for fam in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] { fonts.families.entry(fam).or_default().push("jp".into()); } @@ -776,11 +807,23 @@ fn setup_theme(ctx: &egui::Context) { use egui::{FontFamily, TextStyle}; let text_styles: std::collections::BTreeMap = [ - (TextStyle::Small, FontId::new(10.0, FontFamily::Proportional)), + ( + TextStyle::Small, + FontId::new(10.0, FontFamily::Proportional), + ), (TextStyle::Body, FontId::new(12.0, FontFamily::Proportional)), - (TextStyle::Button, FontId::new(12.0, FontFamily::Proportional)), - (TextStyle::Heading, FontId::new(13.0, FontFamily::Proportional)), - (TextStyle::Monospace, FontId::new(12.0, FontFamily::Monospace)), + ( + TextStyle::Button, + FontId::new(12.0, FontFamily::Proportional), + ), + ( + TextStyle::Heading, + FontId::new(13.0, FontFamily::Proportional), + ), + ( + TextStyle::Monospace, + FontId::new(12.0, FontFamily::Monospace), + ), ] .into(); @@ -822,7 +865,8 @@ fn setup_theme(ctx: &egui::Context) { fn unity_font_bytes(name: &str) -> Option> { let inst = sdb::find_editor_instance(&std::env::current_dir().ok()?)?; - let json: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(inst).ok()?).ok()?; + let json: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(inst).ok()?).ok()?; let contents = json.get("app_contents_path")?.as_str()?; let p = std::path::Path::new(contents) .join("Resources") @@ -882,7 +926,11 @@ fn build_ui( } }); ui.add_enabled_ui(s.attached && !s.stopped, |ui| { - if ui.button("⏸").on_hover_text("Pause / Break All (p)").clicked() { + if ui + .button("⏸") + .on_hover_text("Pause / Break All (p)") + .clicked() + { cmds.push(Cmd::Pause); } }); @@ -914,11 +962,18 @@ fn build_ui( // Target picker: the editor, plus any debuggable players found on the network. ui.separator(); - let cur = if s.cur_target.is_empty() { "Editor" } else { s.cur_target.as_str() }; + let cur = if s.cur_target.is_empty() { + "Editor" + } else { + s.cur_target.as_str() + }; egui::ComboBox::from_id_salt("target") .selected_text(cur) .show_ui(ui, |ui| { - if ui.selectable_label(cur == "Editor", "Editor (this project)").clicked() { + if ui + .selectable_label(cur == "Editor", "Editor (this project)") + .clicked() + { cmds.push(Cmd::SelectTarget(Target { ip: String::new(), port: 0, @@ -972,9 +1027,18 @@ fn build_ui( .resizable(true) .default_size(210.0) .show(ui, |ui| { - egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| { - tree_panel(ui, ctx.tree, *focus == Focus::Tree, expanded, tree_sel, new_view); - }); + egui::ScrollArea::both() + .auto_shrink([false, false]) + .show(ui, |ui| { + tree_panel( + ui, + ctx.tree, + *focus == Focus::Tree, + expanded, + tree_sel, + new_view, + ); + }); }); // --- right: three vertically-stacked, resizable panes --- @@ -987,102 +1051,153 @@ fn build_ui( let min = 46.0_f32; let total = ui.available_height(); let stack_max = (total - 2.0 * min).max(min); - egui::Panel::top("p_stack").resizable(true).default_size(150.0).size_range(egui::Rangef::new(min, stack_max)).show(ui, |ui| { - // Threads strip: only when the stop involves more than one managed thread. - if s.threads.len() > 1 { - ui.label(RichText::new("Threads").strong()); - egui::ScrollArea::both().id_salt("sa_threads").max_height(72.0).auto_shrink([false, false]).show(ui, |ui| { - for t in &s.threads { - let selected = t.id == s.cur_thread; - let col = if selected { accent } else { c(0xc4, 0xc4, 0xc4) }; - let resp = ui.add( - egui::Label::new( - RichText::new(format!("{} · {}", t.name, t.location)).monospace().color(col), - ) - .selectable(false) - .truncate() - .sense(egui::Sense::click()), - ); - if selected { - ui.painter().rect_filled( - resp.rect.expand2(egui::vec2(4.0, 1.0)), - 2.0, - accent.gamma_multiply(0.18), + egui::Panel::top("p_stack") + .resizable(true) + .default_size(150.0) + .size_range(egui::Rangef::new(min, stack_max)) + .show(ui, |ui| { + // Threads strip: only when the stop involves more than one managed thread. + if s.threads.len() > 1 { + ui.label(RichText::new("Threads").strong()); + egui::ScrollArea::both() + .id_salt("sa_threads") + .max_height(72.0) + .auto_shrink([false, false]) + .show(ui, |ui| { + for t in &s.threads { + let selected = t.id == s.cur_thread; + let col = if selected { + accent + } else { + c(0xc4, 0xc4, 0xc4) + }; + let resp = ui.add( + egui::Label::new( + RichText::new(format!("{} · {}", t.name, t.location)) + .monospace() + .color(col), + ) + .selectable(false) + .truncate() + .sense(egui::Sense::click()), + ); + if selected { + ui.painter().rect_filled( + resp.rect.expand2(egui::vec2(4.0, 1.0)), + 2.0, + accent.gamma_multiply(0.18), + ); + } + if resp.clicked() && !selected { + cmds.push(Cmd::SelectThread(t.id)); + } + } + }); + ui.separator(); + } + ui.label(RichText::new("Call Stack").strong()); + egui::ScrollArea::both() + .id_salt("sa_stack") + .auto_shrink([false, false]) + .show(ui, |ui| { + for (i, f) in s.stack.iter().enumerate() { + let selected = i == s.cur_frame; + let col = if selected { + accent + } else { + c(0xc4, 0xc4, 0xc4) + }; + let resp = ui.add( + egui::Label::new(RichText::new(f).monospace().color(col)) + .selectable(false) + .sense(egui::Sense::click()), ); + if selected { + ui.painter().rect_filled( + resp.rect.expand2(egui::vec2(4.0, 1.0)), + 2.0, + accent.gamma_multiply(0.18), + ); + } + if resp.clicked() && i != s.cur_frame { + cmds.push(Cmd::SelectFrame(i)); + } } - if resp.clicked() && !selected { - cmds.push(Cmd::SelectThread(t.id)); - } - } - }); - ui.separator(); - } - ui.label(RichText::new("Call Stack").strong()); - egui::ScrollArea::both().id_salt("sa_stack").auto_shrink([false, false]).show(ui, |ui| { - for (i, f) in s.stack.iter().enumerate() { - let selected = i == s.cur_frame; - let col = if selected { accent } else { c(0xc4, 0xc4, 0xc4) }; - let resp = ui.add( - egui::Label::new(RichText::new(f).monospace().color(col)) - .selectable(false) - .sense(egui::Sense::click()), - ); - if selected { - ui.painter().rect_filled( - resp.rect.expand2(egui::vec2(4.0, 1.0)), - 2.0, - accent.gamma_multiply(0.18), - ); - } - if resp.clicked() && i != s.cur_frame { - cmds.push(Cmd::SelectFrame(i)); - } - } + }); }); - }); let vars_max = (ui.available_height() - min).max(min); - egui::Panel::top("p_vars").resizable(true).default_size(240.0).size_range(egui::Rangef::new(min, vars_max)).show(ui, |ui| { - ui.label(RichText::new("Variables").strong()); - egui::ScrollArea::both().id_salt("sa_vars").auto_shrink([false, false]).show(ui, |ui| { - egui::CollapsingHeader::new("Local").default_open(true).show(ui, |ui| { - if s.locals.is_empty() { - ui.label(RichText::new("(none)").weak()); - } - var_tree(ui, &s.locals, &s.children, var_open, cmds, 0, &mut Vec::new()); - }); - if !s.this_label.is_empty() { - egui::CollapsingHeader::new(format!("this : {}", s.this_label)) - .default_open(true) - .show(ui, |ui| { - var_tree(ui, &s.members, &s.children, var_open, cmds, 0, &mut Vec::new()); - }); - } + egui::Panel::top("p_vars") + .resizable(true) + .default_size(240.0) + .size_range(egui::Rangef::new(min, vars_max)) + .show(ui, |ui| { + ui.label(RichText::new("Variables").strong()); + egui::ScrollArea::both() + .id_salt("sa_vars") + .auto_shrink([false, false]) + .show(ui, |ui| { + egui::CollapsingHeader::new("Local") + .default_open(true) + .show(ui, |ui| { + if s.locals.is_empty() { + ui.label(RichText::new("(none)").weak()); + } + var_tree( + ui, + &s.locals, + &s.children, + var_open, + cmds, + 0, + &mut Vec::new(), + ); + }); + if !s.this_label.is_empty() { + egui::CollapsingHeader::new(format!("this : {}", s.this_label)) + .default_open(true) + .show(ui, |ui| { + var_tree( + ui, + &s.members, + &s.children, + var_open, + cmds, + 0, + &mut Vec::new(), + ); + }); + } + }); }); - }); egui::CentralPanel::default().show(ui, |ui| { ui.label(RichText::new("Breakpoints").strong()); - egui::ScrollArea::both().id_salt("sa_bps").auto_shrink([false, false]).show(ui, |ui| { - if s.all_bps.is_empty() { - ui.label(RichText::new("(none)").weak()); - } - for (file, line) in &s.all_bps { - let resp = ui - .horizontal(|ui| { - ui.label(RichText::new("●").color(c(0xc8, 0x3a, 0x3a))); - ui.add( - egui::Label::new(RichText::new(format!("{file}:{line}")).monospace()) + egui::ScrollArea::both() + .id_salt("sa_bps") + .auto_shrink([false, false]) + .show(ui, |ui| { + if s.all_bps.is_empty() { + ui.label(RichText::new("(none)").weak()); + } + for (file, line) in &s.all_bps { + let resp = ui + .horizontal(|ui| { + ui.label(RichText::new("●").color(c(0xc8, 0x3a, 0x3a))); + ui.add( + egui::Label::new( + RichText::new(format!("{file}:{line}")).monospace(), + ) .sense(egui::Sense::click()), - ) - }) - .inner; - if resp.clicked() { - if let Some((abs, _)) = ctx.files.iter().find(|(_, n)| n == file) { - *new_view = Some(abs.clone()); - *goto = Some(*line); + ) + }) + .inner; + if resp.clicked() { + if let Some((abs, _)) = ctx.files.iter().find(|(_, n)| n == file) { + *new_view = Some(abs.clone()); + *goto = Some(*line); + } } } - } - }); + }); }); }); @@ -1133,9 +1248,13 @@ fn var_tree( .sense(egui::Sense::click()), ); let name = ui.add( - egui::Label::new(RichText::new(&v.name).monospace().color(c(0xa0, 0xa0, 0xa0))) - .selectable(false) - .sense(egui::Sense::click()), + egui::Label::new( + RichText::new(&v.name) + .monospace() + .color(c(0xa0, 0xa0, 0xa0)), + ) + .selectable(false) + .sense(egui::Sense::click()), ); if hit.clicked() || name.clicked() { if open { @@ -1143,20 +1262,31 @@ fn var_tree( } else { var_open.insert(e.id); if !children.contains_key(&e.id) { - cmds.push(Cmd::ExpandVar { id: e.id, array: e.array }); + cmds.push(Cmd::ExpandVar { + id: e.id, + array: e.array, + }); } } } } else { ui.add_space(12.0); - ui.label(RichText::new(&v.name).monospace().color(c(0xa0, 0xa0, 0xa0))); + ui.label( + RichText::new(&v.name) + .monospace() + .color(c(0xa0, 0xa0, 0xa0)), + ); } if !v.value.is_empty() { ui.label(RichText::new("=").weak()); // The value is selectable (copy it); names/triangles are not. ui.add( - egui::Label::new(RichText::new(&v.value).monospace().color(c(0xd2, 0xd2, 0xd2))) - .selectable(true), + egui::Label::new( + RichText::new(&v.value) + .monospace() + .color(c(0xd2, 0xd2, 0xd2)), + ) + .selectable(true), ); } }); @@ -1234,7 +1364,10 @@ fn source_view(ui: &mut egui::Ui, ctx: &UiCtx, focused: bool, out: &mut SourcePa if resp.hovered() { let d = ui.input(|i| i.smooth_scroll_delta); if d.x != 0.0 || d.y != 0.0 { - out.acts.push(EditorAct::Scroll { dx: -d.x * ppp, dy: -d.y * ppp }); + out.acts.push(EditorAct::Scroll { + dx: -d.x * ppp, + dy: -d.y * ppp, + }); } out.hover = resp.hover_pos(); } @@ -1256,10 +1389,20 @@ fn source_view(ui: &mut egui::Ui, ctx: &UiCtx, focused: bool, out: &mut SourcePa if focused { let events = ui.input(|i| i.events.clone()); for ev in events { - if let egui::Event::Key { key, pressed: true, modifiers, .. } = ev { + if let egui::Event::Key { + key, + pressed: true, + modifiers, + .. + } = ev + { if let Some(name) = map_editor_key(key, &modifiers) { - out.acts - .push(EditorAct::Key(name, modifiers.ctrl, modifiers.alt, modifiers.shift)); + out.acts.push(EditorAct::Key( + name, + modifiers.ctrl, + modifiers.alt, + modifiers.shift, + )); } } } @@ -1614,7 +1757,11 @@ fn session_thread( }; // Default target: this project's editor; the user can switch to a discovered player. // An empty ip is the "Editor" sentinel — resolved fresh (its pid can change) below. - let mut target = Target { ip: String::new(), port: 0, label: "Editor".to_string() }; + let mut target = Target { + ip: String::new(), + port: 0, + label: "Editor".to_string(), + }; loop { if target.ip.is_empty() { match sdb::editor_endpoint(&root) { @@ -1635,7 +1782,9 @@ fn session_thread( let conn = match sdb::connect(&target.ip, target.port) { Ok(c) => c, Err(e) => { - set(&state, &proxy, |s| s.status = format!("{}: connect failed: {e}", target.label)); + set(&state, &proxy, |s| { + s.status = format!("{}: connect failed: {e}", target.label) + }); // Nothing to do until the user picks a (different) target. match wait_for_target(&rx) { Some(t) => { @@ -1646,7 +1795,14 @@ fn session_thread( } } }; - match run_connection(conn, &seed, state.clone(), &rx, proxy.clone(), &target.label) { + match run_connection( + conn, + &seed, + state.clone(), + &rx, + proxy.clone(), + &target.label, + ) { Some(t) => target = t, // switch targets and reconnect None => return, // disconnected / died / channel closed } @@ -1679,7 +1835,12 @@ fn run_connection( let mut bps: Vec = seed .iter() .cloned() - .map(|(file, line)| Bp { file, line, armed: false, req: None }) + .map(|(file, line)| Bp { + file, + line, + armed: false, + req: None, + }) .collect(); set(&state, &proxy, |s| { s.attached = true; @@ -1691,7 +1852,8 @@ fn run_connection( // Watch for a new play-mode AppDomain: the Play domain reload invalidates any // breakpoint armed on an edit-mode method, so we re-sync + re-arm on the fresh types. - conn.set_event(wire::kind::APPDOMAIN_CREATE, wire::suspend::NONE, &[]).ok(); + conn.set_event(wire::kind::APPDOMAIN_CREATE, wire::suspend::NONE, &[]) + .ok(); let mut watch_req: Option = None; rewatch(&mut conn, &bps, &mut watch_req); arm_loaded(&mut conn, &mut bps, &state, &proxy); @@ -1706,13 +1868,19 @@ fn run_connection( // The editor menu, re-opened while this debugger is already running, touches // `focus.request` to ask us to come forward instead of spawning a second window. let focus_path = unterm_state_path("focus.request"); - let mut last_focus = std::fs::metadata(&focus_path).ok().and_then(|m| m.modified().ok()); + let mut last_focus = std::fs::metadata(&focus_path) + .ok() + .and_then(|m| m.modified().ok()); // Live breakpoint sync: watch the shared store for edits the editor makes while we // run, and reconcile. `last_bp` is bumped after our own writes so we don't re-read // them back. let bp_path = unterm_state_path("breakpoints.json"); - let bp_mtime = || std::fs::metadata(&bp_path).ok().and_then(|m| m.modified().ok()); + let bp_mtime = || { + std::fs::metadata(&bp_path) + .ok() + .and_then(|m| m.modified().ok()) + }; let mut last_bp = bp_mtime(); // Retry arming: when we attach mid-play the agent may not answer type queries yet, @@ -1721,7 +1889,9 @@ fn run_connection( let mut last_arm = std::time::Instant::now(); loop { - let f = std::fs::metadata(&focus_path).ok().and_then(|m| m.modified().ok()); + let f = std::fs::metadata(&focus_path) + .ok() + .and_then(|m| m.modified().ok()); if f != last_focus { last_focus = f; proxy.send_event(Wake::Focus).ok(); @@ -1734,9 +1904,7 @@ fn run_connection( reconcile_breakpoints(&mut conn, &mut bps, &state, &proxy, &ui_view); } - if bps.iter().any(|b| !b.armed) - && last_arm.elapsed() >= Duration::from_millis(500) - { + if bps.iter().any(|b| !b.armed) && last_arm.elapsed() >= Duration::from_millis(500) { last_arm = std::time::Instant::now(); arm_loaded(&mut conn, &mut bps, &state, &proxy); sync_bps(&state, &proxy, &bps, &ui_view); @@ -1758,15 +1926,25 @@ fn run_connection( if base.is_empty() { continue; } - if let Some(pos) = bps.iter().position(|b| basename(&b.file) == base && b.line == line) { + if let Some(pos) = bps + .iter() + .position(|b| basename(&b.file) == base && b.line == line) + { let req = bps[pos].req; req.map(|q| conn.clear_event(wire::kind::BREAKPOINT, q)); bps.remove(pos); } else { - let mut bp = Bp { file: base.clone(), line, armed: false, req: None }; + let mut bp = Bp { + file: base.clone(), + line, + armed: false, + req: None, + }; if let Ok(types) = conn.types_for_source_file(&bp.file, true) { if !types.is_empty() { - if let Some(req) = arm(&mut conn, &types, &bp.file, bp.line, &state, &proxy) { + if let Some(req) = + arm(&mut conn, &types, &bp.file, bp.line, &state, &proxy) + { bp.armed = true; bp.req = Some(req); } @@ -1790,7 +1968,9 @@ fn run_connection( conn.frames(t) .ok() .and_then(|f| f.into_iter().next()) - .and_then(|f| conn.debug_info(f.method).ok().map(|i| (i, f.il_offset))) + .and_then(|f| { + conn.debug_info(f.method).ok().map(|i| (i, f.il_offset)) + }) .and_then(|(i, il)| il_to_source(&i, il)) .map(|(_, ln)| ln > 0) .unwrap_or(false) @@ -1850,7 +2030,8 @@ fn run_connection( // jump the source view to its location. Cmd::SelectFrame(idx) if cur_thread != 0 => { if let Some(frame) = cur_frames.get(*idx).cloned() { - let (locals, members, this_label) = frame_vars(&mut conn, cur_thread, &frame); + let (locals, members, this_label) = + frame_vars(&mut conn, cur_thread, &frame); let (file, line) = conn .debug_info(frame.method) .ok() @@ -1930,7 +2111,12 @@ fn run_connection( if let Some(fresh) = load_breakpoints() { bps = fresh .into_iter() - .map(|(file, line)| Bp { file, line, armed: false, req: None }) + .map(|(file, line)| Bp { + file, + line, + armed: false, + req: None, + }) .collect(); } rewatch(&mut conn, &bps, &mut watch_req); @@ -1939,9 +2125,14 @@ fn run_connection( wire::kind::TYPE_LOAD => { for bp in &mut bps { if !bp.armed { - if let Some(req) = - arm(&mut conn, &[ev.type_id], &bp.file, bp.line, &state, &proxy) - { + if let Some(req) = arm( + &mut conn, + &[ev.type_id], + &bp.file, + bp.line, + &state, + &proxy, + ) { bp.armed = true; bp.req = Some(req); } @@ -2126,7 +2317,10 @@ fn reconcile_breakpoints( // Drop breakpoints no longer in the store (clearing their agent request). let mut i = 0; while i < bps.len() { - if fresh.iter().any(|(f, l)| *f == bps[i].file && *l == bps[i].line) { + if fresh + .iter() + .any(|(f, l)| *f == bps[i].file && *l == bps[i].line) + { i += 1; } else { if let Some(req) = bps[i].req.take() { @@ -2141,7 +2335,12 @@ fn reconcile_breakpoints( if bps.iter().any(|b| b.file == file && b.line == line) { continue; } - let mut bp = Bp { file: file.clone(), line, armed: false, req: None }; + let mut bp = Bp { + file: file.clone(), + line, + armed: false, + req: None, + }; if let Ok(types) = conn.types_for_source_file(&bp.file, true) { if !types.is_empty() { if let Some(req) = arm(conn, &types, &bp.file, bp.line, state, proxy) { @@ -2158,7 +2357,10 @@ fn reconcile_breakpoints( /// Read the shared breakpoint store as (basename, 1-based line) pairs. Returns `None` /// on a missing/unreadable file so a transient error can't wipe live breakpoints. fn load_breakpoints() -> Option> { - let path = project_root().join("Library").join("Unterm").join("breakpoints.json"); + let path = project_root() + .join("Library") + .join("Unterm") + .join("breakpoints.json"); let text = std::fs::read_to_string(&path).ok()?; let v: serde_json::Value = serde_json::from_str(&text).ok()?; let mut out = Vec::new(); @@ -2201,7 +2403,9 @@ fn arm( match conn.set_breakpoint(method, il) { Ok(req) => { let name = conn.method_name(method).unwrap_or_default(); - set(state, proxy, |s| s.push_log(format!("breakpoint: {name}+0x{il:x}"))); + set(state, proxy, |s| { + s.push_log(format!("breakpoint: {name}+0x{il:x}")) + }); Some(req) } Err(_) => None, @@ -2225,7 +2429,11 @@ fn collect_threads(conn: &mut sdb::Connection) -> Vec { continue; // native/threadpool thread with no managed frame: skip }; let raw = conn.thread_name(id).unwrap_or_default(); - let name = if raw.is_empty() { format!("Thread {id}") } else { raw }; + let name = if raw.is_empty() { + format!("Thread {id}") + } else { + raw + }; let mname = conn.method_name(method).unwrap_or_default(); out.push(ThreadInfo { id, @@ -2252,7 +2460,11 @@ fn dump(conn: &mut sdb::Connection, thread: u32) -> Stop { file = src.clone(); line = ln; } - let loc = if ln > 0 { format!("{}:{ln}", basename(&src)) } else { String::new() }; + let loc = if ln > 0 { + format!("{}:{ln}", basename(&src)) + } else { + String::new() + }; stack.push(format!("#{i} {name} {loc}")); } @@ -2260,7 +2472,15 @@ fn dump(conn: &mut sdb::Connection, thread: u32) -> Stop { Some(top) => frame_vars(conn, thread, top), None => (Vec::new(), Vec::new(), String::new()), }; - Stop { stack, frames, locals, members, this_label, file, line } + Stop { + stack, + frames, + locals, + members, + this_label, + file, + line, + } } /// Locals, `this` members, and the `this` type label for a single call-stack frame. @@ -2379,7 +2599,11 @@ fn render_value(conn: &mut sdb::Connection, v: &value::Value) -> String { .unwrap_or_else(|| format!("obj#{id}")), Value::ValueType { fields, .. } => format!( "({})", - fields.iter().map(|f| render_value(conn, f)).collect::>().join(", ") + fields + .iter() + .map(|f| render_value(conn, f)) + .collect::>() + .join(", ") ), other => other.summary(), } @@ -2464,8 +2688,11 @@ fn write_pid_file() { #[cfg(any(target_os = "macos", target_os = "windows"))] fn editor_pid() -> Option { let inst = sdb::find_editor_instance(&std::env::current_dir().ok()?)?; - let json: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(inst).ok()?).ok()?; - json.get("process_id").and_then(|v| v.as_i64()).map(|v| v as i32) + let json: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(inst).ok()?).ok()?; + json.get("process_id") + .and_then(|v| v.as_i64()) + .map(|v| v as i32) } /// Observe `NSWorkspace` app-activation; when our editor becomes frontmost, post @@ -2666,8 +2893,8 @@ fn install_activation_observer(proxy: EventLoopProxy) { #[cfg(windows)] fn order_behind_editor(window: &Window) { - use std::sync::atomic::Ordering; use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + use std::sync::atomic::Ordering; use windows::Win32::Foundation::HWND; use windows::Win32::UI::WindowsAndMessaging::{ GetForegroundWindow, GetWindowThreadProcessId, SetWindowPos, SWP_NOACTIVATE, SWP_NOMOVE, @@ -2695,7 +2922,15 @@ fn order_behind_editor(window: &Window) { return; // the editor isn't actually the foreground window } // Insert our window directly below the editor's in z-order (no move/size/focus). - let _ = SetWindowPos(ours, Some(fg), 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE); + let _ = SetWindowPos( + ours, + Some(fg), + 0, + 0, + 0, + 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE, + ); } } @@ -2718,7 +2953,11 @@ fn scan_cs_files() -> Vec<(String, String)> { } walk(&p, out); } else if p.extension().and_then(|s| s.to_str()) == Some("cs") { - let disp = p.file_name().and_then(|s| s.to_str()).unwrap_or("").to_string(); + let disp = p + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string(); out.push((p.to_string_lossy().into_owned(), disp)); } } diff --git a/native/unterm/src/diff.rs b/native/unterm/src/diff.rs index 6e7cd42..236192c 100644 --- a/native/unterm/src/diff.rs +++ b/native/unterm/src/diff.rs @@ -65,15 +65,37 @@ pub fn hunks(base: &str, cur: &str) -> Vec { for op in capture_diff_slices(Algorithm::Myers, &base_lines, &cur_lines) { let h = match op { DiffOp::Equal { .. } => continue, - DiffOp::Insert { old_index, new_index, new_len } => { - Hunk { old_start: old_index, old_len: 0, new_start: new_index, new_len } - } - DiffOp::Delete { old_index, old_len, new_index } => { - Hunk { old_start: old_index, old_len, new_start: new_index, new_len: 0 } - } - DiffOp::Replace { old_index, old_len, new_index, new_len } => { - Hunk { old_start: old_index, old_len, new_start: new_index, new_len } - } + DiffOp::Insert { + old_index, + new_index, + new_len, + } => Hunk { + old_start: old_index, + old_len: 0, + new_start: new_index, + new_len, + }, + DiffOp::Delete { + old_index, + old_len, + new_index, + } => Hunk { + old_start: old_index, + old_len, + new_start: new_index, + new_len: 0, + }, + DiffOp::Replace { + old_index, + old_len, + new_index, + new_len, + } => Hunk { + old_start: old_index, + old_len, + new_start: new_index, + new_len, + }, }; out.push(h); } @@ -88,7 +110,11 @@ pub fn markers_from_hunks(hunks: &[Hunk], n: usize) -> Vec { // Added lines, or a replaced run shown as "modified" for its whole new // span (like VS Code; any surplus removed base lines fold into it). let bit = if h.old_len > 0 { MODIFIED } else { ADDED }; - for m in marks.iter_mut().take((h.new_start + h.new_len).min(n)).skip(h.new_start) { + for m in marks + .iter_mut() + .take((h.new_start + h.new_len).min(n)) + .skip(h.new_start) + { *m |= bit; } } else { @@ -147,7 +173,11 @@ pub fn apply_staged_bits(marks: &mut [u8], hunks: &[Hunk], staged: &[bool]) { continue; } if h.new_len > 0 { - for m in marks.iter_mut().take((h.new_start + h.new_len).min(n)).skip(h.new_start) { + for m in marks + .iter_mut() + .take((h.new_start + h.new_len).min(n)) + .skip(h.new_start) + { *m |= STAGED; } } else if h.new_start < n { @@ -170,7 +200,9 @@ fn ranges_touch(a: (usize, usize), b: (usize, usize)) -> bool { /// reads it unstaged (the working change no longer matches), but an Unstage there /// would still drop the staged version, so the host offers both actions. pub fn overlaps_staged(index_hunks: &[Hunk], head_range: (usize, usize)) -> bool { - index_hunks.iter().any(|ih| ranges_touch((ih.old_start, ih.old_start + ih.old_len), head_range)) + index_hunks + .iter() + .any(|ih| ranges_touch((ih.old_start, ih.old_start + ih.old_len), head_range)) } /// One gutter-displayable hunk: a buffer change vs HEAD, or a change that exists @@ -197,7 +229,9 @@ pub fn display_hunks( index_hunks: &[Hunk], cur: &str, ) -> Vec { - let Some(base) = head.or(index) else { return Vec::new() }; + let Some(base) = head.or(index) else { + return Vec::new(); + }; let cur_hunks = hunks(base, cur); let staged = match (head, index) { (Some(_), Some(ix)) => staged_flags(ix, index_hunks, cur, &cur_hunks), @@ -206,7 +240,11 @@ pub fn display_hunks( let mut out: Vec = cur_hunks .iter() .zip(&staged) - .map(|(h, s)| DisplayHunk { hunk: *h, staged: *s, index_new: None }) + .map(|(h, s)| DisplayHunk { + hunk: *h, + staged: *s, + index_new: None, + }) .collect(); if head.is_some() && index.is_some() { @@ -215,7 +253,10 @@ pub fn display_hunks( // exact staged change is in the buffer (shown via its staged flag) or the // region was re-edited (shown as an unstaged buffer hunk). let ih_range = (ih.old_start, ih.old_start + ih.old_len); - if cur_hunks.iter().any(|h| ranges_touch((h.old_start, h.old_start + h.old_len), ih_range)) { + if cur_hunks + .iter() + .any(|h| ranges_touch((h.old_start, h.old_start + h.old_len), ih_range)) + { continue; } // Buffer == HEAD across this region, so map HEAD lines to buffer lines by @@ -227,7 +268,12 @@ pub fn display_hunks( .sum(); let new_start = (ih.old_start as isize + offset).max(0) as usize; out.push(DisplayHunk { - hunk: Hunk { old_start: ih.old_start, old_len: ih.old_len, new_start, new_len: ih.old_len }, + hunk: Hunk { + old_start: ih.old_start, + old_len: ih.old_len, + new_start, + new_len: ih.old_len, + }, staged: true, index_new: Some((ih.new_start, ih.new_len)), }); @@ -322,7 +368,9 @@ fn blob_text(repo: &git2::Repository, id: git2::Oid) -> Option { /// commit yet, no repo…) — the editor uses HEAD as the diff base (falling back to /// the index before the first commit) and both together for staged detection. pub fn git_texts(path: &Path) -> (Option, Option) { - let Some((repo, rel)) = repo_rel(path) else { return (None, None) }; + let Some((repo, rel)) = repo_rel(path) else { + return (None, None); + }; let head = (|| { let tree = repo.head().ok()?.peel_to_tree().ok()?; let entry = tree.get_path(Path::new(&rel)).ok()?; @@ -353,13 +401,17 @@ pub fn stage_blob(path: &Path, content_lf: &str) -> bool { let (repo, rel) = repo_rel(path)?; let mut index = repo.index().ok()?; let entry = index.get_path(Path::new(&rel), 0)?; // reuse mode/path/flags - // Match the existing blob's line endings so an autocrlf repo isn't rewritten. + // Match the existing blob's line endings so an autocrlf repo isn't rewritten. let crlf = repo .find_blob(entry.id) .ok() .map(|b| b.content().windows(2).any(|w| w == b"\r\n")) .unwrap_or(false); - let bytes = if crlf { content_lf.replace('\n', "\r\n") } else { content_lf.to_string() }; + let bytes = if crlf { + content_lf.replace('\n', "\r\n") + } else { + content_lf.to_string() + }; index.add_frombuffer(&entry, bytes.as_bytes()).ok()?; index.write().ok()?; Some(()) @@ -382,7 +434,11 @@ pub struct DiffFetcher { impl DiffFetcher { pub fn new() -> Self { - Self { path: None, gen: 0, rx: None } + Self { + path: None, + gen: 0, + rx: None, + } } /// Point at a new file (empty/none clears markers) and kick a fetch. @@ -441,7 +497,9 @@ impl DiffFetcher { /// index. The index write is fast (one blob) so it runs synchronously; returns /// false when there's no path or the write failed. pub fn stage(&mut self, content_lf: &str) -> bool { - let Some(path) = self.path.clone() else { return false }; + let Some(path) = self.path.clone() else { + return false; + }; let ok = stage_blob(&path, content_lf); if ok { self.request(); // re-read the (now updated) index @@ -516,24 +574,58 @@ mod tests { let base = "a\nb\nc"; let cur = "a\nB\nc"; let hs = hunks(base, cur); - assert_eq!(hs, vec![Hunk { old_start: 1, old_len: 1, new_start: 1, new_len: 1 }]); + assert_eq!( + hs, + vec![Hunk { + old_start: 1, + old_len: 1, + new_start: 1, + new_len: 1 + }] + ); let old: Vec<&str> = base.split('\n').collect(); - assert_eq!(&old[hs[0].old_start..hs[0].old_start + hs[0].old_len], &["b"]); + assert_eq!( + &old[hs[0].old_start..hs[0].old_start + hs[0].old_len], + &["b"] + ); // Pure deletion: new_len 0, boundary at the line the removed text sat above. let hs = hunks("a\nb\nc", "a\nc"); - assert_eq!(hs, vec![Hunk { old_start: 1, old_len: 1, new_start: 1, new_len: 0 }]); + assert_eq!( + hs, + vec![Hunk { + old_start: 1, + old_len: 1, + new_start: 1, + new_len: 0 + }] + ); // Pure addition: nothing removed (old_len 0), so no peek content. let hs = hunks("a\nc", "a\nb\nc"); - assert_eq!(hs, vec![Hunk { old_start: 1, old_len: 0, new_start: 1, new_len: 1 }]); + assert_eq!( + hs, + vec![Hunk { + old_start: 1, + old_len: 0, + new_start: 1, + new_len: 1 + }] + ); } #[test] fn markers_match_the_direct_path() { // markers_from_hunks(hunks(..)) is what line_markers is built on. - for (b, c) in [("a\nb\nc", "a\nB\nc"), ("a\nb", "a\nb\nc\nd"), ("a\nb\nc", "a\nb")] { - assert_eq!(line_markers(b, c), markers_from_hunks(&hunks(b, c), c.split('\n').count())); + for (b, c) in [ + ("a\nb\nc", "a\nB\nc"), + ("a\nb", "a\nb\nc\nd"), + ("a\nb\nc", "a\nb"), + ] { + assert_eq!( + line_markers(b, c), + markers_from_hunks(&hunks(b, c), c.split('\n').count()) + ); } } @@ -546,19 +638,37 @@ mod tests { assert_eq!(hs.len(), 2); // Stage the first hunk: index starts equal to HEAD. - let idx1 = stage_apply(head, cur, (hs[0].new_start, hs[0].new_start + hs[0].new_len)).unwrap(); + let idx1 = stage_apply( + head, + cur, + (hs[0].new_start, hs[0].new_start + hs[0].new_len), + ) + .unwrap(); assert_eq!(idx1, "a\nB\nc\nd\ne", "only the b→B hunk staged"); // Stage the second hunk against the UPDATED index: B must be preserved // (this is the clobber the index-space diff avoids). - let idx2 = stage_apply(&idx1, cur, (hs[1].new_start, hs[1].new_start + hs[1].new_len)).unwrap(); - assert_eq!(idx2, "a\nB\nc\nD\ne", "second stage keeps the earlier staged hunk"); + let idx2 = stage_apply( + &idx1, + cur, + (hs[1].new_start, hs[1].new_start + hs[1].new_len), + ) + .unwrap(); + assert_eq!( + idx2, "a\nB\nc\nD\ne", + "second stage keeps the earlier staged hunk" + ); // Pure deletion: buffer removed "b"; staging drops it from the index. let head = "a\nb\nc"; let cur = "a\nc"; let hd = hunks(head, cur); - let idx = stage_apply(head, cur, (hd[0].new_start, hd[0].new_start + hd[0].new_len)).unwrap(); + let idx = stage_apply( + head, + cur, + (hd[0].new_start, hd[0].new_start + hd[0].new_len), + ) + .unwrap(); assert_eq!(idx, "a\nc"); // Nothing to stage (buffer == index) → None. @@ -572,7 +682,12 @@ mod tests { let index = "a\nB\nc\nD\ne"; let sh = hunks(head, index); assert_eq!(sh.len(), 2); - let idx = unstage_apply(head, index, (sh[0].old_start, sh[0].old_start + sh[0].old_len)).unwrap(); + let idx = unstage_apply( + head, + index, + (sh[0].old_start, sh[0].old_start + sh[0].old_len), + ) + .unwrap(); assert_eq!(idx, "a\nb\nc\nD\ne", "only the first hunk reverted to HEAD"); // Unstaging a staged deletion restores the removed line. @@ -622,10 +737,23 @@ mod tests { let d = &ds[0]; assert!(d.staged); assert_eq!(d.index_new, Some((1, 1))); - assert_eq!(d.hunk, Hunk { old_start: 1, old_len: 1, new_start: 1, new_len: 1 }); + assert_eq!( + d.hunk, + Hunk { + old_start: 1, + old_len: 1, + new_start: 1, + new_len: 1 + } + ); // Unstaging it from the synthesized hunk's HEAD range restores the index. - let restored = unstage_apply(head, index, (d.hunk.old_start, d.hunk.old_start + d.hunk.old_len)).unwrap(); + let restored = unstage_apply( + head, + index, + (d.hunk.old_start, d.hunk.old_start + d.hunk.old_len), + ) + .unwrap(); assert_eq!(restored, head); } @@ -639,7 +767,10 @@ mod tests { let ds = display_hunks(Some(head), Some(index), &ih, cur); let so: Vec<_> = ds.iter().filter(|d| d.index_new.is_some()).collect(); assert_eq!(so.len(), 1); - assert_eq!(so[0].hunk.new_start, 3, "staged-only hunk mapped past the insertion"); + assert_eq!( + so[0].hunk.new_start, 3, + "staged-only hunk mapped past the insertion" + ); // And the ordinary insertion hunk is still there, unstaged. assert!(ds.iter().any(|d| d.index_new.is_none() && !d.staged)); @@ -671,7 +802,10 @@ mod tests { // staged-only hunk), and the index is untouched by the edit. let ds = display_hunks(Some(head), Some(index), &ih, cur); assert_eq!(ds.len(), 1); - assert!(!ds[0].staged, "further-edited staged hunk must read unstaged"); + assert!( + !ds[0].staged, + "further-edited staged hunk must read unstaged" + ); assert!(ds[0].index_new.is_none(), "no duplicate staged-only hunk"); // 2. Re-staging replaces the old staged version with the current content. @@ -683,7 +817,10 @@ mod tests { // menu offers Unstage alongside Stage — and unstage_apply drops the old // staged version wholesale. assert!(overlaps_staged(&ih, (h.old_start, h.old_start + h.old_len))); - assert!(!overlaps_staged(&ih, (10, 12)), "untouched region has nothing staged"); + assert!( + !overlaps_staged(&ih, (10, 12)), + "untouched region has nothing staged" + ); let dropped = unstage_apply(head, index, (h.old_start, h.old_start + h.old_len)).unwrap(); assert_eq!(dropped, head); } @@ -721,7 +858,8 @@ mod tests { // commit so HEAD exists let tree = repo.find_tree(index.write_tree().unwrap()).unwrap(); let sig = git2::Signature::now("t", "t@t").unwrap(); - repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]).unwrap(); + repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]) + .unwrap(); assert!(stage_blob(&file, "a\nB\nc\n"), "stage_blob returned false"); @@ -734,7 +872,10 @@ mod tests { let diff = repo .diff_tree_to_index(Some(&head_tree), Some(&fresh), None) .unwrap(); - let changed: Vec<_> = diff.deltas().map(|d| d.new_file().path().unwrap().to_path_buf()).collect(); + let changed: Vec<_> = diff + .deltas() + .map(|d| d.new_file().path().unwrap().to_path_buf()) + .collect(); assert!( changed.iter().any(|p| p.ends_with("foo.txt")), "staged change not visible vs HEAD; deltas = {changed:?}" @@ -760,9 +901,16 @@ mod tests { assert_eq!(git_base(&file).as_deref(), Some("a\nB\nc\n")); // LF-normalized read let repo2 = git2::Repository::discover(&dir).unwrap(); - let entry = repo2.index().unwrap().get_path(Path::new("foo.txt"), 0).unwrap(); + let entry = repo2 + .index() + .unwrap() + .get_path(Path::new("foo.txt"), 0) + .unwrap(); let raw = repo2.find_blob(entry.id).unwrap().content().to_vec(); - assert_eq!(raw, b"a\r\nB\r\nc\r\n", "line endings preserved in the index blob"); + assert_eq!( + raw, b"a\r\nB\r\nc\r\n", + "line endings preserved in the index blob" + ); std::fs::remove_dir_all(&dir).unwrap(); } @@ -785,9 +933,13 @@ mod tests { // Commit, then stage a different version: head and index diverge. let tree = repo.find_tree(index.write_tree().unwrap()).unwrap(); let sig = git2::Signature::now("t", "t@t").unwrap(); - repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]).unwrap(); + repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]) + .unwrap(); assert!(stage_blob(&file, "two\n")); - assert_eq!(git_texts(&file), (Some("one\n".into()), Some("two\n".into()))); + assert_eq!( + git_texts(&file), + (Some("one\n".into()), Some("two\n".into())) + ); std::fs::remove_dir_all(&dir).unwrap(); } diff --git a/native/unterm/src/editops.rs b/native/unterm/src/editops.rs index d2fa5c7..6bcf1ba 100644 --- a/native/unterm/src/editops.rs +++ b/native/unterm/src/editops.rs @@ -15,7 +15,9 @@ pub fn to_lines(text: &str) -> Vec { /// Leading whitespace (spaces/tabs) of a line. fn leading_ws(line: &str) -> String { - line.chars().take_while(|c| *c == ' ' || *c == '\t').collect() + line.chars() + .take_while(|c| *c == ' ' || *c == '\t') + .collect() } /// The newline-plus-indent string to insert for auto-indent on Enter: carries the @@ -51,7 +53,11 @@ pub fn outdent(lines: &mut [String], l0: usize, l1: usize) { if let Some(rest) = line.strip_prefix('\t') { lines[i] = rest.to_string(); } else { - let spaces = line.chars().take_while(|c| *c == ' ').count().min(INDENT.len()); + let spaces = line + .chars() + .take_while(|c| *c == ' ') + .count() + .min(INDENT.len()); lines[i] = line.chars().skip(spaces).collect(); } } @@ -65,13 +71,18 @@ pub fn toggle_comment(lines: &mut [String], l0: usize, l1: usize, prefix: &str) let trimmed = prefix.trim_end(); let non_blank: Vec = (l0..=l1).filter(|&i| !lines[i].trim().is_empty()).collect(); let all_commented = !non_blank.is_empty() - && non_blank.iter().all(|&i| lines[i].trim_start().starts_with(trimmed)); + && non_blank + .iter() + .all(|&i| lines[i].trim_start().starts_with(trimmed)); if all_commented { for &i in &non_blank { let indent = leading_ws(&lines[i]); let body = lines[i].trim_start(); - let body = body.strip_prefix(prefix).or_else(|| body.strip_prefix(trimmed)).unwrap_or(body); + let body = body + .strip_prefix(prefix) + .or_else(|| body.strip_prefix(trimmed)) + .unwrap_or(body); lines[i] = format!("{indent}{body}"); } } else { @@ -117,7 +128,13 @@ pub fn duplicate(lines: &mut Vec, l0: usize, l1: usize) { /// Find `query` in `text` starting from character offset `from` (the search wraps /// around). Returns the matched character range [start, end). Empty query → None. -pub fn find(text: &str, query: &str, from: usize, forward: bool, case_sensitive: bool) -> Option<(usize, usize)> { +pub fn find( + text: &str, + query: &str, + from: usize, + forward: bool, + case_sensitive: bool, +) -> Option<(usize, usize)> { if query.is_empty() { return None; } @@ -126,7 +143,13 @@ pub fn find(text: &str, query: &str, from: usize, forward: bool, case_sensitive: if ned.len() > hay.len() { return None; } - let eq = |a: char, b: char| if case_sensitive { a == b } else { a.eq_ignore_ascii_case(&b) || a.to_lowercase().eq(b.to_lowercase()) }; + let eq = |a: char, b: char| { + if case_sensitive { + a == b + } else { + a.eq_ignore_ascii_case(&b) || a.to_lowercase().eq(b.to_lowercase()) + } + }; let matches_at = |i: usize| (0..ned.len()).all(|k| eq(hay[i + k], ned[k])); let last = hay.len() - ned.len(); @@ -260,7 +283,13 @@ pub fn replace_all(text: &str, query: &str, repl: &str, case_sensitive: bool) -> } let hay: Vec = text.chars().collect(); let ned: Vec = query.chars().collect(); - let eq = |a: char, b: char| if case_sensitive { a == b } else { a.eq_ignore_ascii_case(&b) || a.to_lowercase().eq(b.to_lowercase()) }; + let eq = |a: char, b: char| { + if case_sensitive { + a == b + } else { + a.eq_ignore_ascii_case(&b) || a.to_lowercase().eq(b.to_lowercase()) + } + }; let mut out = String::with_capacity(text.len()); let mut i = 0; let mut n = 0u32; @@ -307,8 +336,14 @@ mod tests { #[test] fn replace_all_counts() { - assert_eq!(replace_all("a.a.a", "a", "X", true), ("X.X.X".to_string(), 3)); - assert_eq!(replace_all("Foo foo", "foo", "bar", false), ("bar bar".to_string(), 2)); + assert_eq!( + replace_all("a.a.a", "a", "X", true), + ("X.X.X".to_string(), 3) + ); + assert_eq!( + replace_all("Foo foo", "foo", "bar", false), + ("bar bar".to_string(), 2) + ); assert_eq!(replace_all("abc", "x", "y", true), ("abc".to_string(), 0)); } diff --git a/native/unterm/src/editorview.rs b/native/unterm/src/editorview.rs index 517f4b3..0ada91c 100644 --- a/native/unterm/src/editorview.rs +++ b/native/unterm/src/editorview.rs @@ -88,7 +88,12 @@ impl EditorView { p.set_clear_color(self.clear.0, self.clear.1, self.clear.2, self.clear.3); p.set_text_color(self.fg.0, self.fg.1, self.fg.2, 255); if !self.font_path.is_empty() { - p.set_fonts(&self.font_path, &self.font_path, &self.font_path, &self.font_path); + p.set_fonts( + &self.font_path, + &self.font_path, + &self.font_path, + &self.font_path, + ); } p.set_root(self.root.clone()); self.preview = Some(p); @@ -110,7 +115,10 @@ impl EditorView { /// for click-to-open; empty when not over one or not previewing. pub fn preview_token_at(&mut self, x: f32, y: f32) -> &CString { let tok = if self.preview_on { - self.preview.as_ref().and_then(|p| p.token_at(x, y)).unwrap_or_default() + self.preview + .as_ref() + .and_then(|p| p.token_at(x, y)) + .unwrap_or_default() } else { String::new() }; @@ -122,13 +130,20 @@ impl EditorView { /// of its git HEAD + index versions. Call on load. pub fn set_path(&mut self, path: &str) { let p = path.trim(); - self.diff.set_path(if p.is_empty() { None } else { Some(PathBuf::from(p)) }); + self.diff.set_path(if p.is_empty() { + None + } else { + Some(PathBuf::from(p)) + }); // Preview link resolution: a Markdown file's relative paths resolve against // its own directory. Empty path → project cwd (empty root). self.root = if p.is_empty() { PathBuf::new() } else { - PathBuf::from(p).parent().map(|d| d.to_path_buf()).unwrap_or_default() + PathBuf::from(p) + .parent() + .map(|d| d.to_path_buf()) + .unwrap_or_default() }; if let Some(pr) = self.preview.as_mut() { pr.set_root(self.root.clone()); @@ -186,7 +201,17 @@ impl EditorView { } /// Background rgba + foreground rgb, plus the syntect-vs-dark highlight theme. - pub fn set_theme(&mut self, br: f64, bg: f64, bb: f64, ba: f64, fr: u8, fg: u8, fb: u8, dark: bool) { + pub fn set_theme( + &mut self, + br: f64, + bg: f64, + bb: f64, + ba: f64, + fr: u8, + fg: u8, + fb: u8, + dark: bool, + ) { self.clear = (br, bg, bb, ba); self.fg = (fr, fg, fb); self.edit.set_clear_color(br, bg, bb, ba); @@ -201,7 +226,8 @@ impl EditorView { /// Tree-sitter language token (e.g. "cs"); empty = plain. pub fn set_language(&mut self, token: &str) { let t = token.trim(); - self.edit.set_language(if t.is_empty() { None } else { Some(t) }); + self.edit + .set_language(if t.is_empty() { None } else { Some(t) }); } pub fn render(&mut self) { diff --git a/native/unterm/src/gpu.rs b/native/unterm/src/gpu.rs index 99cdf51..1ff6ba2 100644 --- a/native/unterm/src/gpu.rs +++ b/native/unterm/src/gpu.rs @@ -48,7 +48,13 @@ fn init_gpu() -> Gpu { let (device, queue, adapter) = open_device(&instance); let cache = Cache::new(&device); - Gpu { device, queue, cache, instance, adapter } + Gpu { + device, + queue, + cache, + instance, + adapter, + } } /// The device descriptor shared by every adapter open. Uses the adapter's real @@ -95,9 +101,12 @@ fn open_device(instance: &wgpu::Instance) -> (wgpu::Device, wgpu::Queue, wgpu::A .expect("unterm: no suitable GPU adapter"); let Some(device) = crate::unity::unity_device() else { - log::info!("unterm: editor MTLDevice unavailable (UnityPluginLoad not run); using default adapter"); - let (device, queue) = pollster::block_on(adapter.request_device(&device_descriptor(&adapter))) - .expect("unterm: failed to create device"); + log::info!( + "unterm: editor MTLDevice unavailable (UnityPluginLoad not run); using default adapter" + ); + let (device, queue) = + pollster::block_on(adapter.request_device(&device_descriptor(&adapter))) + .expect("unterm: failed to create device"); return (device, queue, adapter); }; @@ -117,7 +126,9 @@ fn open_device(instance: &wgpu::Instance) -> (wgpu::Device, wgpu::Queue, wgpu::A #[cfg(target_os = "macos")] unsafe fn unity_open_device( device: objc2::rc::Retained>, - queue: Option>>, + queue: Option< + objc2::rc::Retained>, + >, ) -> wgpu::hal::OpenDevice { use objc2_metal::MTLDevice; diff --git a/native/unterm/src/highlight.rs b/native/unterm/src/highlight.rs index bf1b754..063be9f 100644 --- a/native/unterm/src/highlight.rs +++ b/native/unterm/src/highlight.rs @@ -84,30 +84,30 @@ const DARK: &[(u8, u8, u8)] = &[ /// Foreground colors for the light theme, parallel to [`HL_NAMES`] (GitHub-ish). const LIGHT: &[(u8, u8, u8)] = &[ - (207, 34, 46), // keyword - (130, 80, 223), // function - (130, 80, 223), // function.method - (149, 56, 0), // type - (10, 48, 105), // string + (207, 34, 46), // keyword + (130, 80, 223), // function + (130, 80, 223), // function.method + (149, 56, 0), // type + (10, 48, 105), // string (110, 119, 129), // comment - (5, 80, 174), // number - (5, 80, 174), // constant - (5, 80, 174), // constant.builtin - (17, 99, 41), // property - (31, 35, 40), // variable - (5, 80, 174), // operator - (31, 35, 40), // punctuation - (17, 99, 41), // attribute - (149, 56, 0), // constructor - (36, 41, 47), // namespace - (130, 80, 223), // label - (5, 80, 174), // escape - (207, 34, 46), // text.title (heading) — red - (17, 99, 41), // text.literal (code) — green - (130, 80, 223), // text.emphasis (italic) — purple - (149, 56, 0), // text.strong (bold) — brown - (5, 80, 174), // text.uri (link) — blue - (5, 80, 174), // text.reference — blue + (5, 80, 174), // number + (5, 80, 174), // constant + (5, 80, 174), // constant.builtin + (17, 99, 41), // property + (31, 35, 40), // variable + (5, 80, 174), // operator + (31, 35, 40), // punctuation + (17, 99, 41), // attribute + (149, 56, 0), // constructor + (36, 41, 47), // namespace + (130, 80, 223), // label + (5, 80, 174), // escape + (207, 34, 46), // text.title (heading) — red + (17, 99, 41), // text.literal (code) — green + (130, 80, 223), // text.emphasis (italic) — purple + (149, 56, 0), // text.strong (bold) — brown + (5, 80, 174), // text.uri (link) — blue + (5, 80, 174), // text.reference — blue ]; fn color_for(index: usize, dark: bool) -> Color { @@ -176,9 +176,24 @@ fn md_config() -> Option<&'static MdConfig> { let inline_q = Query::new(&inline_lang, tree_sitter_md::HIGHLIGHT_QUERY_INLINE) .map_err(|e| log::warn!("unterm: md inline query failed: {e}")) .ok()?; - let block_cap = block_q.capture_names().iter().map(|n| name_to_index(n)).collect(); - let inline_cap = inline_q.capture_names().iter().map(|n| name_to_index(n)).collect(); - Some(MdConfig { block_lang, inline_lang, block_q, block_cap, inline_q, inline_cap }) + let block_cap = block_q + .capture_names() + .iter() + .map(|n| name_to_index(n)) + .collect(); + let inline_cap = inline_q + .capture_names() + .iter() + .map(|n| name_to_index(n)) + .collect(); + Some(MdConfig { + block_lang, + inline_lang, + block_q, + block_cap, + inline_q, + inline_cap, + }) }) .as_ref() } @@ -187,8 +202,16 @@ fn build_config(language: Language, highlights: &str) -> Option { let query = Query::new(&language, highlights) .map_err(|e| log::warn!("unterm: highlight query failed: {e}")) .ok()?; - let cap_color = query.capture_names().iter().map(|n| name_to_index(n)).collect(); - Some(LangConfig { language, query, cap_color }) + let cap_color = query + .capture_names() + .iter() + .map(|n| name_to_index(n)) + .collect(); + Some(LangConfig { + language, + query, + cap_color, + }) } /// Map a query capture name to a color-table index by longest dotted-prefix match @@ -257,7 +280,12 @@ impl Highlighter { .set_language(&cfg.language) .map_err(|e| log::warn!("unterm: set_language failed: {e}")) .ok()?; - Some(Highlighter::Ts { cfg, parser, tree: None, prev: String::new() }) + Some(Highlighter::Ts { + cfg, + parser, + tree: None, + prev: String::new(), + }) } /// (Re)highlight `text` into per-logical-line colored spans (split on `\n`, @@ -265,7 +293,12 @@ impl Highlighter { /// tree via an incremental edit when the text changed since the last call. pub fn highlight(&mut self, text: &str, dark: bool) -> Vec { match self { - Highlighter::Ts { cfg, parser, tree, prev } => { + Highlighter::Ts { + cfg, + parser, + tree, + prev, + } => { if let Some(t) = tree.as_mut() { if let Some(edit) = text_edit(prev, text) { t.edit(&edit); @@ -277,11 +310,25 @@ impl Highlighter { let line_start = build_line_starts(text); let mut out = empty_line_spans(line_start.len()); if let Some(t) = tree.as_ref() { - run_query_into(&cfg.query, &cfg.cap_color, t, text, &line_start, dark, &mut out); + run_query_into( + &cfg.query, + &cfg.cap_color, + t, + text, + &line_start, + dark, + &mut out, + ); } out } - Highlighter::Md { cfg, block_parser, inline_parser, block_tree, prev } => { + Highlighter::Md { + cfg, + block_parser, + inline_parser, + block_tree, + prev, + } => { if let Some(t) = block_tree.as_mut() { if let Some(edit) = text_edit(prev, text) { t.edit(&edit); @@ -299,7 +346,15 @@ impl Highlighter { // win any overlap. Inline nodes carry raw text the block grammar // leaves unparsed; we reparse each range via included-ranges (the // resulting node offsets stay in document coordinates). - run_query_into(&cfg.block_q, &cfg.block_cap, bt, text, &line_start, dark, &mut out); + run_query_into( + &cfg.block_q, + &cfg.block_cap, + bt, + text, + &line_start, + dark, + &mut out, + ); let mut ranges = Vec::new(); collect_inline_ranges(bt.root_node(), &mut ranges); for r in ranges { @@ -307,7 +362,15 @@ impl Highlighter { continue; } if let Some(it) = inline_parser.parse(text, None) { - run_query_into(&cfg.inline_q, &cfg.inline_cap, &it, text, &line_start, dark, &mut out); + run_query_into( + &cfg.inline_q, + &cfg.inline_cap, + &it, + text, + &line_start, + dark, + &mut out, + ); } } } @@ -346,7 +409,9 @@ fn build_line_starts(text: &str) -> Vec { /// One empty [`LineSpans`] per logical line. fn empty_line_spans(n_lines: usize) -> Vec { - (0..n_lines).map(|_| LineSpans { spans: Vec::new() }).collect() + (0..n_lines) + .map(|_| LineSpans { spans: Vec::new() }) + .collect() } /// Run `query` over `tree` and clip each capture onto the logical lines it @@ -375,7 +440,9 @@ fn run_query_into( let mut caps = cursor.captures(query, tree.root_node(), text.as_bytes()); while let Some((m, ci)) = caps.next() { let cap = m.captures[*ci]; - let Some(Some(idx)) = cap_color.get(cap.index as usize) else { continue }; + let Some(Some(idx)) = cap_color.get(cap.index as usize) else { + continue; + }; let color = color_for(*idx, dark); let (start, end) = (cap.node.start_byte(), cap.node.end_byte()); let mut line = match line_start.binary_search(&start) { @@ -450,7 +517,12 @@ mod tests { assert_eq!(lines.len(), line_lens.len()); for (i, ls) in lines.iter().enumerate() { for (r, _) in &ls.spans { - assert!(r.end <= line_lens[i], "line {i} span {:?} > {}", r, line_lens[i]); + assert!( + r.end <= line_lens[i], + "line {i} span {:?} > {}", + r, + line_lens[i] + ); assert!(r.start < r.end); } } @@ -463,7 +535,11 @@ mod tests { let lines = hl.highlight(src, true); // The first line ("public class Foo {") must get at least a couple of // colored spans (keyword `public`/`class`, type `Foo`). - assert!(lines[0].spans.len() >= 2, "spans: {:?}", lines[0].spans.len()); + assert!( + lines[0].spans.len() >= 2, + "spans: {:?}", + lines[0].spans.len() + ); assert_well_formed(src, &lines); } @@ -502,14 +578,22 @@ mod tests { // The heading line gets colored spans (the `# ` marker and/or the title text). assert!(!lines[0].spans.is_empty(), "heading line had no spans"); // The inline line gets spans from the inline grammar (bold + code span). - assert!(lines[2].spans.len() >= 2, "inline spans: {}", lines[2].spans.len()); + assert!( + lines[2].spans.len() >= 2, + "inline spans: {}", + lines[2].spans.len() + ); // An incremental edit must match a from-scratch parse. let src2 = "# Title!\n\nSome **bold** and `code` here.\n"; let inc = hl.highlight(src2, true); let fresh = Highlighter::new("md").unwrap().highlight(src2, true); assert_eq!(inc.len(), fresh.len()); for (a, f) in inc.iter().zip(fresh.iter()) { - assert_eq!(a.spans.len(), f.spans.len(), "line span-count mismatch after edit"); + assert_eq!( + a.spans.len(), + f.spans.len(), + "line span-count mismatch after edit" + ); } } } diff --git a/native/unterm/src/input.rs b/native/unterm/src/input.rs index a767242..c9080d7 100644 --- a/native/unterm/src/input.rs +++ b/native/unterm/src/input.rs @@ -12,8 +12,8 @@ use glyphon::{ }; use crate::gpu::{self, FORMAT}; -use crate::surface::{self, SharedSurface}; use crate::quads::{Quad, QuadRenderer}; +use crate::surface::{self, SharedSurface}; use std::ffi::c_void; use std::hash::{Hash, Hasher}; @@ -200,14 +200,26 @@ impl InputBox { let swash_cache = SwashCache::new(); let viewport = Viewport::new(&g.device, &g.cache); let mut atlas = TextAtlas::new(&g.device, &g.queue, &g.cache, FORMAT); - let text_renderer = - TextRenderer::new(&mut atlas, &g.device, wgpu::MultisampleState::default(), None); + let text_renderer = TextRenderer::new( + &mut atlas, + &g.device, + wgpu::MultisampleState::default(), + None, + ); let quads = QuadRenderer::new(&g.device, FORMAT); - let popup_text = - TextRenderer::new(&mut atlas, &g.device, wgpu::MultisampleState::default(), None); + let popup_text = TextRenderer::new( + &mut atlas, + &g.device, + wgpu::MultisampleState::default(), + None, + ); let popup_quads = QuadRenderer::new(&g.device, FORMAT); - let peek_text = - TextRenderer::new(&mut atlas, &g.device, wgpu::MultisampleState::default(), None); + let peek_text = TextRenderer::new( + &mut atlas, + &g.device, + wgpu::MultisampleState::default(), + None, + ); let peek_quads = QuadRenderer::new(&g.device, FORMAT); let editor = { @@ -306,8 +318,8 @@ impl InputBox { pub fn set_font(&mut self, path: &str) { self.attrs_dirty = true; // family feeds every line's attrs - // A family name (not a file path) is already in the shared FontSystem (a - // system UI font): address it directly, no file load. + // A family name (not a file path) is already in the shared FontSystem (a + // system UI font): address it directly, no file load. if !gpu::is_font_path(path) { self.font_family = Some(path.to_string()); return; @@ -402,8 +414,13 @@ impl InputBox { /// there, but an Unstage would still drop the staged version, so the host offers /// both Stage (update) and Unstage (drop). pub fn hunk_has_staged(&self, hunk_i: usize) -> bool { - let Some(h) = self.diff_hunks.get(hunk_i) else { return false }; - crate::diff::overlaps_staged(&self.diff_index_hunks, (h.old_start, h.old_start + h.old_len)) + let Some(h) = self.diff_hunks.get(hunk_i) else { + return false; + }; + crate::diff::overlaps_staged( + &self.diff_index_hunks, + (h.old_start, h.old_start + h.old_len), + ) } /// Logical line at surface `y` (px), from the last render's layout. None past the @@ -430,7 +447,11 @@ impl InputBox { let (lo, hi) = if h.new_len > 0 { (h.new_start, h.new_start + h.new_len) // [lo, hi) } else { - let b = if h.new_start < n { h.new_start } else { n.saturating_sub(1) }; + let b = if h.new_start < n { + h.new_start + } else { + n.saturating_sub(1) + }; (b, b + 1) }; // ±1 line of slack so a click near the marker still lands. @@ -446,7 +467,11 @@ impl InputBox { let (lo, hi) = if h.new_len > 0 { (h.new_start, h.new_start + h.new_len) } else { - let b = if h.new_start < n { h.new_start } else { n.saturating_sub(1) }; + let b = if h.new_start < n { + h.new_start + } else { + n.saturating_sub(1) + }; (b, b + 1) }; line >= lo.saturating_sub(1) && line < hi + 1 @@ -469,10 +494,18 @@ impl InputBox { /// (marks the buffer dirty; the base is unchanged so the marker clears on the /// next render). pub fn revert_hunk(&mut self, hunk_i: usize) { - let Some(h) = self.diff_hunks.get(hunk_i).copied() else { return }; - let Some(base) = self.diff_base_text() else { return }; - let base_old: Vec = - base.split('\n').skip(h.old_start).take(h.old_len).map(str::to_string).collect(); + let Some(h) = self.diff_hunks.get(hunk_i).copied() else { + return; + }; + let Some(base) = self.diff_base_text() else { + return; + }; + let base_old: Vec = base + .split('\n') + .skip(h.old_start) + .take(h.old_len) + .map(str::to_string) + .collect(); let n = self.line_count(); if h.new_len > 0 { // Modified/added span: replace the current lines with the base lines @@ -551,7 +584,13 @@ impl InputBox { }; if let Some((is, il)) = self.diff_index_new.get(hi).copied().flatten() { if let Some(index) = self.diff_index.as_deref() { - lines.extend(index.split('\n').skip(is).take(il).map(|s| (true, s.to_string()))); + lines.extend( + index + .split('\n') + .skip(is) + .take(il) + .map(|s| (true, s.to_string())), + ); } } else if h.new_len > 0 { self.editor.with_buffer(|b| { @@ -563,7 +602,11 @@ impl InputBox { if lines.is_empty() { return self.peek.take().is_some(); } - self.peek = Some(Peek { hunk_i: hi, at: (x, y), lines }); + self.peek = Some(Peek { + hunk_i: hi, + at: (x, y), + lines, + }); true } // Left every marker: hide the tooltip (repaint once to clear it). @@ -713,9 +756,12 @@ impl InputBox { /// Current caret as (line, character-column). fn cur_pos(&self) -> (usize, usize) { let c = self.editor.cursor(); - let col = self - .editor - .with_buffer(|b| b.lines.get(c.line).map(|l| byte_to_col(l.text(), c.index)).unwrap_or(0)); + let col = self.editor.with_buffer(|b| { + b.lines + .get(c.line) + .map(|l| byte_to_col(l.text(), c.index)) + .unwrap_or(0) + }); (c.line, col) } @@ -743,8 +789,12 @@ impl InputBox { /// Owned text of logical line `l` (empty if out of range). fn line_text(&self, l: usize) -> String { - self.editor - .with_buffer(|b| b.lines.get(l).map(|x| x.text().to_string()).unwrap_or_default()) + self.editor.with_buffer(|b| { + b.lines + .get(l) + .map(|x| x.text().to_string()) + .unwrap_or_default() + }) } /// Owned text of logical lines `l0..=l1` (clamped to the buffer). @@ -757,16 +807,23 @@ impl InputBox { /// Replace the whole buffer with `lines` as ONE undoable change, then place the /// caret at `caret` (line, char-col) with an optional selection anchor. - fn apply_lines(&mut self, lines: Vec, caret: (usize, usize), sel: Option<(usize, usize)>) { + fn apply_lines( + &mut self, + lines: Vec, + caret: (usize, usize), + sel: Option<(usize, usize)>, + ) { let text = lines.join("\n"); { let mut fs = gpu::lock_font_system(); self.editor.start_change(); self.editor.set_selection(Selection::None); - self.editor.action(&mut fs, Action::Motion(Motion::BufferStart)); + self.editor + .action(&mut fs, Action::Motion(Motion::BufferStart)); let start = self.editor.cursor(); self.editor.set_selection(Selection::Normal(start)); - self.editor.action(&mut fs, Action::Motion(Motion::BufferEnd)); + self.editor + .action(&mut fs, Action::Motion(Motion::BufferEnd)); self.editor.delete_selection(); self.editor.insert_string(&text, None); if let Some(c) = self.editor.finish_change() { @@ -780,7 +837,8 @@ impl InputBox { Some((al, ac)) => { let al = al.min(last); let ab = col_to_byte(&lines[al], ac); - self.editor.set_selection(Selection::Normal(Cursor::new(al, ab))); + self.editor + .set_selection(Selection::Normal(Cursor::new(al, ab))); self.editor.set_cursor(Cursor::new(cl, cb)); } None => { @@ -812,12 +870,20 @@ impl InputBox { let (start, end, repl) = if l1 + 1 < n { // A line follows the block: include the '\n' after l1 so an empty // replacement removes the block cleanly (and a normal one restores it). - let repl = if new_lines.is_empty() { String::new() } else { format!("{joined}\n") }; + let repl = if new_lines.is_empty() { + String::new() + } else { + format!("{joined}\n") + }; (Cursor::new(l0, 0), Cursor::new(l1 + 1, 0), repl) } else if l0 > 0 { // Block runs to EOF but isn't the whole buffer: include the '\n' before // l0 so deletion doesn't leave a trailing empty line. - let repl = if new_lines.is_empty() { String::new() } else { format!("\n{joined}") }; + let repl = if new_lines.is_empty() { + String::new() + } else { + format!("\n{joined}") + }; ( Cursor::new(l0 - 1, self.line_byte_len(l0 - 1)), Cursor::new(l1, self.line_byte_len(l1)), @@ -825,7 +891,11 @@ impl InputBox { ) } else { // Whole buffer. - (Cursor::new(0, 0), Cursor::new(l1, self.line_byte_len(l1)), joined) + ( + Cursor::new(0, 0), + Cursor::new(l1, self.line_byte_len(l1)), + joined, + ) }; self.editor.start_change(); self.editor.set_selection(Selection::Normal(start)); @@ -842,7 +912,8 @@ impl InputBox { Some((al, ac)) => { let al = al.min(last); let ab = col_to_byte(&self.line_text(al), ac); - self.editor.set_selection(Selection::Normal(Cursor::new(al, ab))); + self.editor + .set_selection(Selection::Normal(Cursor::new(al, ab))); self.editor.set_cursor(Cursor::new(cl, cb)); } None => { @@ -867,7 +938,13 @@ impl InputBox { self.splice_lines(l0, l1, &block, (l1, end), Some((l0, 0))); } else { let delta = block[bi].chars().count() as i64 - old as i64; - self.splice_lines(l0, l1, &block, (cur.0, (cur.1 as i64 + delta).max(0) as usize), None); + self.splice_lines( + l0, + l1, + &block, + (cur.0, (cur.1 as i64 + delta).max(0) as usize), + None, + ); } } @@ -885,7 +962,13 @@ impl InputBox { self.splice_lines(l0, l1, &block, (l1, end), Some((l0, 0))); } else { let delta = old as i64 - block[bi].chars().count() as i64; - self.splice_lines(l0, l1, &block, (cur.0, (cur.1 as i64 - delta).max(0) as usize), None); + self.splice_lines( + l0, + l1, + &block, + (cur.0, (cur.1 as i64 - delta).max(0) as usize), + None, + ); } } @@ -903,7 +986,13 @@ impl InputBox { self.splice_lines(l0, l1, &block, (l1, end), Some((l0, 0))); } else { let delta = block[bi].chars().count() as i64 - old as i64; - self.splice_lines(l0, l1, &block, (cur.0, (cur.1 as i64 + delta).max(0) as usize), None); + self.splice_lines( + l0, + l1, + &block, + (cur.0, (cur.1 as i64 + delta).max(0) as usize), + None, + ); } } @@ -1022,7 +1111,11 @@ impl InputBox { .map(|(b, _)| b) .unwrap_or(line.len()) }); - let motion = if cur.index != soft { Motion::SoftHome } else { Motion::Home }; + let motion = if cur.index != soft { + Motion::SoftHome + } else { + Motion::Home + }; self.editor.action(&mut fs, Action::Motion(motion)); } @@ -1092,21 +1185,31 @@ impl InputBox { fn word_target(&self, forward: bool) -> (usize, usize) { let cur = self.cur_pos(); let last = self.editor.with_buffer(|b| b.lines.len().saturating_sub(1)); - let line = self - .editor - .with_buffer(|b| b.lines.get(cur.0).map(|l| l.text().to_string()).unwrap_or_default()); + let line = self.editor.with_buffer(|b| { + b.lines + .get(cur.0) + .map(|l| l.text().to_string()) + .unwrap_or_default() + }); let len = line.chars().count(); if forward { if cur.1 >= len { - if cur.0 < last { (cur.0 + 1, 0) } else { (cur.0, len) } + if cur.0 < last { + (cur.0 + 1, 0) + } else { + (cur.0, len) + } } else { (cur.0, crate::editops::word_right(&line, cur.1)) } } else if cur.1 == 0 { if cur.0 > 0 { - let plen = self - .editor - .with_buffer(|b| b.lines.get(cur.0 - 1).map(|l| l.text().chars().count()).unwrap_or(0)); + let plen = self.editor.with_buffer(|b| { + b.lines + .get(cur.0 - 1) + .map(|l| l.text().chars().count()) + .unwrap_or(0) + }); (cur.0 - 1, plen) } else { (cur.0, 0) @@ -1117,8 +1220,12 @@ impl InputBox { } fn word_byte(&self, line: usize, col: usize) -> usize { - self.editor - .with_buffer(|b| b.lines.get(line).map(|l| col_to_byte(l.text(), col)).unwrap_or(0)) + self.editor.with_buffer(|b| { + b.lines + .get(line) + .map(|l| col_to_byte(l.text(), col)) + .unwrap_or(0) + }) } /// Move the caret one word left/right (code-aware); `shift` extends selection. @@ -1143,7 +1250,8 @@ impl InputBox { let anchor = self.editor.cursor(); let (tl, tc) = self.word_target(forward); self.editor.set_selection(Selection::Normal(anchor)); - self.editor.set_cursor(Cursor::new(tl, self.word_byte(tl, tc))); + self.editor + .set_cursor(Cursor::new(tl, self.word_byte(tl, tc))); } self.editor.start_change(); self.editor.delete_selection(); @@ -1232,7 +1340,6 @@ impl InputBox { self.button != 0 && x >= r[0] && x <= r[0] + r[2] && y >= r[1] && y <= r[1] + r[3] } - pub fn raw_texture(&self) -> *mut c_void { self.shared.raw_texture() } @@ -1281,7 +1388,8 @@ impl InputBox { let attrs = Attrs::new().family(family).color(color); self.editor .with_buffer_mut(|b| b.set_text(&mut fs, text, &attrs, Shaping::Advanced, None)); - self.editor.action(&mut fs, Action::Motion(Motion::BufferEnd)); + self.editor + .action(&mut fs, Action::Motion(Motion::BufferEnd)); // A programmatic buffer replacement invalidates any in-progress IME // composition; drop it so a later clear_preedit can't delete against a stale // anchor that now points past the new buffer (cosmic-text split_off panic). @@ -1383,7 +1491,11 @@ impl InputBox { self.push_change(c); } // Restore the caret, shifted down a line if the import was inserted above it. - let line = if cur.line >= target { cur.line + 1 } else { cur.line }; + let line = if cur.line >= target { + cur.line + 1 + } else { + cur.line + }; self.editor.set_cursor(Cursor::new(line, cur.index)); self.bump(); } @@ -1581,14 +1693,20 @@ impl InputBox { // whole). `.` is a boundary except inside a float literal. self.editor.action(&mut fs, Action::Click { x: bx, y: by }); let cur = self.editor.cursor(); - let line = self - .editor - .with_buffer(|b| b.lines.get(cur.line).map(|l| l.text().to_string()).unwrap_or_default()); + let line = self.editor.with_buffer(|b| { + b.lines + .get(cur.line) + .map(|l| l.text().to_string()) + .unwrap_or_default() + }); let col = byte_to_col(&line, cur.index); let (s, e) = crate::editops::word_at(&line, col); + self.editor.set_selection(Selection::Normal(Cursor::new( + cur.line, + col_to_byte(&line, s), + ))); self.editor - .set_selection(Selection::Normal(Cursor::new(cur.line, col_to_byte(&line, s)))); - self.editor.set_cursor(Cursor::new(cur.line, col_to_byte(&line, e))); + .set_cursor(Cursor::new(cur.line, col_to_byte(&line, e))); } else { let action = match kind { 1 => Action::Drag { x: bx, y: by }, @@ -1601,7 +1719,6 @@ impl InputBox { self.caret_dirty = true; } - /// The current selection text, or None if nothing is selected. pub fn copy(&self) -> Option { self.editor.copy_selection() @@ -1623,10 +1740,12 @@ impl InputBox { pub fn select_all(&mut self) { let mut fs = gpu::lock_font_system(); - self.editor.action(&mut fs, Action::Motion(Motion::BufferStart)); + self.editor + .action(&mut fs, Action::Motion(Motion::BufferStart)); let start = self.editor.cursor(); self.editor.set_selection(Selection::Normal(start)); - self.editor.action(&mut fs, Action::Motion(Motion::BufferEnd)); + self.editor + .action(&mut fs, Action::Motion(Motion::BufferEnd)); } /// Drop any selection (used when focus moves to the transcript). @@ -1690,10 +1809,18 @@ impl InputBox { let width = self.width as f32; let height = self.height as f32; // Reserve a square on the right for the Send/Stop button, if any. - let bw = if self.button != 0 { (28.0 * s).min(height - 2.0).max(0.0) } else { 0.0 }; + let bw = if self.button != 0 { + (28.0 * s).min(height - 2.0).max(0.0) + } else { + 0.0 + }; let btn_x = width - pad - bw; let btn_y = ((height - bw) / 2.0).max(0.0); - self.button_rect = if bw > 0.0 { [btn_x, btn_y, bw, bw] } else { [0.0; 4] }; + self.button_rect = if bw > 0.0 { + [btn_x, btn_y, bw, bw] + } else { + [0.0; 4] + }; let reserve = if bw > 0.0 { bw + pad } else { 0.0 }; // Code-editor gutter: width from the logical line count, shifting the text @@ -1703,7 +1830,11 @@ impl InputBox { // at its plain line-number width. let line_count = self.editor.with_buffer(|b| b.lines.len()).max(1); let bp_dot = (line_height * 0.52).min(font_size); - let bp_col = if self.bp_gutter { bp_dot + pad * 0.45 } else { 0.0 }; + let bp_col = if self.bp_gutter { + bp_dot + pad * 0.45 + } else { + 0.0 + }; let gutter_w = if self.gutter { let digits = ((line_count as f32).log10().floor() as usize + 1).max(2); // digits + a right gap for the numbers + a left lane for the diff markers @@ -1748,7 +1879,11 @@ impl InputBox { self.diff_index_new = display.iter().map(|d| d.index_new).collect(); let n = text.split('\n').count(); self.diff_markers = crate::diff::markers_from_hunks(&self.diff_hunks, n); - crate::diff::apply_staged_bits(&mut self.diff_markers, &self.diff_hunks, &self.diff_staged); + crate::diff::apply_staged_bits( + &mut self.diff_markers, + &self.diff_hunks, + &self.diff_staged, + ); self.diff_gen = self.edit_gen; } } @@ -1772,7 +1907,14 @@ impl InputBox { self.editor.with_buffer_mut(|b| { b.set_metrics(fs, Metrics::new(font_size, line_height)); // Code mode doesn't wrap (long lines scroll horizontally instead). - b.set_wrap(fs, if self.code_mode { Wrap::None } else { Wrap::WordOrGlyph }); + b.set_wrap( + fs, + if self.code_mode { + Wrap::None + } else { + Wrap::WordOrGlyph + }, + ); // Re-apply per-line attrs only when something that feeds them changed // (highlight cache, theme, text color, or font), tracked by `attrs_dirty`. // set_attrs_list itself is cheap (it diffs and skips reshaping), but @@ -1806,7 +1948,11 @@ impl InputBox { // bounded, so this is the only way to see the whole thing. self.editor.with_buffer_mut(|b| { b.set_size(fs, Some(inner_w), None); - b.set_scroll(Scroll { line: 0, vertical: 0.0, horizontal: 0.0 }); + b.set_scroll(Scroll { + line: 0, + vertical: 0.0, + horizontal: 0.0, + }); }); self.editor.shape_as_needed(fs, false); let full_h = self.editor.with_buffer(measure_height); @@ -1843,14 +1989,22 @@ impl InputBox { } } self.scroll_v = self.scroll_v.clamp(0.0, max_scroll); - self.scroll_h = if self.code_mode { self.scroll_h.clamp(0.0, max_scroll_h) } else { 0.0 }; + self.scroll_h = if self.code_mode { + self.scroll_h.clamp(0.0, max_scroll_h) + } else { + 0.0 + }; self.caret_dirty = false; // Pass 2: bound to the box at the kept scroll offset. let scroll_h = self.scroll_h; self.editor.with_buffer_mut(|b| { b.set_size(fs, Some(inner_w), Some(inner_h)); - b.set_scroll(Scroll { line: 0, vertical: self.scroll_v, horizontal: scroll_h }); + b.set_scroll(Scroll { + line: 0, + vertical: self.scroll_v, + horizontal: scroll_h, + }); }); self.editor.shape_as_needed(fs, false); @@ -1963,7 +2117,12 @@ impl InputBox { y, w: (2.0 * s).max(1.0), h: line_height, - color: [self.text_color.r() as f32 / 255.0, self.text_color.g() as f32 / 255.0, self.text_color.b() as f32 / 255.0, 0.9], + color: [ + self.text_color.r() as f32 / 255.0, + self.text_color.g() as f32 / 255.0, + self.text_color.b() as f32 / 255.0, + 0.9, + ], radius: 0.0, }); } @@ -1990,7 +2149,11 @@ impl InputBox { let isize = bw * 0.5; let mut b = Buffer::new(fs, Metrics::new(isize, isize)); b.set_size(fs, Some(bw), Some(bw)); - let ch = if self.button == 2 { "\u{25A0}" } else { "\u{25B6}" }; + let ch = if self.button == 2 { + "\u{25A0}" + } else { + "\u{25B6}" + }; b.set_text( fs, ch, @@ -2042,14 +2205,28 @@ impl InputBox { // Staged hunks draw HOLLOW (an inner cut of the gutter background), the // Zed idiom, so what's already staged reads apart from working changes. if !self.diff_markers.is_empty() { - use crate::diff::{ADDED, DELETED_ABOVE, DELETED_BELOW, MODIFIED, STAGED, STAGED_DEL}; + use crate::diff::{ + ADDED, DELETED_ABOVE, DELETED_BELOW, MODIFIED, STAGED, STAGED_DEL, + }; let dark = self.highlight_dark; // Opaque (alpha 1) + a small radius: the quad SDF leaves a radius-0 // interior at half alpha, which washed these out. Bars overlap by the // radius so consecutive changed lines read as one continuous mark. - let added = if dark { [0.24, 0.64, 0.36, 1.0] } else { [0.18, 0.56, 0.30, 1.0] }; - let modified = if dark { [0.13, 0.54, 0.72, 1.0] } else { [0.10, 0.50, 0.80, 1.0] }; - let deleted = if dark { [0.86, 0.22, 0.22, 1.0] } else { [0.82, 0.12, 0.12, 1.0] }; + let added = if dark { + [0.24, 0.64, 0.36, 1.0] + } else { + [0.18, 0.56, 0.30, 1.0] + }; + let modified = if dark { + [0.13, 0.54, 0.72, 1.0] + } else { + [0.10, 0.50, 0.80, 1.0] + }; + let deleted = if dark { + [0.86, 0.22, 0.22, 1.0] + } else { + [0.82, 0.12, 0.12, 1.0] + }; // The gutter strip's own color, for the hollow inner cut. let gbg = [ (c.r as f32 + shade).min(1.0), @@ -2070,7 +2247,14 @@ impl InputBox { let y = pad + *top; if m & (ADDED | MODIFIED) != 0 { let color = if m & MODIFIED != 0 { modified } else { added }; - quads.push(Quad { x: 0.0, y: y - rr, w: bar_w, h: line_height + rr * 2.0, color, radius: rr }); + quads.push(Quad { + x: 0.0, + y: y - rr, + w: bar_w, + h: line_height + rr * 2.0, + color, + radius: rr, + }); if m & STAGED != 0 { // Hollow: cut the bar's interior back to the gutter color, // leaving a frame (per-line cuts, so a staged line next to @@ -2090,7 +2274,14 @@ impl InputBox { (DELETED_BELOW, y + line_height - wedge_h * 0.5), ] { if m & bit != 0 { - quads.push(Quad { x: 0.0, y: wy, w: wedge_w, h: wedge_h, color: deleted, radius: wedge_h * 0.5 }); + quads.push(Quad { + x: 0.0, + y: wy, + w: wedge_w, + h: wedge_h, + color: deleted, + radius: wedge_h * 0.5, + }); if m & STAGED_DEL != 0 { quads.push(Quad { x: inset, @@ -2140,9 +2331,12 @@ impl InputBox { h.finish() }; if self.gutter_cache.key != Some(key) { - let num_attrs = Attrs::new() - .family(Family::Monospace) - .color(Color::rgba(nc.r(), nc.g(), nc.b(), 120)); + let num_attrs = Attrs::new().family(Family::Monospace).color(Color::rgba( + nc.r(), + nc.g(), + nc.b(), + 120, + )); let mut bufs: Vec = Vec::with_capacity(tops.len()); let mut pos: Vec<(f32, f32)> = Vec::with_capacity(tops.len()); for (li, top) in &tops { @@ -2156,7 +2350,11 @@ impl InputBox { bufs.push(b); pos.push((left, pad + *top)); } - self.gutter_cache = GutterCache { key: Some(key), bufs, pos }; + self.gutter_cache = GutterCache { + key: Some(key), + bufs, + pos, + }; } } else if self.gutter_cache.key.is_some() { self.gutter_cache = GutterCache::default(); @@ -2232,9 +2430,15 @@ impl InputBox { custom_glyphs: &[], }); } - if let Err(e) = - text_renderer.prepare(&g.device, &g.queue, fs, atlas, viewport, areas, swash_cache) - { + if let Err(e) = text_renderer.prepare( + &g.device, + &g.queue, + fs, + atlas, + viewport, + areas, + swash_cache, + ) { // Full atlas / transient device error: log and let the frame // draw without fresh text rather than panic across the C ABI. log::error!("unterm: input glyphon prepare failed: {e}"); @@ -2278,11 +2482,23 @@ impl InputBox { let ph = visible as f32 * row_h + pad; let px = cx.min((width - pw).max(0.0)).max(0.0); - let py = if use_below { cy + row_h } else { (cy - ph).max(0.0) }; - let top = if self.compl_sel >= visible { self.compl_sel + 1 - visible } else { 0 }; + let py = if use_below { + cy + row_h + } else { + (cy - ph).max(0.0) + }; + let top = if self.compl_sel >= visible { + self.compl_sel + 1 - visible + } else { + 0 + }; let bg = self.clear; - let shade = if self.highlight_dark { 0.10_f32 } else { -0.06_f32 }; + let shade = if self.highlight_dark { + 0.10_f32 + } else { + -0.06_f32 + }; let mut pquads: Vec = Vec::with_capacity(2); pquads.push(Quad { x: px, @@ -2306,7 +2522,8 @@ impl InputBox { color: [0.30, 0.50, 0.90, 0.55], radius: 0.0, }); - self.popup_quads.prepare(&g.device, &g.queue, (width, height), &pquads); + self.popup_quads + .prepare(&g.device, &g.queue, (width, height), &pquads); // Each item is a 1-char kind tag + the display label. Strip the tag for // display, keep it to color the row like the editor. @@ -2324,7 +2541,9 @@ impl InputBox { let mut b = Buffer::new(fs, Metrics::new(font_size, row_h)); b.set_size(fs, Some(pw - pad), Some(ph)); b.set_wrap(fs, Wrap::None); // labels never wrap — clip at the popup edge - let base = Attrs::new().family(Family::Monospace).color(self.text_color); + let base = Attrs::new() + .family(Family::Monospace) + .color(self.text_color); b.set_text(fs, &joined, &base, Shaping::Advanced, None); // Color each row like the editor: the name by its kind (function/type/ // property…) and the signature/type part in the type color. @@ -2359,9 +2578,15 @@ impl InputBox { default_color: text_color, custom_glyphs: &[], }; - if let Err(e) = - popup_text.prepare(&g.device, &g.queue, fs, atlas, viewport, [area], swash_cache) - { + if let Err(e) = popup_text.prepare( + &g.device, + &g.queue, + fs, + atlas, + viewport, + [area], + swash_cache, + ) { // Skip the popup's text this frame rather than panic; retried next frame. log::error!("unterm: popup glyphon prepare failed: {e}"); } @@ -2391,7 +2616,8 @@ impl InputBox { .max() .unwrap_or(0); let text_w = (longest as f32 * font_size * 0.6).ceil(); - let card_w = (text_w + sign_w + pad * 2.0).clamp(60.0, (width - pad * 2.0).max(60.0)); + let card_w = + (text_w + sign_w + pad * 2.0).clamp(60.0, (width - pad * 2.0).max(60.0)); let card_h = visible as f32 * line_height + vpad * 2.0; // Top-left just below-right of the pointer — snug, like an OS tooltip @@ -2412,19 +2638,56 @@ impl InputBox { card_y = card_y.clamp(pad, (height - pad - card_h).max(pad)); let shadow = [0.0, 0.0, 0.0, if dark { 0.5 } else { 0.25 }]; - let border = if dark { [0.34, 0.34, 0.36, 1.0] } else { [0.72, 0.72, 0.74, 1.0] }; - let bg = if dark { [0.15, 0.15, 0.16, 1.0] } else { [0.99, 0.99, 0.99, 1.0] }; + let border = if dark { + [0.34, 0.34, 0.36, 1.0] + } else { + [0.72, 0.72, 0.74, 1.0] + }; + let bg = if dark { + [0.15, 0.15, 0.16, 1.0] + } else { + [0.99, 0.99, 0.99, 1.0] + }; // Per-row diff tints (opaque, near-pure so red/green read vividly on the // sRGB target — keep the off-channels low so it doesn't wash to rose/mint). - let row_del = if dark { [0.44, 0.05, 0.05, 1.0] } else { [1.0, 0.62, 0.62, 1.0] }; - let row_add = if dark { [0.05, 0.38, 0.10, 1.0] } else { [0.55, 0.88, 0.58, 1.0] }; + let row_del = if dark { + [0.44, 0.05, 0.05, 1.0] + } else { + [1.0, 0.62, 0.62, 1.0] + }; + let row_add = if dark { + [0.05, 0.38, 0.10, 1.0] + } else { + [0.55, 0.88, 0.58, 1.0] + }; let bw = (1.0 * s).max(1.0); // border thickness let sh = (3.0 * s).max(2.0); // shadow offset let mut pquads: Vec = Vec::with_capacity(3 + visible); // drop shadow, then border, then the neutral card fill. - pquads.push(Quad { x: card_x - bw + sh, y: card_y - bw + sh, w: card_w + bw * 2.0, h: card_h + bw * 2.0, color: shadow, radius: 5.0 * s }); - pquads.push(Quad { x: card_x - bw, y: card_y - bw, w: card_w + bw * 2.0, h: card_h + bw * 2.0, color: border, radius: 5.0 * s }); - pquads.push(Quad { x: card_x, y: card_y, w: card_w, h: card_h, color: bg, radius: 4.0 * s }); + pquads.push(Quad { + x: card_x - bw + sh, + y: card_y - bw + sh, + w: card_w + bw * 2.0, + h: card_h + bw * 2.0, + color: shadow, + radius: 5.0 * s, + }); + pquads.push(Quad { + x: card_x - bw, + y: card_y - bw, + w: card_w + bw * 2.0, + h: card_h + bw * 2.0, + color: border, + radius: 5.0 * s, + }); + pquads.push(Quad { + x: card_x, + y: card_y, + w: card_w, + h: card_h, + color: bg, + radius: 4.0 * s, + }); // one tinted row per diff line. A small radius is required for FULL // opacity: the quad shader's SDF leaves a radius-0 interior at alpha // 0.5 (it only measures the exterior distance). Rows overlap by the @@ -2432,9 +2695,17 @@ impl InputBox { let rr = 2.0 * s; for (i, (added, _)) in pk.lines[..visible].iter().enumerate() { let ry = card_y + vpad + i as f32 * line_height; - pquads.push(Quad { x: card_x, y: ry - rr, w: card_w, h: line_height + rr * 2.0, color: if *added { row_add } else { row_del }, radius: rr }); + pquads.push(Quad { + x: card_x, + y: ry - rr, + w: card_w, + h: line_height + rr * 2.0, + color: if *added { row_add } else { row_del }, + radius: rr, + }); } - self.peek_quads.prepare(&g.device, &g.queue, (width, height), &pquads); + self.peek_quads + .prepare(&g.device, &g.queue, (width, height), &pquads); // Prefix each row with its diff sign; color the whole row's text by kind. let joined: String = pk.lines[..visible] @@ -2445,8 +2716,16 @@ impl InputBox { let mut b = Buffer::new(fs, Metrics::new(font_size, line_height)); b.set_size(fs, Some((card_w - pad * 0.5).max(1.0)), Some(card_h)); b.set_wrap(fs, Wrap::None); // code never wraps — clip at the card edge - let del_c = if dark { Color::rgb(255, 210, 210) } else { Color::rgb(140, 20, 20) }; - let add_c = if dark { Color::rgb(205, 250, 210) } else { Color::rgb(15, 100, 30) }; + let del_c = if dark { + Color::rgb(255, 210, 210) + } else { + Color::rgb(140, 20, 20) + }; + let add_c = if dark { + Color::rgb(205, 250, 210) + } else { + Color::rgb(15, 100, 30) + }; let base = Attrs::new().family(Family::Monospace).color(text_color); b.set_text(fs, &joined, &base, Shaping::Advanced, None); for (line, (added, _)) in b.lines.iter_mut().zip(pk.lines[..visible].iter()) { @@ -2479,9 +2758,15 @@ impl InputBox { default_color: text_color, custom_glyphs: &[], }; - if let Err(e) = - peek_text.prepare(&g.device, &g.queue, fs, atlas, viewport, [area], swash_cache) - { + if let Err(e) = peek_text.prepare( + &g.device, + &g.queue, + fs, + atlas, + viewport, + [area], + swash_cache, + ) { log::error!("unterm: peek glyphon prepare failed: {e}"); } } @@ -2509,19 +2794,28 @@ impl InputBox { multiview_mask: None, }); self.quads.render(&mut pass); - if let Err(e) = self.text_renderer.render(&self.atlas, &self.viewport, &mut pass) { + if let Err(e) = self + .text_renderer + .render(&self.atlas, &self.viewport, &mut pass) + { // Draw the frame without text rather than abort; next frame retries. log::error!("unterm: input glyphon render failed: {e}"); } if popup { self.popup_quads.render(&mut pass); - if let Err(e) = self.popup_text.render(&self.atlas, &self.viewport, &mut pass) { + if let Err(e) = self + .popup_text + .render(&self.atlas, &self.viewport, &mut pass) + { log::error!("unterm: popup glyphon render failed: {e}"); } } if has_peek { self.peek_quads.render(&mut pass); - if let Err(e) = self.peek_text.render(&self.atlas, &self.viewport, &mut pass) { + if let Err(e) = self + .peek_text + .render(&self.atlas, &self.viewport, &mut pass) + { log::error!("unterm: peek glyphon render failed: {e}"); } } @@ -2535,12 +2829,14 @@ impl InputBox { // texture (the zero-copy path has no readback to force completion). self.shared.present(); } - } /// Byte index of character column `col` within `line` (clamped to its end). fn col_to_byte(line: &str, col: usize) -> usize { - line.char_indices().nth(col).map(|(b, _)| b).unwrap_or(line.len()) + line.char_indices() + .nth(col) + .map(|(b, _)| b) + .unwrap_or(line.len()) } /// Character column of byte index `byte` within `line`. @@ -2581,7 +2877,12 @@ fn cursor_char_off(text: &str, cur: Cursor) -> usize { /// Build colored spans for a completion popup label (`name : type`, `Foo(T) : R`): /// the name is colored by the symbol's KIND (from the host) using the editor's /// theme captures, and the rest (params, `:`, type) in the type color. -pub(crate) fn popup_label_attrs(label: &str, kind: char, base: &Attrs, dark: bool) -> glyphon::AttrsList { +pub(crate) fn popup_label_attrs( + label: &str, + kind: char, + base: &Attrs, + dark: bool, +) -> glyphon::AttrsList { let mut al = glyphon::AttrsList::new(base); let paren = label.find('('); let colon = label.find(" : "); @@ -2604,10 +2905,18 @@ pub(crate) fn popup_label_attrs(label: &str, kind: char, base: &Attrs, dark: boo _ => "", }; if name_end > 0 && !capture.is_empty() { - al.add_span(0..name_end, &base.clone().color(crate::highlight::color_of(capture, dark))); + al.add_span( + 0..name_end, + &base + .clone() + .color(crate::highlight::color_of(capture, dark)), + ); } if name_end < label.len() { - al.add_span(name_end..label.len(), &base.clone().color(crate::highlight::color_of("type", dark))); + al.add_span( + name_end..label.len(), + &base.clone().color(crate::highlight::color_of("type", dark)), + ); } al } diff --git a/native/unterm/src/iosurface.rs b/native/unterm/src/iosurface.rs index 2c66b6e..9cc9d1b 100644 --- a/native/unterm/src/iosurface.rs +++ b/native/unterm/src/iosurface.rs @@ -84,7 +84,9 @@ impl SharedSurface { /// Block until the frame is done so Unity samples a finished IOSurface. pub fn present(&mut self) { - let _ = crate::gpu::gpu().device.poll(wgpu::PollType::wait_indefinitely()); + let _ = crate::gpu::gpu() + .device + .poll(wgpu::PollType::wait_indefinitely()); } /// Single-buffered — nothing to advance on idle ticks. diff --git a/native/unterm/src/lib.rs b/native/unterm/src/lib.rs index dc07ca5..38583cc 100644 --- a/native/unterm/src/lib.rs +++ b/native/unterm/src/lib.rs @@ -360,7 +360,9 @@ pub unsafe extern "C" fn unterm_set_preedit(id: u64, text: *const c_char) { let s = if text.is_null() { String::new() } else { - unsafe { CStr::from_ptr(text) }.to_string_lossy().into_owned() + unsafe { CStr::from_ptr(text) } + .to_string_lossy() + .into_owned() }; with_term(id, (), |t| t.set_preedit(&s)); } @@ -646,18 +648,6 @@ pub unsafe extern "C" fn unterm_mcp_respond(id: u64, result_json: *const c_char) } static NEXT_SESSION_ID: AtomicU64 = AtomicU64::new(1); - - - - - - - - - - - - // =========================================================================== // Agent view: a single id-handled object owning the conversation, the transcript // renderer, and the composer. It composes the transcript (history + pending + @@ -707,7 +697,17 @@ pub unsafe extern "C" fn unterm_agentview_create( init_log(); let mcp = ensure_mcp_dispatcher(); let id = NEXT_SESSION_ID.fetch_add(1, Ordering::Relaxed); - let v = AgentView::new(cstr(cwd), mcp, None, pw.max(1), ph.max(1), iw.max(1), ih.max(1), cstr(effort), cstr(claude_cmd)); + let v = AgentView::new( + cstr(cwd), + mcp, + None, + pw.max(1), + ph.max(1), + iw.max(1), + ih.max(1), + cstr(effort), + cstr(claude_cmd), + ); lock_views().insert(id, Box::new(v)); id } @@ -734,7 +734,17 @@ pub unsafe extern "C" fn unterm_agentview_load( (!s.is_empty()).then_some(s) }; let id = NEXT_SESSION_ID.fetch_add(1, Ordering::Relaxed); - let v = AgentView::new(cstr(cwd), mcp, resume, pw.max(1), ph.max(1), iw.max(1), ih.max(1), cstr(effort), cstr(claude_cmd)); + let v = AgentView::new( + cstr(cwd), + mcp, + resume, + pw.max(1), + ph.max(1), + iw.max(1), + ih.max(1), + cstr(effort), + cstr(claude_cmd), + ); lock_views().insert(id, Box::new(v)); id } @@ -814,7 +824,12 @@ pub unsafe extern "C" fn unterm_agentview_set_fonts( bold_italic: *const c_char, ) { if let Some(v) = lock_views().get_mut(&id) { - v.set_fonts(&cstr(regular), &cstr(bold), &cstr(italic), &cstr(bold_italic)); + v.set_fonts( + &cstr(regular), + &cstr(bold), + &cstr(italic), + &cstr(bold_italic), + ); } } @@ -836,7 +851,6 @@ pub extern "C" fn unterm_agentview_input_texture(id: u64) -> *mut c_void { } } - /// Transcript content height in physical px (for the host scrollbar). #[no_mangle] pub extern "C" fn unterm_agentview_content_height(id: u64) -> f32 { @@ -916,7 +930,9 @@ pub extern "C" fn unterm_agentview_browsing(id: u64) -> u8 { /// hover state changed (host should re-render + repaint). #[no_mangle] pub extern "C" fn unterm_agentview_browse_hover(id: u64, x: f32, y: f32) -> u8 { - lock_views().get_mut(&id).map_or(0, |v| v.browse_hover(x, y) as u8) + lock_views() + .get_mut(&id) + .map_or(0, |v| v.browse_hover(x, y) as u8) } /// Toggle listing archived sessions in the browser. @@ -927,11 +943,12 @@ pub extern "C" fn unterm_agentview_browse_toggle_archived(id: u64) { } } - /// How many of the browser's listed sessions are archived. #[no_mangle] pub extern "C" fn unterm_agentview_browse_archived_count(id: u64) -> u64 { - lock_views().get(&id).map_or(0, |v| v.browse_archived_count()) + lock_views() + .get(&id) + .map_or(0, |v| v.browse_archived_count()) } /// Set an interactive permission mode (`default`/`auto`/`plan`/`acceptEdits`). @@ -951,7 +968,10 @@ pub unsafe extern "C" fn unterm_agentview_set_permission_mode(id: u64, mode: *co /// # Safety /// `out_len` writable or null. Pointer valid until the next call on this view. #[no_mangle] -pub unsafe extern "C" fn unterm_agentview_permission_mode(id: u64, out_len: *mut usize) -> *const c_char { +pub unsafe extern "C" fn unterm_agentview_permission_mode( + id: u64, + out_len: *mut usize, +) -> *const c_char { view_string(id, out_len, |v| v.permission_mode()) } @@ -1004,7 +1024,10 @@ pub unsafe extern "C" fn unterm_agentview_commands(id: u64, out_len: *mut usize) /// # Safety /// `out_len` writable or null. Pointer valid until the next call on this view. #[no_mangle] -pub unsafe extern "C" fn unterm_agentview_input_slash_prefix(id: u64, out_len: *mut usize) -> *const c_char { +pub unsafe extern "C" fn unterm_agentview_input_slash_prefix( + id: u64, + out_len: *mut usize, +) -> *const c_char { view_string(id, out_len, |v| v.input_slash_prefix()) } @@ -1014,7 +1037,11 @@ pub unsafe extern "C" fn unterm_agentview_input_slash_prefix(id: u64, out_len: * /// # Safety /// `text` must be a valid C string or null. #[no_mangle] -pub unsafe extern "C" fn unterm_agentview_input_complete(id: u64, prefix_len: u32, text: *const c_char) { +pub unsafe extern "C" fn unterm_agentview_input_complete( + id: u64, + prefix_len: u32, + text: *const c_char, +) { let text = cstr(text); with_view(id, (), |v| v.input_complete(prefix_len as usize, &text)); } @@ -1038,7 +1065,10 @@ pub extern "C" fn unterm_agentview_cancel_queued(id: u64, index: u32) { /// # Safety /// `out_len` writable or null. Pointer valid until the next call on this view. #[no_mangle] -pub unsafe extern "C" fn unterm_agentview_session_id(id: u64, out_len: *mut usize) -> *const c_char { +pub unsafe extern "C" fn unterm_agentview_session_id( + id: u64, + out_len: *mut usize, +) -> *const c_char { view_string(id, out_len, |v| v.session_id()) } @@ -1058,7 +1088,10 @@ pub unsafe extern "C" fn unterm_agentview_title(id: u64, out_len: *mut usize) -> /// # Safety /// `out_len` writable or null. Pointer valid until the next call on this view. #[no_mangle] -pub unsafe extern "C" fn unterm_agentview_take_host_command(id: u64, out_len: *mut usize) -> *const c_char { +pub unsafe extern "C" fn unterm_agentview_take_host_command( + id: u64, + out_len: *mut usize, +) -> *const c_char { view_string(id, out_len, |v| v.take_host_command()) } @@ -1066,7 +1099,12 @@ pub unsafe extern "C" fn unterm_agentview_take_host_command(id: u64, out_len: *m /// for the host to open if it resolves to a file. Empty when not on a token. /// `out_len` writable or null; pointer valid until the next call on this view. #[no_mangle] -pub unsafe extern "C" fn unterm_agentview_panel_token_at(id: u64, x: f32, y: f32, out_len: *mut usize) -> *const c_char { +pub unsafe extern "C" fn unterm_agentview_panel_token_at( + id: u64, + x: f32, + y: f32, + out_len: *mut usize, +) -> *const c_char { view_string(id, out_len, |v| v.panel_token_at(x, y)) } @@ -1091,7 +1129,10 @@ fn relative_snap() -> &'static Mutex { /// # Safety /// `out_len` must be writable or null. #[no_mangle] -pub unsafe extern "C" fn unterm_format_relative(unix_secs: u64, out_len: *mut usize) -> *const c_char { +pub unsafe extern "C" fn unterm_format_relative( + unix_secs: u64, + out_len: *mut usize, +) -> *const c_char { ffi_guard(std::ptr::null(), || { let label = clock::format_relative(unix_secs, clock::now_secs()); let mut snap = relative_snap().lock_recover(); @@ -1354,7 +1395,10 @@ pub extern "C" fn unterm_agentview_input_select_all(id: u64) { /// # Safety /// `out_len` writable or null. Pointer valid until the next call on this view. #[no_mangle] -pub unsafe extern "C" fn unterm_agentview_input_copy(id: u64, out_len: *mut usize) -> *const c_char { +pub unsafe extern "C" fn unterm_agentview_input_copy( + id: u64, + out_len: *mut usize, +) -> *const c_char { view_string(id, out_len, |v| v.input_copy()) } @@ -1372,7 +1416,10 @@ pub unsafe extern "C" fn unterm_agentview_input_cut(id: u64, out_len: *mut usize /// # Safety /// `out_len` writable or null. Pointer valid until the next call on this view. #[no_mangle] -pub unsafe extern "C" fn unterm_agentview_input_text(id: u64, out_len: *mut usize) -> *const c_char { +pub unsafe extern "C" fn unterm_agentview_input_text( + id: u64, + out_len: *mut usize, +) -> *const c_char { view_string(id, out_len, |v| v.input_text()) } @@ -1395,58 +1442,6 @@ unsafe fn view_string( ptr } - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - // =========================================================================== // Code editor view: an id-handled editing surface (tree-sitter highlighting + // line-number gutter) the Unity side blits and drives. Lives in its own @@ -2038,7 +2033,11 @@ pub unsafe extern "C" fn unterm_editor_word_prefix(id: u64, out_len: *mut usize) /// # Safety /// `items` must be a valid C string or null. #[no_mangle] -pub unsafe extern "C" fn unterm_editor_set_completions(id: u64, items: *const c_char, selected: u32) { +pub unsafe extern "C" fn unterm_editor_set_completions( + id: u64, + items: *const c_char, + selected: u32, +) { let items = cstr(items); with_editor(id, (), |e| e.set_completions(&items, selected as usize)); } @@ -2046,7 +2045,9 @@ pub unsafe extern "C" fn unterm_editor_set_completions(id: u64, items: *const c_ /// The caret's absolute character offset in the document (for semantic completion). #[no_mangle] pub extern "C" fn unterm_editor_caret_offset(id: u64) -> u32 { - lock_editors().get(&id).map_or(0, |e| e.caret_offset() as u32) + lock_editors() + .get(&id) + .map_or(0, |e| e.caret_offset() as u32) } /// Show/refresh the native completion popup (a non-activating NSPanel) at screen @@ -2072,9 +2073,24 @@ pub extern "C" fn unterm_popup_show( dark: u8, ) { let items = cstr(items); - let clear = wgpu::Color { r: br as f64, g: bg as f64, b: bb as f64, a: 1.0 }; + let clear = wgpu::Color { + r: br as f64, + g: bg as f64, + b: bb as f64, + a: 1.0, + }; let text = glyphon::Color::rgb(fr, fg, fb); - popup::show(&items, selected as usize, scroll as usize, x, y, scale, clear, text, dark != 0); + popup::show( + &items, + selected as usize, + scroll as usize, + x, + y, + scale, + clear, + text, + dark != 0, + ); } /// Like `unterm_popup_show`, but anchored ABOVE the caret (`x`,`y` is the caret TOP @@ -2098,9 +2114,24 @@ pub extern "C" fn unterm_popup_show_above( dark: u8, ) { let items = cstr(items); - let clear = wgpu::Color { r: br as f64, g: bg as f64, b: bb as f64, a: 1.0 }; + let clear = wgpu::Color { + r: br as f64, + g: bg as f64, + b: bb as f64, + a: 1.0, + }; let text = glyphon::Color::rgb(fr, fg, fb); - popup::show_above(&items, selected as usize, scroll as usize, x, y, scale, clear, text, dark != 0); + popup::show_above( + &items, + selected as usize, + scroll as usize, + x, + y, + scale, + clear, + text, + dark != 0, + ); } /// Hide the native completion popup. macOS only. @@ -2133,9 +2164,24 @@ pub extern "C" fn unterm_popup_sig_show( dark: u8, ) { let line = cstr(line); - let clear = wgpu::Color { r: br as f64, g: bg as f64, b: bb as f64, a: 1.0 }; + let clear = wgpu::Color { + r: br as f64, + g: bg as f64, + b: bb as f64, + a: 1.0, + }; let text = glyphon::Color::rgb(fr, fg, fb); - popup::show_sig(&line, active_start as usize, active_len as usize, x, y, scale, clear, text, dark != 0); + popup::show_sig( + &line, + active_start as usize, + active_len as usize, + x, + y, + scale, + clear, + text, + dark != 0, + ); } /// Hide the native signature-help hint. macOS only. @@ -2154,7 +2200,12 @@ pub extern "C" fn unterm_popup_sig_hide() { /// `title` and `body` must be valid C strings or null. #[cfg(any(target_os = "macos", windows))] #[no_mangle] -pub unsafe extern "C" fn unterm_notify_show(title: *const c_char, body: *const c_char, scale: f32, dark: u8) { +pub unsafe extern "C" fn unterm_notify_show( + title: *const c_char, + body: *const c_char, + scale: f32, + dark: u8, +) { let title = cstr(title); let body = cstr(body); popup::show_notify(&title, &body, scale, dark != 0); diff --git a/native/unterm/src/markdown.rs b/native/unterm/src/markdown.rs index 096d1ea..3753ca0 100644 --- a/native/unterm/src/markdown.rs +++ b/native/unterm/src/markdown.rs @@ -21,13 +21,20 @@ pub struct Span { /// A block-level element. pub enum Block { Paragraph(Vec), - Heading { level: u8, spans: Vec }, + Heading { + level: u8, + spans: Vec, + }, Code { text: String, lang: Option, diff: bool, }, - ListItem { depth: u8, marker: String, spans: Vec }, + ListItem { + depth: u8, + marker: String, + spans: Vec, + }, Quote(Vec), /// A table: a header row then body rows; each cell is a run of spans. Table { @@ -220,4 +227,3 @@ pub fn parse(md: &str) -> Vec { out } - diff --git a/native/unterm/src/palette.rs b/native/unterm/src/palette.rs index 55c0157..f4c6784 100644 --- a/native/unterm/src/palette.rs +++ b/native/unterm/src/palette.rs @@ -27,7 +27,11 @@ pub fn selection_bg(bg: [u8; 3]) -> [u8; 3] { const ACCENT: [u8; 3] = [0x4d, 0x7a, 0xc7]; const T: f32 = 0.55; let mix = |a: u8, b: u8| (a as f32 * (1.0 - T) + b as f32 * T).round() as u8; - [mix(bg[0], ACCENT[0]), mix(bg[1], ACCENT[1]), mix(bg[2], ACCENT[2])] + [ + mix(bg[0], ACCENT[0]), + mix(bg[1], ACCENT[1]), + mix(bg[2], ACCENT[2]), + ] } impl Default for Theme { @@ -69,12 +73,12 @@ pub fn resolve(color: Color, theme: &Theme) -> [u8; 3] { let idx = named as usize; match idx { 0..=15 => theme.ansi[idx], - 256 => theme.fg, // Foreground - 257 => theme.bg, // Background - 258 => theme.cursor, // Cursor - 259..=266 => dim(theme.ansi[idx - 259]), // DimBlack..DimWhite - 267 => theme.fg, // BrightForeground - 268 => dim(theme.fg), // DimForeground + 256 => theme.fg, // Foreground + 257 => theme.bg, // Background + 258 => theme.cursor, // Cursor + 259..=266 => dim(theme.ansi[idx - 259]), // DimBlack..DimWhite + 267 => theme.fg, // BrightForeground + 268 => dim(theme.fg), // DimForeground _ => theme.fg, } } diff --git a/native/unterm/src/panel.rs b/native/unterm/src/panel.rs index 3cbc699..b858c9e 100644 --- a/native/unterm/src/panel.rs +++ b/native/unterm/src/panel.rs @@ -28,8 +28,8 @@ use syntect::parsing::SyntaxSet; use syntect::util::LinesWithEndings; use crate::gpu::{self, FORMAT}; -use crate::surface::{self, SharedSurface}; use crate::quads::{Quad, QuadRenderer}; +use crate::surface::{self, SharedSurface}; use std::ffi::c_void; /// Record/unit separators used to encode role-tagged blocks in `set_text`. @@ -111,14 +111,14 @@ struct Measured { buffer: Arc, text: String, // visible text (must match the buffer, for selection) height: f32, - card_alpha: f32, // 0 = no card background - indent: f32, // left indent in physical px (lists / quotes) - code: bool, // a code block: rendered unwrapped + horizontally scrollable - natural_w: f32, // unwrapped content width (code blocks only) + card_alpha: f32, // 0 = no card background + indent: f32, // left indent in physical px (lists / quotes) + code: bool, // a code block: rendered unwrapped + horizontally scrollable + natural_w: f32, // unwrapped content width (code blocks only) table: Option, // a drawn grid of cells (overrides `buffer`) - tool_key: Option, // tool blocks: fold-state key (click-to-toggle target) - header_h: f32, // tool blocks: height of the header line(s), for the hit rect - stamp: u64, // time separators: raw unix stamp (0 otherwise) + tool_key: Option, // tool blocks: fold-state key (click-to-toggle target) + header_h: f32, // tool blocks: height of the header line(s), for the hit rect + stamp: u64, // time separators: raw unix stamp (0 otherwise) } /// A measured table: positioned cell buffers plus the grid-line/header rects to @@ -223,7 +223,14 @@ fn parse_blocks(text: &str) -> Vec { continue; // same time as the previous separator → merge } last_stamp_label = Some(label.clone()); - out.push(Block { role, text: label, stamp, tool_id: None, tool_preview: String::new(), tool_detail: String::new() }); + out.push(Block { + role, + text: label, + stamp, + tool_id: None, + tool_preview: String::new(), + tool_detail: String::new(), + }); continue; } out.push(Block { @@ -339,8 +346,12 @@ impl PanelRenderer { let swash_cache = SwashCache::new(); let viewport = Viewport::new(&g.device, &g.cache); let mut atlas = TextAtlas::new(&g.device, &g.queue, &g.cache, FORMAT); - let text_renderer = - TextRenderer::new(&mut atlas, &g.device, wgpu::MultisampleState::default(), None); + let text_renderer = TextRenderer::new( + &mut atlas, + &g.device, + wgpu::MultisampleState::default(), + None, + ); let quads = QuadRenderer::new(&g.device, FORMAT); Self { @@ -498,8 +509,14 @@ impl PanelRenderer { } let last = self.laid.len() - 1; self.sel = Some(( - TextPos { block: 0, offset: 0 }, - TextPos { block: last, offset: self.laid[last].text.len() }, + TextPos { + block: 0, + offset: 0, + }, + TextPos { + block: last, + offset: self.laid[last].text.len(), + }, )); } @@ -593,8 +610,7 @@ impl PanelRenderer { while end < n && !text.is_char_boundary(end) { end += 1; } - let tok = text[start..end] - .trim_matches(|c: char| "()[]{}<>,;:\"'`".contains(c)); + let tok = text[start..end].trim_matches(|c: char| "()[]{}<>,;:\"'`".contains(c)); if tok.is_empty() { None } else { @@ -615,7 +631,11 @@ impl PanelRenderer { for bi in lo.block..=hi.block.min(self.laid.len().saturating_sub(1)) { let blk = &self.laid[bi]; let sel_start = if bi == lo.block { lo.offset } else { 0 }; - let sel_end = if bi == hi.block { hi.offset } else { blk.text.len() }; + let sel_end = if bi == hi.block { + hi.offset + } else { + blk.text.len() + }; let line_starts = line_starts(&blk.buffer); for run in blk.buffer.layout_runs() { let line_off = line_starts.get(run.line_i).copied().unwrap_or(0); @@ -713,9 +733,11 @@ impl PanelRenderer { let (Some(key), Some(c)) = (l.code_key, l.clip) else { continue; }; - if x >= c[0] && x <= c[0] + c[2] && y >= c[1] && y <= c[1] + c[3] && l.max_hscroll > 0.5 { + if x >= c[0] && x <= c[0] + c[2] && y >= c[1] && y <= c[1] + c[3] && l.max_hscroll > 0.5 + { let cur = self.hscroll.get(&key).copied().unwrap_or(0.0); - self.hscroll.insert(key, (cur + dx).clamp(0.0, l.max_hscroll)); + self.hscroll + .insert(key, (cur + dx).clamp(0.0, l.max_hscroll)); return true; } } @@ -727,8 +749,7 @@ impl PanelRenderer { /// doesn't scroll the whole transcript). pub fn scroll_v(&mut self, x: f32, y: f32, dy: f32) -> bool { if let Some(r) = self.plan_rect { - if self.plan_max > 0.5 - && x >= r[0] && x <= r[0] + r[2] && y >= r[1] && y <= r[1] + r[3] + if self.plan_max > 0.5 && x >= r[0] && x <= r[0] + r[2] && y >= r[1] && y <= r[1] + r[3] { self.plan_scroll = (self.plan_scroll + dy).clamp(0.0, self.plan_max); return true; @@ -811,7 +832,12 @@ impl PanelRenderer { self.scale.to_bits().hash(&mut h); self.scroll.to_bits().hash(&mut h); self.plan_scroll.to_bits().hash(&mut h); - (self.clear.r.to_bits(), self.clear.g.to_bits(), self.clear.b.to_bits(), self.clear.a.to_bits()) + ( + self.clear.r.to_bits(), + self.clear.g.to_bits(), + self.clear.b.to_bits(), + self.clear.a.to_bits(), + ) .hash(&mut h); self.text_color.0.hash(&mut h); self.font_family.hash(&mut h); @@ -828,8 +854,11 @@ impl PanelRenderer { let mut expanded: Vec<(u64, bool)> = self.expanded.iter().map(|(k, v)| (*k, *v)).collect(); expanded.sort_unstable(); expanded.hash(&mut h); - let mut hscroll: Vec<(u64, u32)> = - self.hscroll.iter().map(|(k, v)| (*k, v.to_bits())).collect(); + let mut hscroll: Vec<(u64, u32)> = self + .hscroll + .iter() + .map(|(k, v)| (*k, v.to_bits())) + .collect(); hscroll.sort_unstable(); hscroll.hash(&mut h); // The render target's identity: resize and the Windows placeholder→shared @@ -883,8 +912,16 @@ impl PanelRenderer { .as_deref() .map(Family::Name) .unwrap_or(Family::Monospace); - let bold = self.font_bold.as_deref().map(Family::Name).unwrap_or(regular); - let italic = self.font_italic.as_deref().map(Family::Name).unwrap_or(regular); + let bold = self + .font_bold + .as_deref() + .map(Family::Name) + .unwrap_or(regular); + let italic = self + .font_italic + .as_deref() + .map(Family::Name) + .unwrap_or(regular); let bold_italic = self .font_bold_italic .as_deref() @@ -936,8 +973,9 @@ impl PanelRenderer { // Fold state first: it's a measure input (an unfolded tool shapes its // detail), so it participates in the cache key. let has_detail = !b.tool_detail.is_empty(); - let fold_key = - (b.role == Role::Tool).then(|| b.tool_id.as_deref().filter(|_| has_detail).map(hash_str)).flatten(); + let fold_key = (b.role == Role::Tool) + .then(|| b.tool_id.as_deref().filter(|_| has_detail).map(hash_str)) + .flatten(); if let Some(k) = fold_key { live_tool_keys.push(k); } @@ -960,12 +998,23 @@ impl PanelRenderer { let items = if let Some(items) = old_cache.remove(&key) { items // unchanged since last frame: reuse the shaped buffers } else if (b.role == Role::Agent || b.role == Role::Plan) && !b.text.is_empty() { - let w = if b.role == Role::Plan { plan_w } else { content_w }; + let w = if b.role == Role::Plan { + plan_w + } else { + content_w + }; markdown::parse(&b.text) .iter() .filter_map(|mb| { build_md( - &mut fs, mb, w, font_size, line_height, card_pad, faces, text_color, + &mut fs, + mb, + w, + font_size, + line_height, + card_pad, + faces, + text_color, lum < 0.5, ) }) @@ -982,14 +1031,32 @@ impl PanelRenderer { // unfolded) the detail render smaller. Build the folded form first for // the click-target height, then re-build with the detail if open. let mut m = build_tool( - &mut fs, &b.text, &b.tool_preview, None, content_w, reserve, font_size, - line_height, card_pad, faces.regular, text_color, + &mut fs, + &b.text, + &b.tool_preview, + None, + content_w, + reserve, + font_size, + line_height, + card_pad, + faces.regular, + text_color, ); let header_h = (m.height - card_pad * 2.0).max(0.0); if unfolded { m = build_tool( - &mut fs, &b.text, &b.tool_preview, Some(&b.tool_detail), content_w, reserve, - font_size, line_height, card_pad, faces.regular, text_color, + &mut fs, + &b.text, + &b.tool_preview, + Some(&b.tool_detail), + content_w, + reserve, + font_size, + line_height, + card_pad, + faces.regular, + text_color, ); } m.tool_key = fold_key; @@ -997,7 +1064,14 @@ impl PanelRenderer { vec![m] } else { let mut m = build_plain( - &mut fs, b, content_w, font_size, line_height, card_pad, faces.regular, text_color, + &mut fs, + b, + content_w, + font_size, + line_height, + card_pad, + faces.regular, + text_color, ); // Timestamp separators sit at the right edge: `indent` shifts a // block's left edge, so indent by the leftover width. Keep the raw @@ -1071,7 +1145,11 @@ impl PanelRenderer { // Buttons scroll inline with the transcript (not pinned to the bottom), so // no bottom strip is reserved; they're added to the content total below and // placed right after the last block, `gap` beneath it. - let buttons_h = if rows.is_empty() { 0.0 } else { gap + button_block_h }; + let buttons_h = if rows.is_empty() { + 0.0 + } else { + gap + button_block_h + }; let content_bottom = height - pad; // The plan box is capped: it contributes at most `plan_region_h` to the @@ -1088,7 +1166,11 @@ impl PanelRenderer { None => (0.0, 0.0), }; // The drawn box adds `card_pad` of inner padding above and below the content. - let plan_box_h = if plan_range.is_some() { plan_inner_h + card_pad * 2.0 } else { 0.0 }; + let plan_box_h = if plan_range.is_some() { + plan_inner_h + card_pad * 2.0 + } else { + 0.0 + }; self.plan_max = (plan_total - plan_inner_h).max(0.0); self.plan_scroll = self.plan_scroll.clamp(0.0, self.plan_max); @@ -1160,14 +1242,28 @@ impl PanelRenderer { if let Some(tbl) = &m.table { for c in &tbl.cells { self.laid.push(LaidBlock { - buffer: c.buffer.clone(), text: c.text.clone(), tx: tx + c.dx, ty: plan_y + c.dy, - hscroll: 0.0, clip: Some(clip), code_key: None, max_hscroll: 0.0, stamp: 0, + buffer: c.buffer.clone(), + text: c.text.clone(), + tx: tx + c.dx, + ty: plan_y + c.dy, + hscroll: 0.0, + clip: Some(clip), + code_key: None, + max_hscroll: 0.0, + stamp: 0, }); } } else { self.laid.push(LaidBlock { - buffer: m.buffer.clone(), text: m.text.clone(), tx, ty: plan_y, - hscroll: 0.0, clip: Some(clip), code_key: None, max_hscroll: 0.0, stamp: 0, + buffer: m.buffer.clone(), + text: m.text.clone(), + tx, + ty: plan_y, + hscroll: 0.0, + clip: Some(clip), + code_key: None, + max_hscroll: 0.0, + stamp: 0, }); } plan_y += m.height + gap; @@ -1176,11 +1272,16 @@ impl PanelRenderer { if self.plan_max > 0.5 && plan_total > 0.0 { let track = plan_inner_h; let thumb_h = (track * plan_inner_h / plan_total).max(20.0 * s); - let thumb_y = plan_box_top + card_pad + let thumb_y = plan_box_top + + card_pad + (self.plan_scroll / self.plan_max) * (track - thumb_h); quads.push(Quad { - x: pad + content_w - 4.0 * s, y: thumb_y, w: 3.0 * s, h: thumb_h, - color: [overlay, overlay, overlay, 0.45], radius: 1.5 * s, + x: pad + content_w - 4.0 * s, + y: thumb_y, + w: 3.0 * s, + h: thumb_h, + color: [overlay, overlay, overlay, 0.45], + radius: 1.5 * s, }); } y = plan_box_top + plan_box_h + gap; @@ -1248,7 +1349,8 @@ impl PanelRenderer { // triangle (▶ folded / ▼ open) is pinned to the header's right edge. if let Some(key) = m.tool_key { let hit_h = (card_pad * 2.0 + m.header_h).min(m.height); - self.tool_rects.push((key, [x0, y, (content_w - m.indent).max(1.0), hit_h])); + self.tool_rects + .push((key, [x0, y, (content_w - m.indent).max(1.0), hit_h])); let glyph = if self.expanded.get(&key).copied().unwrap_or(false) { "▼" } else { @@ -1259,7 +1361,9 @@ impl PanelRenderer { gb.set_text( &mut fs, glyph, - &Attrs::new().family(faces.regular).color(dim(text_color, 150)), + &Attrs::new() + .family(faces.regular) + .color(dim(text_color, 150)), Shaping::Advanced, None, ); @@ -1274,7 +1378,12 @@ impl PanelRenderer { let inner_w = (content_w - card_pad * 2.0).max(1.0); let max_h = (m.natural_w - inner_w).max(0.0); let key = hash_str(&m.text); - let cur = self.hscroll.get(&key).copied().unwrap_or(0.0).clamp(0.0, max_h); + let cur = self + .hscroll + .get(&key) + .copied() + .unwrap_or(0.0) + .clamp(0.0, max_h); self.hscroll.insert(key, cur); live_keys.push(key); let left = tx.max(0.0); @@ -1434,7 +1543,10 @@ impl PanelRenderer { multiview_mask: None, }); self.quads.render(&mut pass); - if let Err(e) = self.text_renderer.render(&self.atlas, &self.viewport, &mut pass) { + if let Err(e) = self + .text_renderer + .render(&self.atlas, &self.viewport, &mut pass) + { // Draw the frame without text rather than abort; next frame retries. log::error!("unterm: panel glyphon render failed: {e}"); } @@ -1452,7 +1564,6 @@ impl PanelRenderer { // Keep this frame's shaped blocks for the next one (see `block_cache`). self.block_cache = groups.into_iter().collect(); } - } /// Stable content hash, used to key a code block's horizontal scroll. @@ -1472,7 +1583,11 @@ fn syntax_set() -> &'static SyntaxSet { fn theme(dark: bool) -> &'static Theme { static T: OnceLock = OnceLock::new(); let ts = T.get_or_init(ThemeSet::load_defaults); - let name = if dark { "base16-ocean.dark" } else { "InspiredGitHub" }; + let name = if dark { + "base16-ocean.dark" + } else { + "InspiredGitHub" + }; &ts.themes[name] } @@ -1544,7 +1659,11 @@ fn build_plain( text_color: Color, ) -> Measured { let carded = b.role.carded(); - let inner_w = if carded { content_w - card_pad * 2.0 } else { content_w }; + let inner_w = if carded { + content_w - card_pad * 2.0 + } else { + content_w + }; let color = match b.role { Role::Thought => dim(text_color, 150), Role::Tool => dim(text_color, 205), @@ -1578,7 +1697,11 @@ fn build_plain( Role::Queued => 0.05, _ => 0.0, }; - let height = if carded { text_h + card_pad * 2.0 } else { text_h }; + let height = if carded { + text_h + card_pad * 2.0 + } else { + text_h + }; Measured { buffer: Arc::new(buffer), text: b.text.clone(), @@ -1615,7 +1738,10 @@ fn build_tool( let inner_w = content_w - card_pad * 2.0 - reserve_right; let small = Metrics::new(font_size * 0.84, line_height * 0.84); let head_attrs = Attrs::new().family(family).color(dim(text_color, 205)); - let small_attrs = Attrs::new().family(family).color(dim(text_color, 165)).metrics(small); + let small_attrs = Attrs::new() + .family(family) + .color(dim(text_color, 165)) + .metrics(small); // Owned span texts (kept alive for the borrowed slices set_rich_text takes). let mut texts: Vec = vec![header.to_string()]; @@ -1725,10 +1851,30 @@ fn build_md( use markdown::Block as MB; match mb { MB::Paragraph(spans) => { - let (buffer, text) = - shape_spans(fs, spans, content_w, font_size, line_height, false, text_color, faces); + let (buffer, text) = shape_spans( + fs, + spans, + content_w, + font_size, + line_height, + false, + text_color, + faces, + ); let height = measure_height(&buffer); - Some(Measured { buffer: Arc::new(buffer), text, height, card_alpha: 0.0, indent: 0.0, code: false, natural_w: 0.0, table: None, tool_key: None, header_h: 0.0, stamp: 0 }) + Some(Measured { + buffer: Arc::new(buffer), + text, + height, + card_alpha: 0.0, + indent: 0.0, + code: false, + natural_w: 0.0, + table: None, + tool_key: None, + header_h: 0.0, + stamp: 0, + }) } MB::Heading { level, spans } => { let scale = match level { @@ -1748,7 +1894,19 @@ fn build_md( faces, ); let height = measure_height(&buffer); - Some(Measured { buffer: Arc::new(buffer), text, height, card_alpha: 0.0, indent: 0.0, code: false, natural_w: 0.0, table: None, tool_key: None, header_h: 0.0, stamp: 0 }) + Some(Measured { + buffer: Arc::new(buffer), + text, + height, + card_alpha: 0.0, + indent: 0.0, + code: false, + natural_w: 0.0, + table: None, + tool_key: None, + header_h: 0.0, + stamp: 0, + }) } MB::Code { text, lang, diff } => { // Code is rendered unwrapped and clipped to the card; the panel @@ -1763,7 +1921,8 @@ fn build_md( }; if *diff { // Color +/- lines like a diff (kept whole, including newlines). - let lines: Vec = text.split_inclusive('\n').map(|l| l.to_string()).collect(); + let lines: Vec = + text.split_inclusive('\n').map(|l| l.to_string()).collect(); let parts: Vec<(&str, Attrs)> = lines .iter() .map(|l| { @@ -1823,7 +1982,11 @@ fn build_md( header_h: 0.0, }) } - MB::ListItem { depth, marker, spans } => { + MB::ListItem { + depth, + marker, + spans, + } => { let indent = (*depth as f32) * (font_size * 1.2); let mut all: Vec = Vec::with_capacity(spans.len() + 1); all.push(markdown::Span { @@ -1842,7 +2005,19 @@ fn build_md( faces, ); let height = measure_height(&buffer); - Some(Measured { buffer: Arc::new(buffer), text, height, card_alpha: 0.0, indent, code: false, natural_w: 0.0, table: None, tool_key: None, header_h: 0.0, stamp: 0 }) + Some(Measured { + buffer: Arc::new(buffer), + text, + height, + card_alpha: 0.0, + indent, + code: false, + natural_w: 0.0, + table: None, + tool_key: None, + header_h: 0.0, + stamp: 0, + }) } MB::Quote(spans) => { let indent = font_size; @@ -1857,11 +2032,30 @@ fn build_md( faces, ); let height = measure_height(&buffer); - Some(Measured { buffer: Arc::new(buffer), text, height, card_alpha: 0.0, indent, code: false, natural_w: 0.0, table: None, tool_key: None, header_h: 0.0, stamp: 0 }) - } - MB::Table { headers, rows } => { - build_table(fs, headers, rows, content_w, font_size, line_height, faces, text_color) + Some(Measured { + buffer: Arc::new(buffer), + text, + height, + card_alpha: 0.0, + indent, + code: false, + natural_w: 0.0, + table: None, + tool_key: None, + header_h: 0.0, + stamp: 0, + }) } + MB::Table { headers, rows } => build_table( + fs, + headers, + rows, + content_w, + font_size, + line_height, + faces, + text_color, + ), MB::Rule => None, } } @@ -1900,7 +2094,16 @@ fn build_table( let mut col_w = vec![0.0_f32; cols]; for row in &all { for (i, cell) in row.iter().enumerate() { - let (buf, _) = shape_spans(fs, cell, 1.0e6, font_size, line_height, false, text_color, faces); + let (buf, _) = shape_spans( + fs, + cell, + 1.0e6, + font_size, + line_height, + false, + text_color, + faces, + ); col_w[i] = col_w[i].max(measure_width(&buf) + pad_x * 2.0); } } @@ -1923,8 +2126,16 @@ fn build_table( for (i, cell) in row.iter().enumerate() { let inner = (col_w[i] - pad_x * 2.0).max(1.0); let header = r == 0; - let (buf, text) = - shape_spans(fs, cell, inner, font_size, line_height, header, text_color, faces); + let (buf, text) = shape_spans( + fs, + cell, + inner, + font_size, + line_height, + header, + text_color, + faces, + ); let h = measure_height(&buf); row_h[r] = row_h[r].max(h); out_row.push((buf, text, h)); diff --git a/native/unterm/src/popup.rs b/native/unterm/src/popup.rs index d9e5c3e..09dbd1d 100644 --- a/native/unterm/src/popup.rs +++ b/native/unterm/src/popup.rs @@ -18,8 +18,6 @@ use glyphon::{ use crate::gpu::{self}; use crate::quads::{Quad, QuadRenderer}; -#[cfg(target_os = "macos")] -use std::ffi::c_void; #[cfg(target_os = "macos")] use objc2::rc::Retained; #[cfg(target_os = "macos")] @@ -32,6 +30,8 @@ use objc2_app_kit::{NSBackingStoreType, NSPanel, NSScreen, NSWindowStyleMask}; use objc2_foundation::{NSPoint, NSRect, NSSize}; #[cfg(target_os = "macos")] use objc2_quartz_core::CAMetalLayer; +#[cfg(target_os = "macos")] +use std::ffi::c_void; #[cfg(windows)] use std::num::NonZeroIsize; @@ -49,8 +49,8 @@ use windows::Win32::UI::WindowsAndMessaging::{ GetWindowRect, GetWindowThreadProcessId, IsIconic, IsWindowVisible, RegisterClassW, SetForegroundWindow, SetLayeredWindowAttributes, SetWindowPos, ShowWindow, GWL_EXSTYLE, GW_OWNER, HWND_TOPMOST, LWA_ALPHA, SWP_NOACTIVATE, SW_HIDE, SW_RESTORE, SW_SHOWNOACTIVATE, - WM_LBUTTONUP, WNDCLASSW, WS_EX_LAYERED, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, - WS_EX_TOPMOST, WS_EX_TRANSPARENT, WS_POPUP, + WM_LBUTTONUP, WNDCLASSW, WS_EX_LAYERED, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, WS_EX_TOPMOST, + WS_EX_TRANSPARENT, WS_POPUP, }; const ROW: f32 = 18.0; // logical row height (scaled) @@ -208,12 +208,33 @@ fn create(notify: bool) -> Option { panel.setAlphaValue(0.0); panel.orderFrontRegardless(); - Some(Popup { panel, layer, surface, atlas, viewport, text, quads, swash, format, alpha, w: 0, h: 0 }) + Some(Popup { + panel, + layer, + surface, + atlas, + viewport, + text, + quads, + swash, + format, + alpha, + w: 0, + h: 0, + }) } #[cfg(target_os = "macos")] #[allow(clippy::too_many_arguments)] -fn show_inner(p: &mut Popup, placement: Placement, content: Content, scale: f32, clear: wgpu::Color, text_color: Color, dark: bool) { +fn show_inner( + p: &mut Popup, + placement: Placement, + content: Content, + scale: f32, + clear: wgpu::Color, + text_color: Color, + dark: bool, +) { let s = scale.max(0.5); let font_size = 14.0 * s; let row_h = ROW * s; @@ -337,7 +358,9 @@ unsafe extern "system" fn enum_main_window(hwnd: HWND, lparam: LPARAM) -> window let search = &mut *(lparam.0 as *mut MainWinSearch); let mut wpid = 0u32; GetWindowThreadProcessId(hwnd, Some(&mut wpid)); - let owned = GetWindow(hwnd, GW_OWNER).map(|o| !o.0.is_null()).unwrap_or(false); + let owned = GetWindow(hwnd, GW_OWNER) + .map(|o| !o.0.is_null()) + .unwrap_or(false); let ex = GetWindowLongPtrW(hwnd, GWL_EXSTYLE) as u32; let is_tool = ex & WS_EX_TOOLWINDOW.0 != 0; if wpid == search.pid && !owned && !is_tool && IsWindowVisible(hwnd).as_bool() { @@ -437,13 +460,34 @@ fn create(notify: bool) -> Option { let (format, alpha) = pick_format_alpha(&surface); let (atlas, viewport, text, quads, swash) = make_renderers(format); - Some(Popup { hwnd, shown: false, surface, atlas, viewport, text, quads, swash, format, alpha, w: 0, h: 0 }) + Some(Popup { + hwnd, + shown: false, + surface, + atlas, + viewport, + text, + quads, + swash, + format, + alpha, + w: 0, + h: 0, + }) } } #[cfg(windows)] #[allow(clippy::too_many_arguments)] -fn show_inner(p: &mut Popup, placement: Placement, content: Content, scale: f32, clear: wgpu::Color, text_color: Color, dark: bool) { +fn show_inner( + p: &mut Popup, + placement: Placement, + content: Content, + scale: f32, + clear: wgpu::Color, + text_color: Color, + dark: bool, +) { let s = scale.max(0.5); let font_size = 14.0 * s; let row_h = ROW * s; @@ -485,7 +529,15 @@ fn show_inner(p: &mut Popup, placement: Placement, content: Content, scale: f32, } }; unsafe { - let _ = SetWindowPos(p.hwnd, Some(HWND_TOPMOST), px, py, wpx as i32, hpx as i32, SWP_NOACTIVATE); + let _ = SetWindowPos( + p.hwnd, + Some(HWND_TOPMOST), + px, + py, + wpx as i32, + hpx as i32, + SWP_NOACTIVATE, + ); } p.configure(wpx, hpx, s); @@ -530,7 +582,9 @@ impl Drop for Popup { // ------------------------------------------------------------------------- shared /// Pick a surface format (prefer sRGB) and alpha mode (prefer transparency-capable). -fn pick_format_alpha(surface: &wgpu::Surface<'static>) -> (wgpu::TextureFormat, wgpu::CompositeAlphaMode) { +fn pick_format_alpha( + surface: &wgpu::Surface<'static>, +) -> (wgpu::TextureFormat, wgpu::CompositeAlphaMode) { let g = gpu::gpu(); let caps = surface.get_capabilities(&g.adapter); let format = caps @@ -538,22 +592,45 @@ fn pick_format_alpha(surface: &wgpu::Surface<'static>) -> (wgpu::TextureFormat, .iter() .copied() .find(|f| f.is_srgb()) - .unwrap_or_else(|| caps.formats.first().copied().unwrap_or(wgpu::TextureFormat::Bgra8UnormSrgb)); + .unwrap_or_else(|| { + caps.formats + .first() + .copied() + .unwrap_or(wgpu::TextureFormat::Bgra8UnormSrgb) + }); let alpha = caps .alpha_modes .iter() .copied() .find(|&a| a == wgpu::CompositeAlphaMode::PostMultiplied) - .unwrap_or_else(|| caps.alpha_modes.first().copied().unwrap_or(wgpu::CompositeAlphaMode::Auto)); + .unwrap_or_else(|| { + caps.alpha_modes + .first() + .copied() + .unwrap_or(wgpu::CompositeAlphaMode::Auto) + }); (format, alpha) } -fn make_renderers(format: wgpu::TextureFormat) -> (TextAtlas, Viewport, TextRenderer, QuadRenderer, glyphon::SwashCache) { +fn make_renderers( + format: wgpu::TextureFormat, +) -> ( + TextAtlas, + Viewport, + TextRenderer, + QuadRenderer, + glyphon::SwashCache, +) { let g = gpu::gpu(); let swash = glyphon::SwashCache::new(); let viewport = Viewport::new(&g.device, &g.cache); let mut atlas = TextAtlas::new(&g.device, &g.queue, &g.cache, format); - let text = TextRenderer::new(&mut atlas, &g.device, wgpu::MultisampleState::default(), None); + let text = TextRenderer::new( + &mut atlas, + &g.device, + wgpu::MultisampleState::default(), + None, + ); let quads = QuadRenderer::new(&g.device, format); (atlas, viewport, text, quads, swash) } @@ -605,26 +682,72 @@ impl Popup { /// '\n'-joined `kind+label` lines; `x`/`y` are the caret's screen position in POINTS /// (top-left origin, from Unity's GUIToScreenPoint); `scale` is pixels-per-point. #[allow(clippy::too_many_arguments)] -pub fn show(items: &str, selected: usize, scroll: usize, x: f32, y: f32, scale: f32, clear: wgpu::Color, text_color: Color, dark: bool) { +pub fn show( + items: &str, + selected: usize, + scroll: usize, + x: f32, + y: f32, + scale: f32, + clear: wgpu::Color, + text_color: Color, + dark: bool, +) { if items.is_empty() { hide(); return; } let lines: Vec<&str> = items.split('\n').collect(); - show_slot(0, Placement::Caret { x, y, above: false }, Content::List { lines, selected, scroll, badges: true }, scale, clear, text_color, dark); + show_slot( + 0, + Placement::Caret { x, y, above: false }, + Content::List { + lines, + selected, + scroll, + badges: true, + }, + scale, + clear, + text_color, + dark, + ); } /// Like [`show`], but anchored ABOVE the caret (the list's bottom sits just above /// `y`, the caret TOP in points). For a composer docked at the window bottom, where /// a below-anchored list would fall off-screen. #[allow(clippy::too_many_arguments)] -pub fn show_above(items: &str, selected: usize, scroll: usize, x: f32, y: f32, scale: f32, clear: wgpu::Color, text_color: Color, dark: bool) { +pub fn show_above( + items: &str, + selected: usize, + scroll: usize, + x: f32, + y: f32, + scale: f32, + clear: wgpu::Color, + text_color: Color, + dark: bool, +) { if items.is_empty() { hide(); return; } let lines: Vec<&str> = items.split('\n').collect(); - show_slot(0, Placement::Caret { x, y, above: true }, Content::List { lines, selected, scroll, badges: false }, scale, clear, text_color, dark); + show_slot( + 0, + Placement::Caret { x, y, above: true }, + Content::List { + lines, + selected, + scroll, + badges: false, + }, + scale, + clear, + text_color, + dark, + ); } /// Hide the completion list. @@ -636,13 +759,39 @@ pub fn hide() { /// is the full signature; `active_start`/`active_len` are CHAR offsets of the active /// parameter within `line` to highlight. `x`/`y` are the caret TOP in screen points. #[allow(clippy::too_many_arguments)] -pub fn show_sig(line: &str, active_start: usize, active_len: usize, x: f32, y: f32, scale: f32, clear: wgpu::Color, text_color: Color, dark: bool) { +pub fn show_sig( + line: &str, + active_start: usize, + active_len: usize, + x: f32, + y: f32, + scale: f32, + clear: wgpu::Color, + text_color: Color, + dark: bool, +) { if line.is_empty() { hide_sig(); return; } - let accent = if dark { Color::rgb(120, 170, 255) } else { Color::rgb(0, 90, 200) }; - show_slot(1, Placement::Caret { x, y, above: true }, Content::Sig { line, active: (active_start, active_len), accent }, scale, clear, text_color, dark); + let accent = if dark { + Color::rgb(120, 170, 255) + } else { + Color::rgb(0, 90, 200) + }; + show_slot( + 1, + Placement::Caret { x, y, above: true }, + Content::Sig { + line, + active: (active_start, active_len), + accent, + }, + scale, + clear, + text_color, + dark, + ); } /// Hide the signature-help hint. @@ -659,11 +808,41 @@ pub fn show_notify(title: &str, body: &str, scale: f32, dark: bool) { return; } let (clear, text_color, accent) = if dark { - (wgpu::Color { r: 0.13, g: 0.13, b: 0.15, a: 1.0 }, Color::rgb(232, 232, 238), Color::rgb(120, 170, 255)) + ( + wgpu::Color { + r: 0.13, + g: 0.13, + b: 0.15, + a: 1.0, + }, + Color::rgb(232, 232, 238), + Color::rgb(120, 170, 255), + ) } else { - (wgpu::Color { r: 0.97, g: 0.97, b: 0.98, a: 1.0 }, Color::rgb(28, 28, 34), Color::rgb(0, 100, 210)) + ( + wgpu::Color { + r: 0.97, + g: 0.97, + b: 0.98, + a: 1.0, + }, + Color::rgb(28, 28, 34), + Color::rgb(0, 100, 210), + ) }; - show_slot(2, Placement::ScreenTopRight, Content::Notify { title, body, accent }, scale, clear, text_color, dark); + show_slot( + 2, + Placement::ScreenTopRight, + Content::Notify { + title, + body, + accent, + }, + scale, + clear, + text_color, + dark, + ); } /// Hide the agent notification card. @@ -672,7 +851,15 @@ pub fn hide_notify() { } #[allow(clippy::too_many_arguments)] -fn show_slot(slot: u8, placement: Placement, content: Content, scale: f32, clear: wgpu::Color, text_color: Color, dark: bool) { +fn show_slot( + slot: u8, + placement: Placement, + content: Content, + scale: f32, + clear: wgpu::Color, + text_color: Color, + dark: bool, +) { with_slot(slot, |cell| { let mut guard = cell.borrow_mut(); if guard.is_none() { @@ -722,7 +909,10 @@ fn content_size(content: &Content, font_size: f32, row_h: f32, pad: f32) -> (u32 } fn char_to_byte(s: &str, char_idx: usize) -> usize { - s.char_indices().nth(char_idx).map(|(b, _)| b).unwrap_or(s.len()) + s.char_indices() + .nth(char_idx) + .map(|(b, _)| b) + .unwrap_or(s.len()) } /// One-letter kind badge shown before a completion label. Keeps the editor's kind @@ -768,13 +958,21 @@ fn render( (clear.b as f32 + shade).clamp(0.0, 1.0), 1.0, ], - radius: if cfg!(windows) { 0.0 } else { 4.0 * (font_size / 14.0) }, + radius: if cfg!(windows) { + 0.0 + } else { + 4.0 * (font_size / 14.0) + }, }); // A notification insets its text past the left accent bar and pads the top so // the two lines sit centred; the caret panels hug the top-left. let is_notify = matches!(&content, Content::Notify { .. }); - let (text_left, text_top) = if is_notify { (pad * 1.8, pad) } else { (pad * 0.5, pad * 0.5) }; + let (text_left, text_top) = if is_notify { + (pad * 1.8, pad) + } else { + (pad * 0.5, pad * 0.5) + }; let mut fs = gpu::lock_font_system(); let base = Attrs::new().family(Family::Monospace).color(text_color); @@ -785,7 +983,12 @@ fn render( buf.set_wrap(&mut fs, Wrap::None); match content { - Content::List { lines, selected, scroll, badges } => { + Content::List { + lines, + selected, + scroll, + badges, + } => { // The host owns the scroll offset: the wheel scrolls the view without // moving the selection, and arrows move the selection. Clamp defensively. let total = lines.len(); @@ -832,7 +1035,11 @@ fn render( bl.set_attrs_list(crate::input::popup_label_attrs(&label, kind, &base, dark)); } } - Content::Sig { line, active, accent } => { + Content::Sig { + line, + active, + accent, + } => { buf.set_text(&mut fs, line, &base, Shaping::Advanced, None); let (cs, cl) = active; if cl > 0 { @@ -847,7 +1054,11 @@ fn render( } } } - Content::Notify { title, body, accent } => { + Content::Notify { + title, + body, + accent, + } => { // A slim left accent bar so the card reads as a notification. let bar_w = 3.5 * (font_size / 14.0); quads.push(Quad { @@ -865,7 +1076,11 @@ fn render( }); // Title in the primary text colour, subtitle a touch softer on the second // line — but still high-contrast against the card so it stays readable. - let dim = if dark { Color::rgb(206, 206, 214) } else { Color::rgb(74, 74, 84) }; + let dim = if dark { + Color::rgb(206, 206, 214) + } else { + Color::rgb(74, 74, 84) + }; let joined = format!("{title}\n{body}"); buf.set_text(&mut fs, &joined, &base, Shaping::Advanced, None); if let Some(bl) = buf.lines.get_mut(1) { @@ -875,9 +1090,20 @@ fn render( } buf.shape_until_scroll(&mut fs, false); - p.viewport.update(&g.queue, Resolution { width: p.w, height: p.h }); + p.viewport.update( + &g.queue, + Resolution { + width: p.w, + height: p.h, + }, + ); p.quads.prepare(&g.device, &g.queue, (w, h), &quads); - let bounds = TextBounds { left: 0, top: 0, right: p.w as i32, bottom: p.h as i32 }; + let bounds = TextBounds { + left: 0, + top: 0, + right: p.w as i32, + bottom: p.h as i32, + }; p.text .prepare( &g.device, @@ -901,7 +1127,12 @@ fn render( // On Windows the layered HWND can't show per-pixel alpha, so clear to the opaque // background; on macOS clear transparent and let the rounded quad show through. let load = if cfg!(windows) { - wgpu::LoadOp::Clear(wgpu::Color { r: clear.r, g: clear.g, b: clear.b, a: 1.0 }) + wgpu::LoadOp::Clear(wgpu::Color { + r: clear.r, + g: clear.g, + b: clear.b, + a: 1.0, + }) } else { wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT) }; @@ -933,10 +1164,14 @@ fn render( } _ => return false, }; - let view = frame.texture.create_view(&wgpu::TextureViewDescriptor::default()); + let view = frame + .texture + .create_view(&wgpu::TextureViewDescriptor::default()); let mut enc = g .device - .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("unterm-popup") }); + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("unterm-popup"), + }); { let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("unterm-popup-pass"), @@ -944,7 +1179,10 @@ fn render( view: &view, depth_slice: None, resolve_target: None, - ops: wgpu::Operations { load, store: wgpu::StoreOp::Store }, + ops: wgpu::Operations { + load, + store: wgpu::StoreOp::Store, + }, })], depth_stencil_attachment: None, timestamp_writes: None, diff --git a/native/unterm/src/quads.rs b/native/unterm/src/quads.rs index 061941b..2001c9d 100644 --- a/native/unterm/src/quads.rs +++ b/native/unterm/src/quads.rs @@ -17,7 +17,11 @@ struct GrowBuffer { impl GrowBuffer { fn new(label: &'static str) -> Self { - Self { label, buf: None, capacity: 0 } + Self { + label, + buf: None, + capacity: 0, + } } fn upload(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, bytes: &[u8]) { @@ -278,8 +282,16 @@ impl MeshRenderer { array_stride: std::mem::size_of::() as u64, step_mode: wgpu::VertexStepMode::Vertex, attributes: &[ - wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x2, offset: 0, shader_location: 0 }, - wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x4, offset: 8, shader_location: 1 }, + wgpu::VertexAttribute { + format: wgpu::VertexFormat::Float32x2, + offset: 0, + shader_location: 0, + }, + wgpu::VertexAttribute { + format: wgpu::VertexFormat::Float32x4, + offset: 8, + shader_location: 1, + }, ], }; let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { @@ -307,7 +319,13 @@ impl MeshRenderer { multiview_mask: None, cache: None, }); - Self { pipeline, bind_group, uniform_buf, verts: GrowBuffer::new("unterm-mesh-verts"), count: 0 } + Self { + pipeline, + bind_group, + uniform_buf, + verts: GrowBuffer::new("unterm-mesh-verts"), + count: 0, + } } pub fn prepare( @@ -320,10 +338,14 @@ impl MeshRenderer { queue.write_buffer( &self.uniform_buf, 0, - bytemuck::bytes_of(&Uniforms { resolution: [resolution.0, resolution.1], _pad: [0.0; 2] }), + bytemuck::bytes_of(&Uniforms { + resolution: [resolution.0, resolution.1], + _pad: [0.0; 2], + }), ); self.count = verts.len() as u32; - self.verts.upload(device, queue, bytemuck::cast_slice(verts)); + self.verts + .upload(device, queue, bytemuck::cast_slice(verts)); } pub fn render<'a>(&'a self, pass: &mut wgpu::RenderPass<'a>) { diff --git a/native/unterm/src/renderer.rs b/native/unterm/src/renderer.rs index 9a3532e..53a6f73 100644 --- a/native/unterm/src/renderer.rs +++ b/native/unterm/src/renderer.rs @@ -13,12 +13,12 @@ use glyphon::{ use alacritty_terminal::grid::Dimensions; use alacritty_terminal::term::cell::Flags; -use alacritty_terminal::term::{point_to_viewport, TermMode, Term}; +use alacritty_terminal::term::{point_to_viewport, Term, TermMode}; use crate::gpu::{self, FORMAT}; -use crate::surface::{IOSurfaceRef, SharedSurface}; use crate::palette::{self, Theme}; use crate::quads::{Quad, QuadRenderer}; +use crate::surface::{IOSurfaceRef, SharedSurface}; use crate::term::EventProxy; use std::collections::hash_map::{DefaultHasher, Entry}; use std::collections::HashMap; @@ -133,8 +133,12 @@ impl Renderer { let shared = crate::surface::create_shared_target(&g.device, width, height, FORMAT); let viewport = Viewport::new(&g.device, &g.cache); let mut atlas = TextAtlas::new(&g.device, &g.queue, &g.cache, FORMAT); - let text_renderer = - TextRenderer::new(&mut atlas, &g.device, wgpu::MultisampleState::default(), None); + let text_renderer = TextRenderer::new( + &mut atlas, + &g.device, + wgpu::MultisampleState::default(), + None, + ); let quads = QuadRenderer::new(&g.device, FORMAT); Renderer { @@ -372,7 +376,11 @@ impl Renderer { std::mem::swap(&mut fg, &mut bg); } if flags.contains(Flags::DIM) { - fg = [fg[0] / 2 + fg[0] / 4, fg[1] / 2 + fg[1] / 4, fg[2] / 2 + fg[2] / 4]; + fg = [ + fg[0] / 2 + fg[0] / 4, + fg[1] / 2 + fg[1] / 4, + fg[2] / 2 + fg[2] / 4, + ]; } // Selected cells take the highlight background (text color is kept). if selection.map_or(false, |r| r.contains(indexed.point)) { @@ -423,7 +431,11 @@ impl Renderer { } // The cursor spans two columns when it sits on a wide (CJK) glyph. let cursor_cells = |p: &alacritty_terminal::index::Point| -> f32 { - if self.cells[p.line * cols + p.column.0].wide { cell_w * 2.0 } else { cell_w } + if self.cells[p.line * cols + p.column.0].wide { + cell_w * 2.0 + } else { + cell_w + } }; if show_cursor { if let Some(p) = cursor_vp { @@ -432,22 +444,63 @@ impl Renderer { let cw = cursor_cells(&p); let col = linear(theme.cursor, 1.0); if focused { - quads.push(Quad { x, y, w: cw, h: cell_h, color: col, radius: 0.0 }); + quads.push(Quad { + x, + y, + w: cw, + h: cell_h, + color: col, + radius: 0.0, + }); } else { // Hollow outline when the window isn't focused. let t = (1.0 * self.scale).max(1.0); - quads.push(Quad { x, y, w: cw, h: t, color: col, radius: 0.0 }); - quads.push(Quad { x, y: y + cell_h - t, w: cw, h: t, color: col, radius: 0.0 }); - quads.push(Quad { x, y, w: t, h: cell_h, color: col, radius: 0.0 }); - quads.push(Quad { x: x + cw - t, y, w: t, h: cell_h, color: col, radius: 0.0 }); + quads.push(Quad { + x, + y, + w: cw, + h: t, + color: col, + radius: 0.0, + }); + quads.push(Quad { + x, + y: y + cell_h - t, + w: cw, + h: t, + color: col, + radius: 0.0, + }); + quads.push(Quad { + x, + y, + w: t, + h: cell_h, + color: col, + radius: 0.0, + }); + quads.push(Quad { + x: x + cw - t, + y, + w: t, + h: cell_h, + color: col, + radius: 0.0, + }); } } } // Remember the cursor rect (physical px) for the host's IME placement. self.cursor_px = if show_cursor { - cursor_vp - .map(|p| [pad + p.column.0 as f32 * cell_w, pad + p.line as f32 * cell_h, cursor_cells(&p), cell_h]) + cursor_vp.map(|p| { + [ + pad + p.column.0 as f32 * cell_w, + pad + p.line as f32 * cell_h, + cursor_cells(&p), + cell_h, + ] + }) } else { None }; @@ -576,11 +629,20 @@ impl Renderer { } Some((&text[s..e], attrs_of(fg, bold, italic))) }); - buf.set_rich_text(&mut fs, spans, &Attrs::new().family(family), Shaping::Advanced, None); + buf.set_rich_text( + &mut fs, + spans, + &Attrs::new().family(family), + Shaping::Advanced, + None, + ); buf.shape_until_scroll(&mut fs, false); segs.push((buf, pad + seg_start as f32 * cell_w)); } - entry.insert(ShapedRow { segs, last_used: frame }); + entry.insert(ShapedRow { + segs, + last_used: frame, + }); } // --- IME preedit overlay: the in-progress composition at the cursor. --- @@ -601,7 +663,12 @@ impl Renderer { for ch in preedit.chars() { let w = UnicodeWidthChar::width(ch).unwrap_or(0); if col + w.max(1) > cols && col > 0 { - segments.push((line, seg_start, std::mem::take(&mut seg), col - seg_start)); + segments.push(( + line, + seg_start, + std::mem::take(&mut seg), + col - seg_start, + )); line += 1; col = 0; seg_start = 0; @@ -620,8 +687,22 @@ impl Renderer { // Opaque background + underline over the whole segment. let bx = pad + *sc as f32 * cell_w; let bw = *wc as f32 * cell_w; - quads.push(Quad { x: bx, y, w: bw, h: cell_h, color: linear(theme.bg, 1.0), radius: 0.0 }); - quads.push(Quad { x: bx, y: y + cell_h - ut, w: bw, h: ut, color: linear(theme.fg, 1.0), radius: 0.0 }); + quads.push(Quad { + x: bx, + y, + w: bw, + h: cell_h, + color: linear(theme.bg, 1.0), + radius: 0.0, + }); + quads.push(Quad { + x: bx, + y: y + cell_h - ut, + w: bw, + h: ut, + color: linear(theme.fg, 1.0), + radius: 0.0, + }); // Glyphs are placed exactly like the grid: narrow runs are // shaped together and anchored at their start column, while a // wide (CJK) glyph gets its own buffer anchored at its column @@ -660,9 +741,16 @@ impl Renderer { i += 1; } if !run.is_empty() { - let mut buf = Buffer::new(&mut fs, Metrics::new(font_px, line_h)); + let mut buf = + Buffer::new(&mut fs, Metrics::new(font_px, line_h)); buf.set_size(&mut fs, None, Some(line_h)); - buf.set_text(&mut fs, &run, &attrs_of(theme.fg, false, false), Shaping::Advanced, None); + buf.set_text( + &mut fs, + &run, + &attrs_of(theme.fg, false, false), + Shaping::Advanced, + None, + ); buf.shape_until_scroll(&mut fs, false); overlay.push((buf, pad + run_col as f32 * cell_w, y)); } @@ -730,8 +818,12 @@ impl Renderer { height: self.height, }, ); - self.quads - .prepare(&g.device, &g.queue, (self.width as f32, self.height as f32), &quads); + self.quads.prepare( + &g.device, + &g.queue, + (self.width as f32, self.height as f32), + &quads, + ); if let Err(e) = self.text_renderer.prepare( &g.device, &g.queue, @@ -753,7 +845,12 @@ impl Renderer { let g = gpu::gpu(); let clear = { let c = linear(theme.bg, 1.0); - wgpu::Color { r: c[0] as f64, g: c[1] as f64, b: c[2] as f64, a: 1.0 } + wgpu::Color { + r: c[0] as f64, + g: c[1] as f64, + b: c[2] as f64, + a: 1.0, + } }; let mut encoder = g .device @@ -778,7 +875,10 @@ impl Renderer { multiview_mask: None, }); self.quads.render(&mut pass); - if let Err(e) = self.text_renderer.render(&self.atlas, &self.viewport, &mut pass) { + if let Err(e) = self + .text_renderer + .render(&self.atlas, &self.viewport, &mut pass) + { // Draw the frame without text rather than abort; next frame retries. log::error!("unterm: glyphon render failed: {e}"); } diff --git a/native/unterm/src/sdb/mod.rs b/native/unterm/src/sdb/mod.rs index 6f65017..aa3497b 100644 --- a/native/unterm/src/sdb/mod.rs +++ b/native/unterm/src/sdb/mod.rs @@ -207,7 +207,9 @@ impl Connection { "reply id {rid} != expected {id}" ))); } - Packet::Command { cmd_set, cmd, data, .. } => { + Packet::Command { + cmd_set, cmd, data, .. + } => { self.absorb_command(cmd_set, cmd, &data); } } @@ -222,7 +224,9 @@ impl Connection { } loop { match wire::read_packet(&mut self.stream)? { - Packet::Command { cmd_set, cmd, data, .. } => { + Packet::Command { + cmd_set, cmd, data, .. + } => { if let Some(ev) = decode_command(cmd_set, cmd, &data) { return Ok(ev); } @@ -307,10 +311,7 @@ impl Connection { /// (on Unity, user types load into a child domain at play time — prefer the /// TYPE_LOAD event path for arming pending breakpoints). pub fn types_for_source_file(&mut self, file: &str, ignore_case: bool) -> Result> { - let payload = Encoder::new() - .string(file) - .byte(ignore_case as u8) - .finish(); + let payload = Encoder::new().string(file).byte(ignore_case as u8).finish(); let data = self.request(cs::VM, vm::GET_TYPES_FOR_SOURCE_FILE, &payload)?; let mut d = Decoder::new(&data); let n = d.uint()? as usize; @@ -407,7 +408,9 @@ impl Connection { for m in modifiers { match m { Modifier::LocationOnly { method, il } => { - enc.byte(wire::modifier::LOCATION_ONLY).id(*method).long(*il); + enc.byte(wire::modifier::LOCATION_ONLY) + .id(*method) + .long(*il); } Modifier::SourceFileOnly(files) => { enc.byte(wire::modifier::SOURCE_FILE_ONLY) @@ -522,7 +525,10 @@ impl Connection { let payload = enc.finish(); let data = self.request(cs::STACK_FRAME, wire::frame::GET_VALUES, &payload)?; let mut d = Decoder::new(&data); - positions.iter().map(|_| value::decode_value(&mut d)).collect() + positions + .iter() + .map(|_| value::decode_value(&mut d)) + .collect() } pub fn frame_this(&mut self, thread: u32, frame: i32) -> Result { @@ -588,7 +594,10 @@ impl Connection { let payload = enc.finish(); let data = self.request(cs::OBJECT_REF, wire::object::GET_VALUES, &payload)?; let mut d = Decoder::new(&data); - field_ids.iter().map(|_| value::decode_value(&mut d)).collect() + field_ids + .iter() + .map(|_| value::decode_value(&mut d)) + .collect() } /// The element count of an array (first dimension; SZARRAYs are one-dimensional). @@ -630,7 +639,11 @@ impl Connection { let mut d = Decoder::new(&data); let ns = d.string()?; let name = d.string()?; - Ok(if ns.is_empty() { name } else { format!("{ns}.{name}") }) + Ok(if ns.is_empty() { + name + } else { + format!("{ns}.{name}") + }) } } @@ -874,7 +887,9 @@ fn local_ipv4_interfaces() -> Vec { if !addr.is_null() && (*addr).sa_family as i32 == libc::AF_INET { let sin = addr as *const libc::sockaddr_in; // s_addr is stored in network byte order, i.e. the [a,b,c,d] octets. - out.push(std::net::Ipv4Addr::from((*sin).sin_addr.s_addr.to_ne_bytes())); + out.push(std::net::Ipv4Addr::from( + (*sin).sin_addr.s_addr.to_ne_bytes(), + )); } p = (*p).ifa_next; } diff --git a/native/unterm/src/sdb/value.rs b/native/unterm/src/sdb/value.rs index bebde6c..1027a4b 100644 --- a/native/unterm/src/sdb/value.rs +++ b/native/unterm/src/sdb/value.rs @@ -49,7 +49,10 @@ pub enum Value { /// A managed string object id (fetch contents via STRING_REF.GET_VALUE). String(u32), /// A reference object id (Class/Object/Array/SzArray). `tag` keeps the kind. - Object { tag: u8, id: u32 }, + Object { + tag: u8, + id: u32, + }, /// A boxed/inline value type with its fields. ValueType { klass: u32, diff --git a/native/unterm/src/sdb/wire.rs b/native/unterm/src/sdb/wire.rs index 2064f01..5706977 100644 --- a/native/unterm/src/sdb/wire.rs +++ b/native/unterm/src/sdb/wire.rs @@ -217,8 +217,17 @@ pub type Result = std::result::Result; /// unsolicited command from the agent (composite events arrive this way). #[derive(Debug)] pub enum Packet { - Reply { id: u32, error: u16, data: Vec }, - Command { id: u32, cmd_set: u8, cmd: u8, data: Vec }, + Reply { + id: u32, + error: u16, + data: Vec, + }, + Command { + id: u32, + cmd_set: u8, + cmd: u8, + data: Vec, + }, } /// Big-endian payload builder. Ids are encoded as 4-byte ints (verified on Unity). diff --git a/native/unterm/src/sessions.rs b/native/unterm/src/sessions.rs index 4c21cf2..8e7a92d 100644 --- a/native/unterm/src/sessions.rs +++ b/native/unterm/src/sessions.rs @@ -358,7 +358,11 @@ fn compute(req: &Request) -> Vec { if path.extension().and_then(|e| e.to_str()) != Some("jsonl") { continue; } - let Some(id) = path.file_stem().and_then(|s| s.to_str()).map(str::to_string) else { + let Some(id) = path + .file_stem() + .and_then(|s| s.to_str()) + .map(str::to_string) + else { continue; }; let is_archived = archived.contains(&id); @@ -490,7 +494,11 @@ fn user_title(body: &str) -> Option { } fn first_line(s: &str) -> String { - let line = s.lines().find(|l| !l.trim().is_empty()).unwrap_or("").trim(); + let line = s + .lines() + .find(|l| !l.trim().is_empty()) + .unwrap_or("") + .trim(); truncate_chars(line, 80) } @@ -577,7 +585,10 @@ mod tests { .join("\n"); // body-only term matches and yields a snippet. let (_, snip) = scan(&text, cwd, "frobnicator").unwrap(); - assert!(snip.to_lowercase().contains("frobnicator"), "snippet={snip:?}"); + assert!( + snip.to_lowercase().contains("frobnicator"), + "snippet={snip:?}" + ); // title term matches. assert!(scan(&text, cwd, "widgets").is_some()); // miss -> None. @@ -587,9 +598,15 @@ mod tests { #[test] fn commands_and_caveats_are_not_titles() { assert_eq!(user_title("/model"), None); - assert_eq!(user_title("whatever"), None); + assert_eq!( + user_title("whatever"), + None + ); assert_eq!(user_title(" "), None); - assert_eq!(user_title("real prose\nsecond").as_deref(), Some("real prose")); + assert_eq!( + user_title("real prose\nsecond").as_deref(), + Some("real prose") + ); } #[test] diff --git a/native/unterm/src/surface/d3d.rs b/native/unterm/src/surface/d3d.rs index a654212..3f54e20 100644 --- a/native/unterm/src/surface/d3d.rs +++ b/native/unterm/src/surface/d3d.rs @@ -125,10 +125,17 @@ impl SharedSurface { pub fn begin_frame(&mut self) { if self.buffer.raw_texture.is_null() && !unity::unity_device().is_null() { if let Some(b) = unsafe { - try_shared_buffer(&crate::gpu::gpu().device, self.width, self.height, self.format) + try_shared_buffer( + &crate::gpu::gpu().device, + self.width, + self.height, + self.format, + ) } { self.buffer = b; - log::info!("unterm: shared texture upgraded after the Unity device became available"); + log::info!( + "unterm: shared texture upgraded after the Unity device became available" + ); } } } @@ -136,7 +143,9 @@ impl SharedSurface { /// Block until the GPU finishes the submitted frame (render + copy) so Unity /// samples a complete texture. Same as the macOS path. pub fn present(&mut self) { - let _ = crate::gpu::gpu().device.poll(wgpu::PollType::wait_indefinitely()); + let _ = crate::gpu::gpu() + .device + .poll(wgpu::PollType::wait_indefinitely()); } /// Single-buffered — nothing to advance on idle ticks. diff --git a/native/unterm/src/term.rs b/native/unterm/src/term.rs index 1e5f1ab..5ee0b7f 100644 --- a/native/unterm/src/term.rs +++ b/native/unterm/src/term.rs @@ -427,7 +427,10 @@ impl Terminal { // glyph: the wide char re-creates its own spacer when the dump is // re-parsed, so emitting this cell's space would add an extra column // — widening every CJK glyph's gap on restore. - if cell.flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) { + if cell + .flags + .intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) + { continue; } let fg = palette::resolve(cell.fg, theme); @@ -470,7 +473,11 @@ impl Terminal { /// pid (so it doesn't depend on the shell emitting OSC 7). Empty if there's no /// live shell or the cwd can't be read. Used to restore the cwd on resume. pub fn cwd(&mut self) -> String { - self.shared.cwd.lock().map(|c| c.clone()).unwrap_or_default() + self.shared + .cwd + .lock() + .map(|c| c.clone()) + .unwrap_or_default() } /// [`cwd`](Self::cwd) as a stable C string (valid until the next call). @@ -505,8 +512,8 @@ impl Terminal { t.resize(TermSize { cols, rows }); } if let Some(pty) = &self.pty { - pty.resize(cols as u16, rows as u16); - } + pty.resize(cols as u16, rows as u16); + } } self.shared.dirty.store(true, Ordering::Relaxed); } @@ -521,8 +528,8 @@ impl Terminal { t.resize(TermSize { cols, rows }); } if let Some(pty) = &self.pty { - pty.resize(cols as u16, rows as u16); - } + pty.resize(cols as u16, rows as u16); + } } self.shared.dirty.store(true, Ordering::Relaxed); } @@ -547,7 +554,8 @@ impl Terminal { pub fn render(&mut self) { self.shared.dirty.store(false, Ordering::Relaxed); if let Ok(term) = self.term.lock() { - self.renderer.render(&term, &self.theme, self.focused, &self.preedit); + self.renderer + .render(&term, &self.theme, self.focused, &self.preedit); } } @@ -563,7 +571,11 @@ impl Terminal { } pub fn title(&self) -> String { - self.shared.title.lock().map(|g| g.clone()).unwrap_or_default() + self.shared + .title + .lock() + .map(|g| g.clone()) + .unwrap_or_default() } pub fn is_alive(&mut self) -> bool { diff --git a/native/unterm/src/unity.rs b/native/unterm/src/unity.rs index b885d8f..8c760ff 100644 --- a/native/unterm/src/unity.rs +++ b/native/unterm/src/unity.rs @@ -185,10 +185,14 @@ mod windows_gfx { "unterm: captured Unity device kind={kind:?} adapter=vendor:0x{vendor:04x} device:0x{dev_id:04x}" ); } else { - log::warn!("unterm: captured Unity device kind={kind:?} but adapter id unavailable"); + log::warn!( + "unterm: captured Unity device kind={kind:?} but adapter id unavailable" + ); } } else { - log::info!("unterm: Unity graphics device not ready yet (kind={kind:?}); will retry on init"); + log::info!( + "unterm: Unity graphics device not ready yet (kind={kind:?}); will retry on init" + ); } }