show names on tabs in tabbed layout

This commit is contained in:
2026-08-01 14:19:24 +02:00
parent 8d99d9171c
commit 4a08d19365
7 changed files with 457 additions and 12 deletions
+12 -7
View File
@@ -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. 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 **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 draws one tab per window, the focused one highlighted. Each tab is clickable and
are clickable. Titles are *not* drawn — that would mean a font stack — but the carries the window's title, elided with an ellipsis when the tab is too narrow.
window list is published over IPC, so a bar can draw a textual tab strip 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 alongside (the bundled quickshell config does). Set `tabbar_height = 0` to drop
the built-in strip entirely and let the bar own it. 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; } att_wm.override { configFile = ./my-config.zig; }
``` ```
`config.zig` covers border width and colours, gaps, tab bar height and colours, `config.zig` covers border width and colours, gaps, tab bar height, colours and
the default layout, `nmaster` / `mfact`, tag names, focus-follows-mouse, key and text size, the default layout, `nmaster` / `mfact`, tag names,
pointer bindings, autostart commands, input devices, and window rules: focus-follows-mouse, key and pointer bindings, autostart commands, input
devices, and window rules:
```zig ```zig
pub const rules = [_]Rule{ 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/input.zig` | Input configuration types — no Wayland, unit tested |
| `src/config.zig` | Compile-time configuration | | `src/config.zig` | Compile-time configuration |
| `src/ipc.zig` | Socket server and JSON encoding | | `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 | | `src/sys.zig` | Thin Linux syscall wrappers |
The single most important invariant: **river only permits window management The single most important invariant: **river only permits window management
+51 -3
View File
@@ -21,6 +21,7 @@ const Wm = @import("Wm.zig");
const Window = @import("Window.zig"); const Window = @import("Window.zig");
const shm = @import("shm.zig"); const shm = @import("shm.zig");
const color = @import("color.zig"); const color = @import("color.zig");
const font = @import("font.zig");
const layout = @import("layout.zig"); const layout = @import("layout.zig");
const Box = layout.Box; 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 /// The strip of tabs drawn above the windows in the tabbed layout. One tab per
/// layout. One block per window, the focused one highlighted; titles are not /// window, labelled with its title and the focused one highlighted. The same
/// drawn here but are published over IPC for bars that want to render them. /// titles are published over IPC, for bars that would rather draw the tabs
/// themselves with a real font.
pub const TabBar = struct { pub const TabBar = struct {
wm: *Wm, wm: *Wm,
@@ -240,6 +242,7 @@ pub const TabBar = struct {
const sep = color.toArgb8888(config.tab_separator); const sep = color.toArgb8888(config.tab_separator);
buffer.fill(local, sep); buffer.fill(local, sep);
const scale = textScale(box.height);
for (self.rects.items, windows) |rect, win| { for (self.rects.items, windows) |rect, win| {
const is_focused = focused != null and focused.? == win; const is_focused = focused != null and focused.? == win;
const argb = color.toArgb8888(if (is_focused) config.tab_focused else config.tab_normal); 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, .height = rect.height,
}; };
buffer.fill(inner, argb); 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 // Translate the hit rectangles into global coordinates for click
@@ -286,6 +292,48 @@ pub const TabBar = struct {
self.windows.clearRetainingCapacity(); 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. /// Which window's tab covers this global coordinate, if any.
pub fn windowAt(self: *const TabBar, x: i32, y: i32) ?*Window { pub fn windowAt(self: *const TabBar, x: i32, y: i32) ?*Window {
if (!self.mapped) return null; if (!self.mapped) return null;
+13
View File
@@ -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 { fn setString(self: *Window, field: *?[]u8, value: ?[*:0]const u8) void {
const gpa = self.wm.gpa; const gpa = self.wm.gpa;
if (field.*) |old| gpa.free(old); 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.setString(&self.title, ev.title);
self.applyRules(); self.applyRules();
self.wm.ipcDirty(); 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| { .identifier => |ev| {
+8
View File
@@ -41,6 +41,14 @@ pub const tab_normal: u32 = 0x2c2c2cff;
/// Drawn as a 1px line between adjacent tabs. /// Drawn as a 1px line between adjacent tabs.
pub const tab_separator: u32 = 0x1a1a1aff; 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 ────────────────────────────────────────────────────────────────── // ─── Layout ──────────────────────────────────────────────────────────────────
pub const default_layout = action.Layout.master; pub const default_layout = action.Layout.master;
+240
View File
@@ -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);
}
+48 -2
View File
@@ -1,8 +1,8 @@
//! Minimal wl_shm buffer pool for the tab bar. //! 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 //! 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 //! solid rectangles and text in the bitmap font from `font.zig`, so this
//! than pulling in pixman or a font stack. //! deliberately stops at "memfd, mmap, fill" rather than pulling in pixman.
const std = @import("std"); const std = @import("std");
const posix = std.posix; const posix = std.posix;
@@ -12,6 +12,7 @@ const sys = @import("sys.zig");
const wayland = @import("wayland"); const wayland = @import("wayland");
const wl = wayland.client.wl; const wl = wayland.client.wl;
const font = @import("font.zig");
const layout = @import("layout.zig"); const layout = @import("layout.zig");
const Box = layout.Box; 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 { fn deinit(self: *Buffer, gpa: Allocator) void {
self.wl_buffer.destroy(); self.wl_buffer.destroy();
posix.munmap(self.data); posix.munmap(self.data);
+85
View File
@@ -3,6 +3,7 @@ const testing = std.testing;
const act = @import("action"); const act = @import("action");
const input = @import("input"); const input = @import("input");
const font = @import("font.zig");
const layout = @import("layout.zig"); const layout = @import("layout.zig");
const Box = layout.Box; 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" }); const moved = merged.merge(.{ .map_to_output = "DP-2" });
try testing.expectEqualStrings("DP-2", moved.map_to_output.?); 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('あ'));
}