diff --git a/Cargo.lock b/Cargo.lock index c50edae..4a5d90b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,26 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "cc" version = "1.4.4" @@ -18,6 +38,39 @@ dependencies = [ "shlex", ] +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "cosmic-text" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be17b688510d934ce13f48a2beba700e11583e281e0fda99c22bb256a14eda73" +dependencies = [ + "bitflags", + "fontdb", + "harfrust", + "linebender_resource_handle", + "log", + "rangemap", + "rustc-hash", + "self_cell", + "skrifa 0.40.0", + "smol_str", + "swash", + "sys-locale", + "unicode-bidi", + "unicode-linebreak", + "unicode-script", + "unicode-segmentation", +] + [[package]] name = "downcast-rs" version = "1.2.1" @@ -40,6 +93,60 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +[[package]] +name = "font-types" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "font-types" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64eb721ca85a34323425f4041adc5d82704d3782d5f8f03793bc012419dce23" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + +[[package]] +name = "harfrust" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9da2e5ae821f6e96664977bf974d6d6a2d6682f9ccee23e62ec1d134246845f9" +dependencies = [ + "bitflags", + "bytemuck", + "core_maths", + "read-fonts 0.37.0", + "smallvec", +] + [[package]] name = "itoa" version = "1.0.18" @@ -52,12 +159,30 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + [[package]] name = "linux-raw-sys" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + [[package]] name = "memchr" version = "2.8.3" @@ -73,6 +198,12 @@ dependencies = [ "libc", ] +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "pkg-config" version = "0.3.34" @@ -106,6 +237,46 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rangemap" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d" + +[[package]] +name = "read-fonts" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5" +dependencies = [ + "bytemuck", + "core_maths", + "font-types 0.11.3", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types 0.12.4", + "once_cell", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustix" version = "1.1.4" @@ -119,6 +290,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + [[package]] name = "serde" version = "1.0.229" @@ -168,12 +345,58 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "skrifa" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbdfe3d2475fbd7ddd1f3e5cf8288a30eb3e5f95832829570cd88115a7434ac" +dependencies = [ + "bytemuck", + "read-fonts 0.37.0", +] + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts 0.41.0", +] + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + [[package]] name = "smallvec" version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "smol_str" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" + +[[package]] +name = "swash" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e" +dependencies = [ + "skrifa 0.44.0", + "yazi", + "zeno", +] + [[package]] name = "swayipc" version = "4.0.0" @@ -207,6 +430,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + [[package]] name = "thiserror" version = "2.0.20" @@ -227,12 +459,66 @@ dependencies = [ "syn", ] +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wayland-backend" version = "0.3.17" @@ -322,6 +608,7 @@ dependencies = [ name = "wlgrid" version = "0.1.0" dependencies = [ + "cosmic-text", "memmap2", "rustix", "swayipc", @@ -330,6 +617,18 @@ dependencies = [ "wayland-protocols-wlr", ] +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index c33167b..573b509 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ wayland-protocols-wlr = { version = "0.3", features = ["client"] } memmap2 = "0.9" rustix = { version = "1", features = ["fs", "mm", "shm"] } swayipc = "4" +cosmic-text = "0.19" [profile.release] strip = true diff --git a/README.md b/README.md index 7d0a767..4b363dc 100644 --- a/README.md +++ b/README.md @@ -14,27 +14,35 @@ about 60 ms. sway-tree 0.6ms window list + con_ids over sway IPC toplevels 0.2ms ext-foreign-toplevel-list handles constraints 1.5ms every capture session's buffer size, in one roundtrip -capture 55.0ms 8 windows, all frames in flight at once -mapped 4.2ms layer surface + subsurfaces on screen +capture 52.5ms 8 windows, all frames in flight at once +labels 0.0ms shaped on a worker thread while the captures ran +mapped 4.9ms layer surface + subsurfaces on screen ``` The capture phase is the compositor reading full-resolution window pixels out of the GPU. It is bandwidth-bound (~1.1 GB/s here) and unaffected by how large the -thumbnails are. +thumbnails are — which also makes it a free window to do other work in. Loading +a font and rasterising its first glyphs costs ~20ms, so labels are shaped on a +worker thread started before the captures and joined after them, and cost +nothing in wall clock. ## Status -Working, and usable as a switcher today: a static grid with keyboard navigation. -Labels, filtering and live previews are next — see the roadmap. +Working, and usable as a switcher today: a labelled static grid with keyboard +navigation. Filtering and live previews are next — see the roadmap. ## Usage ``` -wlgrid [--print] [--verbose] [--timeout SECS] +wlgrid [--print] [--verbose] [--hide-labels] [--font FAMILY] [--font-size PX] + [--timeout SECS] ``` - `--print` writes the selected sway `con_id` to stdout instead of focusing it - `--verbose` prints phase timings and how many windows were captured +- `--hide-labels` draws an icon-only grid +- `--font FAMILY` label font family (default `Berkeley Mono`) +- `--font-size PX` label size in logical px - `--timeout SECS` exits after a deadline (an escape hatch: the overlay takes an exclusive keyboard grab) @@ -60,8 +68,14 @@ cannot drive it. That goes away with xkb support, which filtering needs anyway. Colours, font metrics and grid geometry come from the rofi theme this replaces (gruvbox dark, a yellow selection filling the element padding, `ceil(sqrt(n))` -columns capped at 4, 16:9 tiles) and live in `src/theme.rs`. They will move to a -config file so they can't drift from the `.rasi`. +columns capped at 4, 16:9 tiles, `title · app` centred underneath) and live in +`src/theme.rs`. They will move to a config file so they can't drift from the +`.rasi`. + +The font is looked up by family name. Your own font directories are scanned +first because they are small; the full system scan (~37ms) happens only if the +family isn't found there, and an unknown family then falls back to whatever +cosmic-text picks rather than failing. Long titles are ellipsised to the cell. ## Requirements @@ -79,7 +93,6 @@ unaffected. It matters more once previews are live. ## Roadmap -- **M2** labels: real text via cosmic-text, `--hide-labels` for an icon-only grid - **M3** type-to-filter with fzf-quality fuzzy matching (and xkb keyboard input) - **M4** live previews: keep the capture sessions open and re-capture on a rate limit, `--live all|current|none` @@ -89,5 +102,5 @@ unaffected. It matters more once previews are live. ``` cargo build --release -cargo test # grid geometry +cargo test # grid geometry, ellipsising, glyph output ``` diff --git a/src/main.rs b/src/main.rs index 28d990a..4e09b2e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ mod shm; mod sway; +mod text; mod theme; use std::error::Error; @@ -156,6 +157,7 @@ struct App { sel: usize, shift: bool, + labels: Option, surface: Option, chrome: Option, chrome_buffers: Vec, @@ -191,6 +193,7 @@ impl App { scale, sel: 0, shift: false, + labels: None, surface: None, chrome: None, chrome_buffers: Vec::new(), @@ -379,29 +382,40 @@ impl App { parent.commit(); } - /// Repaint background, selection highlight and border. + /// Repaint background, selection highlight, labels and border. fn paint(&mut self) { - let (theme, scale, sel) = (&self.theme, self.scale, self.sel); - let elem = self.layout.elem(sel as i32); + let (scale, sel) = (self.scale, self.sel); + let elem = scaled(self.layout.elem(sel as i32), scale); + // Gather geometry before borrowing the chrome and the labels together. + let label_boxes: Vec<(usize, Rect)> = (0..self.tiles.len()) + .filter_map(|i| self.layout.label(i as i32).map(|r| (i, scaled(r, scale)))) + .collect(); + let t = &self.theme; + let (bg, sel_bg, fg, sel_fg, border, border_px) = ( + t.bg, + t.sel_bg, + t.fg, + t.sel_fg, + t.border, + t.border_px * scale, + ); + let labels = self.labels.as_mut(); let Some(chrome) = self.chrome.as_mut() else { return; }; let slot = chrome.next_slot(); let (cw, ch) = (chrome.w, chrome.h); let mut p = chrome.painter(); - p.fill(theme.bg); + p.fill(bg); // The selection fills the whole element box, padding included — the same // thing rofi's element background does. - p.rect( - Rect { - x: elem.x * scale, - y: elem.y * scale, - w: elem.w * scale, - h: elem.h * scale, - }, - theme.sel_bg, - ); - p.frame(theme.border_px * scale, theme.border); + p.rect(elem, sel_bg); + if let Some(labels) = labels { + for (i, at) in label_boxes { + labels.draw(&mut p, i, at, if i == sel { sel_fg } else { fg }); + } + } + p.frame(border_px, border); let surface = self.surface.clone().expect("show() runs first"); surface.attach(self.chrome_buffers.get(slot), 0, 0); @@ -480,6 +494,16 @@ impl Phases { } } +/// Logical rect -> physical rect, for painting into the scaled chrome buffer. +fn scaled(r: Rect, scale: i32) -> Rect { + Rect { + x: r.x * scale, + y: r.y * scale, + w: r.w * scale, + h: r.h * scale, + } +} + fn pump( queue: &mut EventQueue, app: &mut App, @@ -494,6 +518,9 @@ fn pump( struct Args { print: bool, verbose: bool, + hide_labels: bool, + font: Option, + font_size: Option, timeout: Option, } @@ -501,6 +528,9 @@ fn parse_args() -> Result { let mut args = Args { print: false, verbose: false, + hide_labels: false, + font: None, + font_size: None, timeout: None, }; let mut it = std::env::args().skip(1); @@ -508,13 +538,22 @@ fn parse_args() -> Result { match arg.as_str() { "--print" => args.print = true, "-v" | "--verbose" => args.verbose = true, + "--hide-labels" => args.hide_labels = true, + "--font" => args.font = Some(it.next().ok_or("--font needs a family name")?), + "--font-size" => { + let v = it.next().ok_or("--font-size needs px")?; + args.font_size = Some(v.parse().map_err(|_| format!("bad --font-size: {v}"))?); + } "--timeout" => { let v = it.next().ok_or("--timeout needs seconds")?; let secs: f64 = v.parse().map_err(|_| format!("bad --timeout: {v}"))?; args.timeout = Some(Duration::from_secs_f64(secs)); } "-h" | "--help" => { - println!("usage: wlgrid [--print] [--verbose] [--timeout SECS]"); + println!( + "usage: wlgrid [--print] [--verbose] [--hide-labels] \ + [--font FAMILY] [--font-size PX] [--timeout SECS]" + ); std::process::exit(0); } other => return Err(format!("unknown argument: {other}")), @@ -562,10 +601,38 @@ fn run() -> Result> { phases.mark("sway-tree"); + let base = Theme::default(); + let font_px = args.font_size.unwrap_or(base.font_px); + let theme = Theme { + labels: !args.hide_labels, + font: args.font.unwrap_or_else(|| base.font.clone()), + line_h: match args.font_size { + Some(_) => (font_px * 1.3).ceil() as i32, + None => base.line_h, + }, + font_px, + ..base + }; + + // Start shaping labels now: it costs ~55ms of font loading and glyph + // rasterising, and the captures below are ~55ms of waiting on the + // compositor, so the two overlap almost exactly. + let label_job = theme.labels.then(|| { + let layout = Layout::new(&theme, wins.len() as i32); + let box_w = layout.label(0).map(|r| r.w).unwrap_or(theme.tile_w); + text::spawn( + wins.iter().map(sway::Win::label).collect(), + theme.font.clone(), + theme.font_px * scale as f32, + (theme.line_h * scale) as f32, + (box_w * scale) as f32, + ) + }); + let conn = Connection::connect_to_env()?; let (globals, mut queue) = registry_queue_init::(&conn)?; let qh = queue.handle(); - let mut app = App::new(&globals, &qh, wins, Theme::default(), scale)?; + let mut app = App::new(&globals, &qh, wins, theme, scale)?; // Two roundtrips: one for the toplevel list, one for each handle's state. queue.roundtrip(&mut app)?; @@ -580,9 +647,22 @@ fn run() -> Result> { pump(&mut queue, &mut app, |a| a.captures_settled())?; phases.mark("capture"); + if let Some(job) = label_job { + app.labels = job.join().map_err(|_| "label thread panicked")?.into(); + } + phases.mark("labels"); + if args.verbose { let ready = app.tiles.iter().filter(|t| t.ready).count(); let matched = app.tiles.iter().filter(|t| t.handle.is_some()).count(); + for (i, t) in app.tiles.iter().enumerate() { + eprintln!( + " [{i}] con_id={} {}{}", + t.win.con_id, + t.win.label(), + if t.ready { "" } else { " (no thumbnail)" } + ); + } eprintln!( "wlgrid: {} window(s), {matched} matched, {ready} captured; \ grid {}x{}, surface {}x{} logical at scale {}", diff --git a/src/shm.rs b/src/shm.rs index 83c58af..f34f639 100644 --- a/src/shm.rs +++ b/src/shm.rs @@ -74,7 +74,14 @@ pub struct Painter<'a> { h: i32, } -impl Painter<'_> { +impl<'a> Painter<'a> { + /// Wrap a raw ARGB8888 span, so the text path can be exercised without a + /// compositor. `Chrome::painter` is the normal way in. + #[cfg(test)] + pub fn new(px: &'a mut [u8], w: i32, h: i32) -> Self { + Self { px, w, h } + } + pub fn fill(&mut self, c: Argb) { for p in self.px.chunks_exact_mut(4) { p.copy_from_slice(&c.to_le_bytes()); @@ -94,6 +101,31 @@ impl Painter<'_> { } } + /// Blend a solid span at `a/255` coverage, clipped to `clip`. Glyph spans + /// arrive this way: colour plus a coverage alpha. + pub fn blend(&mut self, r: Rect, (sr, sg, sb, sa): (u8, u8, u8, u8), clip: Rect) { + if sa == 0 { + return; + } + let a = sa as u32; + let x0 = r.x.max(clip.x).max(0); + let y0 = r.y.max(clip.y).max(0); + let x1 = (r.x + r.w).min(clip.x + clip.w).min(self.w); + let y1 = (r.y + r.h).min(clip.y + clip.h).min(self.h); + for y in y0..y1 { + let row = (y * self.w * 4) as usize; + for x in x0..x1 { + let o = row + (x * 4) as usize; + // Argb8888 little-endian: B, G, R, A. + for (i, src) in [(0usize, sb), (1, sg), (2, sr)] { + let dst = self.px[o + i] as u32; + self.px[o + i] = ((src as u32 * a + dst * (255 - a)) / 255) as u8; + } + self.px[o + 3] = 255; + } + } + } + /// A `width`-thick frame just inside the surface edge. pub fn frame(&mut self, width: i32, c: Argb) { let (w, h) = (self.w, self.h); diff --git a/src/text.rs b/src/text.rs new file mode 100644 index 0000000..6615183 --- /dev/null +++ b/src/text.rs @@ -0,0 +1,186 @@ +//! Labels. +//! +//! Building a font system and rasterising the first glyphs costs ~55ms, which is +//! almost exactly the window the compositor spends copying window pixels back +//! for us. So all of it happens on a worker thread started before the captures +//! and joined after them: by the time anything is drawn, every label is shaped +//! and its glyphs are already in the cache, and painting one costs ~0.1ms. +//! +//! Sizes here are physical pixels — the caller scales logical units first, +//! because the chrome buffer it paints into is physical too. + +use std::thread::{self, JoinHandle}; + +use cosmic_text::{ + Align, Attrs, Buffer, Color, Family, FontSystem, Metrics, Shaping, Stretch, SwashCache, Weight, + Wrap, fontdb, +}; + +use crate::shm::Painter; +use crate::theme::{Argb, Rect}; + +pub struct Labels { + fs: FontSystem, + cache: SwashCache, + lines: Vec, +} + +/// Shape `texts` into one centred single line each, at most `box_w` wide. +pub fn spawn( + texts: Vec, + family: String, + font_px: f32, + line_h: f32, + box_w: f32, +) -> JoinHandle { + thread::spawn(move || build(texts, family, font_px, line_h, box_w)) +} + +/// Load the smallest font database that can render `family`. +/// +/// `FontSystem::new()` scans every system font, which costs ~37ms — most of the +/// startup budget. A user's own font directories are tiny by comparison, so try +/// those first and only pay for the full scan when the family really isn't +/// there (which is also what makes an unknown family fall back gracefully). +fn font_db(family: &str) -> FontSystem { + let mut db = fontdb::Database::new(); + if let Ok(home) = std::env::var("HOME") { + db.load_fonts_dir(format!("{home}/.fonts")); + db.load_fonts_dir(format!("{home}/.local/share/fonts")); + } + let found = db + .faces() + .any(|f| f.families.iter().any(|(name, _)| name == family)); + if !found { + db.load_system_fonts(); + } + // The locale only orders CJK fallbacks; labels here are app ids and titles. + FontSystem::new_with_locale_and_db("en-US".to_string(), db) +} + +fn build(texts: Vec, family: String, font_px: f32, line_h: f32, box_w: f32) -> Labels { + let mut fs = font_db(&family); + let mut cache = SwashCache::new(); + // An unknown family is not an error: cosmic-text falls back to a system + // face, which is the whole reason the font is named rather than pathed. + let attrs = Attrs::new() + .family(Family::Name(&family)) + .weight(Weight(500)) + .stretch(Stretch::SemiCondensed); + let metrics = Metrics::new(font_px, line_h); + + let mut lines = Vec::with_capacity(texts.len()); + for text in &texts { + let fitted = ellipsize(&mut fs, &attrs, metrics, text, box_w); + let mut buf = Buffer::new(&mut fs, metrics); + buf.set_wrap(Wrap::None); + buf.set_size(Some(box_w), Some(line_h)); + buf.set_text(&fitted, &attrs, Shaping::Advanced, Some(Align::Center)); + // Warm the glyph cache here instead of on the first paint. + buf.draw(&mut fs, &mut cache, Color::rgb(0, 0, 0), |_, _, _, _, _| {}); + lines.push(buf); + } + Labels { fs, cache, lines } +} + +/// Shorten `text` until it fits in `box_w`, ending with an ellipsis — window +/// titles are arbitrarily long, and rofi ellipsised them too. +fn ellipsize( + fs: &mut FontSystem, + attrs: &Attrs, + metrics: Metrics, + text: &str, + box_w: f32, +) -> String { + let measure = |fs: &mut FontSystem, s: &str| { + let mut b = Buffer::new(fs, metrics); + b.set_wrap(Wrap::None); + b.set_size(None, Some(metrics.line_height)); + b.set_text(s, attrs, Shaping::Advanced, None); + b.shape_until_scroll(fs, false); + b.layout_runs().map(|r| r.line_w).fold(0.0, f32::max) + }; + + let full = measure(fs, text); + if full <= box_w { + return text.to_string(); + } + let chars: Vec = text.chars().collect(); + // Proportional first guess, then shrink geometrically. Bounded, because a + // pathological title should not cost hundreds of reshapes. + let mut keep = ((chars.len() as f32) * box_w / full).floor() as usize; + for _ in 0..12 { + keep = keep.min(chars.len().saturating_sub(1)); + let mut s: String = chars[..keep].iter().collect(); + s.push('…'); + if keep == 0 || measure(fs, &s) <= box_w { + return s; + } + keep = (keep * 9 / 10).min(keep.saturating_sub(1)); + } + let mut s: String = chars[..keep.min(chars.len())].iter().collect(); + s.push('…'); + s +} + +impl Labels { + /// Draw label `i` inside `at` (physical px), clipped to it. + pub fn draw(&mut self, p: &mut Painter, i: usize, at: Rect, color: Argb) { + let Some(buf) = self.lines.get_mut(i) else { + return; + }; + let rgb = Color::rgb((color >> 16) as u8, (color >> 8) as u8, color as u8); + buf.draw(&mut self.fs, &mut self.cache, rgb, |x, y, w, h, c| { + p.blend( + Rect { + x: at.x + x, + y: at.y + y, + w: w as i32, + h: h as i32, + }, + (c.r(), c.g(), c.b(), c.a()), + at, + ); + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The whole point of a label is pixels on the surface, so assert some. + #[test] + fn draws_visible_glyphs() { + let (w, h) = (400, 34); + let mut labels = build( + vec!["Hello".to_string()], + "monospace".to_string(), + 26.0, + h as f32, + w as f32, + ); + let mut px = vec![0u8; (w * h * 4) as usize]; + let mut p = Painter::new(&mut px, w, h); + let at = Rect { x: 0, y: 0, w, h }; + labels.draw(&mut p, 0, at, 0x00ffffff); + let touched = px + .chunks_exact(4) + .filter(|c| c[0] != 0 || c[1] != 0 || c[2] != 0) + .count(); + assert!(touched > 20, "only {touched} pixels were painted"); + } + + #[test] + fn ellipsizes_long_titles() { + let mut fs = FontSystem::new(); + let attrs = Attrs::new(); + let metrics = Metrics::new(26.0, 34.0); + let long = "a very long window title that certainly does not fit in one narrow cell"; + let out = ellipsize(&mut fs, &attrs, metrics, long, 200.0); + assert!(out.ends_with('…'), "got {out:?}"); + assert!(out.chars().count() < long.chars().count()); + // Short text is left alone. + assert_eq!(ellipsize(&mut fs, &attrs, metrics, "zsh", 200.0), "zsh"); + } +} diff --git a/src/theme.rs b/src/theme.rs index 0c1e490..eeed233 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -27,6 +27,16 @@ pub struct Theme { /// Margin between the grid and the window edge. pub margin: i32, pub max_cols: i32, + /// Gap between a thumbnail and its label (rasi `element { spacing }`). + pub spacing: i32, + /// Label font: a family name resolved against system fonts, with whatever + /// cosmic-text falls back to if it is missing. Size and line height are + /// logical px — rofi's "Berkeley Mono 12" at pango size="small". + pub font: String, + pub font_px: f32, + pub line_h: i32, + /// Draw labels at all (rofigrid's --hide-labels drew an icon-only grid). + pub labels: bool, } impl Default for Theme { @@ -44,6 +54,11 @@ impl Default for Theme { gap: 15, margin: 12, max_cols: 4, + spacing: 10, + font: "Berkeley Mono".to_string(), + font_px: 13.3, + line_h: 17, + labels: true, } } } @@ -59,6 +74,10 @@ pub struct Layout { margin: i32, gap: i32, pad: i32, + tile_h: i32, + spacing: i32, + line_h: i32, + labels: bool, } impl Layout { @@ -71,7 +90,9 @@ impl Layout { } cols = cols.clamp(1, t.max_cols); let rows = (n + cols - 1) / cols; - let (elem_w, elem_h) = (t.tile_w + 2 * t.pad, t.tile_h + 2 * t.pad); + // An element is the thumbnail, optionally a label under it, and padding. + let label_row = if t.labels { t.spacing + t.line_h } else { 0 }; + let (elem_w, elem_h) = (t.tile_w + 2 * t.pad, t.tile_h + label_row + 2 * t.pad); Self { cols, rows, @@ -82,6 +103,10 @@ impl Layout { margin: t.margin, gap: t.gap, pad: t.pad, + tile_h: t.tile_h, + spacing: t.spacing, + line_h: t.line_h, + labels: t.labels, } } @@ -96,16 +121,30 @@ impl Layout { } } - /// The thumbnail box for index i, i.e. the element box minus its padding. + /// The thumbnail box for index i: the top of the element, above the label. pub fn tile(&self, i: i32) -> Rect { let e = self.elem(i); Rect { x: e.x + self.pad, y: e.y + self.pad, w: e.w - 2 * self.pad, - h: e.h - 2 * self.pad, + h: self.tile_h, } } + + /// The single line of text under the thumbnail, if labels are drawn. + pub fn label(&self, i: i32) -> Option { + if !self.labels { + return None; + } + let t = self.tile(i); + Some(Rect { + x: t.x, + y: t.y + t.h + self.spacing, + w: t.w, + h: self.line_h, + }) + } } #[derive(Clone, Copy, Debug, PartialEq)] @@ -180,6 +219,28 @@ mod tests { } } + #[test] + fn labels_add_a_row_under_each_thumbnail() { + let mut t = Theme::default(); + let with = Layout::new(&t, 4); + t.labels = false; + let without = Layout::new(&t, 4); + let rows = 2; + assert_eq!(with.height - without.height, rows * (t.spacing + t.line_h)); + assert!(without.label(0).is_none()); + + let t = Theme::default(); + let l = Layout::new(&t, 4); + for i in 0..4 { + let (tile, label, elem) = (l.tile(i), l.label(i).unwrap(), l.elem(i)); + assert_eq!(tile.h, t.tile_h); + assert_eq!(label.y, tile.y + tile.h + t.spacing); + assert_eq!(label.w, tile.w); + // Everything, padding included, stays inside the element. + assert!(label.y + label.h + t.pad <= elem.y + elem.h); + } + } + #[test] fn fit_preserves_aspect_and_centres() { let box_ = Rect {