From f5a0b6136fc1cafb486d362a2f77312343b531cd Mon Sep 17 00:00:00 2001 From: Asmir A Date: Sat, 1 Aug 2026 15:03:55 +0200 Subject: [PATCH] use freetype for font display --- README.md | 13 +- build.zig | 4 + flake.nix | 2 + nix/package.nix | 4 + src/Face.zig | 350 ++++++++++++++++++++++++++++++++++++++++++++++++ src/Output.zig | 44 +++--- src/Wm.zig | 24 ++++ src/config.zig | 8 +- src/font.zig | 243 ++++++++------------------------- src/shm.zig | 90 +++++++++---- src/test.zig | 115 ++++++++++------ 11 files changed, 605 insertions(+), 292 deletions(-) create mode 100644 src/Face.zig diff --git a/README.md b/README.md index 3087e53..5553aae 100644 --- a/README.md +++ b/README.md @@ -191,9 +191,13 @@ 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 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. +Titles are drawn with FreeType in whatever font the fontconfig pattern +`tab_font` matches (`"sans:size=11"` by default, so `fc-match 'sans:size=11'` +shows what you will get), antialiased and subpixel positioned, shrunk to fit +when `tabbar_height` leaves no room for the size asked for. Codepoints the +matched font has no glyph for send att_wm back to fontconfig for one that does, +so a CJK or emoji title is not a row of boxes; there is no shaping, so scripts +that need it come out as unjoined letters. 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. @@ -492,7 +496,8 @@ river's identifier is shared between them. | `src/config.zig` | Compile-time configuration | | `src/ipc.zig` | Socket server and JSON encoding | | `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/Face.zig` | The tab bar's font: fontconfig matching, FreeType rasterising | +| `src/font.zig` | Measuring and eliding titles — no font needed, unit tested | | `src/sys.zig` | Thin Linux syscall wrappers | The single most important invariant: **river only permits window management diff --git a/build.zig b/build.zig index ceb2bd6..653a168 100644 --- a/build.zig +++ b/build.zig @@ -84,6 +84,10 @@ pub fn build(b: *Build) void { exe.root_module.addImport("config", config); exe.root_module.linkSystemLibrary("wayland-client", .{}); exe.root_module.linkSystemLibrary("xkbcommon", .{}); + // The tab bar draws window titles in a real font: fontconfig picks it, + // FreeType rasterises it. See src/Face.zig. + exe.root_module.linkSystemLibrary("freetype2", .{}); + exe.root_module.linkSystemLibrary("fontconfig", .{}); exe.pie = pie; b.installArtifact(exe); diff --git a/flake.nix b/flake.nix index 071e549..6472135 100644 --- a/flake.nix +++ b/flake.nix @@ -38,6 +38,8 @@ wayland wayland-protocols libxkbcommon + freetype + fontconfig ]; packages = with pkgs; [ river diff --git a/nix/package.nix b/nix/package.nix index 5c9b604..94748db 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -9,6 +9,8 @@ wayland-protocols, wayland-scanner, libxkbcommon, + freetype, + fontconfig, # Path to a replacement src/config.zig. dwm-style compile-time configuration: # att_wm.override { configFile = ./my-config.zig; } # Deliberately not called `config`: callPackage would fill that from the @@ -38,6 +40,8 @@ stdenv.mkDerivation { wayland-protocols wayland-scanner libxkbcommon + freetype + fontconfig ]; zigBuildFlags = [ diff --git a/src/Face.zig b/src/Face.zig new file mode 100644 index 0000000..93b908e --- /dev/null +++ b/src/Face.zig @@ -0,0 +1,350 @@ +//! The font the tab bar draws its titles in: fontconfig picks it, FreeType +//! rasterises it. +//! +//! The bar is the only thing att_wm draws itself, and window titles are text +//! the user reads at a glance, so they are worth a real font — matched by +//! pattern (`config.tab_font`) rather than by path, hinted, and rendered to +//! antialiased coverage that `shm.Buffer` blends over the tab. +//! +//! Titles are whatever a client cares to set, so one font is rarely enough: a +//! codepoint the matched font has no glyph for sends us back to fontconfig for +//! one that does. That is as far as the text stack goes. There is no shaping, +//! so scripts that need it — Arabic, Devanagari — come out as unjoined letters +//! rather than correctly; a window manager is not the place to link HarfBuzz. +//! +//! One face is shared by every output. It is not thread safe, which is fine: +//! everything here happens on the one Wayland event loop. + +const Face = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const font = @import("font.zig"); + +const c = @cImport({ + @cInclude("fontconfig/fontconfig.h"); + @cInclude("freetype/freetype.h"); +}); + +/// FreeType's flag constants come from macros; these are the two we need, +/// narrowed to the FT_Int32 the load functions take. +const load = struct { + const render: i32 = @intCast(c.FT_LOAD_RENDER); + const no_hinting: i32 = @intCast(c.FT_LOAD_NO_HINTING); + + /// FT_LOAD_TARGET_(x): the render mode packed into bits 16..19. + fn target(mode: c_uint) i32 { + return @intCast((mode & 15) << 16); + } +}; + +const pixel_mode_gray: u8 = @intCast(c.FT_PIXEL_MODE_GRAY); + +/// Below this size the antialiasing is doing all the work and the text is a +/// smear; a bar too short for it is better off overflowing a little. +const min_px = 6; + +/// Enough for a title mixing Latin, CJK and emoji, and a bound on what a +/// client can make us open by cycling its title through the world's scripts. +const max_fonts = 8; + +/// How many codepoints to remember the font for before starting over. Titles +/// repeat their characters constantly, so even a small cache turns almost every +/// lookup into a hash hit. +const cache_limit = 1024; + +gpa: Allocator, +library: c.FT_Library, +/// Index 0 is what the pattern matched; the rest were opened for codepoints it +/// had no glyph for. +fonts: std.ArrayList(Font), +/// Which font in `fonts` covers a codepoint, `no_font` for none of them. +coverage: std.AutoHashMapUnmanaged(u21, u8), +/// The substituted request, kept to match fallbacks against so they follow the +/// size and weight that was asked for. +pattern: *c.FcPattern, +/// Pixel size fontconfig resolved the pattern to; the ceiling for `fitHeight`. +preferred_px: u32, +/// Pixel size the fonts are drawn at. Applied to each lazily, so a face that +/// is only used for one codepoint is not resized on every bar redraw. +px: u32, +/// FT_LOAD_TARGET_* and friends, from the pattern's hinting settings. +load_flags: i32, + +const no_font: u8 = std.math.maxInt(u8); + +const Font = struct { + face: c.FT_Face, + /// The file it came from, so the same one is not opened twice. + path: []u8, + /// Size this face is currently set to, which lags `Face.px`. + px: u32 = 0, +}; + +/// A rendered glyph: 8-bit coverage, plus where it sits relative to the pen on +/// the baseline. Borrowed from a face's glyph slot, so it is only valid until +/// the next `render` call. +pub const Glyph = struct { + coverage: []const u8, + width: u32, + height: u32, + /// Bytes per row, which is not always `width`. + pitch: usize, + left: i32, + top: i32, +}; + +pub const Error = error{ + FontconfigInit, + BadPattern, + NoFontMatched, + FreetypeInit, + FontUnreadable, + OutOfMemory, +}; + +/// Match `pattern` — ordinary fontconfig syntax, e.g. "sans:size=11" — and open +/// what it resolves to. +pub fn open(gpa: Allocator, pattern: [:0]const u8) Error!Face { + if (c.FcInit() != c.FcTrue) return error.FontconfigInit; + + var library: c.FT_Library = null; + if (c.FT_Init_FreeType(&library) != 0) return error.FreetypeInit; + + const pat = c.FcNameParse(pattern.ptr) orelse { + _ = c.FT_Done_FreeType(library); + return error.BadPattern; + }; + + // From here `self` owns both the library and the pattern, so one errdefer + // releases everything however far this gets. + var self: Face = .{ + .gpa = gpa, + .library = library, + .fonts = .empty, + .coverage = .empty, + .pattern = pat, + .preferred_px = 12, + .px = 0, + .load_flags = load.target(c.FT_RENDER_MODE_LIGHT), + }; + errdefer self.deinit(); + + // Fill the pattern out the way every fontconfig client is expected to: + // configuration first, then the built-in defaults for whatever is still + // unset, or the match comes back without a size or a hint style. + if (c.FcConfigSubstitute(null, pat, c.FcMatchPattern) != c.FcTrue) return error.BadPattern; + c.FcDefaultSubstitute(pat); + + var result: c.FcResult = undefined; + const matched = c.FcFontMatch(null, pat, &result) orelse return error.NoFontMatched; + defer c.FcPatternDestroy(matched); + if (result != c.FcResultMatch) return error.NoFontMatched; + + var size: f64 = 0; + if (c.FcPatternGetDouble(matched, c.FC_PIXEL_SIZE, 0, &size) != c.FcResultMatch) size = 0; + if (size >= min_px) self.preferred_px = @intFromFloat(@round(size)); + self.load_flags = loadFlags(matched); + + if (try self.openMatched(matched) == null) return error.FontUnreadable; + self.setPixelSize(self.preferred_px); + return self; +} + +pub fn deinit(self: *Face) void { + for (self.fonts.items) |*f| { + _ = c.FT_Done_Face(f.face); + self.gpa.free(f.path); + } + self.fonts.deinit(self.gpa); + self.coverage.deinit(self.gpa); + c.FcPatternDestroy(self.pattern); + _ = c.FT_Done_FreeType(self.library); + self.* = undefined; +} + +/// Open the font a completed fontconfig match names, or return the one already +/// open for that file. Null when FreeType will not have it. +fn openMatched(self: *Face, matched: *c.FcPattern) Allocator.Error!?u8 { + var path: [*c]c.FcChar8 = undefined; + if (c.FcPatternGetString(matched, c.FC_FILE, 0, &path) != c.FcResultMatch) return null; + var index: c_int = 0; + _ = c.FcPatternGetInteger(matched, c.FC_INDEX, 0, &index); + + const name = std.mem.span(@as([*:0]const u8, @ptrCast(path))); + for (self.fonts.items, 0..) |f, i| { + if (std.mem.eql(u8, f.path, name)) return @intCast(i); + } + + var face: c.FT_Face = null; + if (c.FT_New_Face(self.library, @ptrCast(path), index, &face) != 0) return null; + errdefer _ = c.FT_Done_Face(face); + + const owned = try self.gpa.dupe(u8, name); + errdefer self.gpa.free(owned); + + try self.fonts.append(self.gpa, .{ .face = face, .path = owned }); + return @intCast(self.fonts.items.len - 1); +} + +/// Hinting as the pattern asks for it. Light is the default fontconfig hands +/// out and the one that suits us best: it straightens stems vertically and +/// leaves horizontal metrics alone, which is what keeps subpixel-positioned +/// text evenly spaced. +fn loadFlags(matched: *c.FcPattern) i32 { + var hinting: c.FcBool = c.FcTrue; + _ = c.FcPatternGetBool(matched, c.FC_HINTING, 0, &hinting); + if (hinting != c.FcTrue) return load.no_hinting | load.target(c.FT_RENDER_MODE_NORMAL); + + var style: c_int = c.FC_HINT_SLIGHT; + _ = c.FcPatternGetInteger(matched, c.FC_HINT_STYLE, 0, &style); + return switch (style) { + c.FC_HINT_NONE => load.no_hinting | load.target(c.FT_RENDER_MODE_NORMAL), + c.FC_HINT_FULL => load.target(c.FT_RENDER_MODE_NORMAL), + // Medium and slight both mean "vertical only" to FreeType. + else => load.target(c.FT_RENDER_MODE_LIGHT), + }; +} + +fn setPixelSize(self: *Face, px: u32) void { + if (self.px == px) return; + self.px = px; + // The primary carries the metrics the bar lays text out with, so it is + // resized now; the rest wait until something needs them. + self.resize(&self.fonts.items[0]); +} + +fn resize(self: *Face, f: *Font) void { + if (f.px == self.px) return; + if (c.FT_Set_Pixel_Sizes(f.face, 0, self.px) != 0) return; + f.px = self.px; +} + +/// Shrink the text to fit a strip `height` pixels tall, up to the size the +/// pattern asked for. A tab bar thinner than the configured font is a bar +/// height the user chose deliberately; the text should give way, not overflow. +pub fn fitHeight(self: *Face, height: i32) void { + if (height <= 0) return; + var px = self.preferred_px; + while (true) { + self.setPixelSize(px); + // Two pixels of air above and below the line. + if (self.lineHeight() + 4 <= height or px <= min_px) return; + px -= 1; + } +} + +/// Distance from the top of the ascenders to the bottom of the descenders, in +/// whole pixels. +pub fn lineHeight(self: *const Face) i32 { + const m = self.fonts.items[0].face.*.size.*.metrics; + return @intCast((m.ascender - m.descender + 63) >> 6); +} + +/// Where the baseline goes in a strip `height` pixels tall, measured from its +/// top, so that the text sits centred in it. +pub fn baselineIn(self: *const Face, height: i32) i32 { + const m = self.fonts.items[0].face.*.size.*.metrics; + const ascent: i32 = @intCast((m.ascender + 63) >> 6); + return @divFloor(height - self.lineHeight(), 2) + ascent; +} + +/// The face to draw a codepoint with, and its glyph index in that face. Falls +/// back to the primary's .notdef box when nothing on the system has the glyph, +/// which is at least honest about being a character we cannot draw. +fn lookup(self: *Face, cp: u21) struct { face: c.FT_Face, glyph: c_uint } { + const primary = &self.fonts.items[0]; + const which = self.coverage.get(cp) orelse blk: { + const found = self.find(cp); + // A title cycling through scripts must not grow this without bound. + if (self.coverage.count() >= cache_limit) self.coverage.clearRetainingCapacity(); + self.coverage.put(self.gpa, cp, found) catch {}; + break :blk found; + }; + if (which == no_font or which >= self.fonts.items.len) { + self.resize(primary); + return .{ .face = primary.face, .glyph = 0 }; + } + + const f = &self.fonts.items[which]; + self.resize(f); + return .{ .face = f.face, .glyph = c.FT_Get_Char_Index(f.face, cp) }; +} + +/// Which open font has a glyph for `cp`, opening one more if none does. +fn find(self: *Face, cp: u21) u8 { + for (self.fonts.items, 0..) |f, i| { + if (c.FT_Get_Char_Index(f.face, cp) != 0) return @intCast(i); + } + if (self.fonts.items.len >= max_fonts) return no_font; + + // Ask fontconfig the same question it answers for every other client: what + // font like this one has this character? + const charset = c.FcCharSetCreate() orelse return no_font; + defer c.FcCharSetDestroy(charset); + if (c.FcCharSetAddChar(charset, cp) != c.FcTrue) return no_font; + + const pat = c.FcPatternDuplicate(self.pattern) orelse return no_font; + defer c.FcPatternDestroy(pat); + if (c.FcPatternAddCharSet(pat, c.FC_CHARSET, charset) != c.FcTrue) return no_font; + + var result: c.FcResult = undefined; + const matched = c.FcFontMatch(null, pat, &result) orelse return no_font; + defer c.FcPatternDestroy(matched); + if (result != c.FcResultMatch) return no_font; + + const opened = (self.openMatched(matched) catch return no_font) orelse return no_font; + // fontconfig answers with its best match whether or not it covers the + // character, so the answer still has to be checked. + const f = &self.fonts.items[opened]; + if (c.FT_Get_Char_Index(f.face, cp) == 0) return no_font; + return opened; +} + +/// Horizontal advance of a codepoint in 26.6 fixed point — the unhinted, +/// fractional one, so that a string's width does not depend on where in a pixel +/// it starts. +pub fn advance(self: *Face, cp: u21) i32 { + const l = self.lookup(cp); + if (c.FT_Load_Glyph(l.face, l.glyph, self.load_flags) != 0) return 0; + // linearHoriAdvance is 16.16; the pen counts in 26.6. + return @intCast(l.face.*.glyph.*.linearHoriAdvance >> 10); +} + +/// Advance of the ellipsis, which `font.fit` needs to reserve room for. +pub fn ellipsisAdvance(self: *Face) i32 { + return self.advance(font.ellipsis); +} + +/// Rasterise a codepoint positioned `subpixel` 26.6 units into its cell. +/// Returns null for glyphs with nothing to draw, spaces above all. +pub fn render(self: *Face, cp: u21, subpixel: i32) ?Glyph { + const l = self.lookup(cp); + + // Shifting the outline instead of rounding the pen is what buys evenly + // spaced text: a glyph landing a third of a pixel along is rasterised a + // third of a pixel along. + var delta: c.FT_Vector = .{ .x = @intCast(subpixel & 63), .y = 0 }; + c.FT_Set_Transform(l.face, null, &delta); + defer c.FT_Set_Transform(l.face, null, null); + + if (c.FT_Load_Glyph(l.face, l.glyph, self.load_flags | load.render) != 0) return null; + + const slot = l.face.*.glyph; + const bitmap = slot.*.bitmap; + if (bitmap.width == 0 or bitmap.rows == 0) return null; + // FT_RENDER_MODE_NORMAL means 8-bit coverage; a face carrying embedded + // bitmaps can hand back a 1-bit one instead, which this does not unpack. + if (bitmap.pixel_mode != pixel_mode_gray) return null; + + const pitch: usize = @abs(bitmap.pitch); + const rows: usize = bitmap.rows; + return .{ + .coverage = bitmap.buffer[0 .. pitch * rows], + .width = bitmap.width, + .height = bitmap.rows, + .pitch = pitch, + .left = slot.*.bitmap_left, + .top = slot.*.bitmap_top, + }; +} diff --git a/src/Output.zig b/src/Output.zig index ffdb946..f680262 100644 --- a/src/Output.zig +++ b/src/Output.zig @@ -22,6 +22,7 @@ const Window = @import("Window.zig"); const shm = @import("shm.zig"); const color = @import("color.zig"); const font = @import("font.zig"); +const Face = @import("Face.zig"); const layout = @import("layout.zig"); const Box = layout.Box; @@ -242,7 +243,10 @@ pub const TabBar = struct { const sep = color.toArgb8888(config.tab_separator); buffer.fill(local, sep); - const scale = textScale(box.height); + // One face, sized once for the whole bar: every tab is the same height. + const face = self.wm.tabFace(); + if (face) |f| f.fitHeight(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); @@ -256,8 +260,10 @@ pub const TabBar = struct { }; 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); + if (face) |f| { + const ink = color.toArgb8888(if (is_focused) config.tab_text_focused else config.tab_text_normal); + drawLabel(buffer, f, inner, label(win), ink); + } } // Translate the hit rectangles into global coordinates for click @@ -304,34 +310,22 @@ pub const TabBar = struct { 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; + fn drawLabel(buffer: *shm.Buffer, face: *Face, tab: Box, text: []const u8, argb: u32) void { + // Enough air either side that neighbouring titles do not read as one. + const pad = @divFloor(face.px, 2) + 2; + const avail = tab.width - 2 * @as(i32, @intCast(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 f = font.fit(text, font.fixed(avail), face); + if (f.end == 0 and !f.elided) 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 x = tab.x + @divFloor(tab.width - font.pixels(f.width), 2); + const y = tab.y + face.baselineIn(tab.height); - 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); + const pen = buffer.drawText(face, text[0..f.end], x, y, argb, tab); + if (f.elided) _ = buffer.drawGlyph(face, font.ellipsis, pen, y, argb, tab); } /// Which window's tab covers this global coordinate, if any. diff --git a/src/Wm.zig b/src/Wm.zig index ec110f4..073043a 100644 --- a/src/Wm.zig +++ b/src/Wm.zig @@ -28,6 +28,7 @@ const Output = @import("Output.zig"); const Seat = @import("Seat.zig"); const InputManager = @import("InputManager.zig"); const Ipc = @import("ipc.zig").Ipc; +const Face = @import("Face.zig"); const color = @import("color.zig"); const layout = @import("layout.zig"); const Box = layout.Box; @@ -97,6 +98,11 @@ repeat_fd: sys.fd_t, repeat_seat: ?*Seat = null, repeat_index: ?usize = null, +/// The font the tab bar draws titles in, opened on the first draw that needs +/// one so that a configuration with no tab bar never touches fontconfig. +tab_face: ?Face = null, +tab_face_tried: bool = false, + /// Scratch buffers reused by the layout pass to avoid per-frame allocation. scratch_windows: std.ArrayList(*Window) = .empty, scratch_cells: std.ArrayList(Box) = .empty, @@ -221,8 +227,26 @@ fn freeGlobals(self: *Wm) void { self.shm = null; } +/// The tab bar font, or null if there is none to be had. Opening it is tried +/// exactly once: a machine with no fonts installed gets bare tabs and one line +/// in the log, not a failure to start and not a message every frame. +pub fn tabFace(self: *Wm) ?*Face { + if (!self.tab_face_tried) { + self.tab_face_tried = true; + self.tab_face = Face.open(self.gpa, config.tab_font) catch |err| blk: { + std.log.err("no font for \"{s}\" ({s}); tab titles will not be drawn", .{ + config.tab_font, + @errorName(err), + }); + break :blk null; + }; + } + return if (self.tab_face) |*f| f else null; +} + pub fn deinit(self: *Wm) void { const gpa = self.gpa; + if (self.tab_face) |*f| f.deinit(); for (self.windows.items) |win| win.destroy(); // Outputs hold references into wl_outputs, so they must go first. diff --git a/src/config.zig b/src/config.zig index 308f17d..1d22234 100644 --- a/src/config.zig +++ b/src/config.zig @@ -44,10 +44,10 @@ 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; +/// The font titles are drawn in, as a fontconfig pattern — the same syntax +/// `fc-match` takes, so `fc-match 'sans:size=11'` shows what this will resolve +/// to. The size is trimmed to whatever `tabbar_height` has room for. +pub const tab_font: [:0]const u8 = "sans:size=11"; // ─── Layout ────────────────────────────────────────────────────────────────── diff --git a/src/font.zig b/src/font.zig index b267982..c955860 100644 --- a/src/font.zig +++ b/src/font.zig @@ -1,92 +1,86 @@ -//! A 5x8 bitmap font, and the text measuring that goes with it. +//! Text measuring for the tab bar: how much of a title fits, and where it has +//! to be cut short. //! -//! 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. +//! Deliberately free of FreeType — it asks whatever it is handed how wide a +//! codepoint is and does the arithmetic, so the eliding rules can be tested +//! without a font on the machine running the tests. `Face` is what supplies the +//! widths in the running window manager. //! -//! 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. +//! Widths are 26.6 fixed point, FreeType's unit: whole pixels are too coarse to +//! accumulate a string's width in without the spacing drifting. 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; - +/// Cut titles are ended with this rather than three periods, which at tab-bar +/// sizes look like a smudge. 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; +/// Whole pixels to 26.6. +pub fn fixed(px: i32) i32 { + return px * 64; } -/// 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); +/// 26.6 to whole pixels, rounded up: a width is how much room the text needs. +pub fn pixels(width: i32) i32 { + return @divFloor(width + 63, 64); } pub const Fit = struct { /// Bytes of the string to draw. end: usize, - /// Characters those bytes come to, an ellipsis not counted. - cells: usize, + /// Width in 26.6 of what will be drawn, the ellipsis included. + width: i32, /// 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 }; +/// Fit a string into `max_width` 26.6 units, giving up as many trailing +/// characters as it takes to leave room for an ellipsis when the string does +/// not fit whole. +/// +/// `measure` is anything with `advance(u21) i32` and `ellipsisAdvance() i32`. +pub fn fit(text: []const u8, max_width: i32, measure: anytype) Fit { + const empty: Fit = .{ .end = 0, .width = 0, .elided = false }; + if (max_width <= 0) return empty; + + const dots = measure.ellipsisAdvance(); 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 width: i32 = 0; + // The longest prefix seen so far that still leaves room for an ellipsis. var cut: usize = 0; + var cut_width: i32 = 0; + var cut_fits = dots <= max_width; - 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 }; + while (it.next()) |cp| { + const adv = measure.advance(cp); + if (width + adv > max_width) { + // Not even an ellipsis fits: better to draw nothing than a lone + // pair of dots where a title should be. + if (!cut_fits) return empty; + return .{ .end = cut, .width = cut_width + dots, .elided = true }; + } + width += adv; + if (width + dots <= max_width) { + cut = it.i; + cut_width = width; + cut_fits = true; } } - return .{ .end = text.len, .cells = cells, .elided = false }; + return .{ .end = text.len, .width = width, .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. +/// replacement character, which draws as whatever the face has for it. Titles +/// come from arbitrary clients, so refusing to render one is not an option. +/// +/// Control characters come back as spaces, so that a stray newline in a title +/// is a gap rather than a .notdef box. pub const Iterator = struct { bytes: []const u8, i: usize = 0, - const replacement: u21 = 0xfffd; + pub const replacement: u21 = 0xfffd; pub fn next(self: *Iterator) ?u21 { if (self.i >= self.bytes.len) return null; @@ -104,137 +98,6 @@ pub const Iterator = struct { return replacement; }; self.i += len; - return cp; + return if (cp < 0x20 or cp == 0x7f) ' ' else 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 bfb11b1..e8c75a4 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 and text in the bitmap font from `font.zig`, so this -//! deliberately stops at "memfd, mmap, fill" rather than pulling in pixman. +//! solid rectangles and antialiased text, so this deliberately stops at +//! "memfd, mmap, fill, blend" rather than pulling in pixman. const std = @import("std"); const posix = std.posix; @@ -13,6 +13,7 @@ const wayland = @import("wayland"); const wl = wayland.client.wl; const font = @import("font.zig"); +const Face = @import("Face.zig"); const layout = @import("layout.zig"); const Box = layout.Box; @@ -69,41 +70,76 @@ pub const Buffer = struct { 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; + /// Draw text along the baseline `y`, starting at `x`, clipped to `clip`, + /// and return the 26.6 pen position the next glyph would start at. + /// + /// The pen is carried in 26.6 and handed to the face as a subpixel offset + /// rather than rounded per glyph: rounding is what makes rendered text look + /// unevenly spaced, and it is cheap to avoid. + pub fn drawText(self: *Buffer, face: *Face, text: []const u8, x: i32, y: i32, argb: u32, clip: Box) i32 { + var pen = font.fixed(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; + // Stop as soon as the pen leaves the clip rather than rasterising + // the rest of a long title into nothing. + if (pen >= font.fixed(clip.x + clip.width)) break; + pen = self.drawGlyph(face, cp, pen, y, argb, clip); } 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); + /// Draw one glyph with its origin at the 26.6 pen position `pen` on the + /// baseline `y`, and return the pen position after it. + pub fn drawGlyph(self: *Buffer, face: *Face, cp: u21, pen: i32, y: i32, argb: u32, clip: Box) i32 { + const advance = face.advance(cp); + if (face.render(cp, pen & 63)) |g| { + self.blendCoverage(g, @divFloor(pen, 64) + g.left, y - g.top, argb, clip); + } + return pen + advance; + } + + /// Composite a glyph's coverage over what is already in the buffer, with + /// (x, y) the top left of its bitmap. + fn blendCoverage(self: *Buffer, g: Face.Glyph, x: i32, y: i32, argb: u32, clip: Box) void { + const x0 = @max(@max(0, clip.x), x); + const y0 = @max(@max(0, clip.y), y); + const x1 = @min(@min(self.width, clip.x + clip.width), x + @as(i32, @intCast(g.width))); + const y1 = @min(@min(self.height, clip.y + clip.height), y + @as(i32, @intCast(g.height))); + if (x1 <= x0 or y1 <= y0) return; + + const px = self.pixels(); + const stride: usize = @intCast(self.width); + var row = y0; + while (row < y1) : (row += 1) { + const src = g.coverage[@as(usize, @intCast(row - y)) * g.pitch ..]; + const dst_row = @as(usize, @intCast(row)) * stride; + var col = x0; + while (col < x1) : (col += 1) { + const cov = src[@intCast(col - x)]; + if (cov == 0) continue; + const dst = &px[dst_row + @as(usize, @intCast(col))]; + dst.* = if (cov == 0xff) argb else blend(argb, dst.*, cov); } } } + /// `src` over `dst` at coverage `cov`. Both are premultiplied, so this is + /// the same arithmetic on all four channels, alpha included. + fn blend(src: u32, dst: u32, cov: u8) u32 { + const a: u32 = cov; + const inv: u32 = 255 - a; + var out: u32 = 0; + for ([_]u5{ 0, 8, 16, 24 }) |shift| { + const s = (src >> shift) & 0xff; + const d = (dst >> shift) & 0xff; + // +127 to round to nearest; text over a dark tab loses a level to + // truncation otherwise, and thin stems are made of those levels. + const v = (s * a + d * inv + 127) / 255; + out |= v << shift; + } + return out; + } + 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 3e68dc7..6e319c0 100644 --- a/src/test.zig +++ b/src/test.zig @@ -426,44 +426,87 @@ test "map_to_output merges like any other setting" { // ─── tab titles ────────────────────────────────────────────────────────────── +/// Stands in for a `Face`, so the eliding rules can be checked without a font +/// on the machine running the tests. One cell wide per character, in the 26.6 +/// units `font.fit` counts in. +const Mono = struct { + pub fn advance(_: Mono, _: u21) i32 { + return font.fixed(1); + } + pub fn ellipsisAdvance(self: Mono) i32 { + return self.advance(font.ellipsis); + } +}; + +/// A proportional face: 'i' is half a cell, the ellipsis two. Real faces are +/// like this, and it is where fixed-cell arithmetic goes wrong. +const Prop = struct { + pub fn advance(_: Prop, cp: u21) i32 { + return switch (cp) { + 'i' => @divExact(font.fixed(1), 2), + font.ellipsis => font.fixed(2), + else => font.fixed(1), + }; + } + pub fn ellipsisAdvance(self: Prop) i32 { + return self.advance(font.ellipsis); + } +}; + test "a title that fits is drawn whole" { - const f = font.fit("foot", 10); + const f = font.fit("foot", font.fixed(10), Mono{}); try testing.expectEqual(@as(usize, 4), f.end); - try testing.expectEqual(@as(usize, 4), f.cells); + try testing.expectEqual(font.fixed(4), f.width); try testing.expect(!f.elided); - // Exactly full is still whole: the ellipsis costs a cell, so only spend it + // Exactly full is still whole: the ellipsis costs room, so only spend it // when something is actually cut. - const exact = font.fit("foot", 4); - try testing.expectEqual(@as(usize, 4), exact.cells); + const exact = font.fit("foot", font.fixed(4), Mono{}); + try testing.expectEqual(@as(usize, 4), exact.end); 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); +test "a title too long for the tab is cut short for the ellipsis" { + const f = font.fit("firefox", font.fixed(4), Mono{}); try testing.expectEqualStrings("fir", "firefox"[0..f.end]); - try testing.expectEqual(@as(usize, 3), f.cells); + try testing.expectEqual(font.fixed(4), f.width); 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); + // Room for the ellipsis alone leaves nothing but the ellipsis, and no room + // at all draws nothing rather than indexing past the end. + const one = font.fit("firefox", font.fixed(1), Mono{}); try testing.expectEqual(@as(usize, 0), one.end); try testing.expect(one.elided); - const none = font.fit("firefox", 0); + const none = font.fit("firefox", 0, Mono{}); try testing.expectEqual(@as(usize, 0), none.end); try testing.expect(!none.elided); } +test "eliding gives up as many characters as the ellipsis costs" { + // "wiki" alone would fit in three cells; the two-cell ellipsis means all + // but the first character has to go, which counting cells would miss. + const f = font.fit("wikiw", font.fixed(3), Prop{}); + try testing.expect(f.elided); + try testing.expectEqualStrings("w", "wikiw"[0..f.end]); + try testing.expect(f.width <= font.fixed(3)); + + // Narrow characters earn their place: three of them survive where two wide + // ones would not. + const narrow = font.fit("wiiwwww", font.fixed(4), Prop{}); + try testing.expect(narrow.elided); + try testing.expectEqualStrings("wii", "wiiwwww"[0..narrow.end]); + try testing.expect(narrow.width <= font.fixed(4)); +} + 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); + // Four codepoints, six bytes: a tab four cells wide holds them all. + const f = font.fit("ünïx", font.fixed(4), Mono{}); + try testing.expectEqual("ünïx".len, f.end); try testing.expect(!f.elided); // And a cut lands on a codepoint boundary, never mid-sequence. - const cut = font.fit("ünïx", 3); + const cut = font.fit("ünïx", font.fixed(3), Mono{}); try testing.expect(cut.elided); try testing.expectEqualStrings("ün", "ünïx"[0..cut.end]); } @@ -478,32 +521,20 @@ test "malformed utf-8 in a title yields one glyph per bad byte" { 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 "control characters in a title draw as spaces" { + var it: font.Iterator = .{ .bytes = "a\nb\tc\x7f" }; + try testing.expectEqual(@as(?u21, 'a'), it.next()); + try testing.expectEqual(@as(?u21, ' '), it.next()); + try testing.expectEqual(@as(?u21, 'b'), it.next()); + try testing.expectEqual(@as(?u21, ' '), it.next()); + try testing.expectEqual(@as(?u21, 'c'), it.next()); + try testing.expectEqual(@as(?u21, ' '), it.next()); + try testing.expectEqual(@as(?u21, null), it.next()); } -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('あ')); +test "fixed point widths round up to whole pixels" { + try testing.expectEqual(@as(i32, 0), font.pixels(0)); + try testing.expectEqual(@as(i32, 1), font.pixels(1)); + try testing.expectEqual(@as(i32, 1), font.pixels(font.fixed(1))); + try testing.expectEqual(@as(i32, 2), font.pixels(font.fixed(1) + 1)); }