Something went wrong. Try again.
A history browser for git, a replacement for gitk, drawn with Vulkan on Wayland.
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// 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);}