diff --git a/README.md b/README.md index da0f9c3..3087e53 100644 --- a/README.md +++ b/README.md @@ -189,9 +189,12 @@ order — the first master slot — and takes focus, as in dwm. A window spawned onto a tag nobody is viewing leaves both the order and focus alone. **tabbed** — same geometry as monocle, minus a strip at the top where att_wm -draws one solid colour block per window, the focused one highlighted. The blocks -are clickable. Titles are *not* drawn — that would mean a font stack — but the -window list is published over IPC, so a bar can draw a textual tab strip +draws one tab per window, the focused one highlighted. Each tab is clickable and +carries the window's title, elided with an ellipsis when the tab is too narrow. +Titles are drawn in a 5x8 bitmap font built into the binary (`src/font.zig`) — +a font stack for one strip of pixels would be a poor trade — scaled by whole +pixels to suit `tabbar_height`, or by `tab_font_scale` if you would rather say. +The same window list is published over IPC, so a bar can draw its own tab strip alongside (the bundled quickshell config does). Set `tabbar_height = 0` to drop the built-in strip entirely and let the bar own it. @@ -217,9 +220,10 @@ zig build -Dconfig=/path/to/my-config.zig att_wm.override { configFile = ./my-config.zig; } ``` -`config.zig` covers border width and colours, gaps, tab bar height and colours, -the default layout, `nmaster` / `mfact`, tag names, focus-follows-mouse, key and -pointer bindings, autostart commands, input devices, and window rules: +`config.zig` covers border width and colours, gaps, tab bar height, colours and +text size, the default layout, `nmaster` / `mfact`, tag names, +focus-follows-mouse, key and pointer bindings, autostart commands, input +devices, and window rules: ```zig pub const rules = [_]Rule{ @@ -487,7 +491,8 @@ river's identifier is shared between them. | `src/input.zig` | Input configuration types — no Wayland, unit tested | | `src/config.zig` | Compile-time configuration | | `src/ipc.zig` | Socket server and JSON encoding | -| `src/shm.zig` | memfd buffers for the tab bar | +| `src/shm.zig` | memfd buffers for the tab bar, and drawing into them | +| `src/font.zig` | The built-in 5x8 bitmap font — no Wayland, unit tested | | `src/sys.zig` | Thin Linux syscall wrappers | The single most important invariant: **river only permits window management diff --git a/src/Output.zig b/src/Output.zig index bff08bd..ffdb946 100644 --- a/src/Output.zig +++ b/src/Output.zig @@ -21,6 +21,7 @@ const Wm = @import("Wm.zig"); const Window = @import("Window.zig"); const shm = @import("shm.zig"); const color = @import("color.zig"); +const font = @import("font.zig"); const layout = @import("layout.zig"); const Box = layout.Box; @@ -157,9 +158,10 @@ fn onLayerEvent(_: *river.LayerShellOutputV1, event: river.LayerShellOutputV1.Ev } } -/// The strip of solid colour blocks drawn above the windows in the tabbed -/// layout. One block per window, the focused one highlighted; titles are not -/// drawn here but are published over IPC for bars that want to render them. +/// The strip of tabs drawn above the windows in the tabbed layout. One tab per +/// window, labelled with its title and the focused one highlighted. The same +/// titles are published over IPC, for bars that would rather draw the tabs +/// themselves with a real font. pub const TabBar = struct { wm: *Wm, @@ -240,6 +242,7 @@ pub const TabBar = struct { const sep = color.toArgb8888(config.tab_separator); buffer.fill(local, sep); + const scale = textScale(box.height); for (self.rects.items, windows) |rect, win| { const is_focused = focused != null and focused.? == win; const argb = color.toArgb8888(if (is_focused) config.tab_focused else config.tab_normal); @@ -252,6 +255,9 @@ pub const TabBar = struct { .height = rect.height, }; buffer.fill(inner, argb); + + const ink = color.toArgb8888(if (is_focused) config.tab_text_focused else config.tab_text_normal); + drawLabel(buffer, inner, label(win), scale, ink); } // Translate the hit rectangles into global coordinates for click @@ -286,6 +292,48 @@ pub const TabBar = struct { self.windows.clearRetainingCapacity(); } + /// What to write on a window's tab: its title, or its app id while it has + /// not set one, so that a tab is never blank. + fn label(win: *const Window) []const u8 { + if (win.title) |t| { + if (t.len > 0) return t; + } + if (win.app_id) |a| { + if (a.len > 0) return a; + } + return "?"; + } + + /// Whole-pixel scale for the font: the largest that leaves a little air + /// above and below in a bar this tall. `config.tab_font_scale` overrides + /// it for anyone wanting big letters in a thin bar, or the reverse. + fn textScale(height: i32) i32 { + if (config.tab_font_scale > 0) return config.tab_font_scale; + var s: i32 = 1; + while (s < 4 and font.cell_height * (s + 1) + 4 <= height) s += 1; + return s; + } + + /// Centre a title in one tab, eliding it with an ellipsis when the tab is + /// too narrow to hold it. + fn drawLabel(buffer: *shm.Buffer, tab: Box, text: []const u8, scale: i32, argb: u32) void { + const pad = 2 * scale; + const avail = tab.width - 2 * pad; + if (avail <= 0) return; + + const f = font.fit(text, font.cellsForWidth(avail, scale)); + const cells = f.cells + @intFromBool(f.elided); + if (cells == 0) return; + + const x = tab.x + @divFloor(tab.width - font.textWidth(cells, scale), 2); + // Centre the part above the baseline; the descender row may hang into + // the padding, which is what it is for. + const y = tab.y + @divFloor(tab.height - (font.cell_height - 1) * scale, 2); + + const pen = buffer.drawText(text[0..f.end], x, y, scale, argb, tab); + if (f.elided) buffer.drawGlyph(font.ellipsis, pen, y, scale, argb, tab); + } + /// Which window's tab covers this global coordinate, if any. pub fn windowAt(self: *const TabBar, x: i32, y: i32) ?*Window { if (!self.mapped) return null; diff --git a/src/Window.zig b/src/Window.zig index 85da4e7..1359127 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -137,6 +137,15 @@ pub fn applyRules(self: *Window) void { } } +/// Whether this window currently has a tab drawn for it, i.e. it is tiled and +/// visible on an output that is showing the tabbed layout. +fn onTabBar(self: *Window) bool { + if (config.tabbar_height <= 0) return false; + if (!self.visible or self.floating or self.fullscreen or self.closed) return false; + const out = self.output orelse return false; + return out.state().layout == .tabbed; +} + fn setString(self: *Window, field: *?[]u8, value: ?[*:0]const u8) void { const gpa = self.wm.gpa; if (field.*) |old| gpa.free(old); @@ -190,6 +199,10 @@ fn onEvent(_: *river.WindowV1, event: river.WindowV1.Event, self: *Window) void self.setString(&self.title, ev.title); self.applyRules(); self.wm.ipcDirty(); + // The tab bar has this title painted into it, so a window that is + // wearing a tab needs the strip redrawn. Terminals retitle + // themselves constantly, so ask only when it will show. + if (self.onTabBar()) self.wm.needsManage(); }, .identifier => |ev| { diff --git a/src/config.zig b/src/config.zig index 4948b9b..308f17d 100644 --- a/src/config.zig +++ b/src/config.zig @@ -41,6 +41,14 @@ pub const tab_normal: u32 = 0x2c2c2cff; /// Drawn as a 1px line between adjacent tabs. pub const tab_separator: u32 = 0x1a1a1aff; +/// Window titles are drawn on the tabs in these colours. +pub const tab_text_focused: u32 = 0xffffffff; +pub const tab_text_normal: u32 = 0xbbbbbbff; +/// Whole-pixel scale for the 5x8 built-in font the titles are drawn in: 2 +/// gives 10x16 letters. Zero picks the largest scale `tabbar_height` has room +/// for, which is what you want after changing the bar height. +pub const tab_font_scale: i32 = 0; + // ─── Layout ────────────────────────────────────────────────────────────────── pub const default_layout = action.Layout.master; diff --git a/src/font.zig b/src/font.zig new file mode 100644 index 0000000..b267982 --- /dev/null +++ b/src/font.zig @@ -0,0 +1,240 @@ +//! A 5x8 bitmap font, and the text measuring that goes with it. +//! +//! The tab bar has to label its tabs, and a window manager that draws exactly +//! one strip of pixels has no business linking a font stack for it. So the +//! glyphs live here as data: printable ASCII plus an ellipsis for elided +//! titles, drawn as bit rows and expanded by whole pixels when scaled. +//! +//! Anything else — every non-ASCII codepoint a title may hold — renders as the +//! fallback box, which is at least honest about being a character we cannot +//! draw. + +const std = @import("std"); + +pub const cell_width = 5; +pub const cell_height = 8; +/// Columns from one cell to the next: the glyph plus a one column gap. +pub const advance = cell_width + 1; + +/// One glyph: `cell_height` rows, bit `cell_width - 1` the leftmost column. +pub const Glyph = [cell_height]u8; + +pub const first_char = 0x20; +pub const last_char = 0x7e; + +pub const ellipsis: u21 = '…'; + +/// Rows for a codepoint. Control characters come back blank rather than as a +/// box, so a stray newline in a title does not turn into visible noise. +pub fn glyph(cp: u21) *const Glyph { + if (cp < first_char or cp == 0x7f) return &glyphs[0]; + if (cp <= last_char) return &glyphs[cp - first_char]; + if (cp == ellipsis) return &ellipsis_glyph; + return &fallback_glyph; +} + +/// Width in pixels of `cells` characters, without the trailing gap. +pub fn textWidth(cells: usize, scale: i32) i32 { + if (cells == 0) return 0; + const n: i32 = @intCast(cells); + return (n * advance - 1) * scale; +} + +/// How many characters fit in a strip that wide. +pub fn cellsForWidth(width: i32, scale: i32) usize { + if (width <= 0 or scale <= 0) return 0; + // The last cell needs no trailing gap, so lend it one. + const cells = @divFloor(width + scale, advance * scale); + return if (cells <= 0) 0 else @intCast(cells); +} + +pub const Fit = struct { + /// Bytes of the string to draw. + end: usize, + /// Characters those bytes come to, an ellipsis not counted. + cells: usize, + /// The string was too long; draw an ellipsis after `end`. + elided: bool, +}; + +/// Fit a string into `max_cells` character cells, giving up the last cell to +/// an ellipsis when the string does not fit whole. +pub fn fit(text: []const u8, max_cells: usize) Fit { + if (max_cells == 0) return .{ .end = 0, .cells = 0, .elided = false }; + + var it: Iterator = .{ .bytes = text }; + var cells: usize = 0; + // Where the string is cut if it turns out not to fit: one cell short, so + // the ellipsis has somewhere to go. + var cut: usize = 0; + + while (it.next()) |_| { + cells += 1; + if (cells + 1 == max_cells) cut = it.i; + // One character past the end is what proves it does not fit. + if (cells > max_cells) { + return .{ .end = cut, .cells = max_cells - 1, .elided = true }; + } + } + return .{ .end = text.len, .cells = cells, .elided = false }; +} + +/// UTF-8 iteration that never fails: anything malformed comes back as the +/// replacement character, which draws as the fallback box. Titles come from +/// arbitrary clients, so refusing to render one is not an option. +pub const Iterator = struct { + bytes: []const u8, + i: usize = 0, + + const replacement: u21 = 0xfffd; + + pub fn next(self: *Iterator) ?u21 { + if (self.i >= self.bytes.len) return null; + + const len = std.unicode.utf8ByteSequenceLength(self.bytes[self.i]) catch { + self.i += 1; + return replacement; + }; + if (self.i + len > self.bytes.len) { + self.i = self.bytes.len; + return replacement; + } + const cp = std.unicode.utf8Decode(self.bytes[self.i..][0..len]) catch { + self.i += len; + return replacement; + }; + self.i += len; + return cp; + } +}; + +// ─── Glyph data ────────────────────────────────────────────────────────────── +// +// Written as pictures rather than hex so a wrong pixel is visible in review. +// Rows 0..6 hold the glyph, with the baseline under row 6; row 7 is there for +// the descenders of g, j, p, q and y. + +fn parse(comptime picture: [cell_height][]const u8) Glyph { + var rows: Glyph = @splat(0); + for (picture, 0..) |line, y| { + if (line.len != cell_width) @compileError("glyph row must be " ++ + std.fmt.comptimePrint("{d}", .{cell_width}) ++ " columns wide"); + for (line, 0..) |ch, x| { + if (ch == '#') rows[y] |= @as(u8, 1) << @intCast(cell_width - 1 - x); + } + } + return rows; +} + +const fallback_glyph = parse(.{ ".....", "#####", "#...#", "#...#", "#...#", "#...#", "#####", "....." }); +const ellipsis_glyph = parse(.{ ".....", ".....", ".....", ".....", ".....", ".....", "#.#.#", "....." }); + +const glyphs = blk: { + // Ninety-five glyphs of forty pixels each is a lot of comptime looping. + @setEvalBranchQuota(20_000); + var table: [last_char - first_char + 1]Glyph = undefined; + for (art, 0..) |a, i| table[i] = parse(a); + break :blk table; +}; + +const art = [_][cell_height][]const u8{ + .{ ".....", ".....", ".....", ".....", ".....", ".....", ".....", "....." }, // space + .{ "..#..", "..#..", "..#..", "..#..", "..#..", ".....", "..#..", "....." }, // ! + .{ ".#.#.", ".#.#.", ".#.#.", ".....", ".....", ".....", ".....", "....." }, // " + .{ ".#.#.", ".#.#.", "#####", ".#.#.", "#####", ".#.#.", ".#.#.", "....." }, // # + .{ "..#..", ".####", "#.#..", ".###.", "..#.#", "####.", "..#..", "....." }, // $ + .{ "##...", "##..#", "...#.", "..#..", ".#...", "#..##", "...##", "....." }, // % + .{ ".##..", "#..#.", "#.#..", ".#...", "#.#.#", "#..#.", ".##.#", "....." }, // & + .{ "..#..", "..#..", "..#..", ".....", ".....", ".....", ".....", "....." }, // ' + .{ "...#.", "..#..", ".#...", ".#...", ".#...", "..#..", "...#.", "....." }, // ( + .{ ".#...", "..#..", "...#.", "...#.", "...#.", "..#..", ".#...", "....." }, // ) + .{ ".....", "#.#.#", ".###.", "#####", ".###.", "#.#.#", ".....", "....." }, // * + .{ ".....", "..#..", "..#..", "#####", "..#..", "..#..", ".....", "....." }, // + + .{ ".....", ".....", ".....", ".....", ".....", "..##.", "..##.", ".#..." }, // , + .{ ".....", ".....", ".....", "#####", ".....", ".....", ".....", "....." }, // - + .{ ".....", ".....", ".....", ".....", ".....", "..##.", "..##.", "....." }, // . + .{ "....#", "...#.", "...#.", "..#..", ".#...", ".#...", "#....", "....." }, // / + .{ ".###.", "#...#", "#..##", "#.#.#", "##..#", "#...#", ".###.", "....." }, // 0 + .{ "..#..", ".##..", "..#..", "..#..", "..#..", "..#..", ".###.", "....." }, // 1 + .{ ".###.", "#...#", "....#", "...#.", "..#..", ".#...", "#####", "....." }, // 2 + .{ "#####", "...#.", "..#..", "...#.", "....#", "#...#", ".###.", "....." }, // 3 + .{ "...#.", "..##.", ".#.#.", "#..#.", "#####", "...#.", "...#.", "....." }, // 4 + .{ "#####", "#....", "####.", "....#", "....#", "#...#", ".###.", "....." }, // 5 + .{ "..##.", ".#...", "#....", "####.", "#...#", "#...#", ".###.", "....." }, // 6 + .{ "#####", "....#", "...#.", "..#..", ".#...", ".#...", ".#...", "....." }, // 7 + .{ ".###.", "#...#", "#...#", ".###.", "#...#", "#...#", ".###.", "....." }, // 8 + .{ ".###.", "#...#", "#...#", ".####", "....#", "...#.", ".##..", "....." }, // 9 + .{ ".....", ".....", "..##.", "..##.", ".....", "..##.", "..##.", "....." }, // : + .{ ".....", ".....", "..##.", "..##.", ".....", "..##.", "..##.", ".#..." }, // ; + .{ ".....", "...#.", "..#..", ".#...", "..#..", "...#.", ".....", "....." }, // < + .{ ".....", ".....", "#####", ".....", "#####", ".....", ".....", "....." }, // = + .{ ".....", ".#...", "..#..", "...#.", "..#..", ".#...", ".....", "....." }, // > + .{ ".###.", "#...#", "....#", "...#.", "..#..", ".....", "..#..", "....." }, // ? + .{ ".###.", "#...#", "#.###", "#.#.#", "#.###", "#....", ".###.", "....." }, // @ + .{ ".###.", "#...#", "#...#", "#####", "#...#", "#...#", "#...#", "....." }, // A + .{ "####.", "#...#", "#...#", "####.", "#...#", "#...#", "####.", "....." }, // B + .{ ".###.", "#...#", "#....", "#....", "#....", "#...#", ".###.", "....." }, // C + .{ "###..", "#..#.", "#...#", "#...#", "#...#", "#..#.", "###..", "....." }, // D + .{ "#####", "#....", "#....", "####.", "#....", "#....", "#####", "....." }, // E + .{ "#####", "#....", "#....", "####.", "#....", "#....", "#....", "....." }, // F + .{ ".###.", "#...#", "#....", "#.###", "#...#", "#...#", ".####", "....." }, // G + .{ "#...#", "#...#", "#...#", "#####", "#...#", "#...#", "#...#", "....." }, // H + .{ ".###.", "..#..", "..#..", "..#..", "..#..", "..#..", ".###.", "....." }, // I + .{ "..###", "...#.", "...#.", "...#.", "...#.", "#..#.", ".##..", "....." }, // J + .{ "#...#", "#..#.", "#.#..", "##...", "#.#..", "#..#.", "#...#", "....." }, // K + .{ "#....", "#....", "#....", "#....", "#....", "#....", "#####", "....." }, // L + .{ "#...#", "##.##", "#.#.#", "#.#.#", "#...#", "#...#", "#...#", "....." }, // M + .{ "#...#", "#...#", "##..#", "#.#.#", "#..##", "#...#", "#...#", "....." }, // N + .{ ".###.", "#...#", "#...#", "#...#", "#...#", "#...#", ".###.", "....." }, // O + .{ "####.", "#...#", "#...#", "####.", "#....", "#....", "#....", "....." }, // P + .{ ".###.", "#...#", "#...#", "#...#", "#.#.#", "#..#.", ".##.#", "....." }, // Q + .{ "####.", "#...#", "#...#", "####.", "#.#..", "#..#.", "#...#", "....." }, // R + .{ ".####", "#....", "#....", ".###.", "....#", "....#", "####.", "....." }, // S + .{ "#####", "..#..", "..#..", "..#..", "..#..", "..#..", "..#..", "....." }, // T + .{ "#...#", "#...#", "#...#", "#...#", "#...#", "#...#", ".###.", "....." }, // U + .{ "#...#", "#...#", "#...#", "#...#", "#...#", ".#.#.", "..#..", "....." }, // V + .{ "#...#", "#...#", "#...#", "#.#.#", "#.#.#", "##.##", "#...#", "....." }, // W + .{ "#...#", "#...#", ".#.#.", "..#..", ".#.#.", "#...#", "#...#", "....." }, // X + .{ "#...#", "#...#", ".#.#.", "..#..", "..#..", "..#..", "..#..", "....." }, // Y + .{ "#####", "....#", "...#.", "..#..", ".#...", "#....", "#####", "....." }, // Z + .{ ".###.", ".#...", ".#...", ".#...", ".#...", ".#...", ".###.", "....." }, // [ + .{ "#....", "#....", ".#...", "..#..", "...#.", "....#", "....#", "....." }, // backslash + .{ ".###.", "...#.", "...#.", "...#.", "...#.", "...#.", ".###.", "....." }, // ] + .{ "..#..", ".#.#.", "#...#", ".....", ".....", ".....", ".....", "....." }, // ^ + .{ ".....", ".....", ".....", ".....", ".....", ".....", ".....", "#####" }, // _ + .{ ".#...", "..#..", ".....", ".....", ".....", ".....", ".....", "....." }, // ` + .{ ".....", ".....", ".###.", "....#", ".####", "#...#", ".####", "....." }, // a + .{ "#....", "#....", "####.", "#...#", "#...#", "#...#", "####.", "....." }, // b + .{ ".....", ".....", ".###.", "#...#", "#....", "#...#", ".###.", "....." }, // c + .{ "....#", "....#", ".####", "#...#", "#...#", "#...#", ".####", "....." }, // d + .{ ".....", ".....", ".###.", "#...#", "#####", "#....", ".###.", "....." }, // e + .{ "..##.", ".#..#", ".#...", "####.", ".#...", ".#...", ".#...", "....." }, // f + .{ ".....", ".....", ".####", "#...#", "#...#", ".####", "....#", ".###." }, // g + .{ "#....", "#....", "####.", "#...#", "#...#", "#...#", "#...#", "....." }, // h + .{ "..#..", ".....", ".##..", "..#..", "..#..", "..#..", ".###.", "....." }, // i + .{ "...#.", ".....", "..##.", "...#.", "...#.", "...#.", "#..#.", ".##.." }, // j + .{ "#....", "#....", "#..#.", "#.#..", "##...", "#.#..", "#..#.", "....." }, // k + .{ ".##..", "..#..", "..#..", "..#..", "..#..", "..#..", ".###.", "....." }, // l + .{ ".....", ".....", "##.#.", "#.#.#", "#.#.#", "#...#", "#...#", "....." }, // m + .{ ".....", ".....", "####.", "#...#", "#...#", "#...#", "#...#", "....." }, // n + .{ ".....", ".....", ".###.", "#...#", "#...#", "#...#", ".###.", "....." }, // o + .{ ".....", ".....", "####.", "#...#", "#...#", "####.", "#....", "#...." }, // p + .{ ".....", ".....", ".####", "#...#", "#...#", ".####", "....#", "....#" }, // q + .{ ".....", ".....", "#.##.", "##..#", "#....", "#....", "#....", "....." }, // r + .{ ".....", ".....", ".####", "#....", ".###.", "....#", "####.", "....." }, // s + .{ ".#...", ".#...", "####.", ".#...", ".#...", ".#..#", "..##.", "....." }, // t + .{ ".....", ".....", "#...#", "#...#", "#...#", "#...#", ".####", "....." }, // u + .{ ".....", ".....", "#...#", "#...#", "#...#", ".#.#.", "..#..", "....." }, // v + .{ ".....", ".....", "#...#", "#...#", "#.#.#", "#.#.#", ".#.#.", "....." }, // w + .{ ".....", ".....", "#...#", ".#.#.", "..#..", ".#.#.", "#...#", "....." }, // x + .{ ".....", ".....", "#...#", "#...#", "#...#", ".####", "....#", ".###." }, // y + .{ ".....", ".....", "#####", "...#.", "..#..", ".#...", "#####", "....." }, // z + .{ "...##", "..#..", "..#..", ".#...", "..#..", "..#..", "...##", "....." }, // { + .{ "..#..", "..#..", "..#..", "..#..", "..#..", "..#..", "..#..", "....." }, // | + .{ "##...", "..#..", "..#..", "...#.", "..#..", "..#..", "##...", "....." }, // } + .{ ".....", ".....", ".##..", "#..##", ".....", ".....", ".....", "....." }, // ~ +}; + +comptime { + std.debug.assert(art.len == last_char - first_char + 1); +} diff --git a/src/shm.zig b/src/shm.zig index 28cfd2e..bfb11b1 100644 --- a/src/shm.zig +++ b/src/shm.zig @@ -1,8 +1,8 @@ //! Minimal wl_shm buffer pool for the tab bar. //! //! The tab bar is the only thing att_wm draws itself, and it draws nothing but -//! solid rectangles, so this deliberately stops at "memfd, mmap, fill" rather -//! than pulling in pixman or a font stack. +//! solid rectangles and text in the bitmap font from `font.zig`, so this +//! deliberately stops at "memfd, mmap, fill" rather than pulling in pixman. const std = @import("std"); const posix = std.posix; @@ -12,6 +12,7 @@ const sys = @import("sys.zig"); const wayland = @import("wayland"); const wl = wayland.client.wl; +const font = @import("font.zig"); const layout = @import("layout.zig"); const Box = layout.Box; @@ -58,6 +59,51 @@ pub const Buffer = struct { } } + /// Fill a rectangle, clipped to `clip` as well as to the buffer. + pub fn fillClipped(self: *Buffer, rect: Box, clip: Box, argb: u32) void { + const x0 = @max(rect.x, clip.x); + const y0 = @max(rect.y, clip.y); + const x1 = @min(rect.x + rect.width, clip.x + clip.width); + const y1 = @min(rect.y + rect.height, clip.y + clip.height); + if (x1 <= x0 or y1 <= y0) return; + self.fill(.{ .x = x0, .y = y0, .width = x1 - x0, .height = y1 - y0 }, argb); + } + + /// Draw text with the top left of its first cell at (x, y), clipped to + /// `clip`, and return the x the next cell would start at. The font has no + /// antialiasing, so lit pixels are simply written: nothing to blend. + pub fn drawText(self: *Buffer, text: []const u8, x: i32, y: i32, scale: i32, argb: u32, clip: Box) i32 { + var pen = x; + var it: font.Iterator = .{ .bytes = text }; + while (it.next()) |cp| { + // Stop as soon as the pen leaves the clip rather than walking the + // rest of a long title a pixel at a time. + if (pen >= clip.x + clip.width) break; + self.drawGlyph(cp, pen, y, scale, argb, clip); + pen += font.advance * scale; + } + return pen; + } + + pub fn drawGlyph(self: *Buffer, cp: u21, x: i32, y: i32, scale: i32, argb: u32, clip: Box) void { + const rows = font.glyph(cp); + for (rows, 0..) |bits, row| { + if (bits == 0) continue; + const py = y + @as(i32, @intCast(row)) * scale; + var col: u3 = 0; + while (col < font.cell_width) : (col += 1) { + const mask = @as(u8, 1) << @intCast(font.cell_width - 1 - col); + if (bits & mask == 0) continue; + self.fillClipped(.{ + .x = x + @as(i32, col) * scale, + .y = py, + .width = scale, + .height = scale, + }, clip, argb); + } + } + } + fn deinit(self: *Buffer, gpa: Allocator) void { self.wl_buffer.destroy(); posix.munmap(self.data); diff --git a/src/test.zig b/src/test.zig index da486e6..3e68dc7 100644 --- a/src/test.zig +++ b/src/test.zig @@ -3,6 +3,7 @@ const testing = std.testing; const act = @import("action"); const input = @import("input"); +const font = @import("font.zig"); const layout = @import("layout.zig"); const Box = layout.Box; @@ -422,3 +423,87 @@ test "map_to_output merges like any other setting" { const moved = merged.merge(.{ .map_to_output = "DP-2" }); try testing.expectEqualStrings("DP-2", moved.map_to_output.?); } + +// ─── tab titles ────────────────────────────────────────────────────────────── + +test "a title that fits is drawn whole" { + const f = font.fit("foot", 10); + try testing.expectEqual(@as(usize, 4), f.end); + try testing.expectEqual(@as(usize, 4), f.cells); + try testing.expect(!f.elided); + + // Exactly full is still whole: the ellipsis costs a cell, so only spend it + // when something is actually cut. + const exact = font.fit("foot", 4); + try testing.expectEqual(@as(usize, 4), exact.cells); + try testing.expect(!exact.elided); +} + +test "a title too long for the tab loses its last cell to the ellipsis" { + const f = font.fit("firefox", 4); + try testing.expectEqualStrings("fir", "firefox"[0..f.end]); + try testing.expectEqual(@as(usize, 3), f.cells); + try testing.expect(f.elided); + + // One cell of room leaves nothing but the ellipsis, and no room at all + // draws nothing rather than indexing past the end. + const one = font.fit("firefox", 1); + try testing.expectEqual(@as(usize, 0), one.end); + try testing.expect(one.elided); + + const none = font.fit("firefox", 0); + try testing.expectEqual(@as(usize, 0), none.end); + try testing.expect(!none.elided); +} + +test "titles are measured in characters, not bytes" { + // Three codepoints, six bytes: a tab four cells wide holds them all. + const f = font.fit("ünïx", 4); + try testing.expectEqual(@as(usize, 4), f.cells); + try testing.expect(!f.elided); + + // And a cut lands on a codepoint boundary, never mid-sequence. + const cut = font.fit("ünïx", 3); + try testing.expect(cut.elided); + try testing.expectEqualStrings("ün", "ünïx"[0..cut.end]); +} + +test "malformed utf-8 in a title yields one glyph per bad byte" { + var it: font.Iterator = .{ .bytes = "a\xffb\xc3" }; + try testing.expectEqual(@as(?u21, 'a'), it.next()); + try testing.expectEqual(@as(?u21, 0xfffd), it.next()); + try testing.expectEqual(@as(?u21, 'b'), it.next()); + // A truncated sequence at the end must terminate the walk, not loop. + try testing.expectEqual(@as(?u21, 0xfffd), it.next()); + try testing.expectEqual(@as(?u21, null), it.next()); +} + +test "text measuring and cell fitting agree" { + const scale = 2; + // Whatever cellsForWidth says fits must actually fit. + var width: i32 = 0; + while (width < 200) : (width += 1) { + const cells = font.cellsForWidth(width, scale); + try testing.expect(font.textWidth(cells, scale) <= width); + try testing.expect(font.textWidth(cells + 1, scale) > width); + } +} + +test "printable ascii all have glyphs, and unknown codepoints fall back" { + // The space is blank, and nothing else printable is. + var c: u8 = 0x21; + while (c <= 0x7e) : (c += 1) { + const rows = font.glyph(c); + var lit = false; + for (rows) |r| lit = lit or r != 0; + try testing.expect(lit); + // Nothing may spill outside the five column cell. + for (rows) |r| try testing.expectEqual(@as(u8, 0), r & ~@as(u8, 0b11111)); + } + for (font.glyph(' ')) |r| try testing.expectEqual(@as(u8, 0), r); + + // Control characters stay blank; other codepoints get the fallback box. + for (font.glyph('\n')) |r| try testing.expectEqual(@as(u8, 0), r); + try testing.expect(!std.mem.eql(u8, font.glyph('?'), font.glyph('あ'))); + try testing.expectEqualSlices(u8, font.glyph(0xfffd), font.glyph('あ')); +}