Something went wrong. Try again.
A history browser for git, a replacement for gitk, drawn with Vulkan on Wayland.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! A menu over everything else: a column of items, one of which may be//! chosen, opened where the pointer is and kept inside the window.//!//! It knows nothing of what its items do. The application opens it with//! its items, asks what is under the pointer, and is told which item was//! clicked by its index.
const std = @import("std");const aerie = @import("aerie");const Canvas = aerie.gpu.Canvas;const Rect = Canvas.Rect;
const Text = @import("Text.zig");const theme = @import("theme.zig");
const Menu = @This();
pub const max_items = 12;
pub const Item = struct { label: []const u8, enabled: bool = true,};
items: [max_items]Item = undefined,count: usize = 0,rect: Rect = .{ .x = 0, .y = 0, .width = 0, .height = 0 },item_height: f32 = 1,padding: f32 = 0,hovered: ?usize = null,
/// Opens with `items` at `x`, `y`, moved as far as it has to be to fit in/// `bounds`.pub fn open(text: *Text, items: []const Item, x: f32, y: f32, bounds: Rect, scale: f32) !Menu { var m: Menu = .{ .count = @min(items.len, max_items) }; @memcpy(m.items[0..m.count], items[0..m.count]); m.padding = @round(10 * scale); m.item_height = @round(text.lineHeight(.ui) + 8 * scale); var w: f32 = 0; for (m.items[0..m.count]) |item| w = @max(w, try text.measure(.ui, item.label)); w += 2 * m.padding; const h = m.item_height * @as(f32, @floatFromInt(m.count)) + 2 * @round(4 * scale); m.rect = .{ .x = @max(bounds.x, @min(x, bounds.right() - w)), .y = @max(bounds.y, @min(y, bounds.bottom() - h)), .width = w, .height = h, }; return m;}
/// The item at `x`, `y`, enabled or not.pub fn itemAt(m: *const Menu, x: f32, y: f32) ?usize { if (!m.rect.contains(x, y)) return null; const inset = (m.rect.height - m.item_height * @as(f32, @floatFromInt(m.count))) / 2; const i: isize = @intFromFloat(@floor((y - m.rect.y - inset) / m.item_height)); if (i < 0 or i >= m.count) return null; return @intCast(i);}
/// Follows the pointer; true when the highlight moved.pub fn hover(m: *Menu, x: f32, y: f32) bool { const h = if (m.itemAt(x, y)) |i| (if (m.items[i].enabled) i else null) else null; defer m.hovered = h; return h != m.hovered;}
pub fn draw(m: *const Menu, canvas: *Canvas, text: *Text, scale: f32) !void { const saved = canvas.clip; defer canvas.setClip(saved); canvas.setClip(null); const r = m.rect; // A shadow a little below and right, and a border. const s = @round(3 * scale); try canvas.rect(r.x + s, r.y + s, r.width, r.height, theme.current.menu_shadow); try canvas.rect(r.x, r.y, r.width, r.height, theme.current.list_background); try canvas.frame(r.x, r.y, r.width, r.height, @max(1, @round(scale)), theme.current.divider); const inset = (r.height - m.item_height * @as(f32, @floatFromInt(m.count))) / 2; for (m.items[0..m.count], 0..) |item, i| { const top = r.y + inset + @as(f32, @floatFromInt(i)) * m.item_height; const lit = m.hovered == i; if (lit) try canvas.rect(r.x + 1, top, r.width - 2, m.item_height, theme.current.selection_background); const color = if (!item.enabled) theme.current.disabled_foreground else if (lit) theme.current.selection_foreground else theme.current.foreground; _ = try text.draw(canvas, .ui, item.label, r.x + m.padding, top + text.baselineIn(.ui, m.item_height), color); }}
const testing = std.testing;
test "an item is found under the pointer" { var m: Menu = .{ .count = 3, .rect = .{ .x = 100, .y = 100, .width = 80, .height = 3 * 20 + 8 }, .item_height = 20 }; m.items[1] = .{ .label = "b", .enabled = false }; m.items[0] = .{ .label = "a" }; m.items[2] = .{ .label = "c" }; try testing.expectEqual(@as(?usize, 0), m.itemAt(120, 105)); try testing.expectEqual(@as(?usize, 2), m.itemAt(120, 150)); try testing.expectEqual(@as(?usize, null), m.itemAt(90, 150)); // A disabled item is not lit. _ = m.hover(120, 130); try testing.expectEqual(@as(?usize, null), m.hovered); try testing.expect(m.hover(120, 106)); try testing.expectEqual(@as(?usize, 0), m.hovered);}