//! The settings page: preferences that apply to the whole app, grouped into //! titled sections. //! //! Built by hand in the same shape as the layout dialogs rather than out of //! `AdwPreferencesDialog`, so that it wears the app's own chrome — a stock //! preferences window in the middle of a window this heavily restyled reads as //! something that belongs to a different program. //! //! Changes apply the moment you make them, with no confirm button. That is the //! right shape for what is here: picking a scheme repaints the window behind //! the dialog, so the setting *is* its own preview, and an OK button would //! only offer to undo something you can see the result of. The startup list has //! no preview — it is a description of the *next* launch — but the same rule //! reads better than one section of the page behaving differently from the //! other, so it too takes effect as it is edited. //! //! What "takes effect" means is split, though, and deliberately. Every edit goes //! into the in-memory settings immediately; the *file* is written on the edits //! that are worth a write — adding, removing, reordering, changing a layout, //! capturing the open tabs — and once more when the page is closed. Typing a tab //! name is therefore free rather than an fsync per keystroke, and nothing can be //! lost by it: anything else that saves the settings, a theme change included, //! writes the current list along with it. //! //! Only one page is open at a time. The window is remembered while it is up and //! presented again rather than duplicated, since two settings pages disagreeing //! about which scheme is selected is a bug with no upside. const std = @import("std"); const gio = @import("gio"); const gobject = @import("gobject"); const gtk = @import("gtk"); const Layouts = @import("Layouts.zig"); const PaletteEditor = @import("PaletteEditor.zig"); const PathEntry = @import("PathEntry.zig"); const Settings = @import("Settings.zig"); const appearance = @import("appearance.zig"); const SettingsDialog = @This(); /// Asked for when "use current tabs" is clicked. The window writes the open /// tabs into the settings itself — it is the only thing that knows what each tab /// was opened with — and this page then rebuilds its rows from the result. pub const Capture = *const fn (ctx: ?*anyopaque) void; pub const Options = struct { /// The saved layouts, for the per-row layout picker. Borrowed for as long /// as the page is up, which is why the window closes the page as it goes. layouts: *Layouts, on_capture: Capture, ctx: ?*anyopaque, }; /// The label on the first item of every layout picker. Not a layout name, so /// `selectedLayout` answers with an empty string for it. const plain_label = "Plain terminal"; /// One filled-in parameter of a startup row's layout. /// /// The name is copied rather than borrowed from the layout it came from: the /// layout store can be reloaded from disk from the layout menu while this page /// is open, and a row holding pointers into the arena that reload frees would /// be reading freed memory at the next keystroke. const ParamField = struct { name: []u8, /// The text box holding the value. For a directory parameter it is the one /// inside a `PathEntry`, which frees itself with its widgets — so a row /// rebuilt for a different layout leaves nothing of it behind to track. entry: *gtk.Entry, }; /// One tab in the startup list: which layout, what to call it, and a field per /// parameter of whichever layout is currently picked. const StartupRow = struct { dialog: *SettingsDialog, /// The whole row — the header line and the parameter fields under it. box: *gtk.Box, dropdown: *gtk.DropDown, name: *gtk.Entry, emoji: *gtk.Entry, /// Holds the parameter fields, so they can be rebuilt in place when the /// picked layout changes. params_box: *gtk.Box, params: std.ArrayListUnmanaged(*ParamField) = .empty, }; alloc: std.mem.Allocator, window: *gtk.Window, layouts: *Layouts, on_capture: Capture, ctx: ?*anyopaque, /// One per `Settings.Theme`, in declaration order, so the selected one can be /// re-checked without asking each button what it stands for. theme_buttons: [std.enums.values(Settings.Theme).len]*gtk.ToggleButton, /// The palette editor under the theme picker. Held because changing the scheme /// changes which palette it is editing, so it has to be told. colors: *PaletteEditor, /// The confirm-before-quitting toggle. Held only so the switch can be built in /// one place and read in another; nothing re-checks it. confirm_quit: *gtk.Switch, /// The notifications toggle, held for the same reason. notifications: *gtk.Switch, /// Container the startup rows live in, so rows can be added and removed after /// the page is already on screen. startup_box: *gtk.Box, startup: std.ArrayListUnmanaged(*StartupRow) = .empty, /// Shown when the settings file can't be written, rather than letting the page /// look as though the edit landed. error_label: *gtk.Label, /// Set while a click or a rebuild is being applied, so that the signals it /// provokes — the other toggles in the group, the entries being filled in — /// don't re-enter and undo it. updating: bool = false, /// The open dialog, if there is one. A file-level singleton because "the /// settings page" is a singular thing from the user's point of view. var open: ?*SettingsDialog = null; pub fn present(alloc: std.mem.Allocator, parent: *gtk.Window, opts: Options) !void { if (open) |existing| { existing.window.present(); return; } const self = try alloc.create(SettingsDialog); errdefer alloc.destroy(self); self.* = .{ .alloc = alloc, .window = gtk.Window.new(), .layouts = opts.layouts, .on_capture = opts.on_capture, .ctx = opts.ctx, .theme_buttons = undefined, .colors = undefined, .confirm_quit = gtk.Switch.new(), .notifications = gtk.Switch.new(), .startup_box = gtk.Box.new(.vertical, 6), .error_label = gtk.Label.new(null), }; self.colors = try PaletteEditor.create(alloc, .{ .on_report = &onEditorReport, .ctx = self, }); errdefer self.colors.destroy(); self.window.setTitle("Settings"); self.window.setTransientFor(parent); self.window.setModal(1); self.window.setDefaultSize(560, 620); self.window.as(gtk.Widget).addCssClass("playpen-dialog"); const content = gtk.Box.new(.vertical, 12); content.as(gtk.Widget).addCssClass("playpen-dialog-content"); content.append(self.buildAppearance()); content.append(self.buildNotifications()); content.append(self.buildQuitting()); content.append(self.buildStartup()); self.error_label.setXalign(0); self.error_label.setWrap(1); self.error_label.as(gtk.Widget).addCssClass("playpen-dialog-error"); self.error_label.as(gtk.Widget).setVisible(0); content.append(self.error_label.as(gtk.Widget)); // The startup list grows with the number of tabs someone opens at launch, // so the page scrolls rather than pushing the close button off the bottom. const scroller = gtk.ScrolledWindow.new(); scroller.setPolicy(.never, .automatic); scroller.as(gtk.Widget).setVexpand(1); scroller.setChild(content.as(gtk.Widget)); const buttons = gtk.Box.new(.horizontal, 8); buttons.as(gtk.Widget).setHalign(.end); buttons.as(gtk.Widget).addCssClass("playpen-dialog-actions"); // "Close" rather than "OK": nothing here is pending, so there is nothing // for a confirm button to confirm. const close_button = gtk.Button.newWithLabel("Close"); _ = gtk.Button.signals.clicked.connect(close_button, *SettingsDialog, &onClose, self, .{}); buttons.append(close_button.as(gtk.Widget)); const outer = gtk.Box.new(.vertical, 0); outer.append(scroller.as(gtk.Widget)); outer.append(buttons.as(gtk.Widget)); self.window.setChild(outer.as(gtk.Widget)); _ = gtk.Widget.signals.destroy.connect( self.window, *SettingsDialog, &onDestroy, self, .{}, ); open = self; self.window.present(); } /// Close the page, if it is open. /// /// Called as the window is destroyed: the page holds the window's layout store /// and a pointer back to the window, both of which are about to be freed. pub fn close() void { if (open) |existing| existing.window.destroy(); } // ------------------------------------------------------------------------- // Appearance /// The Appearance section: the color scheme, and the palette it resolves to. fn buildAppearance(self: *SettingsDialog) *gtk.Widget { const group = gtk.Box.new(.vertical, 10); group.as(gtk.Widget).addCssClass("playpen-settings-group"); const title = gtk.Label.new("Appearance"); title.setXalign(0); title.as(gtk.Widget).addCssClass("playpen-settings-title"); group.append(title.as(gtk.Widget)); const row = gtk.Box.new(.horizontal, 12); const labels = gtk.Box.new(.vertical, 2); labels.as(gtk.Widget).setHexpand(1); labels.as(gtk.Widget).setValign(.center); const name = gtk.Label.new("Theme"); name.setXalign(0); name.as(gtk.Widget).addCssClass("playpen-dialog-label"); labels.append(name.as(gtk.Widget)); const hint_label = gtk.Label.new("System follows the desktop's light or dark setting."); hint_label.setXalign(0); hint_label.setWrap(1); hint_label.as(gtk.Widget).addCssClass("playpen-dialog-sublabel"); labels.append(hint_label.as(gtk.Widget)); row.append(labels.as(gtk.Widget)); row.append(self.buildThemeChoice()); group.append(row.as(gtk.Widget)); group.append(self.colors.widget()); return group.as(gtk.Widget); } /// The palette editor's half of the error label the startup list below already /// reports through, so a settings file that can't be written says so in one /// place however the edit arrived. fn onEditorReport(ctx: ?*anyopaque, message: ?[:0]const u8) void { const self: *SettingsDialog = @ptrCast(@alignCast(ctx.?)); self.showError(message); } /// The scheme picker: one toggle per choice, drawn as a single linked control. /// /// They are not put in a GTK radio group. Grouped toggles can't be unchecked by /// clicking the active one, which is the behavior we want, but the group also /// emits `toggled` twice per change — once off, once on — and the off half /// would apply whichever scheme happened to be next in the list. Handling the /// exclusivity here instead makes each click exactly one decision. fn buildThemeChoice(self: *SettingsDialog) *gtk.Widget { const box = gtk.Box.new(.horizontal, 0); box.as(gtk.Widget).addCssClass("linked"); box.as(gtk.Widget).addCssClass("playpen-settings-choice"); box.as(gtk.Widget).setValign(.center); const current = appearance.currentTheme(); for (std.enums.values(Settings.Theme), 0..) |value, i| { const button = gtk.ToggleButton.newWithLabel(value.label()); button.setActive(@intFromBool(value == current)); _ = gtk.ToggleButton.signals.toggled.connect( button, *SettingsDialog, &onThemeToggled, self, .{}, ); box.append(button.as(gtk.Widget)); self.theme_buttons[i] = button; } return box.as(gtk.Widget); } fn onThemeToggled(button: *gtk.ToggleButton, self: *SettingsDialog) callconv(.c) void { if (self.updating) return; const values = std.enums.values(Settings.Theme); const index = for (self.theme_buttons, 0..) |candidate, i| { if (candidate == button) break i; } else return; // Clicking the choice you are already on: put it back rather than leaving // the picker with nothing selected, which would show a scheme is in force // that no button claims. if (button.getActive() == 0) { self.updating = true; defer self.updating = false; button.setActive(1); return; } self.updating = true; defer self.updating = false; for (self.theme_buttons, 0..) |other, i| { if (i != index) other.setActive(0); } appearance.setTheme(values[index]); // The palette editor edits whichever scheme is on screen, and that is what // just changed: its swatches now stand for the other palette's colors. self.colors.refresh(); } // ------------------------------------------------------------------------- // Quitting /// The Notifications section: whether a finish reaches the desktop. /// /// The whole-app half of the mute. The per-tab half lives in each row's menu, /// because it is a thing you decide about one tab while looking at it, and /// walking to a settings window to say "not this one, for the next hour" would /// mean nobody ever did. fn buildNotifications(self: *SettingsDialog) *gtk.Widget { const group = gtk.Box.new(.vertical, 10); group.as(gtk.Widget).addCssClass("playpen-settings-group"); const title = gtk.Label.new("Notifications"); title.setXalign(0); title.as(gtk.Widget).addCssClass("playpen-settings-title"); group.append(title.as(gtk.Widget)); const row = gtk.Box.new(.horizontal, 12); const labels = gtk.Box.new(.vertical, 2); labels.as(gtk.Widget).setHexpand(1); labels.as(gtk.Widget).setValign(.center); const name = gtk.Label.new("Tell me when a pane finishes"); name.setXalign(0); name.as(gtk.Widget).addCssClass("playpen-dialog-label"); labels.append(name.as(gtk.Widget)); const hint_label = gtk.Label.new( "A desktop notification when something running in a tab goes quiet, " ++ "for the tabs you aren't looking at. Off, the sidebar dots are the " ++ "only word you get. One tab at a time can be muted from its row menu.", ); hint_label.setXalign(0); hint_label.setWrap(1); hint_label.as(gtk.Widget).addCssClass("playpen-dialog-sublabel"); labels.append(hint_label.as(gtk.Widget)); row.append(labels.as(gtk.Widget)); self.notifications.setActive(@intFromBool(Settings.get().notifications)); self.notifications.as(gtk.Widget).setValign(.center); _ = gobject.Object.signals.notify.connect( self.notifications, *SettingsDialog, &onNotificationsChanged, self, .{ .detail = "active" }, ); row.append(self.notifications.as(gtk.Widget)); group.append(row.as(gtk.Widget)); return group.as(gtk.Widget); } /// Straight to the file, for the same reason `onConfirmQuitChanged` is. fn onNotificationsChanged( toggle: *gtk.Switch, _: *gobject.ParamSpec, self: *SettingsDialog, ) callconv(.c) void { Settings.get().notifications = toggle.getActive() != 0; Settings.get().save() catch { self.showError("Could not write the settings file."); return; }; self.showError(null); } /// The Quitting section: whether closing the window asks first. /// /// Above the startup list rather than below it. That list grows with the number /// of tabs someone opens at launch, and a single-row group underneath it would /// be reachable only by scrolling past every one of them. fn buildQuitting(self: *SettingsDialog) *gtk.Widget { const group = gtk.Box.new(.vertical, 10); group.as(gtk.Widget).addCssClass("playpen-settings-group"); const title = gtk.Label.new("Quitting"); title.setXalign(0); title.as(gtk.Widget).addCssClass("playpen-settings-title"); group.append(title.as(gtk.Widget)); const row = gtk.Box.new(.horizontal, 12); const labels = gtk.Box.new(.vertical, 2); labels.as(gtk.Widget).setHexpand(1); labels.as(gtk.Widget).setValign(.center); const name = gtk.Label.new("Confirm before closing the window"); name.setXalign(0); name.as(gtk.Widget).addCssClass("playpen-dialog-label"); labels.append(name.as(gtk.Widget)); const hint_label = gtk.Label.new( "Closing the window exits every shell in every tab. Off, the window " ++ "goes the moment the window manager says so.", ); hint_label.setXalign(0); hint_label.setWrap(1); hint_label.as(gtk.Widget).addCssClass("playpen-dialog-sublabel"); labels.append(hint_label.as(gtk.Widget)); row.append(labels.as(gtk.Widget)); self.confirm_quit.setActive(@intFromBool(Settings.get().confirm_quit)); self.confirm_quit.as(gtk.Widget).setValign(.center); _ = gobject.Object.signals.notify.connect( self.confirm_quit, *SettingsDialog, &onConfirmQuitChanged, self, .{ .detail = "active" }, ); row.append(self.confirm_quit.as(gtk.Widget)); group.append(row.as(gtk.Widget)); return group.as(gtk.Widget); } /// Straight to the file, as the theme picker does, rather than through /// `persist`: this is one flag, and `commit` would re-read every startup row to /// save it. fn onConfirmQuitChanged( toggle: *gtk.Switch, _: *gobject.ParamSpec, self: *SettingsDialog, ) callconv(.c) void { Settings.get().confirm_quit = toggle.getActive() != 0; Settings.get().save() catch { self.showError("Could not write the settings file."); return; }; self.showError(null); } // ------------------------------------------------------------------------- // Startup // // One row per tab to open at launch: a layout to open, a name and emoji to pin // on the tab, and a field for each of that layout's parameters. This is the // answer to "put my window back the way I had it" — not by remembering a // session, but by writing down the handful of layouts and values that produced // it, which is a thing you can then read, edit and keep. // // "Use current tabs" is the shortcut for filling it in: arrange the window you // want, then say that this is the window you always want. fn buildStartup(self: *SettingsDialog) *gtk.Widget { const group = gtk.Box.new(.vertical, 10); group.as(gtk.Widget).addCssClass("playpen-settings-group"); const title = gtk.Label.new("Startup"); title.setXalign(0); title.as(gtk.Widget).addCssClass("playpen-settings-title"); group.append(title.as(gtk.Widget)); group.append(hint( "Tabs to open when Playpen starts, in order. " ++ "With none listed it opens a single shell.", )); group.append(self.startup_box.as(gtk.Widget)); self.fillStartupRows(); const actions = gtk.Box.new(.horizontal, 8); const add = gtk.Button.newWithLabel("Add tab"); add.as(gtk.Widget).addCssClass("flat"); _ = gtk.Button.signals.clicked.connect(add, *SettingsDialog, &onAddStartup, self, .{}); actions.append(add.as(gtk.Widget)); const capture = gtk.Button.newWithLabel("Use current tabs"); capture.as(gtk.Widget).addCssClass("flat"); capture.as(gtk.Widget).setTooltipText( "Replace this list with the tabs open now, and what each was opened with", ); _ = gtk.Button.signals.clicked.connect(capture, *SettingsDialog, &onCapture, self, .{}); actions.append(capture.as(gtk.Widget)); group.append(actions.as(gtk.Widget)); return group.as(gtk.Widget); } /// Build the rows from the settings, discarding whatever is there now. /// /// The rows are a view of the saved list rather than a model of their own, so /// anything that rewrites the list wholesale — reordering, capturing the open /// tabs — saves and then comes back through here. fn fillStartupRows(self: *SettingsDialog) void { self.updating = true; defer self.updating = false; self.clearStartupRows(); for (Settings.get().startup) |entry| { self.addStartupRow(entry) catch |err| { std.log.err("could not show a startup tab: {s}", .{@errorName(err)}); return; }; } } fn clearStartupRows(self: *SettingsDialog) void { for (self.startup.items) |row| { self.startup_box.remove(row.box.as(gtk.Widget)); self.freeRow(row); } self.startup.clearRetainingCapacity(); } fn freeRow(self: *SettingsDialog, row: *StartupRow) void { self.freeParams(row); row.params.deinit(self.alloc); self.alloc.destroy(row); } fn addStartupRow(self: *SettingsDialog, entry: Settings.StartupTab) !void { const row = try self.alloc.create(StartupRow); errdefer self.alloc.destroy(row); row.* = .{ .dialog = self, .box = gtk.Box.new(.vertical, 4), .dropdown = self.newLayoutPicker(entry.layout), .name = gtk.Entry.new(), .emoji = gtk.Entry.new(), .params_box = gtk.Box.new(.vertical, 4), }; row.box.as(gtk.Widget).addCssClass("playpen-startup-row"); // ---- header line --------------------------------------------------- const header = gtk.Box.new(.horizontal, 6); row.dropdown.as(gtk.Widget).setTooltipText("Which saved layout this tab opens"); _ = gobject.Object.signals.notify.connect( row.dropdown, *StartupRow, &onLayoutPicked, row, .{ .detail = "selected" }, ); header.append(row.dropdown.as(gtk.Widget)); row.name.setPlaceholderText("tab name (optional)"); row.name.as(gtk.Widget).setHexpand(1); setEntryText(row.name, entry.name); _ = gtk.Editable.signals.changed.connect(row.name, *StartupRow, &onRowEdited, row, .{}); header.append(row.name.as(gtk.Widget)); // Narrow, because it holds one glyph. Pasted rather than picked: the picker // is two thousand glyphs deep and belongs to a tab, not to a list of them. row.emoji.setPlaceholderText("🙂"); row.emoji.as(gtk.Editable).setMaxWidthChars(3); row.emoji.as(gtk.Widget).setTooltipText("Emoji for the tab's row — paste one"); setEntryText(row.emoji, entry.emoji); _ = gtk.Editable.signals.changed.connect(row.emoji, *StartupRow, &onRowEdited, row, .{}); header.append(row.emoji.as(gtk.Widget)); header.append(rowButton("go-up-symbolic", "Open this tab earlier", &onMoveUp, row)); header.append(rowButton("go-down-symbolic", "Open this tab later", &onMoveDown, row)); header.append(rowButton("list-remove-symbolic", "Don't open this tab", &onRemoveStartup, row)); row.box.append(header.as(gtk.Widget)); // ---- parameters ---------------------------------------------------- row.params_box.as(gtk.Widget).addCssClass("playpen-startup-params"); row.box.append(row.params_box.as(gtk.Widget)); try self.startup.append(self.alloc, row); self.startup_box.append(row.box.as(gtk.Widget)); try self.fillParams(row, entry.parameters); } fn rowButton( icon: [:0]const u8, tooltip: [:0]const u8, handler: *const fn (*gtk.Button, *StartupRow) callconv(.c) void, row: *StartupRow, ) *gtk.Widget { const button = gtk.Button.newFromIconName(icon); button.as(gtk.Widget).addCssClass("flat"); button.as(gtk.Widget).setTooltipText(tooltip); _ = gtk.Button.signals.clicked.connect(button, *StartupRow, handler, row, .{}); return button.as(gtk.Widget); } /// A picker holding "Plain terminal", then every saved layout. /// /// A layout named by the settings but no longer saved is appended so that it can /// still be selected. Without that, opening this page would quietly re-file the /// entry as a plain terminal the moment anything else was edited — a renamed /// layout would cost you the values you had typed for it, which is precisely /// when you would want them back. fn newLayoutPicker(self: *SettingsDialog, selected: []const u8) *gtk.DropDown { const model = gtk.StringList.new(null); model.append(plain_label); // Counted as they go in rather than taken from the layout list's length: a // name too long to print is skipped, and an index derived from the count // would then point at the wrong layout for every row after it. var count: u32 = 1; var index: u32 = 0; for (self.layouts.items.items) |layout| { var buf: [256]u8 = undefined; const name = std.fmt.bufPrintZ(&buf, "{s}", .{layout.name}) catch continue; model.append(name.ptr); if (std.mem.eql(u8, layout.name, selected)) index = count; count += 1; } if (selected.len > 0 and index == 0) { var buf: [256]u8 = undefined; if (std.fmt.bufPrintZ(&buf, "{s}", .{selected})) |name| { model.append(name.ptr); index = count; } else |_| {} } const dropdown = gtk.DropDown.new(model.as(gio.ListModel), null); dropdown.setSelected(index); return dropdown; } /// The layout a row is set to, or an empty string for a plain terminal. /// /// Read back out of the picker's own model rather than by indexing a list of /// names kept alongside it: the model is the one thing guaranteed to still agree /// with what is on screen, including the entry for a layout that has since been /// renamed away. fn selectedLayout(row: *StartupRow) []const u8 { if (row.dropdown.getSelected() == 0) return ""; const item = row.dropdown.getSelectedItem() orelse return ""; const string = gobject.ext.cast(gtk.StringObject, item) orelse return ""; return std.mem.span(string.getString()); } // ------------------------------------------------------------------------- // Parameter fields /// One entry per parameter of the row's layout, prefilled with the value the /// entry gives it, or with the parameter's own default when it gives none — /// which is the same rule the tab itself opens under. fn fillParams( self: *SettingsDialog, row: *StartupRow, values: []const Settings.Value, ) !void { const layout_name = selectedLayout(row); if (layout_name.len == 0) return; const layout = self.layouts.find(layout_name) orelse { row.params_box.append(hint("This layout is no longer saved.")); return; }; if (layout.parameters.len == 0) return; const grid = gtk.Grid.new(); grid.setRowSpacing(4); grid.setColumnSpacing(8); for (layout.parameters, 0..) |param, i| { const field = try self.alloc.create(ParamField); errdefer self.alloc.destroy(field); var hint_buf: [128]u8 = undefined; const placeholder: ?[*:0]const u8 = std.fmt.bufPrintZ(&hint_buf, "{{{{{s}}}}}", .{param.name}) catch null; const value = valueOf(values, param.name) orelse param.default; // The same two shapes the layout prompt offers, for the same reason: a // startup entry names the same directories, and picking one out of a // chooser here is worth as much as it is there. const entry, const widget = switch (param.type) { .string => blk: { const e = gtk.Entry.new(); e.setPlaceholderText(placeholder); e.as(gtk.Widget).setHexpand(1); setEntryText(e, value); break :blk .{ e, e.as(gtk.Widget) }; }, .directory => blk: { const path = try PathEntry.create(self.alloc); path.setPlaceholderText(placeholder); path.setText(value); break :blk .{ path.entry, path.widget() }; }, }; field.* = .{ .name = try self.alloc.dupe(u8, param.name), .entry = entry, }; errdefer self.alloc.free(field.name); var label_buf: [128]u8 = undefined; const text = std.fmt.bufPrintZ(&label_buf, "{s}", .{ if (param.description.len > 0) param.description else param.name, }) catch "parameter"; const label = gtk.Label.new(text); label.setXalign(0); label.as(gtk.Widget).addCssClass("playpen-dialog-sublabel"); grid.attach(label.as(gtk.Widget), 0, @intCast(i), 1, 1); _ = gtk.Editable.signals.changed.connect( field.entry, *StartupRow, &onRowEdited, row, .{}, ); grid.attach(widget, 1, @intCast(i), 1, 1); try row.params.append(self.alloc, field); } row.params_box.append(grid.as(gtk.Widget)); } fn freeParams(self: *SettingsDialog, row: *StartupRow) void { for (row.params.items) |field| { self.alloc.free(field.name); self.alloc.destroy(field); } row.params.clearRetainingCapacity(); } fn valueOf(values: []const Settings.Value, name: []const u8) ?[]const u8 { for (values) |v| { if (std.mem.eql(u8, v.name, name)) return v.value; } return null; } // ------------------------------------------------------------------------- // Startup handlers fn onAddStartup(_: *gtk.Button, self: *SettingsDialog) callconv(.c) void { { self.updating = true; defer self.updating = false; self.addStartupRow(.{}) catch |err| { std.log.err("could not add a startup tab: {s}", .{@errorName(err)}); return; }; } self.persist(); } fn onRemoveStartup(_: *gtk.Button, row: *StartupRow) callconv(.c) void { const self = row.dialog; for (self.startup.items, 0..) |candidate, i| { if (candidate == row) { _ = self.startup.orderedRemove(i); break; } } self.startup_box.remove(row.box.as(gtk.Widget)); self.freeRow(row); self.persist(); } fn onMoveUp(_: *gtk.Button, row: *StartupRow) callconv(.c) void { row.dialog.move(row, -1); } fn onMoveDown(_: *gtk.Button, row: *StartupRow) callconv(.c) void { row.dialog.move(row, 1); } /// Reorder by writing the new order out and rebuilding from it, rather than by /// shuffling widgets. The rows are already a view of the saved list, so this is /// the one path that can't leave the two disagreeing. fn move(self: *SettingsDialog, row: *StartupRow, delta: isize) void { const from = for (self.startup.items, 0..) |candidate, i| { if (candidate == row) break i; } else return; const to = @as(isize, @intCast(from)) + delta; if (to < 0 or to >= @as(isize, @intCast(self.startup.items.len))) return; std.mem.swap(*StartupRow, &self.startup.items[from], &self.startup.items[@intCast(to)]); self.persist(); self.fillStartupRows(); } fn onRowEdited(_: *gtk.Entry, row: *StartupRow) callconv(.c) void { // Typing is committed to memory but not to disk — see the note at the top // of the file on why the file is written on the structural edits instead. row.dialog.commit(); } /// The picked layout changed, so the parameter fields under it are no longer the /// right ones. They are rebuilt at the new layout's defaults rather than carried /// across by name: two layouts that happen to share a parameter name rarely mean /// the same thing by it, and a default is a better guess than a value typed for /// something else. fn onLayoutPicked(_: *gtk.DropDown, _: *gobject.ParamSpec, row: *StartupRow) callconv(.c) void { const self = row.dialog; if (self.updating) return; { self.updating = true; defer self.updating = false; self.freeParams(row); while (row.params_box.as(gtk.Widget).getFirstChild()) |child| { row.params_box.remove(child); } self.fillParams(row, &.{}) catch |err| { std.log.err("could not show a layout's parameters: {s}", .{@errorName(err)}); }; } self.persist(); } fn onCapture(_: *gtk.Button, self: *SettingsDialog) callconv(.c) void { // The window writes the settings itself, since only it knows what each tab // was opened with; this page then reflects whatever that produced. self.on_capture(self.ctx); self.fillStartupRows(); self.showError(null); } // ------------------------------------------------------------------------- // Committing /// Read the rows into the settings, in the order they are on screen. fn commit(self: *SettingsDialog) void { if (self.updating) return; var entries: std.ArrayListUnmanaged(Settings.StartupTab) = .empty; defer { for (entries.items) |entry| self.alloc.free(entry.parameters); entries.deinit(self.alloc); } for (self.startup.items) |row| { const values = self.alloc.alloc(Settings.Value, row.params.items.len) catch return; for (row.params.items, values) |field, *out| { out.* = .{ .name = field.name, .value = entryText(field.entry) }; } entries.append(self.alloc, .{ .layout = selectedLayout(row), .name = trimmed(entryText(row.name)), .emoji = trimmed(entryText(row.emoji)), .parameters = values, }) catch { self.alloc.free(values); return; }; } // Everything above borrows from the widgets, and `setStartup` copies, so // nothing outlives this call. Settings.get().setStartup(entries.items) catch |err| { std.log.err("could not update the startup tabs: {s}", .{@errorName(err)}); }; } /// Commit, then write the file. fn persist(self: *SettingsDialog) void { self.commit(); Settings.get().save() catch { self.showError("Could not write the settings file."); return; }; self.showError(null); } /// Write the file without reading the rows first. /// /// This is the closing path, and it cannot commit: `destroy` reaches us from /// `gtk_widget_dispose`, by which point the entries this page was built from /// have already been disposed and asking one for its text is a crash. It doesn't /// need to — every edit is committed to memory as it is made, so what is in the /// settings is already what was on screen. fn saveQuietly() void { Settings.get().save() catch { std.log.err("failed to save settings", .{}); }; } fn showError(self: *SettingsDialog, message: ?[:0]const u8) void { if (message) |text| { self.error_label.setText(text); self.error_label.as(gtk.Widget).setVisible(1); } else { self.error_label.as(gtk.Widget).setVisible(0); } } fn onClose(_: *gtk.Button, self: *SettingsDialog) callconv(.c) void { self.window.destroy(); } fn onDestroy(_: *gtk.Window, self: *SettingsDialog) callconv(.c) void { if (open == self) open = null; // The last write, catching whatever was typed since the previous one. saveQuietly(); for (self.startup.items) |row| self.freeRow(row); self.startup.deinit(self.alloc); self.colors.destroy(); self.alloc.destroy(self); } // ------------------------------------------------------------------------- // Small widget helpers fn hint(text: [:0]const u8) *gtk.Widget { const label = gtk.Label.new(text); label.setXalign(0); label.setWrap(1); label.as(gtk.Widget).addCssClass("playpen-dialog-hint"); return label.as(gtk.Widget); } fn setEntryText(entry: *gtk.Entry, text: []const u8) void { var buf: [4096]u8 = undefined; const z = std.fmt.bufPrintZ(&buf, "{s}", .{text}) catch return; entry.as(gtk.Editable).setText(z); } fn entryText(entry: *gtk.Entry) []const u8 { return std.mem.span(entry.as(gtk.Editable).getText()); } fn trimmed(text: []const u8) []const u8 { return std.mem.trim(u8, text, &std.ascii.whitespace); }