Something went wrong. Try again.
A GPU terminal emulator for Wayland and Windows on libghostty-vt, drawing with Vulkan, running a program on a pty or a serial port.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! The application: the configuration, the platform, the GPU, the fonts,//! and the windows, and the loop that runs them.
const std = @import("std");const builtin = @import("builtin");const Allocator = std.mem.Allocator;const Io = std.Io;const vt = @import("ghostty-vt");const serial = @import("serial");const build_options = @import("build_options");
const Config = @import("config/Config.zig");const load = @import("config/load.zig");const plat_mod = @import("aerie").platform;const Device = @import("aerie").gpu.Device;const Grid = @import("aerie").text.Family;const Fonts = @import("aerie").text.Fonts;const TextShaper = @import("aerie").text.Shaper;const TitleBar = @import("decor/TitleBar.zig");const Glyphs = @import("aerie").text.Glyphs;const bindings_mod = @import("input/bindings.zig");const Backend = @import("backend/Backend.zig");const Window = @import("Window.zig");const StatusWindow = @import("StatusWindow.zig");const l10n = @import("l10n.zig");const Tab = @import("Tab.zig");const Pane = @import("Pane.zig");const desktop = @import("desktop.zig");const Activation = desktop.Activation;const req = @import("request.zig");const search = @import("shell_search.zig");const location = @import("location.zig");const jump_list = @import("jump_list.zig");const ProfileSelector = @import("ProfileSelector.zig");const settings_mod = @import("settings.zig");const bell_mod = @import("BellSounds.zig");const BellSounds = bell_mod.BellSounds;const Portal = desktop.Portal;const Dnd = @import("Dnd.zig");const ClipboardTransfers = @import("ClipboardTransfers.zig");const links = @import("links.zig");const Scopes = desktop.Scopes;const startup = @import("startup.zig");const host = @import("os/host.zig");const Session = @import("termio/Session.zig");const Wake = @import("os/Wake.zig");const prog_status = @import("status.zig");const PaneConfigs = @import("PaneConfigs.zig");const foreground = @import("os/foreground.zig");
const App = @This();
const log = std.log.scoped(.app);
gpa: Allocator,io: Io,env: *const std.process.Environ.Map,loaded: load.Loaded,theme: Config.Theme,bindings: []bindings_mod.Binding,/// The configuration as each pane's foreground program sees it, for one/// that reads `ctx.foreground`.pane_configs: PaneConfigs,/// When every pane's foreground program is next looked for.foreground_poll_at: ?Io.Timestamp = null,/// The configuration's global key bindings, caught by the compositor or/// the desktop portal wherever the focus is; see `registerGlobals`.globals: []bindings_mod.Global = &.{},/// Global hotkeys waiting on an activation token to act with, by cookie.hotkey_waiting: std.AutoHashMapUnmanaged(u32, Config.Action) = .empty,
platform: *plat_mod.Platform,device: *Device,/// What `grid` was resolved among, and keeps its faces.fonts: Fonts,grid: Grid,glyphs: Glyphs,/// The title bar's family, among `fonts`; see `titleFamily`.title_family: ?Grid = null,/// Its size in points, as the desktop named it.title_points: f32 = 0,title_shaper: TextShaper,/// Scratch for the title bar, kept between frames.title_shaped: std.ArrayList(TextShaper.Glyph) = .empty,/// The font size in points, which the font-size bindings change.font_points: f32,/// The scale the grid is made for: the last one a window was given.scale: f32 = 1,
windows: std.ArrayList(*Window) = .empty,/// The window most recently opened or focused: where a new tab asked for/// from outside goes.last_window: ?*Window = null,/// The last pane ID handed out.last_pane_id: u64 = 0,/// A hash of every pane and its title when they were last published to/// the shell search, to publish again only on a change.published: u64 = 0,/// This harrier keeps the jump list: on Windows, the primary that is not/// elevated. An elevated one, sharing harrier's taskbar button, leaves it/// alone.jump_list_keeper: bool = false,/// A hash of what the jump list was last given, its panes and profiles.jump_list_digest: u64 = 0,/// When it was last replaced, and when it is next to be, if a change came/// too soon after.jump_list_at: ?Io.Timestamp = null,jump_list_due: ?Io.Timestamp = null,/// A pane's progress or status changed this turn of the loop, which the/// status window shows.status_changed: bool = false,/// The status window, while it is open.status_window: ?*StatusWindow = null,/// A tab dragged out of its window and not yet dropped.tab_drag: ?TabDrag = null,/// What the loop waits on, kept to save allocating it every turn: to read,/// and to write.fds: std.ArrayList(plat_mod.Handle) = .empty,fds_out: std.ArrayList(plat_mod.Handle) = .empty,/// Drag and drop for the programs in the panes.dnd: Dnd,/// Programs' clipboard reads, under way.clipboard: ClipboardTransfers,/// A cgroup for each program; see `Config.Cgroup`.scopes: Scopes,/// That a program could not have one has been said.scopes_warned: bool = false,/// Links clicked, waiting for an activation token to open them with, by/// the cookie the token was asked for with. Each is owned.opening: std.AutoHashMapUnmanaged(u32, []u8) = .empty,last_cookie: u32 = 0,/// This machine, for kitty's drag and drop protocol: a program that says/// it is on the same one is given dropped files' paths rather than their/// contents.machine_id: ?Session.dnd.MachineId = null,/// On Windows, the one wakeup every session signals, waited on once for all/// of them: a wait there takes at most 64 handles. Null elsewhere, where/// each session has its own.session_notify: ?Wake = null,
/// The name on the session bus, when this harrier owns it.server: ?*Activation.Server = null,/// The desktop's settings, watched; null without a portal.portal: ?*Portal = null,/// The settings the configuration was last evaluated with, and which of/// the portal's changes that was.settings: settings_mod.Settings,settings_generation: u64 = 0,/// The configuration file named with `--config`, read again on a reload;/// null for the default one.config_path: ?[]const u8 = null,/// `--no-config`: the built-in defaults, on every reload too.no_config: bool = false,/// Running as the systemd user service: started with no window, and kept/// running a while after the last one closes.service: bool = false,/// The bell sounds the configuration names as files, and the connection to/// the sound output they play through.bell_sounds: BellSounds,
pub const Options = struct { validation: bool = false, service: bool = false, /// See `App.config_path`. config_path: ?[]const u8 = null, /// See `App.no_config`. no_config: bool = false, /// See `App.portal`; owned by the app from here on. portal: ?*Portal = null, /// What `loaded` was evaluated with, and as of which change; owned by /// the app from here on. settings: ?settings_mod.Settings = null, settings_generation: u64 = 0, /// The process's environment, which on Linux says where PipeWire is, /// for the bell sounds. environ: ?std.process.Environ = null,};
/// Takes ownership of `loaded`, `options.portal` and `options.settings`,/// also on error.pub fn init(gpa: Allocator, io: Io, env: *const std.process.Environ.Map, loaded: load.Loaded, options: Options) !*App { var owned = loaded; errdefer owned.deinit(); errdefer if (options.portal) |p| p.destroy(); var owned_settings = options.settings orelse settings_mod.Settings.init(gpa); errdefer owned_settings.deinit(); const app = try gpa.create(App); errdefer gpa.destroy(app); const cfg = loaded.config();
var problem: []const u8 = ""; const theme = cfg.theme(&problem) catch { log.err("{s}: not a color", .{problem}); return error.InvalidConfig; }; try l10n.init(gpa, env, cfg.language); errdefer l10n.deinit();
// The fonts, which take the longest of anything here and need nothing // else, on a thread of their own while the compositor and the GPU are // got ready. var fonts: FontsInit = .{ .gpa = gpa, .io = io, .env = env, .family = cfg.font.family, .size_px = pixels(cfg.font.size, 1) }; const fonts_thread = std.Thread.spawn(.{}, FontsInit.run, .{&fonts}) catch null; // Waited for on every way out, so that it never outlives `fonts`. var fonts_waited = false; defer if (!fonts_waited) { if (fonts_thread) |t| t.join(); if (fonts.result) |*g| g.deinit() else |_| {} };
var menu: [window_menu.len][]const u8 = undefined; for (window_menu, &menu) |item, *label| label.* = l10n.text(item[0]); const plat = try plat_mod.Platform.init(gpa, io, env, .{ .app_id = req.app_id, .menu = &menu }); errdefer plat.deinit(); startup.mark("window system");
const main_device = plat.mainDevice(); const device = try Device.init(gpa, .{ .application_name = "harrier", .main_device = main_device, .validation = options.validation, .name = cfg.gpu }); errdefer device.deinit(); startup.mark("vulkan device");
if (fonts_thread) |t| t.join() else fonts.run(); fonts_waited = true; var grid_fonts = try fonts.result; errdefer grid_fonts.deinit(); startup.mark("fonts"); var glyphs = try Glyphs.init(gpa); errdefer glyphs.deinit(); startup.mark("glyphs"); var bell_sounds: BellSounds = try .init(gpa, io, options.environ); errdefer bell_sounds.deinit(); bell_sounds.apply(cfg, loaded.path, bell_mod.homeDir(env)); startup.mark("bell sounds");
app.* = .{ .gpa = gpa, .io = io, .env = env, .loaded = loaded, .theme = theme, .bindings = try bindings_mod.load(gpa, cfg.keybind), .pane_configs = .init(gpa), .globals = try bindings_mod.globals(gpa, cfg.keybind), .service = options.service, .platform = plat, .device = device, .fonts = grid_fonts.fonts, .grid = grid_fonts.grid, .glyphs = glyphs, .title_shaper = try .init(gpa, .{}), .portal = options.portal, .settings = owned_settings, .settings_generation = options.settings_generation, .config_path = options.config_path, .no_config = options.no_config, .font_points = cfg.font.size, .dnd = .{ .app = app }, .clipboard = .{ .app = app }, .scopes = .init(gpa, io, env), .machine_id = machineId(), .session_notify = if (builtin.os.tag == .windows) try Wake.init() else null, .bell_sounds = bell_sounds, }; if (builtin.os.tag == .windows) plat.setModalTurn(.{ .context = app, .run = modalTurn }); return app;}
/// The fonts fontconfig knows, scanned, and the grid's family resolved/// among them: made afresh when the family changes, so that a font/// installed since is found.const GridFonts = struct { fonts: Fonts, grid: Grid,
const InitError = Fonts.InitError || Grid.InitError;
fn init(gpa: Allocator, io: Io, env: *const std.process.Environ.Map, family: []const u8, size_px: f32) InitError!GridFonts { var fonts: Fonts = try .init(gpa, io, env, .{ .windows_conf_dir = "harrier" }); errdefer fonts.deinit(); startup.mark("fonts: scan"); // The family keeps the loader, which the scan's value only points // to, so the two can be moved apart. const grid: Grid = try .init(gpa, &fonts, family, size_px); startup.mark("fonts: the regular style resolved"); return .{ .fonts = fonts, .grid = grid }; }
fn deinit(g: *GridFonts) void { g.grid.deinit(); g.fonts.deinit(); }};
/// `GridFonts.init`, on a thread of its own.const FontsInit = struct { gpa: Allocator, io: Io, env: *const std.process.Environ.Map, family: []const u8, size_px: f32, result: GridFonts.InitError!GridFonts = error.NoFont,
fn run(f: *FontsInit) void { f.result = GridFonts.init(f.gpa, f.io, f.env, f.family, f.size_px); }};
/// This machine's identity for kitty's drag and drop protocol, from the/// OS's machine ID; none where there is none, and then every program is/// taken to be on another machine.fn machineId() ?Session.dnd.MachineId { var buf: [128]u8 = undefined; return Session.dnd.machineId(host.machineId(&buf) orelse return null);}
pub fn deinit(app: *App) void { defer l10n.deinit(); if (app.status_window) |sw| sw.destroy(); for (app.windows.items) |w| w.destroy(); app.windows.deinit(app.gpa); // After the windows, whose panes hold its entries. app.pane_configs.deinit(); app.dnd.deinit(); app.clipboard.deinit(); // After the windows, whose sessions signal it. if (app.session_notify) |*n| n.deinit(); app.scopes.deinit(); var it = app.opening.valueIterator(); while (it.next()) |u| app.gpa.free(u.*); app.opening.deinit(app.gpa); app.fds.deinit(app.gpa); app.fds_out.deinit(app.gpa); // Windows keeps the jump list after harrier exits: the profiles stay, // and the panes, all gone, go. if (builtin.os.tag == .windows and app.jump_list_keeper) app.commitJumpList(false); if (app.server) |s| s.destroy(); if (app.portal) |p| p.destroy(); app.settings.deinit(); app.bell_sounds.deinit(); app.glyphs.deinit(); app.dropTitleFamily(); app.title_shaper.deinit(); app.title_shaped.deinit(app.gpa); app.grid.deinit(); app.fonts.deinit(); app.device.deinit(); app.platform.deinit(); app.gpa.free(app.bindings); app.gpa.free(app.globals); app.hotkey_waiting.deinit(app.gpa); app.loaded.deinit(); app.gpa.destroy(app);}
pub fn config(app: *const App) *const Config { return app.loaded.config();}
/// The configuration as `p` sees it: evaluated again with its foreground/// program, for a configuration that reads `ctx.foreground`, else the/// app's. Only `Config.pane_fields` are to be read from it.pub fn configFor(app: *const App, p: *const Pane) *const Config { return if (p.pane_config) |e| e.config() else app.config();}
/// The key bindings in `p`, but the global ones.pub fn bindingsFor(app: *const App, p: *const Pane) []const bindings_mod.Binding { return if (p.pane_config) |e| e.bindings else app.bindings;}
/// Whether panes' foreground programs are looked for at all: only for a/// configuration that can say something different for one.pub fn watchesForeground(app: *const App) bool { return foreground.supported and app.loaded.readsForeground();}
/// Looks at what `p`'s terminal belongs to, and if that is another/// program than before, gives the pane the configuration as that program/// sees it. Without `thorough` only the process group is compared, which/// is one system call; with it, the group's leader is read too, to see an/// `exec` or a `setuid` within the group. True if it changed.pub fn checkForeground(app: *App, p: *Pane, thorough: bool) bool { // Windows' pseudoconsole has no foreground process group. if (comptime !foreground.supported) return false; if (!app.watchesForeground()) return false; const master = switch (p.session.backend) { .pty => |*pty| pty.handle(), else => return false, }; if (p.exited) return false; // None: the program has gone, or is going. What it last was stays. const pgrp = foreground.group(master) orelse return false; if (!thorough) if (p.fg_identity) |id| if (id.pgrp == pgrp) return false; // A leader that has gone, its group living on, stays what it was. const id = foreground.identity(pgrp) orelse return false; if (p.fg_identity) |old| if (old.eql(&id)) return false; p.fg_identity = id; const fg = (foreground.describe(app.gpa, &id) catch return false) orelse return false; if (p.foreground) |old| if (foreground.eql(old, fg)) { foreground.free(app.gpa, fg); return false; }; if (p.foreground) |old| foreground.free(app.gpa, old); p.foreground = fg; app.resolvePaneConfig(p); return true;}
/// Gives `p` the entry for its foreground program, or none, and its/// terminal what that says.fn resolvePaneConfig(app: *App, p: *Pane) void { const was = p.pane_config; p.pane_config = if (p.foreground) |fg| if (app.loaded.readsForeground()) app.pane_configs.acquire(&app.loaded, fg) else null else null; if (was) |e| app.pane_configs.release(e); if (p.pane_config == was) return; if (p.pane_config) |e| app.bell_sounds.also(e.config().bell.sound, app.loaded.path, bell_mod.homeDir(app.env)); app.applyTerminal(p);}
/// Whether any pane's cursor blinks: the app's configuration's, or a pane's/// own for its foreground program.fn anyBlinks(app: *const App) bool { if (app.config().cursor.blink) return true; for (app.pane_configs.entries.items) |e| if (e.refs > 0 and e.config().cursor.blink) return true; return false;}
/// Every pane's foreground program looked for again, thoroughly.fn pollForeground(app: *App) void { for (app.windows.items) |w| { var changed = false; for (w.tabs.items) |t| { var buf: [Tab.max_panes]*Pane = undefined; for (t.panes(&buf)) |p| if (app.checkForeground(p, true)) { changed = true; }; } if (changed) w.dirty = true; }}
/// Gives `p`'s terminal the configuration's colors and cursor as its/// defaults: what it shows until its program sets its own, and goes back/// to when the program resets them. Colors the program set stay set.pub fn applyTerminal(app: *App, p: *Pane) void { const cfg = app.configFor(p); const theme = app.themeFor(p); p.session.lock(); defer p.session.unlock(); const t = &p.session.terminal; t.colors.foreground.default = theme.foreground; t.colors.background.default = theme.background; t.colors.cursor.default = theme.cursor; t.colors.palette.changeDefault(t.gpa(), theme.palette) catch |err| log.warn("the palette: {t}", .{err}); t.setDefaultCursorStyle(switch (cfg.cursor.style) { .block => .block, .bar => .bar, .underline => .underline, }); t.setDefaultCursorBlink(cfg.cursor.blink); p.session.setDragAndDrop(cfg.drag_and_drop); t.setKittyGraphicsSizeLimit(t.gpa(), if (cfg.images.enabled) cfg.images.storage_bytes else 0); // A file named by a program on another machine would be one of ours. const local = p.session.backend == .pty; t.setKittyGraphicsLoadingLimits(if (cfg.images.files and local) .allWithTempDir(app.env.get("TMPDIR") orelse app.env.get("TEMP") orelse "/tmp") else .direct); // Every row, drawn again in the new colors. t.flags.dirty.palette = true; p.rows.stale = true;}
/// `p`'s colors: the configuration's, with those of the profile it/// was opened from over them.pub fn themeFor(app: *App, p: *const Pane) Config.Theme { const base = if (p.pane_config) |pc| pc.theme else app.theme; const e = app.config().profile(p.profile orelse return base) orelse return base; var diag: []const u8 = ""; return Config.overlay(base, e.colors, &diag) catch { log.warn("profile \"{s}\": {s}: not a color", .{ e.name, diag }); return base; };}
/// Opens the profile `e`: in a window of its own, or a tab of `w`, as/// it says, or the other when `flip`.pub fn launchProfile(app: *App, w: *Window, e: *const Config.Profile, flip: bool) void { app.launchProfileOr(w, e, flip) catch |err| log.err("profile \"{s}\": {t}", .{ e.name, err });}
fn launchProfileOr(app: *App, w: *Window, e: *const Config.Profile, flip: bool) !void { if (builtin.os.tag == .windows and e.elevate) { const elevation = @import("windows/elevation.zig"); if (!elevation.elevated()) return app.launchElevated(e.name, (e.open == .tab) != flip, e.cwd); } else if (e.elevate) { log.warn("profile \"{s}\": elevate is for Windows; opening it as it is", .{e.name}); } var r: req.Request = .{ .hold = .unsuccessful }; defer r.deinit(app.gpa); r.profile = try app.gpa.dupe(u8, e.name); r.title = try app.gpa.dupe(u8, e.name); if (e.cwd) |c| r.cwd = try app.gpa.dupe(u8, c); if ((e.open == .tab) != flip) try w.newTab(&r) else try app.newWindow(&r);}
/// Opens the profile `name` as administrator: harrier again, elevated,/// which Windows asks the user about first, opening it in a window/// of its own, or in a tab of the elevated harrier's last window. It starts/// in `cwd`, or where this harrier would start it.fn launchElevated(app: *App, name: []const u8, tab: bool, cwd: ?[]const u8) !void { const elevation = @import("windows/elevation.zig"); var arena_state: std.heap.ArenaAllocator = .init(app.gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); var args: std.ArrayList([]const u8) = .empty; try args.appendSlice(arena, &.{ "--profile", name }); if (tab) try args.append(arena, "--tab"); const dir = cwd orelse @import("main.zig").launchDir(app.io, arena); if (dir) |d| try args.appendSlice(arena, &.{ "--working-directory", d }); elevation.relaunch(app.gpa, args.items) catch |err| switch (err) { error.Cancelled => log.info("profile \"{s}\": not run as administrator, as the user said", .{name}), else => return err, };}
/// Makes `r`, a tab or split being opened from `from`, like it: the same/// profile, its program run again — except a serial port's, which/// cannot be opened twice, so the shell runs with only the profile's colors.pub fn likeProfile(app: *App, r: *req.Request, from: *const Pane) Allocator.Error!void { const name = from.profile orelse return; r.profile = try app.gpa.dupe(u8, name); const e = app.config().profile(name) orelse return; r.profile_program = from.profile_program and e.serial == null; if (!r.profile_program) return; // An unreachable host says why, as it did the first time. r.hold = .unsuccessful; r.title = try app.gpa.dupe(u8, e.name);}
/// Reads and evaluates the configuration again — on a change to the/// desktop's settings, or when asked — and applies it to everything open./// A configuration with a mistake in it is reported and the one in use/// kept.pub fn reloadConfig(app: *App) void { var problem: load.Problem = .{}; const loaded = load.load(app.gpa, app.io, app.env, app.config_path, .{ .settings = &app.settings, .no_file = app.no_config }, &problem) catch |err| switch (err) { error.OutOfMemory => { log.err("reloading the configuration: out of memory", .{}); return; }, else => { log.err("reloading the configuration: {f}", .{&problem}); return; }, }; app.applyConfig(loaded);}
fn applyConfig(app: *App, loaded: load.Loaded) void { var new = loaded; const cfg = new.config(); var diag: []const u8 = ""; const theme = cfg.theme(&diag) catch { log.err("reloading the configuration: {s}: not a color", .{diag}); new.deinit(); return; }; const new_bindings = bindings_mod.load(app.gpa, cfg.keybind) catch { new.deinit(); return; };
const old = app.config(); // Another language: everything harrier says is said again in it. const language_changed = if (old.language) |was| (if (cfg.language) |is| !std.mem.eql(u8, was, is) else true) else cfg.language != null; if (language_changed) { l10n.init(app.gpa, app.env, cfg.language) catch |err| log.err("changing language: {t}", .{err}); app.status_changed = true; if (app.status_window) |sw| sw.pw.setTitle(l10n.text("status-window-title")); if (builtin.os.tag == .windows) { var menu: [window_menu.len][]const u8 = undefined; for (window_menu, &menu) |item, *label| label.* = l10n.text(item[0]); app.platform.setMenu(&menu) catch |err| log.err("changing the window menu's language: {t}", .{err}); } } const family_changed = !std.mem.eql(u8, old.font.family, cfg.font.family); const size_changed = old.font.size != cfg.font.size; const shaping_changed = family_changed or old.font.ligatures != cfg.font.ligatures or old.font.builtin_glyphs != cfg.font.builtin_glyphs or !sameStrings(old.font.features, cfg.font.features);
// A new family is a new grid, made before anything is given up so that // a family that cannot be found leaves the old one in use. var new_grid: ?GridFonts = null; if (family_changed) new_grid = GridFonts.init(app.gpa, app.io, app.env, cfg.font.family, pixels(cfg.font.size, app.scale)) catch |err| blk: { log.err("reloading the configuration: the font \"{s}\": {t}", .{ cfg.font.family, err }); break :blk null; }; // And a shaper for the title bar, which would know the old fonts' faces. var new_title_shaper: ?TextShaper = null; if (new_grid) |*g| new_title_shaper = TextShaper.init(app.gpa, .{}) catch |err| blk: { log.err("reloading the configuration: the title bar's shaper: {t}", .{err}); g.deinit(); new_grid = null; break :blk null; };
// Every pane's evaluation goes with the configuration it was made from. for (app.windows.items) |w| for (w.tabs.items) |t| { var buf: [Tab.max_panes]*Pane = undefined; for (t.panes(&buf)) |p| if (p.pane_config) |e| { app.pane_configs.release(e); p.pane_config = null; }; }; app.pane_configs.clear(); var old_loaded = app.loaded; app.loaded = new; app.gpa.free(app.bindings); app.bindings = new_bindings; app.theme = theme; // Bound again only if they changed, so that a desktop that asks the // user about them does not ask again for nothing. if (bindings_mod.globals(app.gpa, cfg.keybind)) |g| { const same = sameGlobals(app.globals, g); app.gpa.free(app.globals); app.globals = g; if (!same) app.registerGlobals(); } else |_| {}
// The shapers hold the old grid's faces, so they go before it does. if (shaping_changed) for (app.windows.items) |w| w.rebuildShaper(); if (new_grid) |g| { // The title's family is among the fonts that go, and the shaper // knows their faces. app.dropTitleFamily(); app.title_shaper.deinit(); app.title_shaper = new_title_shaper.?; var old_fonts: GridFonts = .{ .fonts = app.fonts, .grid = app.grid }; app.fonts = g.fonts; app.grid = g.grid; old_fonts.deinit(); app.glyphs.clear(); } if (size_changed or new_grid != null) { app.font_points = cfg.font.size; app.resizeFont(); } app.bell_sounds.apply(cfg, new.path, bell_mod.homeDir(app.env)); for (app.windows.items) |w| { for (w.tabs.items) |t| { var buf: [Tab.max_panes]*Pane = undefined; for (t.panes(&buf)) |p| { if (app.watchesForeground()) { // A configuration that did not read it before may now: // a pane whose program was never looked for is. if (p.foreground == null) _ = app.checkForeground(p, true); app.resolvePaneConfig(p); } app.applyTerminal(p); } } w.applyConfig(); } old_loaded.deinit(); app.publishProfiles(); log.info("configuration reloaded", .{});}
fn sameStrings(a: []const []const u8, b: []const []const u8) bool { if (a.len != b.len) return false; for (a, b) |x, y| if (!std.mem.eql(u8, x, y)) return false; return true;}
/// Points to pixels, at the 96 dots per inch Wayland takes a logical pixel/// to be, times the output's scale./// The family a title bar is drawn in: the desktop's interface font, as/// GNOME names it to the portal — `Cantarell 11` — or else sans-serif at/// 10.5 points. Made the first time a window draws its own title bar, so/// that under a compositor that draws them it costs nothing; the terminal's/// grid, should no such font be found.pub fn titleFamily(app: *App) *Grid { if (app.title_family) |*f| return f; const font = titleFont(&app.settings); if (Grid.init(app.gpa, &app.fonts, font.family, pixels(font.points, app.scale))) |f| { app.title_family = f; app.title_points = font.points; return &app.title_family.?; } else |err| { log.warn("the title bar's font \"{s}\": {t}", .{ font.family, err }); return &app.grid; }}
fn dropTitleFamily(app: *App) void { if (app.title_family) |*f| f.deinit(); app.title_family = null;}
/// What a window's title bar is drawn with.pub fn titleText(app: *App) TitleBar.Text { return .{ .family = app.titleFamily(), .shaper = &app.title_shaper, .shaped = &app.title_shaped, .glyphs = &app.glyphs, .grid = &app.grid, };}
const TitleFont = struct { family: []const u8, points: f32 };
/// The desktop's interface font, from `org.gnome.desktop.interface`/// `font-name`: a family, and its size in points after the last space.fn titleFont(settings: *const settings_mod.Settings) TitleFont { const fallback: TitleFont = .{ .family = "sans-serif", .points = 10.5 }; const v = settings.get("org.gnome.desktop.interface", "font-name") orelse return fallback; if (v != .string) return fallback; return parseFontName(v.string) orelse fallback;}
fn parseFontName(s: []const u8) ?TitleFont { const trimmed = std.mem.trim(u8, s, " "); const space = std.mem.findScalarLast(u8, trimmed, ' ') orelse return null; const points = std.fmt.parseFloat(f32, trimmed[space + 1 ..]) catch return null; if (!(points > 0 and points < 200)) return null; const family = std.mem.trimEnd(u8, trimmed[0..space], " ,"); if (family.len == 0) return null; return .{ .family = family, .points = points };}
test "the desktop's interface font, as GNOME names it" { const f = parseFontName("Cantarell 11").?; try std.testing.expectEqualStrings("Cantarell", f.family); try std.testing.expectEqual(@as(f32, 11), f.points); const g = parseFontName("Noto Sans, 10.5").?; try std.testing.expectEqualStrings("Noto Sans", g.family); try std.testing.expectEqual(@as(?TitleFont, null), parseFontName("Cantarell")); try std.testing.expectEqual(@as(?TitleFont, null), parseFontName(" 11"));}
fn pixels(points: f32, scale: f32) f32 { return points * 96.0 / 72.0 * scale;}
/// Owns the name on the session bus, making this harrier the primary that/// later launches hand off to. Only the service must have it.pub fn serve(app: *App) !void { app.server = Activation.Server.start(app.gpa, app.io, app.env) catch |err| { if (app.service) return err; log.debug("not serving {s}: {t}", .{ req.app_id, err }); return; }; // Clicks on its notifications can come through the name now. if (app.portal) |p| p.setOwnsName(true); app.registerGlobals(); app.publishProfiles(); if (builtin.os.tag == .windows) app.jump_list_keeper = !@import("windows/elevation.zig").elevated();}
// ---------------------------------------------------------------------------// Global hotkeys// ---------------------------------------------------------------------------
/// Binds the global key bindings: through the compositor, with/// xx-hotkey-v1 or else vicinae-hotkey-v1, where it has either; through/// the desktop portal's GlobalShortcuts otherwise. Only the harrier that/// owns the name binds them — or one with no session bus at all — so/// that a second harrier does not take them from the first.fn registerGlobals(app: *App) void { if (app.server == null and app.portal != null) return; if (app.platform.hotkeyProtocol()) |proto| { var arena_state: std.heap.ArenaAllocator = .init(app.gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); const specs = arena.alloc(plat_mod.event.HotkeySpec, app.globals.len) catch return; for (app.globals, specs) |g, *spec| { const t = g.trigger; spec.* = .{ .keysym = t.keysym().?, .shift = t.shift, .ctrl = t.ctrl, .alt = t.alt, .super = t.super, .description = describeAction(arena, g.action) catch return, }; } app.platform.setHotkeys(req.app_id, specs); if (app.globals.len > 0) log.info("binding {d} global hotkeys with {s}-hotkey-v1", .{ app.globals.len, @tagName(proto) }); return; } if (app.portal) |p| { var arena_state: std.heap.ArenaAllocator = .init(app.gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); const shortcuts = arena.alloc(Portal.Shortcut, app.globals.len) catch return; for (app.globals, shortcuts, 0..) |g, *s, i| { const buf = arena.alloc(u8, 96) catch return; s.* = .{ .id = globalId(arena, app.globals, i) catch return, .description = describeAction(arena, g.action) catch return, .trigger = g.trigger.xdgTrigger(buf).?, }; } p.bindShortcuts(shortcuts); if (app.globals.len > 0) log.info("binding {d} global shortcuts through the desktop portal", .{app.globals.len}); return; } if (app.globals.len > 0) log.warn("global keybinds need a compositor with xx-hotkey-v1 or vicinae-hotkey-v1, or a desktop portal with GlobalShortcuts", .{});}
/// Global binding `i`'s ID for the portal, which may remember what the user/// chose for it: its action, numbered when the action is bound more than/// once.fn globalId(arena: Allocator, globals: []const bindings_mod.Global, i: usize) ![]const u8 { var n: usize = 0; for (globals[0..i]) |g| { if (g.action == globals[i].action) n += 1; } const name = @tagName(globals[i].action); return if (n == 0) name else std.fmt.allocPrint(arena, "{s}-{d}", .{ name, n + 1 });}
/// What an action does, for a compositor or a desktop to show beside its/// hotkey: its name, in words.fn describeAction(arena: Allocator, action: Config.Action) ![:0]const u8 { const id = try std.fmt.allocPrint(arena, "action-{s}", .{@tagName(action)}); return arena.dupeSentinel(u8, try l10n.format(arena, id, &.{}), 0);}
fn sameGlobals(a: []const bindings_mod.Global, b: []const bindings_mod.Global) bool { if (a.len != b.len) return false; for (a, b) |x, y| if (!std.meta.eql(x.trigger, y.trigger) or x.action != y.action) return false; return true;}
/// An event for the program rather than a window: a hotkey, what became/// of one, or the token one asked for.fn handleAppEvent(app: *App, data: plat_mod.event.Data) void { switch (data) { .hotkey => |h| { if (!h.pressed or h.index >= app.globals.len) return; log.info("global hotkey {s} fired", .{app.globals[h.index].keys}); // Acted on once a token has come, so that the window it raises // or opens can take the focus. app.last_cookie +%= 1; app.hotkey_waiting.put(app.gpa, app.last_cookie, app.globals[h.index].action) catch return; app.platform.requestActivation(h.serial, app.last_cookie); }, .hotkey_state => |s| { if (s.index >= app.globals.len) return; const keys = app.globals[s.index].keys; switch (s.state) { .bound => log.info("global hotkey {s}: bound", .{keys}), .denied => log.warn("global hotkey {s}: the compositor refused it{s}{s}", .{ keys, if (s.message.len > 0) ": " else "", s.message }), .revoked => log.warn("global hotkey {s}: the compositor withdrew it{s}{s}", .{ keys, if (s.message.len > 0) ": " else "", s.message }), } }, .activation_token => |t| if (app.hotkey_waiting.fetchRemove(t.cookie)) |kv| app.runGlobal(kv.value, t.token), else => {}, }}
/// Does what a global hotkey is bound to, raising a window with `token`:/// a new window or tab, or anything else in the window last used, which is/// raised — or, with no window at all, a new one.fn runGlobal(app: *App, action: Config.Action, token: ?[]const u8) void { switch (action) { .new_window => app.open(&.{ .activation_token = token }) catch |err| log.err("new window: {t}", .{err}), .new_tab => app.open(&.{ .new_tab = true, .activation_token = token }) catch |err| log.err("new tab: {t}", .{err}), else => { const w = app.last_window orelse if (app.windows.items.len > 0) app.windows.items[app.windows.items.len - 1] else { app.open(&.{ .activation_token = token }) catch |err| log.err("new window: {t}", .{err}); return; }; if (token) |t| w.pw.activate(t) else w.pw.raise(); if (action != .present_window) app.perform(w, action); }, }}
pub fn newWindow(app: *App, request: *const req.Request) !void { const w = try Window.create(app, request); errdefer w.destroy(); try app.windows.append(app.gpa, w); app.last_window = w;}
pub fn nextPaneId(app: *App) u64 { app.last_pane_id += 1; return app.last_pane_id;}
pub const TabDrag = struct { /// The window it came from, and still is in until it is dropped. window: *Window, tab: *Tab, /// A window of this program took it. dropped: bool = false,};
/// The tab being dragged was dropped on `target` at `x`, `y`, physical/// pixels: onto its tab bar, the tab goes there; anywhere else, it gets a/// window of its own — unless it is alone in its window already, when it/// stays where it is.pub fn dropTab(app: *App, target: *Window, x: f32, y: f32) void { const d = &(app.tab_drag orelse return); d.dropped = true; for (app.windows.items) |w| w.tab_bar.drop_slot = null; target.dirty = true; const src = d.window; const from = std.mem.findScalar(*Tab, src.tabs.items, d.tab) orelse return; const slot = target.tab_bar.insertionAt(x, y) orelse { if (src.tabs.items.len > 1) app.detach(src, from); return; }; if (target == src) { // Back onto its own bar: a move, counting the places without it. src.moveTabTo(from, if (slot > from) slot - 1 else slot); src.select(std.mem.findScalar(*Tab, src.tabs.items, d.tab).?); return; } const t = src.takeTab(from).?; target.insertTab(t, slot) catch |err| { log.err("moving a tab: {t}", .{err}); // Back where it came from, or a window of its own if that is gone. if (!src.closing) src.insertTab(t, from) catch { app.forgetTab(t); t.destroy(); } else app.adopt(t); return; }; app.last_window = target;}
/// The drag is over. Dropped nowhere that took it — outside every window/// of this program, or cancelled — the tab gets a window of its own.pub fn endTabDrag(app: *App) void { const d = app.tab_drag orelse return; app.tab_drag = null; for (app.windows.items) |w| { w.tab_bar.drop_slot = null; w.dirty = true; } if (d.dropped) return; const from = std.mem.findScalar(*Tab, d.window.tabs.items, d.tab) orelse return; if (d.window.tabs.items.len > 1) app.detach(d.window, from);}
/// Tab `i` of `w`, in a window of its own.fn detach(app: *App, w: *Window, i: usize) void { const t = w.takeTab(i) orelse return; app.adopt(t);}
/// A window for `t`, which belongs to none; closed if one cannot be made.fn adopt(app: *App, t: *Tab) void { const w = Window.createWith(app, t, null) catch |err| { log.err("a window for a tab: {t}", .{err}); app.forgetTab(t); t.destroy(); return; }; app.windows.append(app.gpa, w) catch { w.destroy(); return; }; app.last_window = w;}
/// `t` is about to be destroyed: nothing is to point at it any more./// Shows the desktop notification `p`'s program asked for, unless the/// configuration says not to while the pane is being looked at. A/// notification with no title is titled with the pane's./// Whether a notification from `p`, in tab `tab_index` of `w`, goes out:/// by default, only when the pane is not being looked at.fn wantsNotification(app: *App, w: *Window, tab_index: usize, p: *Pane) bool { return switch (app.configFor(p).notifications) { .never => false, .always => true, .unfocused => !(w.focused and tab_index == w.active and w.tab().focused == p), };}
pub fn notify(app: *App, w: *Window, tab_index: usize, p: *Pane, n: Session.Notification) void { const portal = app.portal orelse return; if (!app.wantsNotification(w, tab_index, p)) return; const title = if (n.title.len > 0) n.title else if (p.title.items.len > 0) p.title.items else "harrier"; portal.notify(p.id, title, n.body); p.notified = true;}
/// A program status record that has come into `working`, `blocked` or/// `done` (OSC 7501), as a desktop notification: titled with the record's/// title, or its program, or the pane's; saying what happened, and the/// record's message. A pane sends only so many in a while.pub fn notifyStatus(app: *App, w: *Window, tab_index: usize, p: *Pane, n: prog_status.Notice) void { const portal = app.portal orelse return; if (!app.wantsNotification(w, tab_index, p)) return; if (!p.mayNotice(Io.Clock.awake.now(app.io))) return; const title = n.title orelse n.app orelse if (p.title.items.len > 0) p.title.items else "harrier"; var buf: [2400]u8 = undefined; var out: std.Io.Writer = .fixed(&buf); const state = prog_status.describe(n.state, n.kind); var line_buf: [2400]u8 = undefined; var line: std.Io.Writer = .fixed(&line_buf); if (n.progress) |pct| { l10n.write(&line, "notification-status-percent", &.{ l10n.str("state", state), l10n.num("percent", pct) }) catch {}; } else l10n.write(&line, "notification-status", &.{l10n.str("state", state)}) catch {}; if (n.msg) |m| { l10n.write(&out, "notification-with-message", &.{ l10n.str("text", line.buffered()), l10n.str("message", m) }) catch {}; } else out.writeAll(line.buffered()) catch {}; portal.notify(p.id, title, out.buffered()); p.notified = true;}
/// A pane's bell rang unseen, as a desktop notification: titled with the/// pane's title. A pane sends only so many in a while.pub fn notifyBell(app: *App, w: *Window, tab_index: usize, p: *Pane) void { const portal = app.portal orelse return; if (!app.wantsNotification(w, tab_index, p)) return; if (!p.mayNotice(Io.Clock.awake.now(app.io))) return; portal.notify(p.id, if (p.title.items.len > 0) p.title.items else "harrier", l10n.text("notification-bell")); p.notified = true;}
/// Opens `uri`, a link clicked in `w`, which the app now owns: first asking/// the compositor for a token for whatever opens it to take the focus with.pub fn openLink(app: *App, w: *Window, uri: []u8) void { if (builtin.os.tag == .windows) { defer app.gpa.free(uri); return openWithShell(app.gpa, uri); } if (app.portal == null) { log.warn("no desktop portal to open {s} with", .{uri}); app.gpa.free(uri); return; } app.last_cookie +%= 1; app.opening.put(app.gpa, app.last_cookie, uri) catch { app.gpa.free(uri); return; }; w.pw.requestToken(app.last_cookie);}
/// The token for a link clicked has come: opens it, through the portal —/// a file on this machine as itself, anything else as a URI.pub fn openWithToken(app: *App, cookie: u32, token: ?[]const u8) void { const uri = (app.opening.fetchRemove(cookie) orelse return).value; defer app.gpa.free(uri); if (builtin.os.tag != .linux) return; const portal = app.portal orelse return; var host_buf: [64]u8 = undefined; const host_name = host.name(&host_buf); if (links.localPath(uri, host_name)) |encoded| { var path: std.ArrayList(u8) = .empty; defer path.deinit(app.gpa); links.percentDecode(app.gpa, &path, encoded) catch return; path.append(app.gpa, 0) catch return; const z: [*:0]const u8 = @ptrCast(path.items.ptr); const fd = std.os.linux.open(z, .{ .PATH = true, .CLOEXEC = true }, 0); if (std.os.linux.errno(fd) != .SUCCESS) { log.warn("opening {s}: {t}", .{ uri, std.os.linux.errno(fd) }); return; } portal.openFile(@intCast(fd), uri, token); return; } if (std.ascii.startsWithIgnoreCase(uri, "file:")) { log.warn("not opening {s}: a file on another machine", .{uri}); return; } portal.openUri(uri, token);}
/// Opens `uri` as Explorer would: a file on this machine by its path, and/// anything else as the URI it is, with whatever is registered for it.fn openWithShell(gpa: Allocator, uri: []const u8) void { const win32 = @import("win32").everything; var host_buf: [256]u8 = undefined; var target: std.ArrayList(u8) = .empty; defer target.deinit(gpa); if (links.localPath(uri, host.name(&host_buf))) |encoded| { links.percentDecode(gpa, &target, encoded) catch return; // `/C:/Users/...` names `C:\Users\...`. const path = target.items; if (path.len >= 3 and path[0] == '/' and path[2] == ':') _ = target.orderedRemove(0); for (target.items) |*c| if (c.* == '/') { c.* = '\\'; }; } else if (std.ascii.startsWithIgnoreCase(uri, "file:")) { log.warn("not opening {s}: a file on another machine", .{uri}); return; } else target.appendSlice(gpa, uri) catch return; const wide = std.unicode.utf8ToUtf16LeAllocZ(gpa, target.items) catch return; defer gpa.free(wide); const rc = win32.ShellExecuteW(null, std.unicode.utf8ToUtf16LeStringLiteral("open"), wide, null, null, win32.SW_SHOWNORMAL); // Anything over 32 is success. const code = if (rc) |r| @intFromPtr(r) else 0; if (code <= 32) log.warn("opening {s}: ShellExecute said {d}", .{ uri, code });}
/// Withdraws `p`'s notification, if it has one showing.pub fn withdraw(app: *App, p: *Pane) void { if (!p.notified) return; p.notified = false; if (app.portal) |portal| portal.withdraw(p.id);}
pub fn forgetTab(app: *App, t: *Tab) void { if (app.tab_drag) |d| if (d.tab == t) { app.tab_drag = null; };}
/// What a request asks for: a window, or a tab in the last window.pub fn open(app: *App, asked: *const req.Request) !void { var named = asked.asProfile(); const request = &named; if (request.profile) |name| if (app.config().profile(name)) |e| { // Where the profile says, when the request does not. if (named.cwd == null) named.cwd = e.cwd; // One that runs as administrator, asked for by name of a harrier // that is not. if (builtin.os.tag == .windows and e.elevate and request.profile_program) { const elevation = @import("windows/elevation.zig"); if (!elevation.elevated()) return app.launchElevated(name, request.new_tab, request.cwd); } } else log.warn("no profile is named \"{s}\"; opening the shell", .{name}); if (request.focus_pane) |id| { // A pane of another primary — one gone, whose jump list Windows // kept — is none of these, whatever its ID. const ours = if (request.focus_primary) |pid| pid == host.pid() else true; if (ours) for (app.windows.items) |w| for (w.tabs.items, 0..) |t, i| { var buf: [Tab.max_panes]*Pane = undefined; for (t.panes(&buf)) |p| if (p.id == id) { w.select(i); w.focusPane(p); // A token from whoever asked raises it; without one, it // asks for one of its own. if (request.activation_token) |token| w.pw.activate(token) else w.pw.raise(); app.last_window = w; return; }; }; // Closed since it was found, or never this primary's: from a // search, nothing to show; from the command line, the last window // brought forward, or a window of its own. if (request.focus_primary == null) return; if (app.last_window) |w| { if (request.activation_token) |token| w.pw.activate(token) else w.pw.raise(); return; } var plain = request.*; plain.focus_pane = null; plain.focus_primary = null; return app.newWindow(&plain); } if (request.new_tab) if (app.last_window) |w| { try w.newTab(request); // Its window is raised by the activation token, when there is one, // or else when it is asked to be. if (request.activation_token) |t| w.pw.activate(t) else if (request.raise) w.pw.raise(); return; }; try app.newWindow(request);}
/// Remakes the grid for `scale`, when a window says it is now drawn at one/// different from the last.pub fn useScale(app: *App, scale: f32) void { if (scale == app.scale) return; app.scale = scale; app.resizeFont();}
fn resizeFont(app: *App) void { app.grid.setSize(pixels(app.font_points, app.scale)) catch |err| { log.err("resizing the font: {t}", .{err}); return; }; if (app.title_family) |*f| f.setSize(pixels(app.title_points, app.scale)) catch |err| { log.err("resizing the title bar's font: {t}", .{err}); app.dropTitleFamily(); }; app.glyphs.clear(); for (app.windows.items) |w| w.layout();}
/// What a request runs: its profile's program or port, or else what/// it asks for, or else what the configuration says.pub const Program = struct { /// With no `serial`, the program and its arguments; null for the /// user's shell. command: ?[]const []const u8 = null, serial: ?Config.Serial = null, env: []const Config.EnvVar = &.{},};
pub fn program(app: *App, request: *const req.Request) Program { const cfg = app.config(); if (request.profile) |name| if (cfg.profile(name)) |e| { if (!request.profile_program) return .{ .command = cfg.command }; return .{ .command = e.command, .serial = e.serial, .env = e.env }; }; if (request.serial_path) |path| { var s: Config.Serial = cfg.serial orelse .{ .path = path }; s.path = path; if (request.baud) |b| s.baud = b; return .{ .serial = s }; } if (request.command) |c| return .{ .command = c }; if (cfg.serial) |s| return .{ .serial = s }; return .{ .command = cfg.command };}
/// The backend a new window runs: the serial port when there is one, the/// command otherwise, and the user's shell when there is neither — as the/// request says, and the configuration where it says nothing.pub fn makeBackend(app: *App, size: Backend.Size, request: *const req.Request) !Backend.Backend { const prog = app.program(request); if (prog.serial) |s| { return .{ .serial = try Backend.Serial.open(app.gpa, .{ .path = s.path, .settings = .{ .baud = s.baud, .data_bits = switch (s.data_bits) { 5 => .five, 6 => .six, 7 => .seven, else => .eight, }, .parity = switch (s.parity) { inline else => |p| @field(serial.Parity, @tagName(p)), }, .stop_bits = switch (s.stop_bits) { inline else => |b| @field(serial.StopBits, @tagName(b)), }, .flow = switch (s.flow) { inline else => |f| @field(serial.Flow, @tagName(f)), }, }, }) }; }
// The environment of whoever asked for the window, so that a window // opened from a shell inherits it; harrier's own otherwise. var env: std.process.Environ.Map = if (request.environ) |list| blk: { var m: std.process.Environ.Map = .init(app.gpa); errdefer m.deinit(); for (list) |kv| { const name, const value = splitEnv(kv) orelse continue; try m.put(name, value); } break :blk m; } else try app.env.clone(app.gpa); defer env.deinit(); // Activation tokens are for the window that was asked for, not for // whatever runs in it. _ = env.swapRemove("XDG_ACTIVATION_TOKEN"); _ = env.swapRemove("DESKTOP_STARTUP_ID"); try env.put("TERM", app.config().term); // harrier's own terminfo entry, installed beside it, found first: what // makes `TERM=harrier` work without installing it anywhere else. var terminfo_buf: [std.fs.max_path_bytes]u8 = undefined; if (app.ownTerminfo(&terminfo_buf)) |dir| try env.put("TERMINFO", dir); try env.put("COLORTERM", "truecolor"); try env.put("TERM_PROGRAM", "harrier"); try env.put("TERM_PROGRAM_VERSION", build_options.version); // What the request and its profile set, last, so that either // can say `TERM` for a host that has no entry for harrier. if (request.environ_set) |list| for (list) |kv| { const name, const value = splitEnv(kv) orelse continue; try env.put(name, value); }; for (prog.env) |v| { if (!std.process.Environ.Map.validateKeyForPut(v.name)) { log.warn("not setting {s}: not a name for a variable", .{v.name}); continue; } try env.put(v.name, v.value); }
if (builtin.os.tag == .windows) { const graphemes = Pane.graphemeWidth(app.config(), request.profile) == .unicode; // With no directory, the user's home, not wherever the primary was // started: the system directory, when that was from a shortcut. const cwd = request.cwd orelse @import("windows/directories.zig").home(&env); return .{ .pty = try spawnWindows(app.gpa, prog.command, &env, cwd, size, graphemes) }; }
const shell: []const []const u8 = prog.command orelse &.{env.get("SHELL") orelse app.env.get("SHELL") orelse "/bin/sh"}; var placing: Placing = .{ .app = app, .program = shell[0] }; return .{ .pty = try Backend.Pty.spawn(app.gpa, .{ .argv = shell, .env = &env, .cwd = request.cwd, .size = size, .place = if (app.config().cgroup.enabled) .{ .ctx = &placing, .func = Placing.place } else null, }) };}
/// `command` on a pseudoconsole; with none, the first shell there is of/// PowerShell, Windows PowerShell and `COMSPEC`, as Windows Terminal/// chooses./// A `NAME=value` from an environment, split where the OS splits it; null/// for one with no name that can be set. On Windows a name can start with/// `=`: cmd keeps each drive's directory in one, `=C:=C:\Users\me`, and/// PowerShell passes them on.fn splitEnv(kv: []const u8) ?struct { []const u8, []const u8 } { const from: usize = if (builtin.os.tag == .windows) 1 else 0; const eq = std.mem.findScalarPos(u8, kv, @min(from, kv.len), '=') orelse return null; const name = kv[0..eq]; if (!std.process.Environ.Map.validateKeyForPut(name)) return null; return .{ name, kv[eq + 1 ..] };}
fn spawnWindows(gpa: Allocator, command: ?[]const []const u8, env: *const std.process.Environ.Map, cwd: ?[]const u8, size: Backend.Size, graphemes: bool) !Backend.Pty { if (command) |argv| return Backend.Pty.spawn(gpa, .{ .argv = argv, .env = env, .cwd = cwd, .size = size, .grapheme_cluster = graphemes }); const comspec = env.get("COMSPEC") orelse env.get("ComSpec") orelse "cmd.exe"; for ([_][]const u8{ "pwsh.exe", "powershell.exe", comspec }) |shell| { return Backend.Pty.spawn(gpa, .{ .argv = &.{shell}, .env = env, .cwd = cwd, .size = size, .grapheme_cluster = graphemes }) catch |err| switch (err) { error.FileNotFound => continue, else => return err, }; } return error.FileNotFound;}
/// Puts a program in a scope of its own, between its fork and its exec.const Placing = struct { app: *App, program: []const u8,
fn place(ctx: *anyopaque, pid: std.os.linux.pid_t) bool { const pl: *Placing = @ptrCast(@alignCast(ctx)); const cfg = pl.app.config().cgroup; var buf: [128]u8 = undefined; const description = std.fmt.bufPrint(&buf, "harrier: {s}", .{std.fs.path.basename(pl.program)}) catch "harrier"; pl.app.scopes.place(pid, description, .{ .memory_high = cfg.memory_high, .tasks_max = cfg.tasks_max }) catch |err| { if (cfg.required) { log.err("{s} not started: no cgroup of its own ({t}), and cgroup.required is set", .{ pl.program, err }); return false; } // Said once, rather than for every tab, when it will never work. if (err != error.NoSystemd or !pl.app.scopes_warned) { log.warn("{s} runs in harrier's own cgroup: {t}", .{ pl.program, err }); pl.app.scopes_warned = true; } }; return true; }};
/// harrier's items in the window system's own menu for a window — on/// Windows, the window menu — by their text's ID, in order: a choice comes/// back as its index.pub const window_menu = [_]struct { []const u8, Config.Action }{ .{ "action-new_window", .new_window }, .{ "action-new_tab", .new_tab }, .{ "action-toggle_profile_selector", .toggle_profile_selector },};
pub fn perform(app: *App, w: *Window, action: Config.Action) void { switch (action) { .copy => w.copy(.standard), .paste => w.paste(.standard), .paste_selection => w.paste(.primary), .scroll_page_up, .scroll_page_down, .scroll_top, .scroll_bottom, .scroll_line_up, .scroll_line_down => { const s = w.pane().session; s.lock(); defer s.unlock(); const t = &s.terminal; const page: isize = @intCast(t.rows -| 1); t.scrollViewport(switch (action) { .scroll_page_up => .{ .delta = -page }, .scroll_page_down => .{ .delta = page }, .scroll_line_up => .{ .delta = -1 }, .scroll_line_down => .{ .delta = 1 }, .scroll_top => .top, else => .bottom, }); w.dirty = true; }, .scroll_to_prompt_previous, .scroll_to_prompt_next => { const s = w.pane().session; s.lock(); defer s.unlock(); s.terminal.screens.active.scroll(.{ .delta_prompt = if (action == .scroll_to_prompt_previous) -1 else 1 }); w.dirty = true; }, .font_size_increase, .font_size_decrease, .font_size_reset => { app.font_points = switch (action) { .font_size_increase => @min(app.font_points + 1, 72), .font_size_decrease => @max(app.font_points - 1, 4), else => app.config().font.size, }; app.resizeFont(); }, .new_window => app.newWindow(&.{}) catch |err| log.err("new window: {t}", .{err}), .close_window => w.closing = true, .reload_config => app.reloadConfig(), .new_tab => w.newTabHere(), .close_tab => w.closeTab(w.active), .next_tab => w.cycle(1), .previous_tab => w.cycle(-1), .move_tab_left => w.moveTab(-1), .move_tab_right => w.moveTab(1), .goto_tab_1, .goto_tab_2, .goto_tab_3, .goto_tab_4, .goto_tab_5, .goto_tab_6, .goto_tab_7, .goto_tab_8 => w.select(@backingInt(action) - @backingInt(Config.Action.goto_tab_1)), .last_tab => w.select(w.tabs.items.len - 1), .close_pane => _ = w.closePane(w.active, w.pane()), .new_split_right => w.newSplit(.horizontal), .new_split_down => w.newSplit(.vertical), .goto_pane_left => w.gotoPane(.left), .goto_pane_right => w.gotoPane(.right), .goto_pane_up => w.gotoPane(.up), .goto_pane_down => w.gotoPane(.down), .goto_pane_next => w.cyclePane(1), .goto_pane_previous => w.cyclePane(-1), .resize_split_left => w.resizeSplit(.left), .resize_split_right => w.resizeSplit(.right), .resize_split_up => w.resizeSplit(.up), .resize_split_down => w.resizeSplit(.down), .equalize_splits => w.equalizeSplits(), .toggle_pane_zoom => w.toggleZoom(), .toggle_status => app.toggleStatusWindow(w), .start_search => w.startSearch(), .toggle_profile_selector => w.toggleProfileSelector(), .search_next => w.searchNext(.older), .search_previous => w.searchNext(.newer), .present_window => w.pw.raise(), .unbind => {}, }}
/// Runs until the last window closes — or, as the service, until it has/// been without a window for as long as the configuration says.pub fn run(app: *App) !void { var blink_at = Io.Clock.awake.now(app.io); var idle_since: ?Io.Timestamp = null; while (true) { if (app.windows.items.len == 0) { if (!app.service) break; // A service nobody can reach any more has nothing to wait for. if (app.server == null or app.server.?.gone.load(.acquire)) break; const now = Io.Clock.awake.now(app.io); const since = idle_since orelse now; idle_since = since; if (app.config().service.idle_exit_seconds) |limit| { if (since.durationTo(now).toMilliseconds() >= @as(i64, limit) * 1000) { log.info("no window for {d}s; exiting", .{limit}); break; } } } else idle_since = null;
// Every tab's session, shown or not: a hidden tab's program still // has its output read, its title kept, and its exit noticed. app.fds.clearRetainingCapacity(); app.fds_out.clearRetainingCapacity(); try app.dnd.handles(app.gpa, &app.fds, &app.fds_out); try app.clipboard.handles(app.gpa, &app.fds, &app.fds_out); if (app.server) |s| try app.fds.append(app.gpa, s.wake.handle); if (app.portal) |p| try app.fds.append(app.gpa, p.wake.handle); if (app.session_notify) |n| { try app.fds.append(app.gpa, n.handle); } else for (app.windows.items) |w| for (w.tabs.items) |t| { var buf: [Tab.max_panes]*Pane = undefined; for (t.panes(&buf)) |p| try app.fds.append(app.gpa, p.session.notify.handle); };
// Wake for the cursor's blink, half a second at a time, and for the // idle limit once a second. const now = Io.Clock.awake.now(app.io); const until_blink = now.durationTo(blink_at).toMilliseconds(); var timeout: ?i32 = if (app.anyBlinks() and app.windows.items.len > 0) @intCast(std.math.clamp(until_blink, 0, 500)) else null; if (idle_since != null and app.config().service.idle_exit_seconds != null) timeout = @min(timeout orelse 1000, 1000); // And for a pane's size, shown for a moment, to go. for (app.windows.items) |w| if (w.nextDeadline()) |d| { const ms: i32 = @intCast(std.math.clamp(now.durationTo(d).toMilliseconds(), 0, 60_000)); timeout = @min(timeout orelse ms, ms); }; // And for the jump list, held back after a change too soon. if (app.jump_list_due) |d| { const ms: i32 = @intCast(std.math.clamp(now.durationTo(d).toMilliseconds(), 0, 60_000)); timeout = @min(timeout orelse ms, ms); } // And for a clipboard read's source that has gone quiet. if (app.clipboard.timeout()) |ms| timeout = @min(timeout orelse ms, ms); // And for the panes' foreground programs to be looked at again. if (app.watchesForeground() and app.config().foreground_poll_ms > 0 and app.windows.items.len > 0) { const at = app.foreground_poll_at orelse now; const ms: i32 = @intCast(std.math.clamp(now.durationTo(at).toMilliseconds(), 0, 60_000)); timeout = @min(timeout orelse ms, ms); } // And not at all while a drop is still being read from memory. if (app.dnd.busy()) timeout = 0;
app.platform.wait(app.fds.items, app.fds_out.items, timeout) catch |err| switch (err) { // The compositor has gone, and every window with it. error.ConnectionLost => { log.info("the Wayland connection is gone; exiting", .{}); break; }, };
// The desktop's settings changed: the configuration may say // something different now. if (app.portal) |p| if (p.takeIfNewer(app.gpa, app.settings_generation) catch null) |fresh| { app.settings.deinit(); app.settings = fresh.settings; app.settings_generation = fresh.generation; // The desktop's interface font may be another; the title bar's // is made again when it is next wanted. app.dropTitleFamily(); app.reloadConfig(); };
// A global shortcut fired, through the portal. if (app.portal) |p| while (p.takeFired()) |f| { defer f.deinit(app.gpa); var arena_state: std.heap.ArenaAllocator = .init(app.gpa); defer arena_state.deinit(); for (app.globals, 0..) |g, i| { const id = globalId(arena_state.allocator(), app.globals, i) catch break; if (std.mem.eql(u8, id, f.id)) { log.info("global shortcut {s} fired", .{f.id}); app.runGlobal(g.action, f.activation_token); break; } } };
// A notification clicked: show the pane it came from. if (app.portal) |p| while (p.takeClick()) |click| { defer if (click.activation_token) |t| app.gpa.free(t); app.open(&.{ .focus_pane = click.pane, .activation_token = click.activation_token }) catch |err| log.err("showing a notification's pane: {t}", .{err}); };
if (app.server) |s| while (s.take()) |request| { var r = request; defer r.deinit(app.gpa); app.open(&r) catch |err| log.err("a window asked for over the bus: {t}", .{err}); };
app.dnd.pump(); app.clipboard.pump();
app.handleEvents();
if (app.watchesForeground() and app.config().foreground_poll_ms > 0) { const at_now = Io.Clock.awake.now(app.io); if (app.foreground_poll_at == null or !at_now.compare(.lt, app.foreground_poll_at.?)) { app.foreground_poll_at = at_now.addDuration(.fromMilliseconds(app.config().foreground_poll_ms)); app.pollForeground(); } }
if (app.anyBlinks() and !Io.Clock.awake.now(app.io).compare(.lt, blink_at)) { blink_at = Io.Clock.awake.now(app.io).addDuration(.fromMilliseconds(500)); for (app.windows.items) |w| if (w.focused) { w.cursor_on = !w.cursor_on; w.dirty = true; }; }
var i: usize = 0; while (i < app.windows.items.len) { const w = app.windows.items[i]; w.update(); if (w.closing) { _ = app.windows.orderedRemove(i); if (app.last_window == w) app.last_window = if (app.windows.items.len > 0) app.windows.items[app.windows.items.len - 1] else null; if (app.tab_drag) |d| if (d.window == w) { app.tab_drag = null; }; // The status window goes with the last terminal window, and // is otherwise handed to another. if (app.status_window) |sw| { if (app.windows.items.len == 0) { sw.closing = true; } else if (sw.parent == w) sw.reparent(app.last_window); } w.destroy(); continue; } if (w.wantsDraw()) w.draw() catch |err| { log.err("drawing: {t}", .{err}); if (@errorReturnTrace()) |t| std.debug.dumpErrorReturnTrace(t); }; i += 1; } // The status button, in every window, says what is being // reported; the status window shows it all. const ind = StatusWindow.indicator(app); for (app.windows.items) |w| if (!std.meta.eql(w.title_bar.status, ind)) { w.title_bar.status = ind; w.tab_bar.status = ind; w.dirty = true; }; if (app.status_window) |sw| { if (app.status_changed) sw.dirty = true; if (sw.closing) { sw.destroy(); app.status_window = null; } else if (sw.wantsDraw()) sw.draw() catch |err| log.err("drawing the status window: {t}", .{err}); } app.status_changed = false; app.publishPanes(); if (builtin.os.tag == .windows) app.publishJumpList(); app.publishLauncher(); app.platform.flush(); }}
/// Hands every event the platform has queued to the window it is for, or/// to the app.fn handleEvents(app: *App) void { while (app.platform.nextEvent()) |e| { const user = e.window orelse { app.handleAppEvent(e.data); continue; }; if (app.status_window) |sw| if (user == @as(*anyopaque, sw)) { sw.handle(e.data); continue; }; const w: *Window = @ptrCast(@alignCast(user)); if (!app.owns(w)) continue; w.handle(e.data); }}
/// A turn of the loop from inside Windows' own, while a window's frame is/// dragged: the events so far, and every window brought up to date and/// drawn — at the size it is being dragged to. A window asked to close is/// left for `run`, which is not running, to close.fn modalTurn(context: *anyopaque) void { const app: *App = @ptrCast(@alignCast(context)); app.handleEvents(); for (app.windows.items) |w| { w.update(); if (!w.closing and w.wantsDraw()) w.draw() catch |err| log.err("drawing: {t}", .{err}); }}
/// Tells a dock how far along the programs are, on harrier's icon: the/// mean of every pane's progress that says how far, and urgent while any/// reports an error, or, on Linux, while a pane is rung and the bell asks/// for attention. The server says it only when it has changed, and the/// progress is rounded to a percent so that a busy program does not keep/// it talking. On Windows, each window's taskbar button shows it.fn publishLauncher(app: *App) void { if (builtin.os.tag != .windows and app.server == null) return; var l: req.Launcher = .{}; if (app.config().window.dock_progress) { var sum: u32 = 0; var n: u32 = 0; for (app.windows.items) |w| for (w.tabs.items) |t| { var buf: [Tab.max_panes]*Pane = undefined; for (t.panes(&buf)) |p| if (p.progress) |pr| { if (pr.state == .@"error" or pr.state == .blocked) l.urgent = true; if (pr.percent) |pct| { sum += @min(pct, 100); n += 1; } }; }; if (n > 0) { l.visible = true; l.progress = @as(f64, @floatFromInt(sum / n)) / 100; } } // On Windows an urgent taskbar button is a red one, as for an error; // there the window's button flashes for the bell instead. if (builtin.os.tag != .windows) { for (app.windows.items) |w| if (w.anyRung(.attention)) { l.urgent = true; }; } if (builtin.os.tag == .windows) { app.platform.showProgress(.{ .progress = l.progress, .visible = l.visible, .urgent = l.urgent }); } else if (app.server) |server| server.publishLauncher(l);}
/// Tells the shell search what profiles there are: when it starts serving,/// and whenever the configuration is read again.fn publishProfiles(app: *App) void { const server = app.server orelse return; var arena_state: std.heap.ArenaAllocator = .init(app.gpa); defer arena_state.deinit(); const a = arena_state.allocator(); var list: std.ArrayList(search.Profile) = .empty; for (app.config().profiles) |*e| { var runs_buf: [512]u8 = undefined; const runs = a.dupe(u8, ProfileSelector.describe(e, &runs_buf)) catch return; var text_buf: [1024]u8 = undefined; var text: std.Io.Writer = .fixed(&text_buf); l10n.write(&text, "search-profile", &.{l10n.str("runs", runs)}) catch {}; list.append(a, .{ .name = e.name, .runs = runs, .description = a.dupe(u8, text.buffered()) catch return, .tab = e.open == .tab, }) catch return; } server.publishProfiles(list.items) catch return;}
/// Tells the shell search what panes there are, when that has changed: a/// pane opened, closed or moved, a title set, or a location changed.fn publishPanes(app: *App) void { const server = app.server orelse return; var h = std.hash.Wyhash.init(0); for (app.windows.items) |w| for (w.tabs.items) |t| { var buf: [Tab.max_panes]*Pane = undefined; for (t.panes(&buf)) |p| { h.update(std.mem.asBytes(&p.id)); h.update(p.title.items); h.update(&.{0}); if (p.location) |l| { h.update(l.host); h.update(&.{0}); h.update(l.path); } h.update(&.{0}); } }; const digest = h.final(); if (digest == app.published) return;
var arena: std.heap.ArenaAllocator = .init(app.gpa); defer arena.deinit(); const a = arena.allocator(); var list: std.ArrayList(search.Pane) = .empty; for (app.windows.items) |w| for (w.tabs.items) |t| { var buf: [Tab.max_panes]*Pane = undefined; for (t.panes(&buf)) |p| { var text_buf: [std.fs.max_path_bytes]u8 = undefined; const text = if (p.location) |l| location.format(&text_buf, l, location.homeOf(app.env)) else ""; list.append(a, .{ .id = p.id, .title = p.title.items, .location = a.dupe(u8, text) catch return }) catch return; } }; server.publishPanes(list.items) catch return; app.published = digest;}
/// Gives the jump list the open panes and the profiles when they have/// changed, at most once in `jump_list.min_interval_ms`; a change too soon/// after the last is given once that has passed.fn publishJumpList(app: *App) void { if (!app.jump_list_keeper) return; const digest = app.jumpListDigest(); if (digest == app.jump_list_digest) { app.jump_list_due = null; return; } const now = Io.Clock.awake.now(app.io); if (app.jump_list_at) |at| { const next = at.addDuration(.fromMilliseconds(jump_list.min_interval_ms)); if (now.compare(.lt, next)) { app.jump_list_due = next; return; } } app.commitJumpList(true); app.jump_list_digest = digest; app.jump_list_at = now; app.jump_list_due = null;}
/// What the jump list shows, hashed: each pane's ID, title and location,/// and each profile's name.fn jumpListDigest(app: *App) u64 { var h = std.hash.Wyhash.init(0); for (app.windows.items) |w| for (w.tabs.items) |t| { var buf: [Tab.max_panes]*Pane = undefined; for (t.panes(&buf)) |p| { h.update(std.mem.asBytes(&p.id)); h.update(p.title.items); h.update(&.{0}); if (p.location) |l| { h.update(l.host); h.update(&.{0}); h.update(l.path); } h.update(&.{0}); } }; for (app.config().profiles) |e| { h.update(e.name); h.update(&.{0}); } return h.final();}
/// Replaces the jump list: the profiles, and with `with_panes` the open/// panes too.fn commitJumpList(app: *App, with_panes: bool) void { var arena_state: std.heap.ArenaAllocator = .init(app.gpa); defer arena_state.deinit(); const a = arena_state.allocator(); var panes: std.ArrayList(jump_list.Item) = .empty; if (with_panes) for (app.windows.items) |w| for (w.tabs.items) |t| { var buf: [Tab.max_panes]*Pane = undefined; for (t.panes(&buf)) |p| { var text_buf: [std.fs.max_path_bytes]u8 = undefined; const where = if (p.location) |l| location.format(&text_buf, l, location.homeOf(app.env)) else ""; panes.append(a, jump_list.pane(a, host.pid(), p.id, p.title.items, a.dupe(u8, where) catch return) catch return) catch return; } }; var profiles: std.ArrayList(jump_list.Item) = .empty; for (app.config().profiles) |*e| { var what_buf: [512]u8 = undefined; const what = a.dupe(u8, ProfileSelector.describe(e, &what_buf)) catch return; profiles.append(a, jump_list.profile(a, e.name, what) catch return) catch return; } @import("windows/JumpList.zig").commit(app.gpa, l10n.text("jump-list-panes"), panes.items, profiles.items);}
/// The pane with `id`, and the window it is in, wherever that is now.pub fn findPane(app: *const App, id: u64) ?struct { window: *Window, pane: *Pane } { for (app.windows.items) |w| for (w.tabs.items) |t| { var buf: [Tab.max_panes]*Pane = undefined; for (t.panes(&buf)) |p| if (p.id == id) return .{ .window = w, .pane = p }; }; return null;}
/// The `share/terminfo` installed beside harrier, if it holds harrier's/// entry; in `buf`.fn ownTerminfo(app: *const App, buf: *[std.fs.max_path_bytes]u8) ?[]const u8 { const n = std.process.executableDirPath(app.io, buf) catch return null; const suffix = "/../share/terminfo"; const entry = "/h/harrier"; if (n + suffix.len + entry.len > buf.len) return null; @memcpy(buf[n..][0..suffix.len], suffix); @memcpy(buf[n + suffix.len ..][0..entry.len], entry); Io.Dir.cwd().access(app.io, buf[0 .. n + suffix.len + entry.len], .{}) catch return null; return buf[0 .. n + suffix.len];}
/// Opens the status window, a child of `from`, or closes it.pub fn toggleStatusWindow(app: *App, from: ?*Window) void { if (app.status_window) |sw| { sw.closing = true; return; } app.status_window = StatusWindow.create(app, from) catch |err| { log.err("opening the status window: {t}", .{err}); return; };}
fn owns(app: *const App, w: *Window) bool { for (app.windows.items) |x| if (x == w) return true; return false;}
test splitEnv { const t = std.testing; const plain = splitEnv("TERM=xterm=ish").?; try t.expectEqualStrings("TERM", plain[0]); try t.expectEqualStrings("xterm=ish", plain[1]); try t.expectEqual(null, splitEnv("no value")); if (builtin.os.tag == .windows) { const drive = splitEnv("=C:=C:\\Users\\me").?; try t.expectEqualStrings("=C:", drive[0]); try t.expectEqualStrings("C:\\Users\\me", drive[1]); try t.expectEqual(null, splitEnv("=")); } else { try t.expectEqual(null, splitEnv("=x")); }}