Add ability to set boot layout.
This commit is contained in:
1 parent
6c7c3bfa63
commit
4e622b15af
9 files changed
+1330
-90
No files matched your search
+609
-20
@@ -9,36 +9,111 @@
|
||||
//! 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.
|
||||
//! 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.
|
||||
//!
|
||||
//! Only one 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.
|
||||
//! 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 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,
|
||||
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,
|
||||
|
||||
/// Set while a click is being applied, so that the resulting `toggled` signals
|
||||
/// on the other buttons in the group don't re-enter and undo it.
|
||||
/// 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) !void {
|
||||
pub fn present(alloc: std.mem.Allocator, parent: *gtk.Window, opts: Options) !void {
|
||||
if (open) |existing| {
|
||||
existing.window.present();
|
||||
return;
|
||||
@@ -50,31 +125,53 @@ pub fn present(alloc: std.mem.Allocator, parent: *gtk.Window) !void {
|
||||
self.* = .{
|
||||
.alloc = alloc,
|
||||
.window = gtk.Window.new(),
|
||||
.layouts = opts.layouts,
|
||||
.on_capture = opts.on_capture,
|
||||
.ctx = opts.ctx,
|
||||
.theme_buttons = undefined,
|
||||
.startup_box = gtk.Box.new(.vertical, 6),
|
||||
.error_label = gtk.Label.new(null),
|
||||
};
|
||||
|
||||
self.window.setTitle("Settings");
|
||||
self.window.setTransientFor(parent);
|
||||
self.window.setModal(1);
|
||||
self.window.setDefaultSize(460, -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.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 = gtk.Button.newWithLabel("Close");
|
||||
_ = gtk.Button.signals.clicked.connect(close, *SettingsDialog, &onClose, self, .{});
|
||||
buttons.append(close.as(gtk.Widget));
|
||||
const close_button = gtk.Button.newWithLabel("Close");
|
||||
_ = gtk.Button.signals.clicked.connect(close_button, *SettingsDialog, &onClose, self, .{});
|
||||
buttons.append(close_button.as(gtk.Widget));
|
||||
|
||||
content.append(buttons.as(gtk.Widget));
|
||||
self.window.setChild(content.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,
|
||||
@@ -88,8 +185,18 @@ pub fn present(alloc: std.mem.Allocator, parent: *gtk.Window) !void {
|
||||
self.window.present();
|
||||
}
|
||||
|
||||
/// The Appearance section. One group today; the box it returns is what a
|
||||
/// second section would sit next to.
|
||||
/// 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: one row, the colour scheme.
|
||||
fn buildAppearance(self: *SettingsDialog) *gtk.Widget {
|
||||
const group = gtk.Box.new(.vertical, 10);
|
||||
group.as(gtk.Widget).addCssClass("playpen-settings-group");
|
||||
@@ -110,11 +217,11 @@ fn buildAppearance(self: *SettingsDialog) *gtk.Widget {
|
||||
name.as(gtk.Widget).addCssClass("playpen-dialog-label");
|
||||
labels.append(name.as(gtk.Widget));
|
||||
|
||||
const hint = gtk.Label.new("System follows the desktop's light or dark setting.");
|
||||
hint.setXalign(0);
|
||||
hint.setWrap(1);
|
||||
hint.as(gtk.Widget).addCssClass("playpen-dialog-sublabel");
|
||||
labels.append(hint.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());
|
||||
@@ -187,11 +294,493 @@ fn onThemeToggled(button: *gtk.ToggleButton, self: *SettingsDialog) callconv(.c)
|
||||
appearance.setTheme(values[index]);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 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);
|
||||
|
||||
field.* = .{
|
||||
.name = try self.alloc.dupe(u8, param.name),
|
||||
.entry = gtk.Entry.new(),
|
||||
};
|
||||
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);
|
||||
|
||||
var hint_buf: [128]u8 = undefined;
|
||||
field.entry.setPlaceholderText(
|
||||
std.fmt.bufPrintZ(&hint_buf, "{{{{{s}}}}}", .{param.name}) catch null,
|
||||
);
|
||||
field.entry.as(gtk.Widget).setHexpand(1);
|
||||
setEntryText(field.entry, valueOf(values, param.name) orelse param.default);
|
||||
_ = gtk.Editable.signals.changed.connect(
|
||||
field.entry,
|
||||
*StartupRow,
|
||||
&onRowEdited,
|
||||
row,
|
||||
.{},
|
||||
);
|
||||
grid.attach(field.entry.as(gtk.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.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);
|
||||
}
|
||||
Reference in new issue
Block a user