use freetype for font display

This commit is contained in:
2026-08-01 15:03:55 +02:00
parent 4a08d19365
commit f5a0b6136f
11 changed files with 605 additions and 292 deletions
+9 -4
View File
@@ -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 **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 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. 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`) — Titles are drawn with FreeType in whatever font the fontconfig pattern
a font stack for one strip of pixels would be a poor trade — scaled by whole `tab_font` matches (`"sans:size=11"` by default, so `fc-match 'sans:size=11'`
pixels to suit `tabbar_height`, or by `tab_font_scale` if you would rather say. 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 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.
@@ -492,7 +496,8 @@ river's identifier is shared between them.
| `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, and drawing into them | | `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 | | `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
+4
View File
@@ -84,6 +84,10 @@ pub fn build(b: *Build) void {
exe.root_module.addImport("config", config); exe.root_module.addImport("config", config);
exe.root_module.linkSystemLibrary("wayland-client", .{}); exe.root_module.linkSystemLibrary("wayland-client", .{});
exe.root_module.linkSystemLibrary("xkbcommon", .{}); 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; exe.pie = pie;
b.installArtifact(exe); b.installArtifact(exe);
+2
View File
@@ -38,6 +38,8 @@
wayland wayland
wayland-protocols wayland-protocols
libxkbcommon libxkbcommon
freetype
fontconfig
]; ];
packages = with pkgs; [ packages = with pkgs; [
river river
+4
View File
@@ -9,6 +9,8 @@
wayland-protocols, wayland-protocols,
wayland-scanner, wayland-scanner,
libxkbcommon, libxkbcommon,
freetype,
fontconfig,
# Path to a replacement src/config.zig. dwm-style compile-time configuration: # Path to a replacement src/config.zig. dwm-style compile-time configuration:
# att_wm.override { configFile = ./my-config.zig; } # att_wm.override { configFile = ./my-config.zig; }
# Deliberately not called `config`: callPackage would fill that from the # Deliberately not called `config`: callPackage would fill that from the
@@ -38,6 +40,8 @@ stdenv.mkDerivation {
wayland-protocols wayland-protocols
wayland-scanner wayland-scanner
libxkbcommon libxkbcommon
freetype
fontconfig
]; ];
zigBuildFlags = [ zigBuildFlags = [
+350
View File
@@ -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,
};
}
+19 -25
View File
@@ -22,6 +22,7 @@ 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 font = @import("font.zig");
const Face = @import("Face.zig");
const layout = @import("layout.zig"); const layout = @import("layout.zig");
const Box = layout.Box; const Box = layout.Box;
@@ -242,7 +243,10 @@ 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); // 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| { 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);
@@ -256,8 +260,10 @@ pub const TabBar = struct {
}; };
buffer.fill(inner, argb); buffer.fill(inner, argb);
const ink = color.toArgb8888(if (is_focused) config.tab_text_focused else config.tab_text_normal); if (face) |f| {
drawLabel(buffer, inner, label(win), scale, ink); 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 // Translate the hit rectangles into global coordinates for click
@@ -304,34 +310,22 @@ pub const TabBar = struct {
return "?"; 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 /// Centre a title in one tab, eliding it with an ellipsis when the tab is
/// too narrow to hold it. /// too narrow to hold it.
fn drawLabel(buffer: *shm.Buffer, tab: Box, text: []const u8, scale: i32, argb: u32) void { fn drawLabel(buffer: *shm.Buffer, face: *Face, tab: Box, text: []const u8, argb: u32) void {
const pad = 2 * scale; // Enough air either side that neighbouring titles do not read as one.
const avail = tab.width - 2 * pad; const pad = @divFloor(face.px, 2) + 2;
const avail = tab.width - 2 * @as(i32, @intCast(pad));
if (avail <= 0) return; if (avail <= 0) return;
const f = font.fit(text, font.cellsForWidth(avail, scale)); const f = font.fit(text, font.fixed(avail), face);
const cells = f.cells + @intFromBool(f.elided); if (f.end == 0 and !f.elided) return;
if (cells == 0) return;
const x = tab.x + @divFloor(tab.width - font.textWidth(cells, scale), 2); const x = tab.x + @divFloor(tab.width - font.pixels(f.width), 2);
// Centre the part above the baseline; the descender row may hang into const y = tab.y + face.baselineIn(tab.height);
// 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); const pen = buffer.drawText(face, text[0..f.end], x, y, argb, tab);
if (f.elided) buffer.drawGlyph(font.ellipsis, pen, y, scale, argb, tab); if (f.elided) _ = buffer.drawGlyph(face, font.ellipsis, pen, y, argb, tab);
} }
/// Which window's tab covers this global coordinate, if any. /// Which window's tab covers this global coordinate, if any.
+24
View File
@@ -28,6 +28,7 @@ const Output = @import("Output.zig");
const Seat = @import("Seat.zig"); const Seat = @import("Seat.zig");
const InputManager = @import("InputManager.zig"); const InputManager = @import("InputManager.zig");
const Ipc = @import("ipc.zig").Ipc; const Ipc = @import("ipc.zig").Ipc;
const Face = @import("Face.zig");
const color = @import("color.zig"); const color = @import("color.zig");
const layout = @import("layout.zig"); const layout = @import("layout.zig");
const Box = layout.Box; const Box = layout.Box;
@@ -97,6 +98,11 @@ repeat_fd: sys.fd_t,
repeat_seat: ?*Seat = null, repeat_seat: ?*Seat = null,
repeat_index: ?usize = 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 buffers reused by the layout pass to avoid per-frame allocation.
scratch_windows: std.ArrayList(*Window) = .empty, scratch_windows: std.ArrayList(*Window) = .empty,
scratch_cells: std.ArrayList(Box) = .empty, scratch_cells: std.ArrayList(Box) = .empty,
@@ -221,8 +227,26 @@ fn freeGlobals(self: *Wm) void {
self.shm = null; 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 { pub fn deinit(self: *Wm) void {
const gpa = self.gpa; const gpa = self.gpa;
if (self.tab_face) |*f| f.deinit();
for (self.windows.items) |win| win.destroy(); for (self.windows.items) |win| win.destroy();
// Outputs hold references into wl_outputs, so they must go first. // Outputs hold references into wl_outputs, so they must go first.
+4 -4
View File
@@ -44,10 +44,10 @@ pub const tab_separator: u32 = 0x1a1a1aff;
/// Window titles are drawn on the tabs in these colours. /// Window titles are drawn on the tabs in these colours.
pub const tab_text_focused: u32 = 0xffffffff; pub const tab_text_focused: u32 = 0xffffffff;
pub const tab_text_normal: u32 = 0xbbbbbbff; pub const tab_text_normal: u32 = 0xbbbbbbff;
/// Whole-pixel scale for the 5x8 built-in font the titles are drawn in: 2 /// The font titles are drawn in, as a fontconfig pattern — the same syntax
/// gives 10x16 letters. Zero picks the largest scale `tabbar_height` has room /// `fc-match` takes, so `fc-match 'sans:size=11'` shows what this will resolve
/// for, which is what you want after changing the bar height. /// to. The size is trimmed to whatever `tabbar_height` has room for.
pub const tab_font_scale: i32 = 0; pub const tab_font: [:0]const u8 = "sans:size=11";
// ─── Layout ────────────────────────────────────────────────────────────────── // ─── Layout ──────────────────────────────────────────────────────────────────
+53 -190
View File
@@ -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 //! Deliberately free of FreeType — it asks whatever it is handed how wide a
//! one strip of pixels has no business linking a font stack for it. So the //! codepoint is and does the arithmetic, so the eliding rules can be tested
//! glyphs live here as data: printable ASCII plus an ellipsis for elided //! without a font on the machine running the tests. `Face` is what supplies the
//! titles, drawn as bit rows and expanded by whole pixels when scaled. //! widths in the running window manager.
//! //!
//! Anything else — every non-ASCII codepoint a title may hold — renders as the //! Widths are 26.6 fixed point, FreeType's unit: whole pixels are too coarse to
//! fallback box, which is at least honest about being a character we cannot //! accumulate a string's width in without the spacing drifting.
//! draw.
const std = @import("std"); const std = @import("std");
pub const cell_width = 5; /// Cut titles are ended with this rather than three periods, which at tab-bar
pub const cell_height = 8; /// sizes look like a smudge.
/// 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 = '…'; pub const ellipsis: u21 = '…';
/// Rows for a codepoint. Control characters come back blank rather than as a /// Whole pixels to 26.6.
/// box, so a stray newline in a title does not turn into visible noise. pub fn fixed(px: i32) i32 {
pub fn glyph(cp: u21) *const Glyph { return px * 64;
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. /// 26.6 to whole pixels, rounded up: a width is how much room the text needs.
pub fn textWidth(cells: usize, scale: i32) i32 { pub fn pixels(width: i32) i32 {
if (cells == 0) return 0; return @divFloor(width + 63, 64);
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 { pub const Fit = struct {
/// Bytes of the string to draw. /// Bytes of the string to draw.
end: usize, end: usize,
/// Characters those bytes come to, an ellipsis not counted. /// Width in 26.6 of what will be drawn, the ellipsis included.
cells: usize, width: i32,
/// The string was too long; draw an ellipsis after `end`. /// The string was too long; draw an ellipsis after `end`.
elided: bool, elided: bool,
}; };
/// Fit a string into `max_cells` character cells, giving up the last cell to /// Fit a string into `max_width` 26.6 units, giving up as many trailing
/// an ellipsis when the string does not fit whole. /// characters as it takes to leave room for an ellipsis when the string does
pub fn fit(text: []const u8, max_cells: usize) Fit { /// not fit whole.
if (max_cells == 0) return .{ .end = 0, .cells = 0, .elided = false }; ///
/// `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 it: Iterator = .{ .bytes = text };
var cells: usize = 0; var width: i32 = 0;
// Where the string is cut if it turns out not to fit: one cell short, so // The longest prefix seen so far that still leaves room for an ellipsis.
// the ellipsis has somewhere to go.
var cut: usize = 0; var cut: usize = 0;
var cut_width: i32 = 0;
var cut_fits = dots <= max_width;
while (it.next()) |_| { while (it.next()) |cp| {
cells += 1; const adv = measure.advance(cp);
if (cells + 1 == max_cells) cut = it.i; if (width + adv > max_width) {
// One character past the end is what proves it does not fit. // Not even an ellipsis fits: better to draw nothing than a lone
if (cells > max_cells) { // pair of dots where a title should be.
return .{ .end = cut, .cells = max_cells - 1, .elided = true }; 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 /// UTF-8 iteration that never fails: anything malformed comes back as the
/// replacement character, which draws as the fallback box. Titles come from /// replacement character, which draws as whatever the face has for it. Titles
/// arbitrary clients, so refusing to render one is not an option. /// 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 { pub const Iterator = struct {
bytes: []const u8, bytes: []const u8,
i: usize = 0, i: usize = 0,
const replacement: u21 = 0xfffd; pub const replacement: u21 = 0xfffd;
pub fn next(self: *Iterator) ?u21 { pub fn next(self: *Iterator) ?u21 {
if (self.i >= self.bytes.len) return null; if (self.i >= self.bytes.len) return null;
@@ -104,137 +98,6 @@ pub const Iterator = struct {
return replacement; return replacement;
}; };
self.i += len; 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);
}
+63 -27
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 and text in the bitmap font from `font.zig`, so this //! solid rectangles and antialiased text, so this deliberately stops at
//! deliberately stops at "memfd, mmap, fill" rather than pulling in pixman. //! "memfd, mmap, fill, blend" rather than pulling in pixman.
const std = @import("std"); const std = @import("std");
const posix = std.posix; const posix = std.posix;
@@ -13,6 +13,7 @@ const wayland = @import("wayland");
const wl = wayland.client.wl; const wl = wayland.client.wl;
const font = @import("font.zig"); const font = @import("font.zig");
const Face = @import("Face.zig");
const layout = @import("layout.zig"); const layout = @import("layout.zig");
const Box = layout.Box; 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); 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 /// Draw text along the baseline `y`, starting at `x`, clipped to `clip`,
/// `clip`, and return the x the next cell would start at. The font has no /// and return the 26.6 pen position the next glyph would start at.
/// 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 { /// The pen is carried in 26.6 and handed to the face as a subpixel offset
var pen = x; /// 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 }; var it: font.Iterator = .{ .bytes = text };
while (it.next()) |cp| { while (it.next()) |cp| {
// Stop as soon as the pen leaves the clip rather than walking the // Stop as soon as the pen leaves the clip rather than rasterising
// rest of a long title a pixel at a time. // the rest of a long title into nothing.
if (pen >= clip.x + clip.width) break; if (pen >= font.fixed(clip.x + clip.width)) break;
self.drawGlyph(cp, pen, y, scale, argb, clip); pen = self.drawGlyph(face, cp, pen, y, argb, clip);
pen += font.advance * scale;
} }
return pen; return pen;
} }
pub fn drawGlyph(self: *Buffer, cp: u21, x: i32, y: i32, scale: i32, argb: u32, clip: Box) void { /// Draw one glyph with its origin at the 26.6 pen position `pen` on the
const rows = font.glyph(cp); /// baseline `y`, and return the pen position after it.
for (rows, 0..) |bits, row| { pub fn drawGlyph(self: *Buffer, face: *Face, cp: u21, pen: i32, y: i32, argb: u32, clip: Box) i32 {
if (bits == 0) continue; const advance = face.advance(cp);
const py = y + @as(i32, @intCast(row)) * scale; if (face.render(cp, pen & 63)) |g| {
var col: u3 = 0; self.blendCoverage(g, @divFloor(pen, 64) + g.left, y - g.top, argb, clip);
while (col < font.cell_width) : (col += 1) { }
const mask = @as(u8, 1) << @intCast(font.cell_width - 1 - col); return pen + advance;
if (bits & mask == 0) continue; }
self.fillClipped(.{
.x = x + @as(i32, col) * scale, /// Composite a glyph's coverage over what is already in the buffer, with
.y = py, /// (x, y) the top left of its bitmap.
.width = scale, fn blendCoverage(self: *Buffer, g: Face.Glyph, x: i32, y: i32, argb: u32, clip: Box) void {
.height = scale, const x0 = @max(@max(0, clip.x), x);
}, clip, argb); 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 { fn deinit(self: *Buffer, gpa: Allocator) void {
self.wl_buffer.destroy(); self.wl_buffer.destroy();
posix.munmap(self.data); posix.munmap(self.data);
+73 -42
View File
@@ -426,44 +426,87 @@ test "map_to_output merges like any other setting" {
// ─── tab titles ────────────────────────────────────────────────────────────── // ─── 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" { 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.end);
try testing.expectEqual(@as(usize, 4), f.cells); try testing.expectEqual(font.fixed(4), f.width);
try testing.expect(!f.elided); 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. // when something is actually cut.
const exact = font.fit("foot", 4); const exact = font.fit("foot", font.fixed(4), Mono{});
try testing.expectEqual(@as(usize, 4), exact.cells); try testing.expectEqual(@as(usize, 4), exact.end);
try testing.expect(!exact.elided); try testing.expect(!exact.elided);
} }
test "a title too long for the tab loses its last cell to the ellipsis" { test "a title too long for the tab is cut short for the ellipsis" {
const f = font.fit("firefox", 4); const f = font.fit("firefox", font.fixed(4), Mono{});
try testing.expectEqualStrings("fir", "firefox"[0..f.end]); 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); try testing.expect(f.elided);
// One cell of room leaves nothing but the ellipsis, and no room at all // Room for the ellipsis alone leaves nothing but the ellipsis, and no room
// draws nothing rather than indexing past the end. // at all draws nothing rather than indexing past the end.
const one = font.fit("firefox", 1); const one = font.fit("firefox", font.fixed(1), Mono{});
try testing.expectEqual(@as(usize, 0), one.end); try testing.expectEqual(@as(usize, 0), one.end);
try testing.expect(one.elided); 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.expectEqual(@as(usize, 0), none.end);
try testing.expect(!none.elided); 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" { test "titles are measured in characters, not bytes" {
// Three codepoints, six bytes: a tab four cells wide holds them all. // Four codepoints, six bytes: a tab four cells wide holds them all.
const f = font.fit("ünïx", 4); const f = font.fit("ünïx", font.fixed(4), Mono{});
try testing.expectEqual(@as(usize, 4), f.cells); try testing.expectEqual("ünïx".len, f.end);
try testing.expect(!f.elided); try testing.expect(!f.elided);
// And a cut lands on a codepoint boundary, never mid-sequence. // 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.expect(cut.elided);
try testing.expectEqualStrings("ün", "ünïx"[0..cut.end]); 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()); try testing.expectEqual(@as(?u21, null), it.next());
} }
test "text measuring and cell fitting agree" { test "control characters in a title draw as spaces" {
const scale = 2; var it: font.Iterator = .{ .bytes = "a\nb\tc\x7f" };
// Whatever cellsForWidth says fits must actually fit. try testing.expectEqual(@as(?u21, 'a'), it.next());
var width: i32 = 0; try testing.expectEqual(@as(?u21, ' '), it.next());
while (width < 200) : (width += 1) { try testing.expectEqual(@as(?u21, 'b'), it.next());
const cells = font.cellsForWidth(width, scale); try testing.expectEqual(@as(?u21, ' '), it.next());
try testing.expect(font.textWidth(cells, scale) <= width); try testing.expectEqual(@as(?u21, 'c'), it.next());
try testing.expect(font.textWidth(cells + 1, scale) > width); 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" { test "fixed point widths round up to whole pixels" {
// The space is blank, and nothing else printable is. try testing.expectEqual(@as(i32, 0), font.pixels(0));
var c: u8 = 0x21; try testing.expectEqual(@as(i32, 1), font.pixels(1));
while (c <= 0x7e) : (c += 1) { try testing.expectEqual(@as(i32, 1), font.pixels(font.fixed(1)));
const rows = font.glyph(c); try testing.expectEqual(@as(i32, 2), font.pixels(font.fixed(1) + 1));
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('あ'));
} }