// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! One window on one repository's history, and the loop that runs it. //! //! The loop waits on the window system and on git's output together. //! Commits are added as they arrive, a bounded amount per turn of the //! loop, and the window is drawn when something changed and the compositor //! is ready for a frame — so the first screenful of linux.git is up while //! the other million commits are still coming. const std = @import("std"); const builtin = @import("builtin"); const Allocator = std.mem.Allocator; const Io = std.Io; const aerie = @import("aerie"); const platform = aerie.platform; const ev = platform.event; const gpu = aerie.gpu; const TitleBar = aerie.decor.TitleBar; const Clock = @import("Clock.zig"); const History = @import("history/History.zig"); const Layout = @import("graph/Layout.zig"); const Loader = @import("history/Loader.zig").Loader; const ChangeLoader = @import("history/Loader.zig").ChangeLoader; const Local = @import("history/Loader.zig").Local; const Change = @import("history/Change.zig"); const CommitList = @import("ui/CommitList.zig"); const DiffView = @import("ui/DiffView.zig"); const Document = @import("ui/Document.zig"); const FileList = @import("ui/FileList.zig"); const Panes = @import("ui/Panes.zig"); const Menu = @import("ui/Menu.zig"); const Dialog = @import("ui/Dialog.zig"); const FindBar = @import("ui/FindBar.zig"); const TextField = @import("ui/TextField.zig"); const Query = @import("find/Query.zig"); const FindLoader = @import("history/Loader.zig").FindLoader; const RelationsLoader = @import("history/Loader.zig").RelationsLoader; const Relations = @import("history/Relations.zig"); const RelationsCache = @import("history/RelationsCache.zig"); const summary = @import("history/Loader.zig").summary; const Refs = @import("history/Loader.zig").Refs; const Text = @import("ui/Text.zig"); const Scrollbar = @import("ui/Scrollbar.zig"); const load_config = @import("config/load.zig"); const input = @import("input/bindings.zig"); const theme = @import("ui/theme.zig"); const l10n = @import("l10n.zig"); const App = @This(); const log = std.log.scoped(.app); pub const app_id = "dev.jcollie.grebe"; gpa: Allocator, io: Io, /// The window's title: grebe and the repository's name. title: []const u8, args: []const []const u8, /// The paths among `args`: what follows `--`. paths: []const []const u8, /// How many of the history's rows are pseudo-commits, at its top. pseudo_rows: usize = 0, plat: *platform.Platform, device: *gpu.Device, renderer: gpu.Renderer, fonts: aerie.text.Fonts, text: Text, window: *platform.Window, target: gpu.Target, canvas: gpu.Canvas, title_bar: TitleBar = .{}, /// The desktop's settings, followed for light and dark and its fonts; /// null with no portal. desktop: ?*aerie.desktop.Watcher, /// Which change to the desktop's settings was seen last. desktop_seen: u64, /// The configuration, evaluated again when the desktop's settings change. config: load_config.Loaded, /// What the colors are now: the palette for the desktop's preference, /// with the configuration's colors over it. `theme.current` points here. palette: theme.Palette = theme.light, /// The key bindings: `gitk`'s, with the configuration's over them. bindings: []input.Binding = &.{}, title_shaped: std.ArrayList(aerie.text.Shaper.Glyph) = .empty, history: History, /// The graph of `history`, row for row. graph: Layout, clock: Clock, loader: ?Loader = null, status: Status = .loading, list: CommitList = .{}, panes: Panes = .{}, /// Where the panes are now; see `layout`. pane_layout: Panes.Layout = undefined, /// The divider being dragged. dragging: ?Panes.Divider = null, /// Text in the diff is being selected: the pointer moves the selection's /// end. selecting: bool = false, /// The last press in the diff, to tell a double or triple click. last_click: struct { at: ?Io.Timestamp = null, x: f32 = 0, y: f32 = 0, count: u8 = 0 } = .{}, /// The scrollbar whose thumb is being dragged, and where along the thumb /// it was taken hold of. scrolling: ?struct { bar: Bar, grab: f32 } = null, diff: DiffView = .{}, files: FileList = .{}, /// The selected commit's change, being read, and once read, laid out. change_loader: ?ChangeLoader = null, change: ?Change = null, document: ?Document = null, /// What went wrong reading it, owned. change_failed: ?[]u8 = null, /// The branches and tags about the commit shown, being found; which commit /// that is; and those of every commit found so far. relations_loader: ?RelationsLoader = null, relations_of: ?History.ObjectId = null, relations: RelationsCache = .{}, /// How the diff is asked for: the bar's settings. diff_options: ChangeLoader.Options = .{}, /// What the details and diff pane is showing, to show again. shown: ?ChangeLoader.Target = null, /// Where the pane was when what it shows was asked for again, to be found /// in what comes back; and how far into that line it was scrolled. restore: ?Document.Anchor = null, restore_fraction: f32 = 0, /// What was selected, and where the list and the pane were, when F5 asked /// for the history again, to be found in what comes back. reselect: ?Reselect = null, /// A menu, while it is open: the commit menu, and the row it was opened /// on, or one of the find bar's choices. menu: ?Menu = null, menu_kind: MenuKind = .commit, menu_row: usize = 0, /// The branch or tag the branch or tag menu was opened on, owned. menu_ref: ?[]u8 = null, /// The commit menu's reset item, which names the branch it moves. menu_reset_label: [256]u8 = undefined, /// A question being asked, over everything else, and what answering it /// does. dialog: ?Dialog = null, dialog_for: DialogFor = .notice, /// Where the keyboard goes. focus: enum { list, find } = .list, find_bar: FindBar = .{}, field: TextField = .{}, /// What is being found; its text is the field's. query: Query = .{}, /// A find grebe answers itself, and how many it has matched among the /// rows up to `find_scanned`. matcher: ?Query.Matcher = null, find_count: usize = 0, find_scanned: usize = 0, /// A find git answers, and when to start it: a moment after the typing /// stops, so that git is not started for every letter. find_loader: ?FindLoader = null, find_due: ?Io.Timestamp = null, find_status: FindBar.Status = .idle, /// Where the input method was last told text is typed, if anywhere. text_input: ?ev.Rect = null, /// The marked commit. marked: ?History.ObjectId = null, running: bool = true, dirty: bool = true, focused: bool = true, scale: f32 = 1, size: struct { width: u32, height: u32 } = .{ .width = 0, .height = 0 }, /// The pointer, in physical pixels. pointer_x: f32 = 0, pointer_y: f32 = 0, /// The selected commit's ID in hex, as the primary selection holds it. selection_text: [64]u8 = undefined, snapshot: ?[]const u8, /// The panes' scrollbars. const Bar = enum { list, diff, diff_x, files }; const Status = union(enum) { loading, done, /// What git said, owned. failed: []const u8, /// Something went wrong, and saying what took memory there was none of. out_of_memory, }; pub const Options = struct { /// Revision arguments, as given: git's to read. args: []const []const u8, /// What the window is called. title: []const u8, /// Once the history is read, draw a frame on the CPU into this file, /// as a PPM, and quit: for tests, and for looking at a frame without a /// screenshot. snapshot: ?[]const u8 = null, /// The configuration, which the app takes, also on failure. config: load_config.Loaded, /// The desktop's settings as they are followed, which the app takes, /// also on failure; and which change to them the configuration was /// evaluated against. desktop: ?*aerie.desktop.Watcher, desktop_seen: u64, }; /// Opens the window and starts reading the history. On the heap, since /// the window's events point back at it. pub fn create(gpa: Allocator, io: Io, env: *const std.process.Environ.Map, options: Options) !*App { var loaded = options.config; errdefer loaded.deinit(gpa); errdefer if (options.desktop) |d| d.destroy(); const app = try gpa.create(App); errdefer gpa.destroy(app); const plat = try platform.Platform.init(gpa, io, env, .{ .app_id = app_id }); errdefer plat.deinit(); const device = try gpu.Device.init(gpa, .{ .application_name = "grebe", .main_device = plat.mainDevice() }); errdefer device.deinit(); var renderer = try gpu.Renderer.init(device); errdefer renderer.deinit(); var fonts = try aerie.text.Fonts.init(gpa, io, env, .{}); errdefer fonts.deinit(); app.* = .{ .gpa = gpa, .io = io, .title = options.title, .args = options.args, .paths = for (options.args, 0..) |a, i| { if (std.mem.eql(u8, a, "--")) break options.args[i + 1 ..]; } else &.{}, .plat = plat, .device = device, .renderer = renderer, .fonts = fonts, .text = undefined, .window = undefined, .target = undefined, .canvas = .init(gpa), .history = .init(gpa), .graph = .init(gpa), .clock = .load(gpa, io, env), .snapshot = options.snapshot, .config = loaded, .desktop = options.desktop, .desktop_seen = options.desktop_seen, .diff_options = .{ .context = loaded.config().diff.context, .ignore_whitespace = loaded.config().diff.ignore_whitespace, }, }; errdefer app.clock.deinit(gpa); errdefer app.canvas.deinit(); app.text = try Text.init(gpa, &app.fonts, loaded.config().families(loaded.settings)); errdefer app.text.deinit(); app.applyConfig(); errdefer gpa.free(app.bindings); app.window = try platform.Window.create(plat, .{ .title = options.title, .width = 1000, .height = 700, .user = app, }); errdefer app.window.destroy(); const size = app.window.bufferSize(); app.target = try gpu.Target.init(device, app.window, size.width, size.height, .auto); errdefer app.target.deinit(); app.startLoading(); return app; } /// Evaluates the configuration again if the desktop's settings changed, /// and draws as it then says. fn followDesktop(app: *App) void { const d = app.desktop orelse return; const latest = (d.takeIfNewer(app.gpa, app.desktop_seen) catch return) orelse return; app.desktop_seen = latest.generation; var diag: @import("struthio").Diagnostic = .{}; app.config.reevaluate(app.gpa, latest.settings, &diag) catch |err| { var problem: load_config.Problem = .{ .diag = diag }; if (app.config.path) |p| problem.setPath(p); log.warn("the configuration, evaluated again: {t}: {f}", .{ err, &problem }); return; }; app.applyConfig(); app.text.setFamilies(app.config.config().families(app.config.settings)) catch |err| { log.warn("the fonts the configuration names: {t}", .{err}); }; app.layout(); } /// Takes the colors and the key bindings from the configuration. fn applyConfig(app: *App) void { const c = app.config.config(); var bad_color: ?[]const u8 = null; app.palette = c.palette(app.config.appearance(), &bad_color); theme.current = &app.palette; if (bad_color) |name| log.warn("colors.{s}: not a color, which is written #rrggbb", .{name}); var bad_keys: ?[]const u8 = null; if (input.load(app.gpa, c.keybind, &bad_keys)) |list| { app.gpa.free(app.bindings); app.bindings = list; } else |err| log.warn("the key bindings: {t}", .{err}); if (bad_keys) |keys| log.warn("keybind \"{s}\": not keys this can read", .{keys}); app.dirty = true; } pub fn destroy(app: *App) void { const gpa = app.gpa; if (app.loader) |*l| l.deinit(gpa, app.io); if (app.status == .failed) gpa.free(app.status.failed); app.clearChange(); app.dropRelations(); app.relations.deinit(gpa); app.dropReselect(); app.dropRestore(); app.stopFind(); app.closeDialog(); app.dropMenuRef(); app.field.deinit(gpa); app.history.deinit(); app.graph.deinit(); app.clock.deinit(gpa); app.title_shaped.deinit(gpa); app.canvas.deinit(); app.target.deinit(); app.window.destroy(); app.text.deinit(); app.fonts.deinit(); app.renderer.deinit(); app.device.deinit(); app.plat.deinit(); gpa.free(app.bindings); app.config.deinit(gpa); if (app.desktop) |d| d.destroy(); gpa.destroy(app); } /// Where the details and diff pane is: the line at its top, as something /// to find in another diff of the same thing, and how far it is scrolled. const DiffPlace = struct { scroll: f32, scroll_x: f32, files_scroll: f32, /// Owned until it is handed to `restore`. anchor: ?Document.Anchor, fraction: f32, }; /// A reload's memory of the list: the commit selected, the row it was on, /// and how far below the top of the list that row was drawn; and what the /// pane showed, and where. const Reselect = struct { id: History.ObjectId, row: usize, offset: f32, /// Where the list is to be scrolled: where it was, until the commit /// comes back, and then wherever puts that commit where it was. scroll: f32, shown: ?ChangeLoader.Target, place: DiffPlace, found: bool = false, /// The scroll last set, to tell when the user has scrolled since. applied: ?f32 = null, }; fn diffPlace(app: *App) DiffPlace { var anchor: ?Document.Anchor = null; if (app.document) |*d| if (app.change) |*c| { anchor = d.anchorAt(app.gpa, c, app.diff.topLine()) catch null; }; return .{ .scroll = app.diff.scroll, .scroll_x = app.diff.scroll_x, .files_scroll = app.files.scroll, .anchor = anchor, .fraction = app.diff.scroll - @as(f32, @floatFromInt(app.diff.topLine())) * app.diff.line_height, }; } /// Puts the pane back at `place`, for what was asked for again: where it /// was until the new diff comes, and then the same line, as near as the /// new diff has it. Takes the anchor. fn restoreDiffPlace(app: *App, place: *DiffPlace) void { app.diff.scroll = place.scroll; app.diff.scroll_x = place.scroll_x; app.files.scroll = place.files_scroll; app.dropRestore(); app.restore = place.anchor; app.restore_fraction = place.fraction; place.anchor = null; } /// F5: reads the history again, keeping the selected commit selected, and /// the list and the pane where they were, as far as what comes back has /// them. fn reload(app: *App) void { app.dropReselect(); if (app.list.selected) |row| if (row < app.history.len()) { app.reselect = .{ .id = app.history.get(row).id, .row = row, .offset = @as(f32, @floatFromInt(row)) * app.list.row_height - app.list.scroll, .scroll = app.list.scroll, .shown = app.shown, .place = app.diffPlace(), }; }; app.startLoading(); app.dirty = true; } fn dropReselect(app: *App) void { if (app.reselect) |*r| if (r.place.anchor) |an| an.deinit(app.gpa); app.reselect = null; } /// Puts the list back as it was before a reload, as the history comes: /// scrolled where it was, as far as there are rows for it, until the /// commit that was selected arrives, which is selected again where it was /// drawn. If it never does, `done`, the row it was on is selected instead. /// The user scrolling or selecting meanwhile ends it. fn followReselect(app: *App, done: bool) void { const r = &(app.reselect orelse return); if (r.applied) |a| if (app.list.scroll != a) return app.dropReselect(); if (r.found) { if (app.list.selected != r.row) return app.dropReselect(); } else if (app.list.selected != null) return app.dropReselect(); const count = app.history.len(); if (!r.found) { if (app.history.rowOf(r.id)) |row| { r.found = true; r.row = row; r.scroll = @as(f32, @floatFromInt(row)) * app.list.row_height - r.offset; app.selectRow(row); // What the pane showed, shown again where it was. if (r.shown) |shown| switch (shown) { .between => |b| app.loadBetween(b.from, b.to), else => {}, }; app.restoreDiffPlace(&r.place); } else if (done) { r.found = true; if (count > 0) { r.row = @min(r.row, count - 1); app.selectRow(r.row); } } } app.list.scroll = std.math.clamp(r.scroll, 0, app.list.maxScroll(count)); r.applied = app.list.scroll; app.dirty = true; // Done once the list is where it is to be, or as near as it will get. if (done or (r.found and app.list.scroll == @max(0, r.scroll))) app.dropReselect(); } /// Reads the history from the start: at first, and on a reload. fn startLoading(app: *App) void { if (app.loader) |*l| l.deinit(app.gpa, app.io); app.loader = null; if (app.status == .failed) app.gpa.free(app.status.failed); app.clearChange(); // The refs may have moved since. app.dropRelations(); app.relations.clear(); app.history.clear(); app.graph.clear(); app.list.selected = null; app.list.scroll = 0; app.status = .loading; app.pseudo_rows = 0; app.addPseudoCommits() catch |err| log.warn("looking for changes not committed: {t}", .{err}); app.loader = Loader.start(app.gpa, app.io, app.args, null) catch |err| { app.fail("status-git-failed", err); return; }; } /// Puts the changes not committed yet at the top of the history, as rows /// of their own leading down to `HEAD`, when there are any and the history /// starts from it. fn addPseudoCommits(app: *App) !void { const local = try Local.check(app.gpa, app.io, app.args, null) orelse return; const index_id: History.ObjectId = .ofPseudo(.index); if (local.unstaged) try app.addPseudo(.working_tree, l10n.text("pseudo-working-tree"), if (local.staged) index_id else local.parent); if (local.staged) try app.addPseudo(.index, l10n.text("pseudo-index"), local.parent); } fn addPseudo(app: *App, kind: History.Pseudo, subject: []const u8, parent: History.ObjectId) !void { const id: History.ObjectId = .ofPseudo(kind); try app.history.append(.{ .id = id, .parents = &.{parent}, .author_name = "", .author_email = "", .time = 0, .offset = 0, .subject = subject, .pseudo = kind, }); try app.graph.add(id, &.{parent}); app.pseudo_rows += 1; } /// The history could not be read: the message `id`, saying `err`. fn fail(app: *App, id: []const u8, err: anyerror) void { app.failWith(id, &.{l10n.str("error", @errorName(err))}); } fn failWith(app: *App, id: []const u8, args: l10n.Args) void { if (app.status == .failed) app.gpa.free(app.status.failed); app.status = if (l10n.format(app.gpa, id, args)) |msg| .{ .failed = msg } else |_| .out_of_memory; app.dirty = true; } pub fn run(app: *App) !void { while (app.running) { var fds: [7 + RelationsLoader.max_handles]Loader.Handle = undefined; var n: usize = 0; if (app.desktop) |d| { fds[n] = d.handle; n += 1; } if (app.loader) |*l| n += l.handles(fds[n..][0..2]).len; if (app.change_loader) |*l| n += l.handles(fds[n..][0..2]).len; if (app.find_loader) |*l| n += l.handles(fds[n..][0..2]).len; if (app.relations_loader) |*l| n += l.handles(fds[n..][0..RelationsLoader.max_handles]).len; // Woken for a find waiting to start, if nothing comes first. const timeout: ?i32 = if (app.find_due) |due| @intCast(std.math.clamp(Io.Clock.awake.now(app.io).durationTo(due).toMilliseconds(), 0, 1000)) else null; try app.plat.wait(fds[0..n], &.{}, timeout); while (app.plat.nextEvent()) |e| try app.event(e); app.followDesktop(); app.serveClipboard(); try app.pump(); try app.pumpChange(); try app.pumpRelations(); app.startDueFind(); app.pumpFind(); if (app.dirty and app.size.width > 0 and app.size.height > 0 and app.window.ready()) try app.draw(); } } /// Lets go of the selected commit's change, and stops reading it. fn clearChange(app: *App) void { app.diff.selection = null; app.selecting = false; if (app.change_loader) |*l| l.deinit(app.gpa, app.io); app.change_loader = null; if (app.document) |*d| d.deinit(); app.document = null; if (app.change) |*c| c.deinit(); app.change = null; if (app.change_failed) |m| app.gpa.free(m); app.change_failed = null; } /// Starts reading the selected commit's change, putting away the last. fn loadChange(app: *App) void { const row = app.list.selected orelse return app.clearChange(); const c = app.history.get(row); const target: ChangeLoader.Target = switch (c.pseudo) { .none => .{ .commit = c.id }, .working_tree => .working_tree, .index => .index, }; app.loadTarget(target, app.history.text(c.subject)); } /// Starts reading `target`'s change into the diff pane, called `title` if /// it is not a commit. fn loadTarget(app: *App, target: ChangeLoader.Target, title: []const u8) void { app.clearChange(); app.dropRestore(); app.shown = target; app.diff.scroll = 0; app.diff.scroll_x = 0; app.files.scroll = 0; app.relate(target); app.change_loader = ChangeLoader.start(app.gpa, app.io, target, app.diff_options, app.paths, title, null) catch |err| { app.change_failed = l10n.format(app.gpa, "status-git-failed", &.{l10n.str("error", @errorName(err))}) catch null; return; }; } /// Asks for what the pane is showing again, with the bar's settings as /// they are now, keeping its place: the same line of the same file, as /// near as the new diff has it. fn reloadShown(app: *App) void { var place = app.diffPlace(); switch (app.shown orelse return app.loadChange()) { .between => |b| app.loadBetween(b.from, b.to), else => app.loadChange(), } app.restoreDiffPlace(&place); } fn dropRestore(app: *App) void { if (app.restore) |an| an.deinit(app.gpa); app.restore = null; } /// Takes in what git has written of the selected commit's change. fn pumpChange(app: *App) !void { const l = &(app.change_loader orelse return); const status = l.pump(app.gpa, app.io) catch |err| { app.clearChange(); app.change_failed = l10n.format(app.gpa, "status-read-failed", &.{l10n.str("error", @errorName(err))}) catch null; app.dirty = true; return; }; switch (status) { .running => return, .done => |change| { l.deinit(app.gpa, app.io); app.change_loader = null; app.change = change; app.document = try Document.build(app.gpa, &app.change.?, &app.history, app.relationsOf(), &app.clock); if (app.restore) |an| { const d = &app.document.?; if (d.lineOf(&app.change.?, an)) |line| { app.diff.scrollTo(line, d.lines.len); _ = app.diff.scrollBy(app.restore_fraction, d.lines.len); } else _ = app.diff.scrollBy(0, d.lines.len); app.dropRestore(); } }, .failed => |msg| { const owned = if (msg.len > 0) app.gpa.dupe(u8, msg) catch null else l10n.format(app.gpa, "status-git-no-reason", &.{}) catch null; app.clearChange(); app.change_failed = owned; }, } app.dirty = true; } /// Starts finding the branches and tags about `target`, if it is a commit /// whose are not already found or being found. fn relate(app: *App, target: ChangeLoader.Target) void { const id = switch (target) { .commit => |id| id, else => return app.dropRelations(), }; if (app.relations_of) |of| if (of.eql(id)) return; app.dropRelations(); app.relations_of = id; if (app.relations.touch(id)) return; app.relations_loader = RelationsLoader.start(app.gpa, app.io, id, null) catch |err| { log.warn("finding the branches and tags about a commit: {t}", .{err}); return; }; } /// Stops finding the relations of the commit that was shown; those found /// are kept. fn dropRelations(app: *App) void { if (app.relations_loader) |*l| l.deinit(app.gpa, app.io); app.relations_loader = null; app.relations_of = null; } /// The relations of the commit the pane shows, if they are found. fn relationsOf(app: *const App) ?*const Relations { const shown = app.shown orelse return null; return switch (shown) { .commit => |id| app.relations.get(id), else => null, }; } /// Takes in the branches and tags about the commit shown, and once they /// are all found, lays its details out again with them, line for line /// where they were. fn pumpRelations(app: *App) !void { const l = &(app.relations_loader orelse return); const status = l.pump(app.gpa, app.io) catch |err| { log.warn("finding the branches and tags about a commit: {t}", .{err}); l.deinit(app.gpa, app.io); app.relations_loader = null; return; }; switch (status) { .running => return, .done => |r| { l.deinit(app.gpa, app.io); app.relations_loader = null; try app.relations.put(app.gpa, app.relations_of.?, r); }, .failed => |said| { log.warn("finding the branches and tags about a commit: {s}", .{said}); l.deinit(app.gpa, app.io); app.relations_loader = null; return; }, } if (app.change) |*c| if (app.document) |*d| { const rebuilt = try Document.build(app.gpa, c, &app.history, app.relationsOf(), &app.clock); d.deinit(); app.document = rebuilt; }; app.dirty = true; } /// Takes in what git has written since the last turn of the loop. fn pump(app: *App) !void { const l = &(app.loader orelse return); var added: usize = 0; const status = l.pump(app.gpa, app.io, &app.history, &added) catch |err| { app.fail("status-read-failed", err); l.deinit(app.gpa, app.io); app.loader = null; app.dropReselect(); return; }; if (added > 0) { app.dirty = true; app.countMatches(); // The graph keeps up with the commits, row for row. while (app.graph.len() < app.history.len()) { const c = app.history.get(app.graph.len()); app.graph.add(c.id, app.history.parentsOf(c)) catch |err| { app.fail("status-layout-failed", err); l.deinit(app.gpa, app.io); app.loader = null; return; }; } // As gitk does: the newest commit, selected as soon as it comes; // after a reload, the one that was selected. if (app.reselect != null) app.followReselect(false) else if (app.list.selected == null) app.selectRow(0); } defer if (app.loader == null and app.reselect != null) app.followReselect(true); switch (status) { .running => {}, .done => { app.status = .done; l.deinit(app.gpa, app.io); app.loader = null; app.dirty = true; }, .cut_short => { app.failWith("status-cut-short", &.{}); l.deinit(app.gpa, app.io); app.loader = null; }, .failed => |msg| { // What git said, in its own words. if (msg.len == 0) app.failWith("status-git-no-reason", &.{}) else { if (app.status == .failed) app.gpa.free(app.status.failed); app.status = if (app.gpa.dupe(u8, msg)) |m| .{ .failed = m } else |_| .out_of_memory; app.dirty = true; } l.deinit(app.gpa, app.io); app.loader = null; }, } } fn event(app: *App, e: ev.Event) !void { switch (e.data) { .close => app.running = false, .resize => |r| { if (r.scale != app.scale) { app.scale = r.scale; try app.text.setScale(r.scale); } const s = app.window.bufferSize(); app.size = .{ .width = s.width, .height = s.height }; app.title_bar.resized(s.width, s.height, r.scale, r.state, r.draw_title_bar); app.layout(); }, .focus => |f| { app.focused = f; app.dirty = true; }, .key => |k| if (k.action != .release) app.key(k), // An input method's text, for the dialog's field or the find bar. .text => |t| if (app.dialog) |*d| { if (d.focused()) |f| f.insert(app.gpa, t) catch {}; app.dirty = true; } else if (app.focus == .find) { app.field.insert(app.gpa, t) catch {}; app.queryChanged(); }, .motion => |m| { app.pointer_x = m.x * app.scale; app.pointer_y = m.y * app.scale; app.motion(); }, .pointer_leave => { if (app.title_bar.leave()) app.dirty = true; if (app.diff.hovered != null) { app.diff.hovered = null; app.dirty = true; } }, .button => |b| { const outcome = if (b.pressed) app.title_bar.press(app.window, b.button, app.pointer_x, app.pointer_y, Io.Clock.awake.now(app.io)) else app.title_bar.release(app.window, b.button, app.pointer_x, app.pointer_y); switch (outcome) { .close => app.running = false, .content => switch (b.button) { .left => if (b.pressed) app.press() else { app.dragging = null; if (app.selecting) { app.selecting = false; app.offerSelection(); } if (app.scrolling != null) { app.scrolling = null; app.dirty = true; } }, .right => if (b.pressed and app.dialog == null) app.openMenu(), else => {}, }, else => app.dirty = true, } }, .scroll => |s| if (app.dialog == null) { const step: f32 = if (s.precise) 1 else 3; // Shift turns a wheel sideways. const dx = (if (app.plat.mods.shift) s.dx + s.dy else s.dx) * step; const dy = (if (app.plat.mods.shift) 0 else s.dy) * step; const x = app.pointer_x; const y = app.pointer_y; var changed = false; if (app.pane_layout.diff.contains(x, y)) { if (dy != 0) changed = app.diff.scrollBy(dy * app.diff.line_height, app.documentLines()); // Sideways a few columns at a time, as far as a line. if (dx != 0) changed = app.diff.scrollXBy(dx * app.diff.line_height) or changed; } else if (app.pane_layout.files.contains(x, y)) { changed = app.files.scrollBy(dy * app.files.row_height, app.fileCount()); } else { changed = app.list.scrollBy(dy * app.list.row_height, app.history.len()); } if (changed) app.dirty = true; }, else => {}, } } /// The pointer moved: a divider follows it while dragged, and the cursor /// and highlights follow what it is over. fn motion(app: *App) void { const x = app.pointer_x; const y = app.pointer_y; if (app.dragging) |d| { app.panes.drag(d, app.contentRect(), x, y, @round(60 * app.scale)); app.layout(); return; } if (app.selecting) return app.dragSelection(x, y); if (app.scrolling) |drag| { const s = app.scrollbar(drag.bar); app.scrollTo(drag.bar, s.offsetFor(s.along(x, y) - drag.grab)); return; } if (app.dialog) |*d| { if (d.hover(x, y)) app.dirty = true; app.window.setCursor(if (d.hovered) |h| switch (h) { .field => .text, else => .pointer, } else .default); return; } if (app.menu) |*m| { if (m.hover(x, y)) app.dirty = true; app.window.setCursor(.default); return; } const over_find = app.find_bar.controlAt(x, y); const lit = if (over_find == .field) null else over_find; if (lit != app.find_bar.hovered) { app.find_bar.hovered = lit; app.dirty = true; } if (over_find) |c| { app.window.setCursor(if (c == .field) .text else .pointer); return; } if (app.title_bar.motion(app.window, x, y)) app.dirty = true; if (!app.title_bar.hovered.eql(.content)) return; const divider = Panes.dividerAt(app.pane_layout, x, y, @round(3 * app.scale)); app.window.setCursor(if (divider) |d| switch (d) { .horizontal => .row_resize, .vertical => .col_resize, } else if (app.diff.controlAt(x, y) != null) .pointer else if (app.diff.body().contains(x, y) and app.document != null) .text else .default); const hovered = app.diff.controlAt(x, y); if (hovered != app.diff.hovered) { app.diff.hovered = hovered; app.dirty = true; } } /// The left button went down on the content. fn press(app: *App) void { const x = app.pointer_x; const y = app.pointer_y; if (app.dialog) |*d| { // Nothing behind it is to be used until it is answered. app.dirty = true; if (d.press(&app.text, x, y, app.scale)) |answer| app.answered(answer); return; } if (app.menu) |m| { // A press anywhere closes the menu; on one of its items, does // what it says. app.menu = null; app.dirty = true; if (m.itemAt(x, y)) |i| if (m.items[i].enabled) app.menuChose(i); return; } if (app.find_bar.controlAt(x, y)) |c| { app.dirty = true; switch (c) { .field => { app.focus = .find; const r = app.find_bar.controls[@backingInt(FindBar.Control.field)]; app.field.place(&app.text, x - r.x - @round(5 * app.scale)) catch {}; }, .previous => app.findNext(-1), .next => app.findNext(1), .mode => app.openChoices(.mode, Query.Mode, c), .matching => app.openChoices(.matching, Query.Matching, c), .fields => app.openChoices(.fields, Query.Fields, c), } return; } app.focus = .list; if (Panes.dividerAt(app.pane_layout, x, y, @round(3 * app.scale))) |d| { app.dragging = d; return; } if (app.pressScrollbar(x, y)) return; if (app.diff.controlAt(x, y)) |c| { switch (c) { .fewer => app.diff_options.context -|= 1, .more => app.diff_options.context = @min(app.diff_options.context + 1, 100), .whitespace => app.diff_options.ignore_whitespace = !app.diff_options.ignore_whitespace, } app.reloadShown(); app.dirty = true; return; } if (app.diff.body().contains(x, y)) return app.pressDiff(x, y); if (app.pane_layout.files.contains(x, y)) { const entry = app.files.entryAt(y, app.fileCount()) orelse return; const doc = &(app.document orelse return); const line = switch (entry) { .details => 0, .file => |i| doc.file_starts[i], }; app.diff.scrollTo(line, doc.lines.len); app.dirty = true; return; } if (app.list.rowAt(y, app.history.len())) |row| app.selectRow(row); } /// Which menu is open. const MenuKind = enum { commit, branch, tag, mode, matching, fields }; /// Opens a menu of the choices of `E` under the find bar's control `c`. fn openChoices(app: *App, kind: MenuKind, comptime E: type, c: FindBar.Control) void { var items: [Menu.max_items]Menu.Item = undefined; const names = @typeInfo(E).@"enum".field_names; inline for (names, 0..) |name, i| items[i] = .{ .label = l10n.text(@field(E, name).label()) }; const r = app.find_bar.controls[@backingInt(c)]; app.menu = Menu.open(&app.text, items[0..names.len], r.x, r.bottom(), app.contentRect(), app.scale) catch return; app.menu_kind = kind; } /// What the commit menu offers, in order. const MenuAction = enum { copy_id, copy_summary, diff_to_selected, diff_from_selected, mark, diff_from_marked, create_branch, create_tag, reset, }; /// What the tag menu offers, in order. const TagAction = enum { copy_name, delete, }; /// What the branch menu offers, in order. const BranchAction = enum { copy_name, rename, delete, }; /// Opens the branch menu on a local branch's label under the pointer, or /// else the commit menu on the row. fn openMenu(app: *App) void { app.menu = null; app.dirty = true; if (!app.pane_layout.list.contains(app.pointer_x, app.pointer_y)) return; const row = app.list.rowAt(app.pointer_y, app.history.len()) orelse return; if (app.list.refAt(&app.text, &app.history, &app.graph, app.pointer_x, app.pointer_y, app.scale) catch null) |hit| { const r = app.history.refsOf(app.history.get(hit.row))[hit.index]; switch (r.kind) { .head => return app.openBranchMenu(app.history.text(r.name), r.current), .tag => return app.openTagMenu(app.history.text(r.name)), else => {}, } } const commit = app.history.get(row).pseudo == .none; const selected_commit = if (app.list.selected) |s| s != row and app.history.get(s).pseudo == .none else false; const marked_other = if (app.marked) |m| !m.eql(app.history.get(row).id) else false; const items = [_]Menu.Item{ .{ .label = l10n.text("menu-copy-id"), .enabled = commit }, .{ .label = l10n.text("menu-copy-summary"), .enabled = commit }, .{ .label = l10n.text("menu-diff-to-selected"), .enabled = commit and selected_commit }, .{ .label = l10n.text("menu-diff-from-selected"), .enabled = commit and selected_commit }, .{ .label = l10n.text("menu-mark"), .enabled = commit }, .{ .label = l10n.text("menu-diff-from-marked"), .enabled = commit and marked_other }, .{ .label = l10n.text("menu-create-branch"), .enabled = commit }, .{ .label = l10n.text("menu-create-tag"), .enabled = commit }, .{ .label = app.resetLabel(), .enabled = commit }, }; comptime std.debug.assert(items.len == @typeInfo(MenuAction).@"enum".field_names.len); app.menu = Menu.open(&app.text, &items, app.pointer_x, app.pointer_y, app.contentRect(), app.scale) catch return; app.menu_kind = .commit; app.menu_row = row; } /// The branch checked out, as the history's labels have it; null when /// `HEAD` is detached, or its branch is not among the commits shown. fn currentBranch(app: *const App) ?[]const u8 { for (app.history.refs.items) |r| if (r.current) return app.history.text(r.name); return null; } /// "Reset main branch to here", naming the branch checked out. fn resetLabel(app: *App) []const u8 { var w: std.Io.Writer = .fixed(&app.menu_reset_label); if (app.currentBranch()) |b| l10n.write(&w, "menu-reset-branch", &.{l10n.str("branch", b)}) catch {} else l10n.write(&w, "menu-reset-head", &.{}) catch {}; return w.buffered(); } /// Opens the branch menu on the branch `name`; one `current`, checked out, /// can be renamed but not deleted. fn openBranchMenu(app: *App, name: []const u8, current: bool) void { app.dropMenuRef(); app.menu_ref = app.gpa.dupe(u8, name) catch return; const items = [_]Menu.Item{ .{ .label = l10n.text("menu-copy-branch-name") }, .{ .label = l10n.text("menu-rename-branch") }, .{ .label = l10n.text("menu-delete-branch"), .enabled = !current }, }; comptime std.debug.assert(items.len == @typeInfo(BranchAction).@"enum".field_names.len); app.menu = Menu.open(&app.text, &items, app.pointer_x, app.pointer_y, app.contentRect(), app.scale) catch return; app.menu_kind = .branch; } /// Opens the tag menu on the tag `name`. fn openTagMenu(app: *App, name: []const u8) void { app.dropMenuRef(); app.menu_ref = app.gpa.dupe(u8, name) catch return; const items = [_]Menu.Item{ .{ .label = l10n.text("menu-copy-tag-name") }, .{ .label = l10n.text("menu-delete-tag") }, }; comptime std.debug.assert(items.len == @typeInfo(TagAction).@"enum".field_names.len); app.menu = Menu.open(&app.text, &items, app.pointer_x, app.pointer_y, app.contentRect(), app.scale) catch return; app.menu_kind = .tag; } fn dropMenuRef(app: *App) void { if (app.menu_ref) |b| app.gpa.free(b); app.menu_ref = null; } /// The item at `i` of the open menu was chosen. fn menuChose(app: *App, i: usize) void { switch (app.menu_kind) { .commit => app.commitMenuChose(@fromBackingInt(@intCast(i))), .branch => app.branchMenuChose(@fromBackingInt(@intCast(i))), .tag => app.tagMenuChose(@fromBackingInt(@intCast(i))), .mode => app.query.mode = @fromBackingInt(@intCast(i)), .matching => app.query.matching = @fromBackingInt(@intCast(i)), .fields => app.query.fields = @fromBackingInt(@intCast(i)), } switch (app.menu_kind) { .commit, .branch, .tag => {}, else => app.queryChanged(), } } fn commitMenuChose(app: *App, action: MenuAction) void { if (app.menu_row >= app.history.len()) return; const this = app.history.get(app.menu_row).id; const chosen = if (app.list.selected) |s| app.history.get(s).id else this; switch (action) { .copy_id => { var buf: [64]u8 = undefined; app.copy(std.fmt.bufPrint(&buf, "{f}", .{this}) catch return); }, .copy_summary => { const s = summary(app.gpa, app.io, this, null) catch null orelse return; defer app.gpa.free(s); app.copy(s); }, .diff_to_selected => app.loadBetween(this, chosen), .diff_from_selected => app.loadBetween(chosen, this), .mark => app.marked = this, .diff_from_marked => if (app.marked) |m| app.loadBetween(m, this), .create_branch => app.askCreateBranch(this), .create_tag => app.askCreateTag(this), .reset => app.askReset(this), } } fn branchMenuChose(app: *App, action: BranchAction) void { const name = app.menu_ref orelse return; switch (action) { .copy_name => app.copy(name), .rename => app.askRenameBranch(name), .delete => app.askDeleteBranch(name, false, null), } } fn tagMenuChose(app: *App, action: TagAction) void { const name = app.menu_ref orelse return; switch (action) { .copy_name => app.copy(name), .delete => app.askDeleteTag(name), } } /// What answering the dialog does. const DialogFor = union(enum) { /// It only says something: there is nothing to do. notice, /// Makes a branch, named in the field, at the commit. create_branch: History.ObjectId, /// Makes a tag, named in the first field, at the commit: annotated /// with the second, if it says anything. create_tag: History.ObjectId, /// Renames the branch, owned, to the name in the field. rename_branch: []u8, /// Deletes the branch, owned, if it is merged or `force`. delete_branch: struct { name: []u8, force: bool }, /// Deletes the tag, owned. delete_tag: []u8, /// Moves the branch checked out to the commit, as far as the choice /// says. reset: History.ObjectId, }; /// Asks `spec`, to do `what` when answered with its first button. Takes /// what `what` owns, also on failure. fn openDialog(app: *App, spec: Dialog.Spec, what: DialogFor) void { app.closeDialog(); app.menu = null; app.dialog_for = what; app.dialog = Dialog.init(app.gpa, spec) catch |err| { log.warn("asking: {t}", .{err}); app.closeDialog(); return; }; app.dialog.?.layout(&app.text, app.dialogBounds(), app.scale) catch {}; app.dirty = true; } fn closeDialog(app: *App) void { if (app.dialog) |*d| d.deinit(app.gpa); app.dialog = null; switch (app.dialog_for) { .rename_branch, .delete_tag => |name| app.gpa.free(name), .delete_branch => |b| app.gpa.free(b.name), else => {}, } app.dialog_for = .notice; app.dirty = true; } /// What the dialog covers: everything under the title bar. fn dialogBounds(app: *App) gpu.Canvas.Rect { var r = app.contentRect(); r.height += app.statusHeight(); return r; } /// Says `message`, under `title_id`, with nothing to answer but OK. fn notify(app: *App, title_id: []const u8, message: []const u8) void { app.openDialog(.{ .title = l10n.text(title_id), .message = message, .buttons = &.{l10n.text("dialog-ok")}, }, .notice); } /// `id`, abbreviated as the dialogs show a commit, and its subject, for /// `l10n` arguments; `buf` holds the ID. fn commitArgs(app: *const App, id: History.ObjectId, buf: *[64]u8) [2]l10n.Argument { const hex = std.fmt.bufPrint(buf, "{f}", .{id}) catch unreachable; const subject = if (app.history.rowOf(id)) |row| app.history.text(app.history.get(row).subject) else ""; return .{ l10n.str("id", hex[0..@min(12, hex.len)]), l10n.str("subject", subject) }; } fn askCreateBranch(app: *App, at: History.ObjectId) void { var buf: [64]u8 = undefined; const message = l10n.format(app.gpa, "branch-create-message", &app.commitArgs(at, &buf)) catch return; defer app.gpa.free(message); app.openDialog(.{ .title = l10n.text("branch-create-title"), .message = message, .fields = &.{.{}}, .buttons = &.{ l10n.text("branch-create-button"), l10n.text("dialog-cancel") }, }, .{ .create_branch = at }); } fn askCreateTag(app: *App, at: History.ObjectId) void { var buf: [64]u8 = undefined; const message = l10n.format(app.gpa, "tag-create-message", &app.commitArgs(at, &buf)) catch return; defer app.gpa.free(message); app.openDialog(.{ .title = l10n.text("tag-create-title"), .message = message, .fields = &.{ .{ .label = l10n.text("tag-create-name") }, .{ .label = l10n.text("tag-create-annotation") } }, .buttons = &.{ l10n.text("tag-create-button"), l10n.text("dialog-cancel") }, }, .{ .create_tag = at }); } fn askReset(app: *App, to: History.ObjectId) void { var buf: [64]u8 = undefined; const branch = l10n.str("branch", app.currentBranch() orelse "HEAD"); const commit = app.commitArgs(to, &buf); const title = l10n.format(app.gpa, "reset-title", &.{branch}) catch return; defer app.gpa.free(title); const message = l10n.format(app.gpa, "reset-message", &.{ branch, commit[0], commit[1] }) catch return; defer app.gpa.free(message); app.openDialog(.{ .title = title, .message = message, // gitk's choices, and its default. .choices = &.{ l10n.text("reset-soft"), l10n.text("reset-mixed"), l10n.text("reset-hard") }, .choice = @backingInt(Refs.ResetMode.mixed), .buttons = &.{ l10n.text("reset-button"), l10n.text("dialog-cancel") }, }, .{ .reset = to }); } /// Asks what to rename the branch `name` to, starting from its name as it /// is. fn askRenameBranch(app: *App, name: []const u8) void { const owned = app.gpa.dupe(u8, name) catch return; const title = l10n.format(app.gpa, "branch-rename-title", &.{l10n.str("branch", name)}) catch { app.gpa.free(owned); return; }; defer app.gpa.free(title); app.openDialog(.{ .title = title, .message = l10n.text("branch-rename-message"), .fields = &.{.{ .text = name }}, .buttons = &.{ l10n.text("branch-rename-button"), l10n.text("dialog-cancel") }, }, .{ .rename_branch = owned }); } /// Asks to delete the branch `name`; or, `force`, having been told why /// git would not, `said`, to delete it anyway. fn askDeleteBranch(app: *App, name: []const u8, force: bool, said: ?[]const u8) void { const owned = app.gpa.dupe(u8, name) catch return; const branch = l10n.str("branch", name); const title = l10n.format(app.gpa, if (force) "branch-delete-refused-title" else "branch-delete-title", &.{branch}) catch { app.gpa.free(owned); return; }; defer app.gpa.free(title); app.openDialog(.{ .title = title, .message = said orelse l10n.text("branch-delete-message"), .buttons = &.{ l10n.text(if (force) "branch-delete-anyway" else "branch-delete-button"), l10n.text("dialog-cancel") }, // Deleting what git would not is not what Return should do. .default = if (force) 1 else 0, }, .{ .delete_branch = .{ .name = owned, .force = force } }); } /// Asks to delete the tag `name`. Nothing stops git deleting one, so /// Return does not. fn askDeleteTag(app: *App, name: []const u8) void { const owned = app.gpa.dupe(u8, name) catch return; const title = l10n.format(app.gpa, "tag-delete-title", &.{l10n.str("tag", name)}) catch { app.gpa.free(owned); return; }; defer app.gpa.free(title); app.openDialog(.{ .title = title, .message = l10n.text("tag-delete-message"), .buttons = &.{ l10n.text("tag-delete-button"), l10n.text("dialog-cancel") }, .default = 1, }, .{ .delete_tag = owned }); } /// The dialog was answered: with its first button, it does what it asks. fn answered(app: *App, answer: Dialog.Answer) void { const chosen = switch (answer) { .dismissed => false, .button => |b| b == 0, }; if (!chosen) return app.closeDialog(); const d = &app.dialog.?; switch (app.dialog_for) { .notice => app.closeDialog(), .create_branch => |at| { const name = std.mem.trim(u8, d.fieldText(0), " "); // Nothing to call it yet: the question stands. if (name.len == 0) return; const outcome = Refs.create(app.gpa, app.io, name, at, null) catch |err| return app.gitFailed("branch-create-failed", err); app.changedRefs(outcome, "branch-create-failed"); }, .create_tag => |at| { const name = std.mem.trim(u8, d.fieldText(0), " "); if (name.len == 0) { // Nothing to call it yet: the question stands, and the // keyboard goes to where the name is wanted. d.focus = 0; return; } const message = std.mem.trim(u8, d.fieldText(1), " "); const outcome = Refs.tag(app.gpa, app.io, name, at, message, null) catch |err| return app.gitFailed("tag-create-failed", err); app.changedRefs(outcome, "tag-create-failed"); }, .rename_branch => |from| { const to = std.mem.trim(u8, d.fieldText(0), " "); // No new name yet, or the same one: the question stands. if (to.len == 0 or std.mem.eql(u8, to, from)) return; const outcome = Refs.rename(app.gpa, app.io, from, to, null) catch |err| return app.gitFailed("branch-rename-failed", err); app.changedRefs(outcome, "branch-rename-failed"); }, .delete_branch => |b| { const outcome = Refs.delete(app.gpa, app.io, b.name, b.force, null) catch |err| return app.gitFailed("branch-delete-failed", err); switch (outcome) { .refused => |said| if (!b.force) { // Most likely not merged, as git says: it may be // deleted anyway, having been told. defer app.gpa.free(said); const name = app.gpa.dupe(u8, b.name) catch return app.closeDialog(); defer app.gpa.free(name); return app.askDeleteBranch(name, true, said); }, .done => {}, } app.changedRefs(outcome, "branch-delete-failed"); }, .delete_tag => |name| { const outcome = Refs.deleteTag(app.gpa, app.io, name, null) catch |err| return app.gitFailed("tag-delete-failed", err); app.changedRefs(outcome, "tag-delete-failed"); }, .reset => |to| { const mode: Refs.ResetMode = @fromBackingInt(@intCast(d.choice)); const outcome = Refs.reset(app.gpa, app.io, to, mode, null) catch |err| return app.gitFailed("reset-failed", err); app.changedRefs(outcome, "reset-failed"); }, } } /// git was asked to change the refs, with `outcome`: the history is /// read again to show it; or what git said instead is shown, under /// `title_id`. fn changedRefs(app: *App, outcome: Refs.Outcome, title_id: []const u8) void { switch (outcome) { .done => { app.closeDialog(); app.reload(); }, .refused => |said| { defer app.gpa.free(said); app.notify(title_id, if (said.len > 0) said else l10n.text("status-git-no-reason")); }, } } /// git could not be run to change the refs. fn gitFailed(app: *App, title_id: []const u8, err: anyerror) void { const message = l10n.format(app.gpa, "status-git-failed", &.{l10n.str("error", @errorName(err))}) catch return app.closeDialog(); defer app.gpa.free(message); app.notify(title_id, message); } /// Shows what `to` has that `from` does not. fn loadBetween(app: *App, from: History.ObjectId, to: History.ObjectId) void { var buf: [160]u8 = undefined; var fh: [64]u8 = undefined; var th: [64]u8 = undefined; const f = std.fmt.bufPrint(&fh, "{f}", .{from}) catch return; const t = std.fmt.bufPrint(&th, "{f}", .{to}) catch return; var w: std.Io.Writer = .fixed(&buf); l10n.write(&w, "diff-between", &.{ l10n.str("from", f[0..@min(12, f.len)]), l10n.str("to", t[0..@min(12, t.len)]) }) catch {}; const title = w.buffered(); app.loadTarget(.{ .between = .{ .from = from, .to = to } }, title); } /// Puts `s` on the clipboard. fn copy(app: *App, s: []const u8) void { app.plat.setClipboardText(.standard, s); } fn isScrolling(app: *const App, bar: Bar) bool { return if (app.scrolling) |s| s.bar == bar else false; } fn documentLines(app: *const App) usize { return if (app.document) |d| d.lines.len else 0; } fn fileCount(app: *const App) usize { return if (app.change) |c| c.files.len else 0; } const keysym = struct { const home = 0xff50; const up = 0xff52; const down = 0xff54; const page_up = 0xff55; const page_down = 0xff56; const end = 0xff57; const f5 = 0xffc2; const backspace = 0xff08; const escape = 0xff1b; const tab = 0xff09; /// What Tab is with shift, on most layouts. const iso_left_tab = 0xfe20; const @"return" = 0xff0d; const kp_enter = 0xff8d; const left = 0xff51; const right = 0xff53; const delete = 0xffff; }; fn key(app: *App, k: ev.Key) void { if (app.dialog != null) return app.dialogKey(k); if (app.menu != null) { // Escape closes the menu; nothing else reaches past it. if (k.keysym == keysym.escape) { app.menu = null; app.dirty = true; } return; } const action = input.find(app.bindings, k); if (app.focus == .find) { // The field's first — what it types, and how it is edited — but // for a binding with a modifier besides shift: finding again, // quitting. const modified = k.mods.ctrl or k.mods.alt or k.mods.super; if (!(modified and action != null) and app.findKey(k)) return; } if (action) |a| app.act(a); } /// Does what a key binding says. fn act(app: *App, a: input.Action) void { const n = app.history.len(); const page: isize = @intCast(app.list.pageRows()); switch (a) { .select_previous => app.moveSelection(-1), .select_next => app.moveSelection(1), .select_page_up => app.moveSelection(-page), .select_page_down => app.moveSelection(page), .select_first => if (n > 0) app.selectRow(0), .select_last => if (n > 0) app.selectRow(n - 1), // The diff, a page at a time, as in gitk. .diff_page_down => if (app.diff.scrollBy(app.diff.page(), app.documentLines())) { app.dirty = true; }, .diff_page_up => if (app.diff.scrollBy(-app.diff.page(), app.documentLines())) { app.dirty = true; }, .find => app.focusFind(), .find_next => app.findNext(1), .find_previous => app.findNext(-1), .reload => app.reload(), .copy => if (app.selectedText()) |t| { defer app.gpa.free(t); app.copy(t); }, .quit => app.running = false, .unbind => {}, } } /// A key while the dialog is open: Return answers with its expected /// button and Escape dismisses it; the arrows move its choice; and the /// rest are its field's. Nothing reaches past it. fn dialogKey(app: *App, k: ev.Key) void { const d = &app.dialog.?; app.dirty = true; switch (k.keysym) { keysym.escape => app.answered(.dismissed), keysym.@"return", keysym.kp_enter => app.answered(.{ .button = d.default }), keysym.up => d.moveChoice(-1), keysym.down => d.moveChoice(1), keysym.tab, keysym.iso_left_tab => d.focusNext(if (k.mods.shift) -1 else 1), else => if (d.focused()) |f| { _ = app.editKey(f, k); }, } } /// What a key did to a field. const Edit = enum { none, moved, changed }; /// Edits `f` as the key `k` says, if it is one a field has a use for. fn editKey(app: *App, f: *TextField, k: ev.Key) Edit { switch (k.keysym) { keysym.backspace => f.backspace(), keysym.delete => f.delete(), keysym.left => f.left(), keysym.right => f.right(), keysym.home => f.home(), keysym.end => f.end(), else => if (k.mods.ctrl) switch (k.keysym) { 'a', 'A' => f.selectAll(), 'v', 'V' => { const pasted = app.plat.readClipboard(app.gpa, .standard) catch null orelse return .none; defer app.gpa.free(pasted); f.insert(app.gpa, pasted) catch {}; }, 'u', 'U' => f.clear(), else => return .none, } else if (k.text.len > 0 and !k.mods.alt) { f.insert(app.gpa, k.text) catch {}; } else return .none, } return switch (k.keysym) { keysym.left, keysym.right, keysym.home, keysym.end => .moved, else => if (k.mods.ctrl and (k.keysym == 'a' or k.keysym == 'A')) .moved else .changed, }; } /// A key while the find bar has the keyboard; false for one the field has /// no use for, which is then a binding's: moving the selection, paging. fn findKey(app: *App, k: ev.Key) bool { app.dirty = true; switch (k.keysym) { keysym.escape, keysym.tab => app.focus = .list, keysym.@"return", keysym.kp_enter => app.findNext(if (k.mods.shift) -1 else 1), keysym.up, keysym.down, keysym.page_up, keysym.page_down => return false, else => switch (app.editKey(&app.field, k)) { .none => return false, .moved => {}, .changed => app.queryChanged(), }, } return true; } fn focusFind(app: *App) void { app.focus = .find; app.field.selectAll(); app.dirty = true; } /// Lets go of the find under way. fn stopFind(app: *App) void { if (app.matcher) |*m| m.deinit(); app.matcher = null; if (app.find_loader) |*l| l.deinit(app.gpa, app.io); app.find_loader = null; app.find_due = null; app.find_count = 0; app.find_scanned = 0; app.find_status = .idle; } /// The find's text or settings changed: what matches is worked out again. fn queryChanged(app: *App) void { app.stopFind(); app.query.text = app.field.text(); app.dirty = true; if (app.query.text.len == 0) return; if (app.query.local()) { app.matcher = Query.Matcher.init(app.gpa, app.query) catch |err| { app.find_status = .{ .message = switch (err) { error.InvalidPattern => l10n.text("find-not-a-pattern"), error.OutOfMemory => l10n.text("find-out-of-memory"), } }; return; }; app.countMatches(); } else { app.find_due = Io.Clock.awake.now(app.io).addDuration(.fromMilliseconds(300)); app.find_status = .searching; } } /// Counts the matches among the rows not yet counted. fn countMatches(app: *App) void { const m = &(app.matcher orelse return); while (app.find_scanned < app.history.len()) : (app.find_scanned += 1) { if (m.matchesRow(&app.history, app.find_scanned)) app.find_count += 1; } app.find_status = .{ .matches = app.find_count }; } /// Starts git on a find whose moment has come. fn startDueFind(app: *App) void { const due = app.find_due orelse return; if (Io.Clock.awake.now(app.io).durationTo(due).toMilliseconds() > 0) return; app.find_due = null; app.find_loader = FindLoader.start(app.gpa, app.io, app.args, app.query, null) catch { app.find_status = .{ .message = l10n.text("find-git-failed") }; return; }; } fn pumpFind(app: *App) void { const l = &(app.find_loader orelse return); var added = false; const status = l.pump(app.gpa, app.io, &added) catch { app.find_status = .{ .message = l10n.text("find-read-failed") }; return; }; if (added) app.dirty = true; switch (status) { .running => {}, .done => { app.find_status = .{ .matches = switch (l.*) { inline else => |*b| b.found.count(), } }; app.dirty = true; }, // What git said is gone with the next pump: something steadier. .failed => { app.find_status = .{ .message = l10n.text("find-git-refused") }; app.dirty = true; }, } } /// Whether `row` is a match of the find under way. fn rowMatches(app: *const App, row: usize) bool { if (app.query.text.len == 0) return false; if (app.matcher) |*m| return m.matchesRow(&app.history, row); if (app.find_loader) |*l| return app.history.get(row).pseudo == .none and l.found(app.history.get(row).id); return false; } fn rowMatchesOpaque(context: *const anyopaque, row: usize) bool { const app: *const App = @ptrCast(@alignCast(context)); return app.rowMatches(row); } /// Selects the next match below the selected commit, or above it. fn findNext(app: *App, direction: isize) void { const n = app.history.len(); if (n == 0 or app.query.text.len == 0) return; var row: isize = if (app.list.selected) |s| @intCast(s) else -1; while (true) { row += direction; if (row < 0 or row >= n) { app.find_status = .{ .message = l10n.text("find-no-more") }; app.dirty = true; return; } if (app.rowMatches(@intCast(row))) { app.selectRow(@intCast(row)); return; } } } fn moveSelection(app: *App, delta: isize) void { const before = app.list.selected; app.list.moveSelection(delta, app.history.len()); if (app.list.selected) |s| if (before != s) app.selected(s); app.dirty = true; } fn selectRow(app: *App, row: usize) void { app.list.select(row, app.history.len()); if (app.list.selected) |s| app.selected(s); app.dirty = true; } /// The selected commit changed to `row`: its ID goes in the primary /// selection, ready for a middle click in a terminal, as gitk does it. fn selected(app: *App, row: usize) void { const c = app.history.get(row); app.loadChange(); if (c.pseudo != .none) return; const id = c.id; const hex = std.fmt.bufPrint(&app.selection_text, "{f}", .{id}) catch return; app.plat.setClipboardText(.primary, hex); } /// Answers whoever pastes what this window put on a clipboard. Windows /// takes the text when it is set, and asks nothing later. fn serveClipboard(app: *App) void { if (builtin.os.tag == .windows) return; while (app.plat.takeClipboardSend()) |snd| { defer app.plat.releaseClipboardSend(snd); // A commit ID, far smaller than a pipe holds: written whole without // waiting on the reader. _ = std.os.linux.write(snd.fd, snd.data.ptr, snd.data.len); } } /// The height of the bar along the bottom. fn statusHeight(app: *App) f32 { return @round(app.text.lineHeight(.ui) + 8 * app.scale); } /// What the title bar and the status bar leave for the panes. fn contentRect(app: *App) gpu.Canvas.Rect { const in = app.title_bar.insets(); const w: f32 = @floatFromInt(app.size.width -| in.left -| in.right); const h: f32 = @floatFromInt(app.size.height -| in.top -| in.bottom); return .{ .x = @floatFromInt(in.left), .y = @floatFromInt(in.top), .width = w, .height = @max(0, h - app.statusHeight()) }; } /// Places the panes. fn layout(app: *App) void { app.title_bar.line_height = app.text.family(.ui).metrics.cell_height; app.pane_layout = app.panes.layout(app.contentRect(), @max(1, @round(4 * app.scale))); // The find bar along the bottom of the list's pane. const bar_h = @round(app.text.lineHeight(.ui) + 12 * app.scale); const list = app.pane_layout.list; const bar = Scrollbar.thickness(app.scale); const list_h = @max(0, list.height - bar_h); app.list.rect = .{ .x = list.x, .y = list.y, .width = @max(0, list.width - bar), .height = list_h }; app.list.vtrack = .{ .x = list.right() - bar, .y = list.y, .width = @min(bar, list.width), .height = list_h }; app.find_bar.rect = .{ .x = list.x, .y = list.y + app.list.rect.height, .width = list.width, .height = @min(bar_h, list.height) }; app.list.row_height = @round(app.text.lineHeight(.ui) + 6 * app.scale); app.list.clamp(app.history.len()); app.diff.rect = app.pane_layout.diff; app.diff.scrollbar_size = bar; app.diff.line_height = @round(app.text.lineHeight(.mono) + 2 * app.scale); app.diff.bar_height = @round(app.text.lineHeight(.ui) + 8 * app.scale); _ = app.diff.scrollBy(0, app.documentLines()); const files = app.pane_layout.files; app.files.rect = .{ .x = files.x, .y = files.y, .width = @max(0, files.width - bar), .height = files.height }; app.files.vtrack = .{ .x = files.right() - bar, .y = files.y, .width = @min(bar, files.width), .height = files.height }; app.files.row_height = @round(app.text.lineHeight(.ui) + 6 * app.scale); _ = app.files.scrollBy(0, app.fileCount()); app.dirty = true; } /// A press on the diff's text: a place to select from, or with a second /// press a word, or with a third the line. fn pressDiff(app: *App, x: f32, y: f32) void { const now = Io.Clock.awake.now(app.io); const c = &app.last_click; const near = @abs(x - c.x) <= 4 * app.scale and @abs(y - c.y) <= 4 * app.scale; const soon = if (c.at) |at| at.durationTo(now).toMilliseconds() <= 400 else false; c.count = if (near and soon) @min(c.count + 1, 3) else 1; c.at = now; c.x = x; c.y = y; const doc = &(app.document orelse return); const pos = app.diff.posAt(&app.text, doc, x, y, app.scale) catch return; app.diff.selection = switch (c.count) { 1 => .{ .anchor = pos, .head = pos }, 2 => blk: { const w = doc.wordAt(pos); break :blk .{ .anchor = .{ .line = pos.line, .byte = w.start }, .head = .{ .line = pos.line, .byte = w.end } }; }, // The line, and its line break: up to the start of the next. else => .{ .anchor = .{ .line = pos.line, .byte = 0 }, .head = if (pos.line + 1 < doc.lines.len) .{ .line = pos.line + 1, .byte = 0 } else .{ .line = pos.line, .byte = doc.lines[pos.line].text.len } }, }; app.selecting = c.count == 1; if (c.count > 1) app.offerSelection(); app.dirty = true; } /// The pointer moved while selecting: the selection's end follows it, and /// the diff scrolls when it goes past the top or the bottom. fn dragSelection(app: *App, x: f32, y: f32) void { const doc = &(app.document orelse return); const b = app.diff.body(); if (y < b.y) _ = app.diff.scrollBy(-app.diff.line_height, doc.lines.len); if (y >= b.bottom()) _ = app.diff.scrollBy(app.diff.line_height, doc.lines.len); const pos = app.diff.posAt(&app.text, doc, x, y, app.scale) catch return; if (app.diff.selection) |*sel| sel.head = pos; app.dirty = true; } /// The text selected in the diff, owned by the caller; null for none. fn selectedText(app: *App) ?[]u8 { const sel = app.diff.selection orelse return null; if (sel.isEmpty()) return null; const doc = &(app.document orelse return null); return doc.textBetween(app.gpa, sel.anchor, sel.head) catch null; } /// Puts the selection in the primary selection, as selecting does in any /// program, for a middle click to paste. fn offerSelection(app: *App) void { const t = app.selectedText() orelse return; defer app.gpa.free(t); app.plat.setClipboardText(.primary, t); } /// One of the panes' scrollbars, as it is now. fn scrollbar(app: *const App, bar: Bar) Scrollbar { return switch (bar) { .list => app.list.scrollbar(app.history.len()), .diff => app.diff.vscrollbar(app.documentLines()), .diff_x => app.diff.hscrollbar(), .files => app.files.scrollbar(app.fileCount()), }; } /// Scrolls the pane `bar` belongs to so that it is `offset` in. fn scrollTo(app: *App, bar: Bar, offset: f32) void { const s = app.scrollbar(bar); const dy = offset - s.offset; const changed = switch (bar) { .list => app.list.scrollBy(dy, app.history.len()), .diff => app.diff.scrollBy(dy, app.documentLines()), .diff_x => app.diff.scrollXBy(dy), .files => app.files.scrollBy(dy, app.fileCount()), }; if (changed) app.dirty = true; } /// A press on a scrollbar: the thumb is taken hold of, and the track /// either side of it pages the pane that way. False when the press is on /// no scrollbar's thumb or track. fn pressScrollbar(app: *App, x: f32, y: f32) bool { for (std.enums.values(Bar)) |bar| { const s = app.scrollbar(bar); if (!s.track.contains(x, y)) continue; switch (s.hit(x, y) orelse return true) { .thumb => app.scrolling = .{ .bar = bar, .grab = s.grabOf(x, y) }, .before => app.scrollTo(bar, s.offset - s.view), .after => app.scrollTo(bar, s.offset + s.view), } app.dirty = true; return true; } return false; } fn draw(app: *App) !void { app.dirty = false; app.list.clamp(app.history.len()); app.buildFrame() catch |err| switch (err) { error.AtlasFull => { // The atlas holds glyphs nothing shows any more: start it // again, and the frame with it. app.text.glyphs.clear(); try app.buildFrame(); }, else => return err, }; try app.renderer.draw( app.size.width, app.size.height, theme.clear(theme.current.background), app.canvas.instances.items, app.canvas.runs.items, &.{}, &app.text.glyphs.alpha, &app.text.glyphs.color, app.target.target(), ); app.updateTextInput(); if (app.snapshot) |path| if (app.status != .loading and app.change_loader == null) { try app.writeSnapshot(path); app.running = false; }; } /// Tells the input method where the find field is while it has the /// keyboard, and that there is nowhere to type when it does not. fn updateTextInput(app: *App) void { const field: ?gpu.Canvas.Rect = if (app.dialog) |*d| (if (d.field_count > 0) d.field_rects[d.focus] else null) else if (app.focus == .find) app.find_bar.controls[@backingInt(FindBar.Control.field)] else null; const want: ?ev.Rect = if (field != null and app.focused) blk: { const r = field.?; break :blk .{ .x = @intFromFloat(r.x / app.scale), .y = @intFromFloat(r.y / app.scale), .width = @intFromFloat(r.width / app.scale), .height = @intFromFloat(r.height / app.scale), }; } else null; if (std.meta.eql(want, app.text_input)) return; app.text_input = want; app.window.setTextInput(want); } /// The frame just drawn, drawn again on the CPU and written to `path`. fn writeSnapshot(app: *App, path: []const u8) !void { const w = app.size.width; const h = app.size.height; const pixels = try app.gpa.alloc(u32, @as(usize, w) * h); defer app.gpa.free(pixels); const bg = theme.current.background; @memset(pixels, @as(u32, bg[3]) << 24 | @as(u32, bg[0]) << 16 | @as(u32, bg[1]) << 8 | bg[2]); gpu.cpu.draw(app.canvas.instances.items, &app.text.glyphs.alpha, &app.text.glyphs.color, pixels, w, h); const file = try Io.Dir.cwd().createFile(app.io, path, .{}); defer file.close(app.io); var buf: [4096]u8 = undefined; var fw = file.writer(app.io, &buf); const out = &fw.interface; try out.print("P6\n{d} {d}\n255\n", .{ w, h }); for (pixels) |px| try out.writeAll(&.{ @truncate(px >> 16), @truncate(px >> 8), @truncate(px) }); try out.flush(); } fn buildFrame(app: *App) !void { const c = &app.canvas; try c.reset(); const marked_row: ?usize = if (app.marked) |m| (if (app.history.rowOf(m)) |r| r else null) else null; const found: ?CommitList.Matches = if (app.query.text.len > 0) .{ .context = app, .matches = rowMatchesOpaque } else null; try app.list.draw(c, &app.text, &app.history, &app.graph, &app.clock, marked_row, found, app.isScrolling(.list), app.scale); try app.find_bar.draw(c, &app.text, &app.field, app.query, app.focus == .find, app.find_status, app.scale); const status: DiffView.Status = if (app.document) |*d| .{ .shown = d } else if (app.change_failed) |m| .{ .failed = m } else if (app.change_loader != null) .loading else .empty; try app.diff.draw(c, &app.text, status, .{ .context = app.diff_options.context, .ignore_whitespace = app.diff_options.ignore_whitespace }, if (app.isScrolling(.diff)) .vertical else if (app.isScrolling(.diff_x)) .horizontal else null, app.scale); const files: []const Change.File = if (app.change) |ch| ch.files else &.{}; try app.files.draw(c, &app.text, files, app.currentEntry(), app.isScrolling(.files), app.scale); for ([_]gpu.Canvas.Rect{ app.pane_layout.horizontal, app.pane_layout.vertical }) |d| try c.rect(d.x, d.y, d.width, d.height, theme.current.divider); try app.drawStatus(); if (app.menu) |*m| try m.draw(c, &app.text, app.scale); if (app.dialog) |*d| try d.draw(c, &app.text, app.dialogBounds(), app.scale); try app.title_bar.build(c, .{ .shaper = &app.text.shaper, .glyphs = &app.text.glyphs, .family = app.text.family(.ui), .shaped = &app.title_shaped, }, app.title, null, .{ .foreground = .{ theme.current.foreground[0], theme.current.foreground[1], theme.current.foreground[2] }, .background = .{ theme.current.background[0], theme.current.background[1], theme.current.background[2] }, }, app.focused); } /// The file list's entry for what is at the top of the diff. fn currentEntry(app: *const App) ?FileList.Entry { const doc = &(app.document orelse return null); return if (doc.fileAt(app.diff.topLine())) |i| .{ .file = i } else .details; } fn drawStatus(app: *App) !void { const c = &app.canvas; const r = app.contentRect(); const y = r.y + r.height; const h = app.statusHeight(); try c.rect(r.x, y, r.width, h, theme.current.status_background); try c.rect(r.x, y, r.width, @max(1, @round(app.scale)), theme.current.divider); var buf: [256]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); const commits = app.history.len() - app.pseudo_rows; const s, const color = switch (app.status) { .loading => blk: { l10n.write(&w, "status-reading", &.{l10n.num("count", commits)}) catch {}; break :blk .{ w.buffered(), theme.current.dim_foreground }; }, .done => blk: { l10n.write(&w, "status-commits", &.{l10n.num("count", commits)}) catch {}; break :blk .{ w.buffered(), theme.current.dim_foreground }; }, // The first line of what went wrong. .failed => |msg| .{ msg[0 .. std.mem.findScalar(u8, msg, '\n') orelse msg.len], theme.current.error_foreground }, .out_of_memory => .{ l10n.text("status-out-of-memory"), theme.current.error_foreground }, }; const saved = c.pushClip(.{ .x = r.x, .y = y, .width = r.width, .height = h }); defer c.setClip(saved); const pad = @round(8 * app.scale); const baseline = y + app.text.baselineIn(.ui, h); _ = try app.text.draw(c, .ui, s, r.x + pad, baseline, color); // At the right, the selected commit, as gitk shows it: its whole ID, // and which row it is. A row of changes not committed has no ID. const row = app.list.selected orelse return; if (row >= app.history.len()) return; var x = r.right() - pad; var row_buf: [64]u8 = undefined; var rw: std.Io.Writer = .fixed(&row_buf); l10n.write(&rw, "status-row", &.{ l10n.num("row", row + 1), l10n.num("rows", app.history.len()) }) catch {}; x -= try app.text.measure(.ui, rw.buffered()); _ = try app.text.draw(c, .ui, rw.buffered(), x, baseline, theme.current.dim_foreground); const commit = app.history.get(row); if (commit.pseudo != .none) return; var id_buf: [64]u8 = undefined; const id = std.fmt.bufPrint(&id_buf, "{f}", .{commit.id}) catch return; x -= 2 * pad + try app.text.measure(.mono, id); _ = try app.text.draw(c, .mono, id, x, y + app.text.baselineIn(.mono, h), theme.current.foreground); const label = l10n.text("status-commit-id"); x -= @round(4 * app.scale) + try app.text.measure(.ui, label); _ = try app.text.draw(c, .ui, label, x, baseline, theme.current.dim_foreground); }