diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/body_layer.zig | 8 | ||||
| -rw-r--r-- | src/builtins.zig | 2 | ||||
| -rw-r--r-- | src/colors.zig | 25 | ||||
| -rw-r--r-- | src/config.zig | 20 | ||||
| -rw-r--r-- | src/detached/wire.zig | 24 | ||||
| -rw-r--r-- | src/draw.zig | 124 | ||||
| -rw-r--r-- | src/gui/gui.zig | 461 | ||||
| -rw-r--r-- | src/ninep/ctl.zig | 1 | ||||
| -rw-r--r-- | src/surface.zig | 15 | ||||
| -rw-r--r-- | src/tagline.zig | 8 | ||||
| -rw-r--r-- | src/themes/acme.zig | 33 |
11 files changed, 657 insertions, 64 deletions
diff --git a/src/body_layer.zig b/src/body_layer.zig index a0195fb8..875f2e36 100644 --- a/src/body_layer.zig +++ b/src/body_layer.zig @@ -156,6 +156,7 @@ pub fn paintPointerSelection( /// The selection colour of each pointer button's sweep: the select button's /// and the look and exec buttons', each tinted toward its own accent. pub fn buttonTints(th: *const colors.Theme) [3][3]u8 { + if (th.sweep_bg) |own| return own; const accent2 = if (std.mem.eql(u8, &th.num, &th.str)) th.comment else th.num; return .{ colors.mix(th.sel_bg, colors.mix(th.sel_bg, th.kw)), @@ -164,6 +165,11 @@ pub fn buttonTints(th: *const colors.Theme) [3][3]u8 { }; } +/// The ink over each button's sweep. +pub fn buttonInks(th: *const colors.Theme) [3][3]u8 { + return th.sweep_fg orelse @splat(th.sel_fg); +} + /// A pane's body, painted once. A file body with compact tree-sitter /// context rows is painted into its body layer -- more rows than the grid /// has, the context rows at the tagline's height -- and the grid's rows are @@ -312,7 +318,7 @@ pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane // A select sweep over the tag is the tag's selection, painted there // in the tag's own selection colours. if (b == pardes.sel_slot and @min(sl.r0, sl.r1) < pane.tag_rows) continue; - paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sl, pane.tag_rows, sel_btn[b], th.sel_fg); + paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sl, pane.tag_rows, sel_btn[b], buttonInks(th)[b]); } // modal line selection (helix `x`): whole body rows, normal mode only if ((pane.body.mode == .normal or modal_hover) and pane.body.msel.active) { diff --git a/src/builtins.zig b/src/builtins.zig index e49deb71..c25311e3 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -172,7 +172,7 @@ pub const registry = struct { if (@intFromEnum(b) == i) return switch (setting.action) { // a switch flips bare, and DumpDir bare is the default .toggle, .transition, .scene, .dump_dir, .lift => false, - .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms, .shader, .shader_animation, .motion, .inactive_dim => true, + .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms, .shader, .shader_animation, .motion, .inactive_dim, .grip_width => true, }; unreachable; } diff --git a/src/colors.zig b/src/colors.zig index aeff7b00..25aa838f 100644 --- a/src/colors.zig +++ b/src/colors.zig @@ -63,6 +63,29 @@ pub const Theme = struct { // The current line number is a quiet foreground cue, not a tag-colored // background block. Older themes fall back to their normal gutter ink. lineno_active: ?[3]u8 = null, + /// A tag's selection, where it differs from the body's (acme's #9eeeee). + tag_sel_bg: ?[3]u8 = null, + /// Each pointer button's sweep, select, exec and look (Mouse.Button + /// order), and the ink over it. Unset, tints of `sel_bg` in `sel_fg`. + sweep_bg: ?[3][3]u8 = null, + sweep_fg: ?[3][3]u8 = null, + /// A pixel shell's rules, in pixels (2 unset); the rule between a tag + /// and its body is always one. + rule_px: ?u8 = null, + /// A grip is acme's button: `box` filled when focused, else a ring of + /// `box_border` (unset: `box_dim`) round the tag's ground; `box_dirty` + /// (unset: `diagnostic_warning`, then `num`) fills it, in either ring, + /// while its file is unsaved. + box_border: ?[3]u8 = null, + box_dirty: ?[3]u8 = null, + /// The rule between a pane's tag and its body, one pixel, quieter than + /// the rules between panes (acme's #8888cc under its 2px black ones). + /// Unset, halfway between the tag's ground and the page. + tag_rule: ?[3]u8 = null, + /// A pixel shell's scroll column, the grip's button over the scrollbar, + /// in pixels at a 17px tagline, the thumb one narrower (acme's 12px + /// Scrollwid, the default). + rail_px: ?u8 = null, }; const curated = struct { @@ -108,6 +131,8 @@ fn fold(comptime C: type) [@typeInfo(C).@"struct".decls.len]Theme { } pub const themes = fold(curated) ++ fold(@import("generated_themes")); +/// The first `native_count` of `themes` are pardes's own; the rest imported. +pub const native_count = @typeInfo(curated).@"struct".decls.len; /// Original and classic-inspired Pardes palettes precede retained imports. pub const native_theme_count = 15; diff --git a/src/config.zig b/src/config.zig index ed7bee92..24e058c7 100644 --- a/src/config.zig +++ b/src/config.zig @@ -118,6 +118,7 @@ pub const leader_path = paths: { if (builtins.capabilities.lift) table.set(.Lift, null); table.set(.Motion, null); table.set(.InactiveDim, null); + if (builtins.capabilities.lift) table.set(.GripWidth, null); if (builtins.capabilities.panel_transitions) { table.set(.PanelSlide, "as"); table.set(.PanelZoom, "az"); @@ -629,6 +630,10 @@ pub const Runtime = struct { /// percent (0 off): the focused pane wins by comparison, its tag tint /// untouched. Kept above min(each cell's contrast, 4.5). inactive_dim: u8 = 0, + /// The grip's button and the scrollbar under it, one width, as a percent + /// of the theme's `rail_px` (acme's 12px Scrollwid at a 17px tagline): + /// a pixel shell's (docs/themes.md). + grip_width: u16 = 150, /// How the fx track's animations move (animation.Motion): off for /// reduced motion. motion: animation.Motion.Flavour = .smooth, @@ -862,6 +867,7 @@ pub const Runtime = struct { motion, /// A percentage, 0 through 100. inactive_dim, + grip_width, transition: animation.Transition, scene: Scene, /// A Shadertoy file into the post chain or out of it; `off` empties @@ -899,6 +905,7 @@ pub const Runtime = struct { .{ .word = "Lift", .action = .lift, .availability = .lift }, .{ .word = "Motion", .action = .motion }, .{ .word = "InactiveDim", .action = .inactive_dim }, + .{ .word = "GripWidth", .action = .grip_width, .availability = .lift }, .{ .word = "MessageLinger", .action = .{ .message_ms = .linger } }, .{ .word = "MessageFall", .action = .{ .message_ms = .fall } }, .{ .word = "MessageDissolve", .action = .{ .message_ms = .dissolve } }, @@ -960,8 +967,8 @@ pub const Runtime = struct { @compileError("scene effects must use the scene-shader capability"), // A toggle is plain state any shell can hold, except one for // what only some shells draw: that one exists where they do. - .lift => if (setting.availability != .lift) - @compileError("Lift must use the lift capability"), + .lift, .grip_width => if (setting.availability != .lift) + @compileError("Lift and GripWidth must use the lift capability (a pixel shell's)"), .toggle => |field| if (field == .ligatures) { if (setting.availability != .ligatures) @compileError("the ligatures toggle must use the ligatures capability"); @@ -1102,6 +1109,12 @@ pub const Runtime = struct { if (percent > 100) return false; state.inactive_dim = percent; }, + .grip_width => { + const text = std.mem.trim(u8, argument orelse return false, " \t\r\n%"); + const percent = std.fmt.parseInt(u16, text, 10) catch return false; + if (percent < 50 or percent > 300) return false; + state.grip_width = percent; + }, .theme, .font => return false, } return true; @@ -1277,6 +1290,7 @@ pub const Runtime = struct { try out.print("{s} unsupported\n", .{setting.word}), .motion => try out.print("{s} {s}\n", .{ setting.word, @tagName(state.motion) }), .inactive_dim => try out.print("{s} {d}\n", .{ setting.word, state.inactive_dim }), + .grip_width => try out.print("{s} {d}\n", .{ setting.word, state.grip_width }), }; if (context.startup_config_path) |path| @@ -1547,6 +1561,7 @@ pub const Runtime = struct { .lift => "rim", .motion => "playful", .inactive_dim => "35", + .grip_width => "200", else => null, }; try std.testing.expect(apply(&state, setting, argument)); @@ -1600,6 +1615,7 @@ pub const Runtime = struct { .lift => "Lift rim\n", .motion => "Motion playful\n", .inactive_dim => "InactiveDim 35\n", + .grip_width => "GripWidth 200\n", .theme, .font => unreachable, }; try std.testing.expect(std.mem.indexOf(u8, report, expected) != null); diff --git a/src/detached/wire.zig b/src/detached/wire.zig index 2a2ccc6b..c0e17054 100644 --- a/src/detached/wire.zig +++ b/src/detached/wire.zig @@ -11,6 +11,19 @@ const limits = @import("../memory.zig").limits; /// line), and a frame carries the core's placed regions after its layers. pub const version: u16 = 8; +/// The Chrome palette is carried field by field as its declaration lists +/// them, so any change to it changes the wire: bump `version`, then this. +const chrome_layout: u64 = 0x7e3d0952317bfee; +comptime { + @setEvalBranchQuota(10_000); + var hash = std.hash.Fnv1a_64.init(); + for (@typeInfo(pardes.Chrome).@"struct".fields) |field| { + hash.update(field.name); + hash.update(@typeName(field.type)); + } + if (hash.final() != chrome_layout) @compileError(std.fmt.comptimePrint("pardes.Chrome changed (layout 0x{x}): bump wire.version, then chrome_layout", .{hash.final()})); +} + pub const Error = error{ Truncated, Overlong, BadTag, BadValue, Trailing, NoSpace }; pub const max_cols: u16 = 512; @@ -248,7 +261,10 @@ pub const Frame = struct { region.owner = try r.getByte(); region.serial = try r.getU32(); region.rect = .{ .x = try r.getU16(), .y = try r.getU16(), .w = try r.getU16(), .h = try r.getU16() }; - region.active = try r.getBool(); + const flags = try r.getByte(); + if (flags > 3) return error.BadValue; + region.active = flags & 1 != 0; + region.dirty = flags & 2 != 0; region.thumb_y = try r.getU16(); region.thumb_h = try r.getU16(); region.notice = try r.getByte(); @@ -807,7 +823,7 @@ pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, point try w.putU16(region.rect.y); try w.putU16(region.rect.w); try w.putU16(region.rect.h); - try w.putBool(region.active); + try w.putByte(@as(u8, @intFromBool(region.active)) | @as(u8, @intFromBool(region.dirty)) << 1); try w.putU16(region.thumb_y); try w.putU16(region.thumb_h); try w.putByte(region.notice); @@ -1898,10 +1914,10 @@ test "detached wire carries compact body rows and exact pointer metrics" { var tag_cells: [3]pardes.Cell = @splat(.{}); tag_cells[2] = logical[10]; const tag: pardes.Layer = .{ .kind = .column, .id = 1, .viewport = .{ .x = 0, .y = 0, .w = 2, .h = 1 }, .cols = 3, .rows = 1, .bg = .{ 11, 22, 33 }, .cells = &tag_cells, .cursor = .{ .x = 2, .y = 0, .bar = true } }; - const placed = [_]pardes.Region{.{ .kind = .rail, .owner = 3, .serial = 42, .rect = .{ .x = 0, .y = 1, .w = 2, .h = 3 }, .active = true, .thumb_y = 1, .thumb_h = 2, .clip = 1, .lift = 0.5 }}; + const placed = [_]pardes.Region{.{ .kind = .rail, .owner = 3, .serial = 42, .rect = .{ .x = 0, .y = 1, .w = 2, .h = 3 }, .active = true, .dirty = true, .thumb_y = 1, .thumb_h = 2, .clip = 1, .lift = 0.5 }}; const out = try gpa.alloc(u8, frameBound(2, 4) + layersBound(&.{layer}, &.{tag}, &placed)); defer gpa.free(out); - const palette: pardes.Chrome = .{ .page = .{ 1, 2, 3 }, .border = .{ 4, 5, 6 }, .tag_focus_bg = .{ 7, 8, 9 }, .scroll_thumb = .{ 10, 11, 12 } }; + const palette: pardes.Chrome = .{ .page = .{ 1, 2, 3 }, .border = .{ 4, 5, 6 }, .tag_focus_bg = .{ 7, 8, 9 }, .scroll_thumb = .{ 10, 11, 12 }, .rule_px = 3, .tag_rule = .{ 13, 14, 15 }, .rail_px = 12, .grip_border = .{ 16, 17, 18 }, .grip_focus_ring = .{ 19, 20, 21 }, .grip_dirty = .{ 22, 23, 24 }, .lift_style = 2 }; const encoded = try encodeFrameLayers(out, 2, 4, null, .link, &cells, &cells, &.{layer}, &.{tag}, &placed, palette); const packet = (try framed(encoded)).?; const frame = (try decodeServer(packet.tag, packet.payload)).frame; diff --git a/src/draw.zig b/src/draw.zig index 2e608132..1f30a40b 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -58,7 +58,7 @@ pub fn place(p: *Pardes, s: *Surface) void { const active = id == p.active; const tag_y = p.tagTop(pane, r); const body_y = p.bodyTop(pane, r); - s.addRegion(.{ .kind = .grip, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x, .y = tag_y, .w = config.GUTTER, .h = pane.tag_rows } }); + s.addRegion(.{ .kind = .grip, .owner = owner, .serial = pane.serial, .active = active, .dirty = tagline.dirty(pane), .rect = .{ .x = r.x, .y = tag_y, .w = config.GUTTER, .h = pane.tag_rows } }); s.addRegion(.{ .kind = .tag, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x + config.GUTTER, .y = tag_y, .w = r.w - config.GUTTER, .h = pane.tag_rows } }); // A folded pane is its tag rows and nothing else. if (pane.collapsed) continue; @@ -160,6 +160,29 @@ pub fn place(p: *Pardes, s: *Surface) void { } } +/// A pane's grip in the grid: `box` focused, else halfway between its ring +/// and `box_dim`, so it shows on its tag without the ring a pixel shell +/// draws (gui.zig draws its button from the region and Chrome instead). +fn gripBg(p: *const Pardes, active: bool) [3]u8 { + const chrome = p.chromeTheme(); + if (active) return chrome.box; + return colors.mix(boxBorder(p), chrome.box_dim); +} + +/// An unfocused grip's ring, and a dirty one's fill (colors.Theme). +pub fn boxBorder(p: *const Pardes) [3]u8 { + return p.theme().box_border orelse p.chromeTheme().box_dim; +} +pub fn boxDirty(p: *const Pardes) [3]u8 { + const th = p.theme(); + return th.box_dirty orelse th.diagnostic_warning orelse th.num; +} + +fn inkOn(bg: [3]u8) [3]u8 { + const lum = (@as(u16, bg[0]) * 3 + @as(u16, bg[1]) * 6 + @as(u16, bg[2])) / 10; + return if (lum > 140) .{ 0x00, 0x00, 0x00 } else .{ 0xff, 0xff, 0xff }; +} + /// The Lift style as drawn: `auto` is a shadow on a light page, and on a /// dark one, where a shadow has nothing to darken, no lift at all (the other /// panes recede instead, `inactiveDim`). @@ -366,6 +389,12 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { .sel_fg = p.theme().sel_fg, .sel_bg = p.theme().sel_bg, .lift_style = @intFromEnum(liftStyle(p)), + .rule_px = p.theme().rule_px orelse 2, + .grip_border = boxBorder(p), + .grip_focus_ring = chrome.box, + .grip_dirty = boxDirty(p), + .tag_rule = p.theme().tag_rule, + .rail_px = @intCast(@min(255, @as(u32, p.theme().rail_px orelse 12) * p.settings.grip_width / 100)), }; place(p, s); // Separators have their own role; a scrollbar need not frame the page. @@ -666,21 +695,28 @@ fn renderPane(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r: if (th.bg) |bg| s.fill(tx, r.y, tw, r.h, .{ .bg = .{ .rgb = bg } }); } - const box_bg = if (active) chrome.box else chrome.box_dim; - const box_lum = (@as(u16, box_bg[0]) * 3 + @as(u16, box_bg[1]) * 6 + @as(u16, box_bg[2])) / 10; - const box_ink: [3]u8 = if (box_lum > 140) .{ 0x00, 0x00, 0x00 } else .{ 0xff, 0xff, 0xff }; + // The grip is acme's button: the focused pane's filled, the others' + // a ring round the tag's ground, a dirty one's the dirty colour inside + // its ring. A pixel shell draws the ring (gui.zig gripDecor); a grid, + // which cannot, marks dirty in the grip's second cell with a bold `*` + // on the dirty colour, a cue that survives any palette. + const band = if (active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; + const dirty = tagline.dirty(pane); + const box_bg = gripBg(p, active); const box_style: CellStyle = .{ - .fg = .{ .rgb = box_ink }, + .fg = .{ .rgb = inkOn(box_bg) }, .bg = .{ .rgb = box_bg }, // Grips keep physical grid dimensions, independently of tag text. .font_role = .tagline, }; - // The grip is the first row's alone, as acme's button is; the rows - // under it are the tag band's, and still grab the pane (the region - // spans them all, as acme's button column does). + // On the first row alone, as acme's button is; the rows under it are + // the tag band's, and still grab the pane (the region spans them all, + // as acme's button column does). s.fill(r.x, tag_y, config.GUTTER, @min(1, pane.tag_rows), box_style); + if (dirty and pane.tag_rows > 0 and config.GUTTER > 1) + s.set(r.x + config.GUTTER - 1, tag_y, "*", .{ .fg = .{ .rgb = inkOn(boxDirty(p)) }, .bg = .{ .rgb = boxDirty(p) }, .bold = true, .font_role = .tagline }); s.fill(r.x, tag_y + 1, config.GUTTER, pane.tag_rows -| 1, .{ - .bg = .{ .rgb = if (active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg }, + .bg = .{ .rgb = band }, .font_role = .tagline, }); s.set(r.x, tag_y, switch (pane.body.mode) { @@ -880,7 +916,7 @@ fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, layer: *pardes.Layer, var cell = panes.File.rawDisplayCol(shown, bounds.lo) -| scroll; const end = panes.File.rawDisplayCol(shown, bounds.hi) -| scroll; while (cell < end and cell < columns) : (cell += 1) { - surface.at(@intCast(cell), 0).style.bg = .{ .rgb = p.theme().sel_bg }; + surface.at(@intCast(cell), 0).style.bg = .{ .rgb = p.theme().tag_sel_bg orelse p.theme().sel_bg }; surface.at(@intCast(cell), 0).style.fg = .{ .rgb = p.theme().sel_fg }; } }; @@ -890,7 +926,7 @@ fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, layer: *pardes.Layer, var col = panes.File.rawDisplayCol(shown, span.lo) -| scroll; const end = panes.File.rawDisplayCol(shown, span.hi) -| scroll; while (col < end and col < columns) : (col += 1) { - surface.at(@intCast(col), 0).style.bg = .{ .rgb = p.theme().sel_bg }; + surface.at(@intCast(col), 0).style.bg = .{ .rgb = p.theme().tag_sel_bg orelse p.theme().sel_bg }; surface.at(@intCast(col), 0).style.fg = .{ .rgb = p.theme().sel_fg }; } } @@ -1131,7 +1167,7 @@ pub fn paintPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pa while (col + pane.tag_scroll < end and col < tw) : (col += 1) { const cell = s.at(tx + @as(u16, @intCast(col)), y); cell.default = false; - cell.style.bg = .{ .rgb = th.sel_bg }; + cell.style.bg = .{ .rgb = th.tag_sel_bg orelse th.sel_bg }; cell.style.fg = .{ .rgb = th.sel_fg }; } } @@ -1151,7 +1187,7 @@ pub fn paintPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pa for (pane.sel, 0..) |selection, button| { if (selection.state == .none or (selection.state == .done and button != 0)) continue; if (button != pardes.sel_slot or @min(selection.r0, selection.r1) >= rows) continue; - body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, selection, 0, th.sel_bg, th.sel_fg); + body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, selection, 0, th.tag_sel_bg orelse th.sel_bg, th.sel_fg); } if (p.look_hover_preview) |preview| { if (preview.pane == id and preview.serial == pane.serial) if (preview.sel) |sel| if (@min(sel.r0, sel.r1) < rows) { @@ -1160,11 +1196,71 @@ pub fn paintPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pa }; } const sel_btn = body_layer.buttonTints(th); + const sel_ink = body_layer.buttonInks(th); for (pane.sel, 0..) |selection, button| { if (selection.state == .none or (selection.state == .done and button != 0)) continue; if (button == pardes.sel_slot or @min(selection.r0, selection.r1) >= rows) continue; - body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, selection, 0, sel_btn[button], th.sel_fg); + body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, selection, 0, sel_btn[button], sel_ink[button]); + } +} + +test "an unfocused grip shows on its tag in the grid, in every native theme" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 30 }); + defer p.deinit(); + _ = try p.setTestFile("text\n"); + p.newScratchBelow(0); + p.animate_theme_changes = false; + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + for (pardes.themes[0..colors.native_count], 0..) |theme, index| { + colors.setThemeIndex(p, index); + p.active = 1; + _ = arena.reset(.retain_capacity); + const s = try p.render(arena.allocator()); + const r = p.rects[0]; + const grip = s.at(r.x, p.tagTop(p.panes[0].?, r)).style.bg.rgb; + const ratio = contrast(grip, p.chromeTheme().tag_bg); + // ponytail: dark and helix have always had a faint grip (1.2:1); flagged, not changed here. + const floor: f32 = if (std.mem.eql(u8, theme.name, "dark") or std.mem.eql(u8, theme.name, "helix")) 1.2 else 1.5; + if (ratio < floor) std.debug.print("{s}: grip {d:.2}\n", .{ theme.name, ratio }); + try std.testing.expect(ratio >= floor); + } +} + +test "a grip's four states, focused or not and clean or dirty, are each its own in every native theme" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 30 }); + defer p.deinit(); + const pane = try p.setTestFile("text\n"); + p.newScratchBelow(0); + p.animate_theme_changes = false; + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const State = struct { first: Cell, second: Cell, dirty: bool }; + for (0..colors.native_count) |index| { + colors.setThemeIndex(p, index); + var states: [4]State = undefined; + for (&states, 0..) |*state, i| { + p.active = if (i & 1 == 0) 0 else 1; + const saved = pane.file.?.saved_revision; + pane.file.?.revision = if (i & 2 != 0) saved + 1 else saved; + _ = arena.reset(.retain_capacity); + const s = try p.render(arena.allocator()); + const r = p.rects[0]; + const y = p.tagTop(pane, r); + state.* = .{ .first = s.at(r.x, y).*, .second = s.at(r.x + 1, y).*, .dirty = s.region(.grip, 0).?.dirty }; + try std.testing.expectEqual(i & 2 != 0, state.dirty); + if (i & 2 == 0) continue; + // Dirty is more than a shade: the grid prints a bold `*` (one + // grid for every shell; a pixel shell draws its button over it). + try std.testing.expectEqualStrings("*", state.second.grapheme()); + try std.testing.expect(state.second.style.bold); + } + for (states, 0..) |a, i| for (states[i + 1 ..]) |b| { + const same = std.meta.eql(a.first.style.bg, b.first.style.bg) and a.second.visuallyEqual(&b.second); + try std.testing.expect(!same); + }; } + pane.file.?.revision = pane.file.?.saved_revision; } test "a tag of three rows has its grip on the first, and band under it that still grabs the pane" { diff --git a/src/gui/gui.zig b/src/gui/gui.zig index d8bae6dc..1a1b1f99 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -2111,7 +2111,13 @@ const Cover = packed struct(u16) { focus: bool = false, /// At a column's left edge, where a tag's anchor is inset. anchor: bool = false, - _: u7 = 0, + /// In a dirty pane's grip. + dirty: bool = false, + /// In a pane's grip (not a column's). + pane_grip: bool = false, + /// In a grip's first row, where its button is. + grip_top: bool = false, + _: u4 = 0, }; /// Marks the grid cells a body or tag layer covers, which the layers paint @@ -2143,10 +2149,13 @@ fn coverFrame(cover: []Cover, cols: u16, rows: u16, body_layers: []const pardes. if (region.kind == .column_grip) bar = region.rect; } for (regions) |region| switch (region.kind) { - .grip => { + .grip, .column_grip => { mark.rect(cover, cols, rows, region.rect, "grip", true); for (0..@min(region.rect.w, 16)) |offset| mark.rect(cover, cols, rows, .{ .x = region.rect.x +| @as(u16, @intCast(offset)), .y = region.rect.y, .w = 1, .h = region.rect.h }, "grip_offset", @as(u4, @intCast(offset))); - if (region.active) mark.rect(cover, cols, rows, region.rect, "focus", true); + if (region.kind == .grip and region.active) mark.rect(cover, cols, rows, region.rect, "focus", true); + if (region.dirty) mark.rect(cover, cols, rows, region.rect, "dirty", true); + if (region.kind == .grip) mark.rect(cover, cols, rows, region.rect, "pane_grip", true); + mark.rect(cover, cols, rows, .{ .x = region.rect.x, .y = region.rect.y, .w = region.rect.w, .h = @min(1, region.rect.h) }, "grip_top", true); }, .tag => if (region.active) mark.rect(cover, cols, rows, region.rect, "focus", true), .column => { @@ -6022,6 +6031,67 @@ fn anchorInset(g: *const Gui, row: u16, win_h: f32) u32 { return taglineBandOffset(row, win_h, g.cell_h, g.tagline_height); } +/// acme's Scrollwid column at a pane's or column's left edge, in window +/// pixels: where its button sits over its scrollbar, one width for both +/// (`rail_px` at acme's 17px font, scaled with the tagline, and always +/// short of the cell so the text keeps a gap), right of the column's rule. +fn scrollColumn(g: *const Gui, chrome: *const pardes.Chrome, col: u16, layout: CellLayout) [2]f32 { + const rule: f32 = if (col == 0) 0 else rulePx(chrome); + const x0 = @round(@as(f32, @floatFromInt(col)) * layout.w + rule); + const scaled: f32 = @round(@as(f32, @floatFromInt(@max(1, chrome.rail_px))) * @as(f32, @floatFromInt(g.tagline_height)) / 17); + // The rail and the grip are GUTTER cells wide; the text starts after. + const w = std.math.clamp(scaled, 4, @max(4, @floor(layout.w * @as(f32, @floatFromInt(config.GUTTER))) - rule - 3)); + return .{ x0, x0 + w }; +} + +/// acme's button for the grip whose first cell is (col, row), in window +/// pixels: the scroll column wide, its tag row tall, from the rule above to +/// the rule below, so button and scrollbar read as one strip. +fn gripButton(g: *const Gui, chrome: *const pardes.Chrome, col: u16, row: u16, layout: CellLayout, win_h: f32) [4]f32 { + _ = win_h; + const x = scrollColumn(g, chrome, col, layout); + const y0 = @round(@as(f32, @floatFromInt(row)) * layout.h); + return .{ x[0], y0, x[1], @round(@as(f32, @floatFromInt(row + 1)) * layout.h) }; +} + +/// A pane's button fill, from its region's state, not the grid's cells +/// (which are drawn for cell shells): dirty, focused, or the tag's ground. +fn gripFill(chrome: *const pardes.Chrome, focus: bool, dirty: bool) [3]u8 { + if (dirty) return chrome.grip_dirty; + return if (focus) chrome.grip_focus_ring else chrome.tag_bg; +} + +fn inkOn(bg: [3]u8) [3]u8 { + const lum = (@as(u16, bg[0]) * 3 + @as(u16, bg[1]) * 6 + @as(u16, bg[2])) / 10; + return if (lum > 140) .{ 0x00, 0x00, 0x00 } else .{ 0xff, 0xff, 0xff }; +} + +/// Cuts an instance's quad to a pixel rectangle, its atlas window with it so +/// the glyph neither moves nor stretches; nothing left, an empty quad. +fn clipInstance(instance: *CellInstance, r: [4]f32, win_w: f32, win_h: f32) void { + const x0 = (instance.x0 + 1) / 2 * win_w; + const x1 = (instance.x1 + 1) / 2 * win_w; + const y0 = (1 - instance.y0) / 2 * win_h; + const y1 = (1 - instance.y1) / 2 * win_h; + const left = @max(0, r[0] - x0); + const right = @max(0, x1 - r[2]); + const top = @max(0, r[1] - y0); + const bottom = @max(0, y1 - r[3]); + if (x1 - right <= x0 + left or y1 - bottom <= y0 + top) { + instance.x1 = instance.x0; + instance.y1 = instance.y0; + return; + } + instance.x0 += left / win_w * 2; + instance.u0 += left / atlas_w; + instance.x1 -= right / win_w * 2; + instance.u1 -= right / atlas_w; + instance.y0 -= top / win_h * 2; + instance.v0 += top / atlas_h; + instance.y1 += bottom / win_h * 2; + instance.v1 -= bottom / atlas_h; +} + fn emitSurfaceCell( g: *Gui, chrome: *const pardes.Chrome, @@ -6054,33 +6124,82 @@ fn emitSurfaceCell( emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .body, (&band_base)[0..1], 0, null, old_layer, false, page); next.* += 1; // Tag glyphs keep their font size inside a full physical-width grip. - emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .tagline, line, at, cursor, old_layer, is_cursor, page); + // A pane's grip cells are the cell shells' form (a dirty `*`): the + // button draws its own glyph over a blank of them. + var blank = line[at]; + blank.text[0] = ' '; + blank.len = 1; + if (here.pane_grip) + emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .tagline, (&blank)[0..1], 0, null, old_layer, false, page) + else + emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .tagline, line, at, cursor, old_layer, is_cursor, page); // A grip's glyph is the cell at its left edge, and a blank one is // no glyph: its marks are decor. const glyph = &line[at - @min(here.grip_offset, at)]; - const grip = here.grip and !glyph.default and glyph.printableAscii() != ' ' and !glyph.style.invisible; + const grip = here.grip and here.grip_top and !glyph.default and glyph.printableAscii() != ' ' and !glyph.style.invisible; const offset: u16 = if (grip) @min(here.grip_offset, at) else 0; // An anchor starts at its column's rule: its left edge is inset by // its top margin so the band frames it on the left as above and // below, and its mark moves right by half that to stay centered. const inset: u32 = if (line_cover[at - offset].anchor) anchorInset(g, row, win_h) else 0; + // A grip is acme's button: its fill is this cell's ground, cut to + // the button (and a dirty one's inset a pixel inside its ring, so + // the ring reads round it), its glyph centered in it. + const button = if (here.grip and here.grip_top) gripButton(g, chrome, col - @min(here.grip_offset, @as(u16, @intCast(at))), row, body_layout, win_h) else null; if (grip) { // A grip is one glyph centered across the gutter, not text: it is // looked up by its codepoint's glyph, never shaped. const drawn = glyphForCodepoint(g, cellCodepoint(glyph)); const cached = cachedGlyph(g, .{ .face = drawn.face, .glyph = drawn.glyph, .role = .tagline, .decoration = GlyphDecoration.fromCell(glyph), .centered = true }); const slot = cached orelse g.space_slot; - const shift: f32 = if (cached != null) @as(f32, @floatFromInt(@as(u32, offset) * g.cell_w)) - @as(f32, @floatFromInt(inset / 2)) else 0; + const b = button.?; + const center = (b[0] + b[2]) / 2 - @as(f32, @floatFromInt(col - offset)) * body_layout.w; + const shift: f32 = if (cached != null) @as(f32, @floatFromInt(@as(u32, offset) * g.cell_w)) + @as(f32, @floatFromInt(config.GUTTER * g.cell_w)) / 2 - center else 0; const resolved = resolveCell(g, glyph[0..1], 0, null, .tagline, false, page); - instances[next.*].fr = @as(f32, @floatFromInt(resolved.fg[0])) / 255; - instances[next.*].fg = @as(f32, @floatFromInt(resolved.fg[1])) / 255; - instances[next.*].fb = @as(f32, @floatFromInt(resolved.fg[2])) / 255; + const ink = if (here.pane_grip) inkOn(gripFill(chrome, here.focus, here.dirty)) else resolved.fg; + instances[next.*].fr = @as(f32, @floatFromInt(ink[0])) / 255; + instances[next.*].fg = @as(f32, @floatFromInt(ink[1])) / 255; + instances[next.*].fb = @as(f32, @floatFromInt(ink[2])) / 255; instances[next.*].u0 = (@as(f32, @floatFromInt(slot.u)) + shift) / atlas_w; instances[next.*].v0 = @as(f32, @floatFromInt(slot.v)) / atlas_h; instances[next.*].v1 = @as(f32, @floatFromInt(slot.v + g.tagline_height)) / atlas_h; } instances[next.*].u1 = instances[next.*].u0 + @as(f32, @floatFromInt(g.cell_w)) / atlas_w; - if (inset > 0 and (!grip or offset == 0)) { + if (button) |b| { + // acme's button fills its tag row from the rule above to the rule + // below: the underlay is its fill, cut to its columns; a pane's + // from its region's state, not the grid's cell form (whose dirty + // `*` is the cell shells'), a column's its cell's own ground. + const base = &instances[next.* - 1]; + const own: [3]f32 = .{ instances[next.*].br, instances[next.*].bg, instances[next.*].bb }; + if (here.pane_grip) { + const fill = gripFill(chrome, here.focus, here.dirty); + base.br = @as(f32, @floatFromInt(fill[0])) / 255; + base.bg = @as(f32, @floatFromInt(fill[1])) / 255; + base.bb = @as(f32, @floatFromInt(fill[2])) / 255; + } else { + base.br = own[0]; + base.bg = own[1]; + base.bb = own[2]; + } + base.effect &= ~clear_bg_bit; + clipInstance(base, .{ b[0], -1e9, b[2], 1e9 }, win_w, win_h); + // Over it, only its own glyph; the band round it and the ring + // are decor (gripDecor). + if (here.pane_grip and !grip) { + instances[next.*].u0 = @as(f32, @floatFromInt(g.space_slot.u)) / atlas_w; + instances[next.*].u1 = instances[next.*].u0 + @as(f32, @floatFromInt(g.cell_w)) / atlas_w; + instances[next.*].v0 = @as(f32, @floatFromInt(g.space_slot.v)) / atlas_h; + instances[next.*].v1 = @as(f32, @floatFromInt(g.space_slot.v + g.tagline_height)) / atlas_h; + } + // Its ground is the fill again (a see-through ground would be + // written, not blended, in an opaque window). + instances[next.*].br = base.br; + instances[next.*].bg = base.bg; + instances[next.*].bb = base.bb; + instances[next.*].effect &= ~clear_bg_bit; + clipInstance(&instances[next.*], b, win_w, win_h); + } else if (inset > 0) { instances[next.*].x0 += @as(f32, @floatFromInt(inset)) / win_w * 2; instances[next.*].u0 += @as(f32, @floatFromInt(inset)) / atlas_w; } @@ -6091,6 +6210,102 @@ fn emitSurfaceCell( next.* += 1; } +test "every pixel of a grip column is band, button or ring, and the button sits over the scrollbar" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .cols = 60, .rows = 20 }); + defer core.deinit(); + const pane = try core.setTestFile("text\n" ** 60); + pane.tag.own = try gpa.dupe(u8, "second\nthird"); + core.newScratchBelow(0); + for ([_]struct { w: u32, h: u32, tag: u32 }{ .{ .w = 10, .h = 20, .tag = 12 }, .{ .w = 17, .h = 35, .tag = 30 }, .{ .w = 9, .h = 19, .tag = 15 }, .{ .w = 12, .h = 24, .tag = 24 } }) |m| for ([_]bool{ false, true }) |bottom| for ([_]usize{ 0, 1 }) |focus| for ([_]bool{ false, true }) |dirty| { + core.settings.tag_bottom = bottom; + core.active = focus; + pane.file.?.revision = pane.file.?.saved_revision + @intFromBool(dirty); + pardes.test_api.sync(core); + const s = try core.render(core.scratch.allocator()); + var g: Gui = undefined; + g.words = .{}; + g.decor = .empty; + defer g.decor.deinit(gpa); + g.cell_w = m.w; + g.cell_h = m.h; + g.tagline_width = m.w; + g.tagline_height = m.tag; + g.space_slot = .{ .u = 0, .v = 0 }; + const layout = fixedCellLayout(&g); + const win_w: f32 = @floatFromInt(60 * m.w); + const win_h: f32 = @floatFromInt(20 * m.h); + const cover = try gpa.alloc(Cover, @as(usize, s.cols) * s.rows); + defer gpa.free(cover); + coverFrame(cover, s.cols, s.rows, s.bodyLayers(), s.tagLayers(), s.regionList()); + var groups = makeGroups(&.{}, false); + try buildDecor(&g, gpa, s, &groups, layout, win_w, win_h, true); + const grip = s.region(.grip, 0).?; + // The grip column's pixels, band first, then its cells, then decor. + const x_px: u32 = grip.rect.x * m.w; + const w_px: u32 = config.GUTTER * m.w; + const y_px: u32 = grip.rect.y * m.h; + const h_px: u32 = grip.rect.h * m.h; + const pixels = try gpa.alloc(?[3]f32, w_px * h_px); + defer gpa.free(pixels); + @memset(pixels, null); + const paint = struct { + fn quad(into: []?[3]f32, x0n: f32, y0n: f32, x1n: f32, y1n: f32, rgb: [3]f32, ww: f32, wh: f32, ox: u32, oy: u32, pw: u32, ph: u32) void { + const px0: i64 = @intFromFloat(@round((x0n + 1) / 2 * ww)); + const px1: i64 = @intFromFloat(@round((x1n + 1) / 2 * ww)); + const py0: i64 = @intFromFloat(@round((1 - y0n) / 2 * wh)); + const py1: i64 = @intFromFloat(@round((1 - y1n) / 2 * wh)); + var y = @max(py0, oy); + while (y < @min(py1, oy + ph)) : (y += 1) { + var x = @max(px0, ox); + while (x < @min(px1, ox + pw)) : (x += 1) into[@intCast((y - oy) * pw + (x - ox))] = rgb; + } + } + }; + var instances: [4]CellInstance = undefined; + for (0..grip.rect.h) |line| for (0..config.GUTTER) |dx| { + const col: u16 = grip.rect.x + @as(u16, @intCast(dx)); + const row: u16 = grip.rect.y + @as(u16, @intCast(line)); + const at = @as(usize, row) * s.cols; + var next: u32 = 0; + emitSurfaceCell(&g, &s.chrome, &instances, &next, col, row, layout, win_w, win_h, null, s.cells[at..][0..s.cols], cover[at..][0..s.cols], col, null, false, false, Ground.opaqueRgb(bg_default)); + for (instances[0..next]) |i| paint.quad(pixels, i.x0, i.y0, i.x1, i.y1, .{ i.br, i.bg, i.bb }, win_w, win_h, x_px, y_px, w_px, h_px); + }; + for (g.decor.items) |i| if (i.fr >= 1 and i.clip_x1 < i.clip_x0) paint.quad(pixels, i.x0, i.y0, i.x1, i.y1, .{ i.br, i.bg, i.bb }, win_w, win_h, x_px, y_px, w_px, h_px); + const rgb = struct { + fn of(v: [3]u8) [3]f32 { + return .{ @as(f32, @floatFromInt(v[0])) / 255, @as(f32, @floatFromInt(v[1])) / 255, @as(f32, @floatFromInt(v[2])) / 255 }; + } + }.of; + const chrome = &s.chrome; + const band = if (focus == 0) chrome.tag_focus_bg else chrome.tag_bg; + // Band, fills, rings, the rules between stacked panes and under the + // tag, and the mode marks' ink (the grip's first cell's). + const ink = inkOn(gripFill(chrome, grip.active, grip.dirty)); + const allowed = [_][3]f32{ rgb(band), rgb(core.chromeTheme().box), rgb(pardes.draw.boxDirty(core)), rgb(chrome.grip_border), rgb(chrome.grip_focus_ring), rgb(chrome.border), rgb(ink), rgb(chrome.tag_rule orelse mixRgb(chrome.tag_bg, chrome.page orelse bg_default)) }; + for (pixels, 0..) |pixel, n| { + const got = pixel orelse return error.TestUnpaintedGripPixel; + const known = for (allowed) |a| { + if (std.meta.eql(a, got)) break true; + } else false; + // Under the first row there is no button: band and rules only. + if (n / w_px >= m.h and n / w_px < h_px - 1 and !std.meta.eql(got, rgb(band)) and !std.meta.eql(got, rgb(chrome.border))) + return error.TestButtonUnderFirstRow; + if (!known) std.debug.print("sliver {any} at {d},{d} of {d}x{d} (metrics {d}x{d}/{d}, bottom {}, focus {d}, dirty {}) page {any}\n", .{ got, n % w_px, n / w_px, w_px, h_px, m.w, m.h, m.tag, bottom, focus, dirty, s.chrome.page }); + try std.testing.expect(known); + } + // The rule under the tag runs unbroken across the grip column. + if (!bottom) for (pixels[(h_px - 1) * w_px ..][0..w_px]) |pixel| + try std.testing.expectEqual(rgb(chrome.tag_rule orelse mixRgb(chrome.tag_bg, chrome.page orelse bg_default)), pixel.?); + // The button's edges are the scrollbar's. + const button = gripButton(&g, chrome, grip.rect.x, grip.rect.y, layout, win_h); + const column = scrollColumn(&g, chrome, s.region(.rail, 0).?.rect.x, layout); + try std.testing.expectEqual(column[0], button[0]); + try std.testing.expectEqual(column[1], button[2]); + }; + pane.file.?.revision = pane.file.?.saved_revision; +} + test "small tagline fonts leave no background gutters between adjacent tags" { const chrome: pardes.Chrome = .{}; var g: Gui = undefined; @@ -6778,9 +6993,9 @@ test "pane indicator halves share dissolve changes and safe exhausted atlas" { try std.testing.expect(gripCellChanged(&surface, &cover, &previous_cover, 1, 0)); try std.testing.expect(!gripCellChanged(&surface, &cover, &previous_cover, 2, 0)); try std.testing.expect(!cover[2].grip); - // A column's grip has no glyph of a pane's. + // A column's grip is a button too, cut the same way (it has no glyph). coverFrame(&cover, 6, 2, &.{}, &.{}, &.{.{ .kind = .column_grip, .rect = .{ .x = 0, .y = 0, .w = 2, .h = 1 } }}); - try std.testing.expect(!cover[0].grip); + try std.testing.expect(cover[0].grip); coverFrame(&cover, 6, 2, &.{}, &.{}, &grip); const chrome: pardes.Chrome = .{}; @@ -7714,6 +7929,12 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g for (groups.items[0..groups.len], 0..) |*group, index| { group.decor_start = @intCast(g.decor.items.len); defer group.decor_count = @as(u32, @intCast(g.decor.items.len)) - group.decor_start; + // The grips' buttons before any rule: the rules over their rows cut + // them, and the band round them never covers a rule. + if (index <= groups.tracks) { + const closing = if (group.track) |active| active.phase == .closing else false; + try gripDecor(g, gpa, if (closing) surface.previous_regions else regions, if (closing) surface.previous_cells else surface.cells, groups, index, chrome, layout, surface.cols, surface.rows, win_w, win_h, group.track); + } if (index == 0) { const top = for (regions) |region| { if (region.kind == .workspace_tag) break region.rect.h; @@ -7723,7 +7944,8 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g .grip, .tag, .column_grip, .column_tag => if (region.rect.y == top) break true, else => {}, } else false; - const height = topbarPaneBorderPixels(g.cell_h, g.tagline_height); + // As wide as the other rules, where the tagline leaves the room. + const height: u32 = @max(1, @min(chrome.rule_px, (g.cell_h -| g.tagline_height) * 2)); if (top != 0 and top < surface.rows and tag_under and height != 0) { const y0: f32 = @floatFromInt(top * g.cell_h - height / 2); try addDecor(g, gpa, 0, y0, win_w, y0 + @as(f32, @floatFromInt(height)), chrome.topbar_rule, 1, null, win_w, win_h); @@ -7732,7 +7954,7 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g for (regions) |bar| { if (bar.kind != .column_grip) continue; const y = @as(f32, @floatFromInt(bar.rect.y + bar.rect.h)) * layout.h; - try addDecor(g, gpa, 0, y - 1, win_w, y, chrome.border, 1, null, win_w, win_h); + try addDecor(g, gpa, 0, y - rulePx(chrome), win_w, y, chrome.border, 1, null, win_w, win_h); break; } // A tag on the last row runs on through the pixels under the grid. @@ -7756,7 +7978,6 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g nbodies += 1; }; const bodies = body_buf[0..nbodies]; - const grid = if (closing) surface.previous_cells else surface.cells; try paneDecor(g, gpa, placed, groups, index, chrome, layout, surface.cols, surface.rows, win_w, win_h, track); // A single spine runs down each column, through every tag and body. if (index == 0) for (regions) |column| { @@ -7765,9 +7986,10 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g const y0 = @as(f32, @floatFromInt(column.rect.y)) * layout.h; const grid_y1 = @as(f32, @floatFromInt(surface.rows)) * layout.h; const y1 = if (win_h - grid_y1 < layout.h) @max(grid_y1, win_h) else grid_y1; - if (y1 > y0) try addDecor(g, gpa, x, y0, x + 1, y1, chrome.border, 1, null, win_w, win_h); + // None at the window's edge: nothing to rule apart there. + if (column.rect.x == 0) continue; + if (y1 > y0) try addDecor(g, gpa, x, y0, x + rulePx(chrome), y1, chrome.border, 1, null, win_w, win_h); }; - try gripDecor(g, gpa, placed, grid, groups, index, layout, surface.cols, surface.rows, win_w, win_h, track); // Cast last in the group, over its cells, images and chrome: a // lifted pane's shadow darkens its neighbours' rails and rules // as it darkens their text, and never the pane itself. @@ -7837,47 +8059,210 @@ fn paneDecor(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, gr const rule_x1 = if (win_w - x1 < layout.w) @max(x1, win_w) else x1; const tag_bottom = tag.rect.y > rail.rect.y; const rule_y = @as(f32, @floatFromInt(if (tag_bottom) tag.rect.y else tag.rect.y + tag.rect.h)) * layout.h; - try addDecor(g, gpa, x0, if (tag_bottom) rule_y else rule_y - 1, rule_x1, if (tag_bottom) rule_y + 1 else rule_y, chrome.border, 1, track, win_w, win_h); + try addDecor(g, gpa, x0, if (tag_bottom) rule_y else rule_y - 1, rule_x1, if (tag_bottom) rule_y + 1 else rule_y, chrome.tag_rule orelse mixRgb(chrome.tag_bg, page), 1, track, win_w, win_h); + // Stacked panes are ruled apart as columns are, where one pane's + // body meets the next one's tag (acme's Border). + { + const pane_top = @min(tag.rect.y, rail.rect.y); + const stacked = for (regions) |other| { + if ((other.kind == .body or other.kind == .tag) and other.owner != rail.owner and other.rect.y + other.rect.h == pane_top and + other.rect.x < tag.rect.x + tag.rect.w and tag.rect.x < other.rect.x + other.rect.w) break true; + } else false; + // Inside the tag band's slack, never over its glyphs: the top + // slack of a tag on top, the bottom slack of the one above when + // tags are at the bottom; a pixel at least. + const y = @as(f32, @floatFromInt(pane_top)) * layout.h; + const spare = g.cell_h -| g.tagline_height; + if (stacked and !tag_bottom) { + const room = taglineBandOffset(pane_top, win_h, g.cell_h, g.tagline_height); + try addDecor(g, gpa, x0, y, rule_x1, y + @as(f32, @floatFromInt(@max(1, @min(chrome.rule_px, room)))), chrome.border, 1, track, win_w, win_h); + } else if (stacked and pane_top > 0) { + const room = spare - taglineBandOffset(pane_top - 1, win_h, g.cell_h, g.tagline_height); + try addDecor(g, gpa, x0, y - @as(f32, @floatFromInt(@max(1, @min(chrome.rule_px, room)))), rule_x1, y, chrome.border, 1, track, win_w, win_h); + } + } const y0 = @as(f32, @floatFromInt(rail.rect.y)) * layout.h; const body_end = rail.rect.y + rail.rect.h; const body_y1 = @as(f32, @floatFromInt(body_end)) * layout.h; const y1 = if (body_end == rows and win_h - body_y1 < layout.h) @max(body_y1, win_h) else body_y1; + // A theme's own track fills its width, the thumb one pixel short + // of it so the track edges it (acme's scrollbar); else a thin rail. + // acme's scrollbar: the track fills the scroll column, under the + // grip's button; the thumb is a pixel short of its right edge, so + // the track edges it. + const column = scrollColumn(g, chrome, rail.rect.x, layout); try addDecor(g, gpa, x0, y0, x0 + layout.w, y1, page, 1, track, win_w, win_h); - try addDecor(g, gpa, x0, y0, x0 + @min(layout.w, 2), y1, chrome.scroll_track, 1, track, win_w, win_h); + try addDecor(g, gpa, column[0], y0, column[1], y1, chrome.scroll_track, 1, track, win_w, win_h); if (rail.thumb_h != 0) { const thumb_y0 = @as(f32, @floatFromInt(rail.rect.y + rail.thumb_y)) * layout.h; const thumb_y1 = if (rail.thumb_y + rail.thumb_h == rail.rect.h) y1 else @as(f32, @floatFromInt(rail.rect.y + rail.thumb_y + rail.thumb_h)) * layout.h; - try addDecor(g, gpa, x0, thumb_y0, x0 + @min(layout.w, @max(2, @floor(layout.w * 0.5))), thumb_y1, chrome.scroll_thumb, 1, track, win_w, win_h); + try addDecor(g, gpa, column[0], thumb_y0, column[1] - 1, thumb_y1, chrome.scroll_thumb, 1, track, win_w, win_h); } } } +/// Halfway between two colours. +fn mixRgb(a: [3]u8, b: [3]u8) [3]u8 { + var out: [3]u8 = undefined; + for (&out, a, b) |*o, x, y| o.* = @intCast((@as(u16, x) + y) / 2); + return out; +} + +/// A rule's width: the theme's own, or one pixel. +fn rulePx(chrome: *const pardes.Chrome) f32 { + return @floatFromInt(@max(1, chrome.rule_px)); +} + /// The two short bars on each row of a grip with no mode glyph there, /// centered in its anchor. Only the panes of group `index`. -fn gripDecor(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, cells: []const pardes.Cell, groups: *const Groups, index: usize, layout: CellLayout, cols: u16, rows: u16, win_w: f32, win_h: f32, track: ?pardes.animation.Track) !void { +fn gripDecor(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, cells: []const pardes.Cell, groups: *const Groups, index: usize, chrome: *const pardes.Chrome, layout: CellLayout, cols: u16, rows: u16, win_w: f32, win_h: f32, track: ?pardes.animation.Track) !void { if (g.tagline_height < 6 or g.tagline_width < 3) return; for (regions) |grip| { - if (grip.kind != .grip or groupOf(groups, grip.serial) != index) continue; + if ((grip.kind != .grip and grip.kind != .column_grip) or groupOf(groups, grip.serial) != index) continue; + if (grip.rect.h == 0 or grip.rect.y >= rows) continue; + const button = gripButton(g, chrome, grip.rect.x, grip.rect.y, layout, win_h); + // Round the button, its first row is the tag's band: nothing of the + // cells under it shows there. + const band = if (grip.active) chrome.tag_focus_bg else chrome.tag_bg; + const left = @as(f32, @floatFromInt(grip.rect.x)) * layout.w; + const right = @as(f32, @floatFromInt(grip.rect.x + grip.rect.w)) * layout.w; + try addDecor(g, gpa, left, button[1], button[0], button[3], band, 1, track, win_w, win_h); + try addDecor(g, gpa, button[2], button[1], right, button[3], band, 1, track, win_w, win_h); + if (grip.kind != .grip) continue; + // acme's button ring, two pixels (ButtonBorder) inside the button: + // the focused one's in its own fill. An unfocused dirty one keeps a + // pixel of band inside its ring, so the four states differ in shape + // whatever a theme's colours (a focused dirty one is acme's + // modbutton, filled up to its ring). + { + // The rules drawn over the row (paneDecor, after this) cut into + // it: the ring runs inside them, from rule to rule. + const tag = for (regions) |part| { + if (part.kind == .tag and part.owner == grip.owner) break part; + } else grip; + const tag_bottom = for (regions) |part| { + if (part.kind == .rail and part.owner == grip.owner) break part.rect.y < tag.rect.y; + } else false; + const stacked = !tag_bottom and for (regions) |other| { + if ((other.kind == .body or other.kind == .tag) and other.owner != grip.owner and other.rect.y + other.rect.h == grip.rect.y and + other.rect.x < tag.rect.x + tag.rect.w and tag.rect.x < other.rect.x + other.rect.w) break true; + } else false; + const top_rule: f32 = if (tag_bottom) 1 else if (stacked) @floatFromInt(@max(1, @min(chrome.rule_px, taglineBandOffset(grip.rect.y, win_h, g.cell_h, g.tagline_height)))) else 0; + const bottom_rule: f32 = if (!tag_bottom and grip.rect.h == 1) 1 else 0; + const x0 = button[0]; + const x1 = button[2]; + const y0 = button[1] + top_rule; + const y1 = button[3] - bottom_rule; + const ring = if (grip.active) chrome.grip_focus_ring else chrome.grip_border; + try addDecor(g, gpa, x0, y0, x1, y0 + 2, ring, 1, track, win_w, win_h); + try addDecor(g, gpa, x0, y1 - 2, x1, y1, ring, 1, track, win_w, win_h); + try addDecor(g, gpa, x0, y0 + 2, x0 + 2, y1 - 2, ring, 1, track, win_w, win_h); + try addDecor(g, gpa, x1 - 2, y0 + 2, x1, y1 - 2, ring, 1, track, win_w, win_h); + if (grip.dirty and !grip.active) { + try addDecor(g, gpa, x0 + 2, y0 + 2, x1 - 2, y0 + 3, band, 1, track, win_w, win_h); + try addDecor(g, gpa, x0 + 2, y1 - 3, x1 - 2, y1 - 2, band, 1, track, win_w, win_h); + try addDecor(g, gpa, x0 + 2, y0 + 3, x0 + 3, y1 - 3, band, 1, track, win_w, win_h); + try addDecor(g, gpa, x1 - 3, y0 + 3, x1 - 2, y1 - 3, band, 1, track, win_w, win_h); + } + } const x = grip.rect.x; for (0..grip.rect.h) |line| { const y = grip.rect.y + @as(u16, @intCast(line)); if (x >= cols or y >= rows) continue; const cell = &cells[@as(usize, y) * cols + x]; if (cell.default or cell.printableAscii() != ' ') continue; - const rgb = switch (cell.style.fg) { - .rgb => |rgb| rgb, - else => continue, - }; - const center_x = (@as(f32, @floatFromInt(x)) + @as(f32, @floatFromInt(config.GUTTER)) / 2) * layout.w + @as(f32, @floatFromInt(anchorInset(g, y, win_h) / 2)); + // The marks are in the button's ink, from the region's state. + const rgb = inkOn(gripFill(chrome, grip.active, grip.dirty)); + // The marks are the first row's alone, centered in the button. + if (line != 0) continue; + const center_x = @floor((button[0] + button[2]) / 2); const center_y = @as(f32, @floatFromInt(y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(y, win_h, g.cell_h, g.tagline_height))) + @as(f32, @floatFromInt(g.tagline_height)) / 2; - const half_w = @max(1, @min(3, layout.w - 2)); + const half_w = @max(1, @min(3, @floor((button[2] - button[0] - 6) / 2))); try addDecor(g, gpa, @floor(center_x - half_w), @floor(center_y - 2), @floor(center_x + half_w), @floor(center_y - 1), rgb, 1, track, win_w, win_h); try addDecor(g, gpa, @floor(center_x - half_w), @floor(center_y + 1), @floor(center_x + half_w), @floor(center_y + 2), rgb, 1, track, win_w, win_h); } } } +test "a theme's own rules and scrollbar are drawn at its widths: acme's 2px black rules and 12px track" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 }); + defer core.deinit(); + _ = try core.newShell(1, ""); + try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false)); + try std.testing.expect(core.executeBuiltinLine(0, "Theme acme")); + for (0..40) |_| core.update(.tick); + pardes.test_api.sync(core); + const s = try core.render(core.scratch.allocator()); + try std.testing.expectEqual(@as(u8, 2), s.chrome.rule_px); + // acme's 12px, at GripWidth's 150% default. + try std.testing.expectEqual(@as(u8, 18), s.chrome.rail_px); + var g: Gui = undefined; + g.decor = .empty; + defer g.decor.deinit(gpa); + g.cell_w = 16; + g.cell_h = 20; + g.tagline_width = 10; + g.tagline_height = 12; + var groups = makeGroups(&.{}, false); + try buildDecor(&g, gpa, s, &groups, fixedCellLayout(&g), 1280, 480, true); + const px = struct { + fn w(item: CellInstance) f32 { + return (item.x1 - item.x0) / 2 * 1280; + } + }; + var spines: usize = 0; + var tracks: usize = 0; + for (g.decor.items) |item| { + const black = item.br == 0 and item.bg == 0 and item.bb == 0; + // No rule at the window's left edge; one 2px spine where columns meet. + if (black and @abs(px.w(item) - 2) < 0.01 and item.y1 < -0.9) { + try std.testing.expect(item.x0 > -1); + spines += 1; + } + const column = scrollColumn(&g, &s.chrome, 40, fixedCellLayout(&g)); + if (@abs(item.br - @as(f32, 0x99) / 255) < 1e-3 and @abs(item.bb - @as(f32, 0x4c) / 255) < 1e-3 and @abs(px.w(item) - (column[1] - column[0])) < 0.01) tracks += 1; + } + try std.testing.expectEqual(@as(usize, 1), spines); + try std.testing.expect(tracks >= 2); +} + +test "the rule between stacked panes stays in the tag band's slack, at any tagline size" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 }); + defer core.deinit(); + _ = try core.newShell(1, ""); + core.newScratchBelow(0); + pardes.test_api.sync(core); + const s = try core.render(core.scratch.allocator()); + const lower = for (s.regionList()) |region| { + if (region.kind == .tag and region.rect.y > 2) break region; + } else return error.TestExpectedStackedTag; + for ([_]u32{ 12, 20 }) |tagline_h| { + var g: Gui = undefined; + g.decor = .empty; + defer g.decor.deinit(gpa); + g.cell_w = 10; + g.cell_h = 20; + g.tagline_width = 6; + g.tagline_height = tagline_h; + var groups = makeGroups(&.{}, false); + try buildDecor(&g, gpa, s, &groups, fixedCellLayout(&g), 800, 480, true); + const top = @as(f32, @floatFromInt(lower.rect.y)) * 20; + const slack: f32 = @floatFromInt(@max(1, pardes.taglineBandOffset(lower.rect.y, 480, 20, tagline_h))); + var found = false; + for (g.decor.items) |item| { + const y0 = (1 - item.y0) / 2 * 480; + const y1 = (1 - item.y1) / 2 * 480; + if (@abs(y0 - top) > 0.01 or @abs(item.br - @as(f32, @floatFromInt(s.chrome.border[0])) / 255) > 1e-3) continue; + found = true; + try std.testing.expect(y1 - y0 <= slack + 0.01); + } + try std.testing.expect(found); + } +} + test "decor is read from the regions, each pane's in its own group" { const gpa = std.testing.allocator; const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 }); @@ -7905,15 +8290,23 @@ test "decor is read from the regions, each pane's in its own group" { // workspace tag; a rail two pixels wide at each pane's. for (core.col_x[0..core.ncol]) |x| { const left = ndc.x(@as(f32, @floatFromInt(x)) * 10); + // Two pixels (rule_px), and none at the window's own edge. const spine = for (g.decor.items) |item| { - if (item.x0 == left and @abs(item.x1 - ndc.x(@as(f32, @floatFromInt(x)) * 10 + 1)) < 0.0001) break item; - } else return error.TestExpectedSpine; - try std.testing.expectApproxEqAbs(@as(f32, 1 - 20.0 / 480.0 * 2), spine.y0, 0.0001); - try std.testing.expectEqual(@as(f32, -1), spine.y1); + if (item.x0 == left and @abs(item.x1 - ndc.x(@as(f32, @floatFromInt(x)) * 10 + 2)) < 0.0001 and + @abs(item.y0 - (1 - 20.0 / 480.0 * 2)) < 0.0001 and std.mem.eql(f32, &.{ item.br, item.bg, item.bb }, &.{ @as(f32, @floatFromInt(s.chrome.border[0])) / 255, @as(f32, @floatFromInt(s.chrome.border[1])) / 255, @as(f32, @floatFromInt(s.chrome.border[2])) / 255 })) break item; + } else null; + try std.testing.expectEqual(x == 0, spine == null); + if (spine) |rule| { + try std.testing.expectApproxEqAbs(@as(f32, 1 - 20.0 / 480.0 * 2), rule.y0, 0.0001); + try std.testing.expectEqual(@as(f32, -1), rule.y1); + } + // The scrollbar fills the scroll column right of the rule. + const column = scrollColumn(&g, &s.chrome, x, layout); const rail = for (g.decor.items) |item| { - if (item.x0 == left and @abs(item.x1 - ndc.x(@as(f32, @floatFromInt(x)) * 10 + 2)) < 0.0001) break item; + if (@abs(item.x0 - ndc.x(column[0])) < 0.0001 and @abs(item.x1 - ndc.x(column[1])) < 0.0001 and + @abs(item.br - @as(f32, @floatFromInt(s.chrome.scroll_track[0])) / 255) < 0.0001) break item; } else return error.TestExpectedRail; - try std.testing.expectApproxEqAbs(@as(f32, s.chrome.scroll_track[0]) / 255, rail.br, 0.0001); + _ = rail; } try std.testing.expectEqual(@as(u32, 0), groups.items[groups.notices].decor_count); const still = groups.items[0].decor_count; diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index 1c21d314..b28e02fa 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -308,6 +308,7 @@ fn writeSettings(p: *Pardes, w: *std.Io.Writer) !void { .lift => try w.print("{s} {s}\n", .{ word, @tagName(s.lift) }), .motion => try w.print("{s} {s}\n", .{ word, @tagName(s.motion) }), .inactive_dim => try w.print("{s} {d}\n", .{ word, s.inactive_dim }), + .grip_width => try w.print("{s} {d}\n", .{ word, s.grip_width }), } } try w.writeAll("LocationsConfig "); diff --git a/src/surface.zig b/src/surface.zig index f16993a8..bc0a019d 100644 --- a/src/surface.zig +++ b/src/surface.zig @@ -356,6 +356,8 @@ pub const Region = struct { rect: layout.Rect, /// The pane or column has the keyboard's focus. active: bool = false, + /// A grip's pane has changes it has not saved. + dirty: bool = false, /// A rail's thumb: rows from the rail's top, and how many. Zero rows is /// a rail with no thumb (an image). A native PDF's thumb is measured in /// document pixels and filled in by Pdf.draw while it paints, so read @@ -403,6 +405,19 @@ pub const Chrome = struct { sel_bg: [3]u8 = .{ 0, 0, 0 }, /// How a lifted part stands off the page (config.Runtime.LiftStyle). lift_style: u8 = 0, + /// Rule width in pixels (colors.Theme.rule_px). + rule_px: u8 = 2, + /// The ring round a grip, acme's button: unfocused, and focused. + grip_border: [3]u8 = .{ 0, 0, 0 }, + grip_focus_ring: [3]u8 = .{ 0, 0, 0 }, + /// A dirty pane's button fill. + grip_dirty: [3]u8 = .{ 0, 0, 0 }, + /// The rule between a pane's tag and its body; null is halfway between + /// the tag's ground and the page the shell actually draws. + tag_rule: ?[3]u8 = null, + /// The scroll column's width, button and scrollbar alike, in pixels at + /// a 17px tagline (acme's 12 at its font); a pixel shell scales it. + rail_px: u8 = 12, }; /// The canonical interface: what a frame of pardes IS. The tty shell writes diff --git a/src/tagline.zig b/src/tagline.zig index 59c51b4d..d0907cbd 100644 --- a/src/tagline.zig +++ b/src/tagline.zig @@ -24,6 +24,12 @@ pub const locations_pane_tail = file_pane_tail ++ " LocationsConfig"; pub const terminal_pane_tail = " " ++ config.terminal_pane_builtins_str; pub const command_pane_tail = " " ++ config.command_pane_builtins_str; const dirty_marker = " *"; + +/// A file with changes it has not saved. +pub fn dirty(pane: *const panes.Pane) bool { + const f = pane.file orelse return false; + return panes.Output.fileTraits(f.output).saves and f.revision != f.saved_revision; +} pub const legacy_pane_tail = " Del"; pub const legacy_file_pane_tail = " Save Del"; // The defaults from the release before Newtty joined every tagline. Recognized @@ -136,7 +142,7 @@ pub fn pathPrefix(arena: std.mem.Allocator, pane: *Pane) ![]const u8 { } if (pane.file) |f| { const name = if (pane.prompt == .name) pane.input.own orelse f.path else f.path; - if (panes.Output.fileTraits(f.output).saves and f.revision != f.saved_revision) + if (dirty(pane)) return std.fmt.allocPrint(arena, "{s}{s}", .{ name, dirty_marker }); return name; } diff --git a/src/themes/acme.zig b/src/themes/acme.zig index d91e626b..17ef4224 100644 --- a/src/themes/acme.zig +++ b/src/themes/acme.zig @@ -6,20 +6,28 @@ //! in) is a mid grey, and 15 (bright white) is BLACK, because a program asking //! for the brightest color on a light page means "make this stand out". //! -//! The selection is acme's own: plan9's DDarkyellow #eeee9e, the colour acme -//! sweeps text with, and black ink over it — acme changes the paper under a -//! selection and never the letters. Out of the same draw.h palette the -//! scrollbar's #99994c (DYellowgreen) is already taken from. +//! Every colour below that acme has is acme's own, from plan9port's acme.c +//! iconinit and draw.h: the tag #eaffff and body #ffffea (each 25% of +//! DPalebluegreen or DPaleyellow over white), their selections #9eeeee and +//! #eeee9e with black ink (acme changes the paper under a selection, never +//! the letters), the exec and look sweeps #aa0000 and #006600 with white ink, +//! the scrollbar's #99994c track under a #ffffea thumb, 12px wide, the +//! #8888cc (DPurpleblue) line between a tag and its body and the column +//! button, and 2px black rules between columns, windows and tags. +//! +//! acme has no focus colour; pardes needs one, so the focused pane's button +//! is filled #8888cc (the column button's look) and every other pane's is +//! acme's own: #eaffff in a 2px #8888cc ring, #000099 inside it when dirty. pub const theme = .{ .name = "acme", .bg = .{ 0xff, 0xff, 0xea }, .fg = .{ 0x00, 0x00, 0x00 }, - .sel_bg = .{ 0xfa, 0xfa, 0x92 }, + .sel_bg = .{ 0xee, 0xee, 0x9e }, .sel_fg = .{ 0x00, 0x00, 0x00 }, .tag_bg = .{ 0xea, 0xff, 0xff }, .tag_fg = .{ 0x00, 0x00, 0x00 }, - .box = .{ 0x66, 0x3f, 0xe0 }, - .box_dim = .{ 0x35, 0x3a, 0x58 }, + .box = .{ 0x88, 0x88, 0xcc }, + .box_dim = .{ 0xea, 0xff, 0xff }, .kw = .{ 0x97, 0x0d, 0xae }, .str = .{ 0x00, 0x77, 0x33 }, .num = .{ 0xa0, 0x52, 0x00 }, @@ -27,6 +35,17 @@ pub const theme = .{ .lineno = .{ 0x99, 0x99, 0x80 }, .scroll_track = .{ 0x99, 0x99, 0x4c }, .scroll_thumb = .{ 0xff, 0xff, 0xea }, + .border = .{ 0x00, 0x00, 0x00 }, + .tag_rule = .{ 0x88, 0x88, 0xcc }, + .rule_px = 2, + .box_border = .{ 0x88, 0x88, 0xcc }, + .box_dirty = .{ 0x00, 0x00, 0x99 }, + .rail_px = 12, + .tag_sel_bg = .{ 0x9e, 0xee, 0xee }, + .sweep_bg = .{ .{ 0xee, 0xee, 0x9e }, .{ 0xaa, 0x00, 0x00 }, .{ 0x00, 0x66, 0x00 } }, + .sweep_fg = .{ .{ 0x00, 0x00, 0x00 }, .{ 0xff, 0xff, 0xff }, .{ 0xff, 0xff, 0xff } }, + .column_box = .{ 0x88, 0x88, 0xcc }, + .column_box_dim = .{ 0x88, 0x88, 0xcc }, .palette = .{ .{ 0x00, 0x00, 0x00 }, .{ 0xaa, 0x22, 0x22 }, .{ 0x22, 0x80, 0x22 }, .{ 0x88, 0x66, 0x00 }, .{ 0x22, 0x44, 0xbb }, .{ 0x99, 0x33, 0x99 }, .{ 0x11, 0x88, 0x88 }, .{ 0x55, 0x55, 0x55 }, |
