Add ability to set boot layout.
This commit is contained in:
@@ -4,7 +4,7 @@ A proof-of-concept workspace built on
|
||||
[libghostty-vt](https://github.com/ghostty-org/ghostty) and GTK4/libadwaita:
|
||||
**vertical tabs**, each holding a split tree of terminal and web panes, with
|
||||
**saved layouts** that open a whole arrangement — directories, scripts and all —
|
||||
in one go.
|
||||
in one go, and a **startup list** that opens the ones you always want.
|
||||
|
||||
The sidebar holds the window controls, a new-tab button, one row per tab, and a
|
||||
settings gear at its foot — the layout Zen Browser uses for vertical tabs —
|
||||
@@ -124,8 +124,8 @@ Pty.zig openpt/fork/exec, controlling terminal setup
|
||||
script.zig runs a command for `$(...)` in a layout's directory
|
||||
key.zig GDK keyval -> libghostty-vt key mapping
|
||||
theme.zig colors libghostty-vt has no opinion about, per scheme
|
||||
Settings.zig preferences: model, JSON on disk
|
||||
SettingsDialog.zig the settings page
|
||||
Settings.zig preferences: theme and startup tabs, JSON on disk
|
||||
SettingsDialog.zig the settings page, including the startup list editor
|
||||
TabSettingsDialog.zig one tab's own settings, and the emoji picker
|
||||
emoji.zig generated: every emoji the picker offers, and the search over them
|
||||
```
|
||||
@@ -288,6 +288,55 @@ A node is a split if it has a `split` key and a leaf otherwise. Saves are
|
||||
atomic — written to a temporary and renamed — so an interrupted write leaves
|
||||
the previous layouts intact rather than a file that won't parse.
|
||||
|
||||
## Startup tabs
|
||||
|
||||
Opening the same three layouts against the same three worktrees every morning is
|
||||
the kind of thing the app should be doing for you. **Settings → Startup** is a
|
||||
list of tabs to open at launch: one row per tab, each naming a saved layout, the
|
||||
values to fill its parameters in with, and optionally a name and an emoji to pin
|
||||
on the row.
|
||||
|
||||
The fastest way to fill it in is *Use current tabs*: arrange the window you want,
|
||||
then say that this is the window you always want. It writes down one entry per
|
||||
open tab, in sidebar order, with whatever each was opened with.
|
||||
|
||||
What is saved is the **recipe, not the session**. An entry is the layout's name
|
||||
and a handful of values, so it is a few lines you can read, edit and keep — and
|
||||
the layout itself stays the thing you maintain. Nothing here restores a
|
||||
scrollback, a running command, or a shell that has since wandered into another
|
||||
directory; launching re-runs the arrangement, and that was the tedious part.
|
||||
|
||||
Rows in the list can be reordered, and the first one is the tab you land in. An
|
||||
entry with no layout picked opens a plain shell, which is worth having — a
|
||||
startup list is often two configured tabs and one ordinary shell to work in. An
|
||||
entry naming a layout that has since been renamed or deleted is skipped with a
|
||||
warning rather than silently costing you a tab, and the settings page keeps
|
||||
showing it — with *This layout is no longer saved* under it — so the values you
|
||||
typed for it survive until you deal with it.
|
||||
|
||||
Parameters that the entry doesn't mention open at the layout's own default, which
|
||||
is the same rule the parameter prompt follows. So an entry only has to carry the
|
||||
values that differ from what the layout already suggests.
|
||||
|
||||
The list lives in `settings.json` beside the theme:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"theme": "dark",
|
||||
"startup": [
|
||||
{ "layout": "Project", "name": "signal", "emoji": "🚀",
|
||||
"parameters": { "path": "~/src/signal" } },
|
||||
{ "layout": "Project", "parameters": { "path": "~/src/playpen" } },
|
||||
{ "name": "scratch" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
With no `startup` list the app opens a single shell, exactly as it did before
|
||||
this existed — and if a list opens nothing at all, that is what you get too,
|
||||
since a window you can't type in is not an outcome worth being faithful for.
|
||||
|
||||
Two design choices worth calling out:
|
||||
|
||||
**No IO thread.** The PTY is read on the GLib main loop through a unix fd
|
||||
@@ -324,6 +373,9 @@ in principle, but a terminal grid is small.
|
||||
- **Saved layouts**: whole tabs — panes, splits, ratios, per-pane directories
|
||||
and scripts — opened in one go, parameterised by `{{name}}`, authored by
|
||||
arranging a tab and saving it. See [Layouts](#layouts)
|
||||
- **A startup list**: the tabs to open at launch, each a layout with its
|
||||
parameters filled in, fillable from the tabs you have open now. See
|
||||
[Startup tabs](#startup-tabs)
|
||||
- **Pane status in the tab strip**, driven by OSC 9;4, so a tab can say whether
|
||||
it is working, waiting on you, or finished and still unanswered. See
|
||||
[Agent status](#agent-status)
|
||||
@@ -566,16 +618,18 @@ the tab simply keeps the name it had.
|
||||
|
||||
Deep navy surfaces with Signal's ultramarine (`#3a76f0`) as the accent, in a
|
||||
light and a dark scheme. `Ctrl+,` or the gear at the foot of the sidebar opens
|
||||
**Settings**, which currently holds one choice: light, dark, or **system**,
|
||||
which is the default and follows the desktop.
|
||||
**Settings**, whose Appearance section holds the choice: light, dark, or
|
||||
**system**, which is the default and follows the desktop.
|
||||
|
||||
The preference lives in `~/.config/playpen/settings.json` (or
|
||||
`$XDG_CONFIG_HOME`), beside `layouts.json`:
|
||||
`$XDG_CONFIG_HOME`), beside `layouts.json`, alongside the
|
||||
[startup tabs](#startup-tabs):
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"theme": "system"
|
||||
"theme": "system",
|
||||
"startup": []
|
||||
}
|
||||
```
|
||||
|
||||
@@ -632,8 +686,10 @@ This is a proof of concept, and the following are deliberately absent:
|
||||
nothing else: no bookmarks, history, downloads, devtools, or find-in-page,
|
||||
and each pane uses WebKit's default context, so nothing is persisted between
|
||||
runs. Links that ask for a new window are ignored rather than opening a pane.
|
||||
- **Restoring a session.** Layouts save arrangements you open deliberately;
|
||||
nothing restores the tabs you happened to have open when the app closed.
|
||||
- **Restoring a session.** [Startup tabs](#startup-tabs) reopen the
|
||||
arrangement you asked for, but nothing captures the state a session got itself
|
||||
into: no scrollback, no running commands, no directories a shell wandered off
|
||||
to. A tab comes back as its layout, not as you left it.
|
||||
- **Kitty graphics, hyperlinks, tab reordering.**
|
||||
- **Custom terminfo.** `TERM` is reported as `xterm-256color` rather than
|
||||
`ghostty`, since we don't install a terminfo entry.
|
||||
|
||||
+286
-38
@@ -1,18 +1,25 @@
|
||||
//! User preferences: the handful of choices that outlive a session.
|
||||
//! User preferences: the choices that outlive a session.
|
||||
//!
|
||||
//! A small JSON file next to `layouts.json`, rewritten whole every time
|
||||
//! something changes. There is one setting today — the colour scheme — but the
|
||||
//! file carries a version from the start, so adding the second one is not also
|
||||
//! the day we invent a format.
|
||||
//! something changes. It carries a version from the start, so adding the next
|
||||
//! setting is not also the day we invent a format.
|
||||
//!
|
||||
//! There are two settings, and they are very different shapes. The colour
|
||||
//! scheme is one word. The **startup tabs** are a list: which saved layout to
|
||||
//! open, what to fill its parameters in with, and what to call the tab — one
|
||||
//! entry per tab, opened in order at launch. That list is why this module holds
|
||||
//! an arena: every string in it is owned here, and replacing the list frees the
|
||||
//! previous one in a single stroke rather than tracking each field.
|
||||
//!
|
||||
//! Because saving rewrites the whole file, there is exactly one `Settings` for
|
||||
//! the process, reached through `get`. Two holders each convinced they knew
|
||||
//! what was in the file would take turns overwriting the other's half of it —
|
||||
//! which is precisely how a startup list would get eaten by a theme change.
|
||||
//!
|
||||
//! File access goes through GLib for the same reasons `Layouts` does: it knows
|
||||
//! the XDG config directory, and `g_file_set_contents` writes to a temporary
|
||||
//! and renames, so an interrupted save leaves the previous settings intact
|
||||
//! rather than a truncated file that won't parse.
|
||||
//!
|
||||
//! Unlike `Layouts` this holds no allocated strings — every field is a fixed
|
||||
//! scalar — so it needs no arena and can be copied freely. Parsing borrows a
|
||||
//! stack buffer for the duration of the call and gives it back.
|
||||
|
||||
const std = @import("std");
|
||||
const glib = @import("glib");
|
||||
@@ -25,11 +32,10 @@ pub const format_version = 1;
|
||||
|
||||
const max_path = 4096;
|
||||
|
||||
/// Ample for a file holding a version and a word, with enough headroom that a
|
||||
/// hand-edited one with comments or extra keys still parses. A settings file
|
||||
/// larger than this is not one we wrote, and defaults are the better answer
|
||||
/// than a partial read of it.
|
||||
const max_file_size = 16 * 1024;
|
||||
/// Ample for a version, a scheme, and a startup list of any length someone
|
||||
/// would sit through opening. A settings file larger than this is not one we
|
||||
/// wrote, and defaults are a better answer than a partial read of it.
|
||||
const max_file_size = 64 * 1024;
|
||||
|
||||
/// Which palette to paint the window with.
|
||||
///
|
||||
@@ -52,8 +58,77 @@ pub const Theme = enum {
|
||||
}
|
||||
};
|
||||
|
||||
/// One of a layout's parameters, and what a startup tab opens it with.
|
||||
pub const Value = struct {
|
||||
name: []const u8,
|
||||
value: []const u8,
|
||||
};
|
||||
|
||||
/// A tab to open at launch.
|
||||
///
|
||||
/// This is the answer to "restore my whole window" without storing a window's
|
||||
/// worth of live state. A tab that came from a layout is already fully
|
||||
/// described by the layout's name and the values it was opened with, and those
|
||||
/// are three short strings — so the thing that gets saved is the *recipe*, and
|
||||
/// launching re-runs it. A tab whose shells have wandered somewhere else since
|
||||
/// is not reproduced, and that is the trade: what comes back is the arrangement
|
||||
/// you set up, not the session you left.
|
||||
pub const StartupTab = struct {
|
||||
/// Saved layout to open, by name. Empty opens a plain shell — the same tab
|
||||
/// the new-tab button makes.
|
||||
layout: []const u8 = "",
|
||||
|
||||
/// Name pinned on the tab's row, as though it had been typed into the
|
||||
/// rename entry. Empty leaves the label following the panes.
|
||||
name: []const u8 = "",
|
||||
|
||||
/// Emoji shown in the row in place of the pane icon. Empty means none.
|
||||
emoji: []const u8 = "",
|
||||
|
||||
/// What to fill the layout's parameters in with. A parameter this doesn't
|
||||
/// name opens at its own default, so an entry only has to carry the values
|
||||
/// that differ from what the layout already suggests.
|
||||
parameters: []const Value = &.{},
|
||||
};
|
||||
|
||||
arena: std.heap.ArenaAllocator,
|
||||
|
||||
theme: Theme = .system,
|
||||
|
||||
/// The tabs to open at launch, in order. Empty means a single plain shell,
|
||||
/// which is what the app did before this setting existed.
|
||||
startup: []const StartupTab = &.{},
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// The process-wide instance
|
||||
|
||||
var instance: ?Settings = null;
|
||||
|
||||
/// Read the settings file into the process-wide store. Called once, before the
|
||||
/// first window is built.
|
||||
pub fn init(alloc: std.mem.Allocator) void {
|
||||
std.debug.assert(instance == null);
|
||||
instance = load(alloc);
|
||||
}
|
||||
|
||||
/// The settings this process is running with.
|
||||
pub fn get() *Settings {
|
||||
if (instance) |*self| return self;
|
||||
// `init` runs from the application's activate handler, which is before
|
||||
// anything that could ask.
|
||||
unreachable;
|
||||
}
|
||||
|
||||
/// Release the store. Safe to call without a matching `init`, so the shutdown
|
||||
/// path doesn't have to know whether the app got as far as activating.
|
||||
pub fn deinit() void {
|
||||
if (instance) |*self| self.arena.deinit();
|
||||
instance = null;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Loading
|
||||
|
||||
/// Read the settings file, falling back to defaults for anything missing.
|
||||
///
|
||||
/// Every failure below lands on the same behaviour — carry on with defaults —
|
||||
@@ -61,8 +136,8 @@ theme: Theme = .system,
|
||||
/// to report an error in, and refusing to start over an unreadable preferences
|
||||
/// file would be a worse outcome than ignoring it. A malformed file is logged
|
||||
/// rather than silently swallowed, since the next save overwrites it.
|
||||
pub fn load() Settings {
|
||||
var self: Settings = .{};
|
||||
fn load(alloc: std.mem.Allocator) Settings {
|
||||
var self: Settings = .{ .arena = .init(alloc) };
|
||||
|
||||
var path_buf: [max_path]u8 = undefined;
|
||||
const path = configPath(&path_buf) orelse return self;
|
||||
@@ -92,25 +167,27 @@ pub fn load() Settings {
|
||||
|
||||
self.parse(contents[0..length]) catch {
|
||||
std.log.warn("could not parse {s}; using defaults", .{path});
|
||||
return .{};
|
||||
// Whatever the failed parse allocated goes with the arena, and a
|
||||
// half-read startup list is worse than none: it would open some of the
|
||||
// tabs and then be written back as though that was all there was.
|
||||
_ = self.arena.reset(.free_all);
|
||||
return .{ .arena = self.arena };
|
||||
};
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
fn parse(self: *Settings, text: []const u8) !void {
|
||||
// The parse tree only has to outlive this function: every value read out
|
||||
// of it is copied into a scalar field, so a stack arena is enough and
|
||||
// nothing here needs to reach the caller's allocator.
|
||||
var buf: [max_file_size * 4]u8 = undefined;
|
||||
var fba: std.heap.FixedBufferAllocator = .init(&buf);
|
||||
const ParseError = error{ OutOfMemory, Malformed };
|
||||
|
||||
const parsed = try std.json.parseFromSliceLeaky(
|
||||
fn parse(self: *Settings, text: []const u8) ParseError!void {
|
||||
// Parsed into the arena and left there: the startup list points into this
|
||||
// tree's strings, so it has to outlive the call.
|
||||
const parsed = std.json.parseFromSliceLeaky(
|
||||
std.json.Value,
|
||||
fba.allocator(),
|
||||
self.arena.allocator(),
|
||||
text,
|
||||
.{},
|
||||
);
|
||||
) catch return error.Malformed;
|
||||
|
||||
const root = switch (parsed) {
|
||||
.object => |o| o,
|
||||
@@ -125,12 +202,137 @@ fn parse(self: *Settings, text: []const u8) !void {
|
||||
if (std.meta.stringToEnum(Theme, value.string)) |t| self.theme = t;
|
||||
}
|
||||
}
|
||||
|
||||
if (root.get("startup")) |value| try self.parseStartup(value);
|
||||
}
|
||||
|
||||
pub const SaveError = error{WriteFailed};
|
||||
/// Read the startup list.
|
||||
///
|
||||
/// Entries that make no sense are skipped rather than failing the file. The
|
||||
/// list is hand-editable — that is half the point of it being a plain array of
|
||||
/// short objects — and one mistyped entry should cost that tab, not the theme
|
||||
/// and every other tab along with it.
|
||||
fn parseStartup(self: *Settings, raw: std.json.Value) ParseError!void {
|
||||
const array = switch (raw) {
|
||||
.array => |a| a,
|
||||
else => {
|
||||
std.log.warn("settings: \"startup\" is not a list; ignoring it", .{});
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
||||
const alloc = self.arena.allocator();
|
||||
|
||||
var tabs: std.ArrayListUnmanaged(StartupTab) = .empty;
|
||||
try tabs.ensureTotalCapacity(alloc, array.items.len);
|
||||
|
||||
for (array.items) |item| {
|
||||
const obj = switch (item) {
|
||||
.object => |o| o,
|
||||
else => {
|
||||
std.log.warn("settings: skipping a startup entry that is not an object", .{});
|
||||
continue;
|
||||
},
|
||||
};
|
||||
|
||||
var tab: StartupTab = .{
|
||||
.layout = try self.dupeField(obj.get("layout")),
|
||||
.name = try self.dupeField(obj.get("name")),
|
||||
.emoji = try self.dupeField(obj.get("emoji")),
|
||||
};
|
||||
|
||||
if (obj.get("parameters")) |params_raw| {
|
||||
tab.parameters = try self.parseValues(params_raw);
|
||||
}
|
||||
|
||||
tabs.appendAssumeCapacity(tab);
|
||||
}
|
||||
|
||||
self.startup = try tabs.toOwnedSlice(alloc);
|
||||
}
|
||||
|
||||
/// `{"name": "value"}` — an object rather than an array of pairs, because a
|
||||
/// parameter can only be filled in once per tab and an object is the shape
|
||||
/// that says so.
|
||||
fn parseValues(self: *Settings, raw: std.json.Value) ParseError![]const Value {
|
||||
const obj = switch (raw) {
|
||||
.object => |o| o,
|
||||
else => {
|
||||
std.log.warn("settings: a startup entry's \"parameters\" is not an object", .{});
|
||||
return &.{};
|
||||
},
|
||||
};
|
||||
|
||||
const alloc = self.arena.allocator();
|
||||
var values: std.ArrayListUnmanaged(Value) = .empty;
|
||||
try values.ensureTotalCapacity(alloc, obj.count());
|
||||
|
||||
var it = obj.iterator();
|
||||
while (it.next()) |kv| {
|
||||
values.appendAssumeCapacity(.{
|
||||
.name = try alloc.dupe(u8, kv.key_ptr.*),
|
||||
.value = try self.dupeField(kv.value_ptr.*),
|
||||
});
|
||||
}
|
||||
|
||||
return values.toOwnedSlice(alloc);
|
||||
}
|
||||
|
||||
/// A string field, or an empty one for anything else — including a number or a
|
||||
/// null where a string was expected, which is a typo rather than a reason to
|
||||
/// throw the file away.
|
||||
fn dupeField(self: *Settings, raw: ?std.json.Value) ParseError![]const u8 {
|
||||
return switch (raw orelse return "") {
|
||||
.string => |s| try self.arena.allocator().dupe(u8, s),
|
||||
else => "",
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Editing
|
||||
|
||||
/// Replace the startup list with a copy of `entries`.
|
||||
///
|
||||
/// Copied into a fresh arena which then takes the old one's place, rather than
|
||||
/// resetting in place and copying into that. It costs one allocation and makes
|
||||
/// the obvious call — hand back a lightly edited `settings.startup` — safe,
|
||||
/// instead of freeing the strings being read from half way through.
|
||||
pub fn setStartup(self: *Settings, entries: []const StartupTab) error{OutOfMemory}!void {
|
||||
var next: std.heap.ArenaAllocator = .init(self.arena.child_allocator);
|
||||
errdefer next.deinit();
|
||||
|
||||
const alloc = next.allocator();
|
||||
|
||||
const tabs = try alloc.alloc(StartupTab, entries.len);
|
||||
for (entries, tabs) |from, *to| {
|
||||
const values = try alloc.alloc(Value, from.parameters.len);
|
||||
for (from.parameters, values) |v, *out| {
|
||||
out.* = .{
|
||||
.name = try alloc.dupe(u8, v.name),
|
||||
.value = try alloc.dupe(u8, v.value),
|
||||
};
|
||||
}
|
||||
|
||||
to.* = .{
|
||||
.layout = try alloc.dupe(u8, from.layout),
|
||||
.name = try alloc.dupe(u8, from.name),
|
||||
.emoji = try alloc.dupe(u8, from.emoji),
|
||||
.parameters = values,
|
||||
};
|
||||
}
|
||||
|
||||
self.arena.deinit();
|
||||
self.arena = next;
|
||||
self.startup = tabs;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Saving
|
||||
|
||||
pub const SaveError = error{ OutOfMemory, WriteFailed };
|
||||
|
||||
/// Write the whole file back out, creating the config directory if needed.
|
||||
pub fn save(self: Settings) SaveError!void {
|
||||
pub fn save(self: *Settings) SaveError!void {
|
||||
var dir_buf: [max_path]u8 = undefined;
|
||||
const dir = configDir(&dir_buf) orelse return error.WriteFailed;
|
||||
if (glib.mkdirWithParents(dir.ptr, 0o700) != 0) return error.WriteFailed;
|
||||
@@ -138,17 +340,8 @@ pub fn save(self: Settings) SaveError!void {
|
||||
var path_buf: [max_path]u8 = undefined;
|
||||
const path = configPath(&path_buf) orelse return error.WriteFailed;
|
||||
|
||||
// Written by hand rather than through the JSON emitter: this is a fixed
|
||||
// two-line document with no user-supplied strings in it, so there is
|
||||
// nothing here that needs escaping and nothing that needs an allocator.
|
||||
var text_buf: [256]u8 = undefined;
|
||||
const text = std.fmt.bufPrint(&text_buf,
|
||||
\\{{
|
||||
\\ "version": {d},
|
||||
\\ "theme": "{s}"
|
||||
\\}}
|
||||
\\
|
||||
, .{ format_version, @tagName(self.theme) }) catch return error.WriteFailed;
|
||||
const text = try self.serialize();
|
||||
defer self.arena.child_allocator.free(text);
|
||||
|
||||
var err: ?*glib.Error = null;
|
||||
if (glib.fileSetContents(path.ptr, text.ptr, @intCast(text.len), &err) == 0) {
|
||||
@@ -157,6 +350,61 @@ pub fn save(self: Settings) SaveError!void {
|
||||
}
|
||||
}
|
||||
|
||||
/// Through the JSON emitter rather than by hand, as this once was: the startup
|
||||
/// list carries names and paths the user typed, and those need escaping.
|
||||
fn serialize(self: *Settings) SaveError![]u8 {
|
||||
var out: std.Io.Writer.Allocating = .init(self.arena.child_allocator);
|
||||
errdefer out.deinit();
|
||||
|
||||
var json: std.json.Stringify = .{
|
||||
.writer = &out.writer,
|
||||
.options = .{ .whitespace = .indent_2 },
|
||||
};
|
||||
|
||||
try json.beginObject();
|
||||
try json.objectField("version");
|
||||
try json.write(format_version);
|
||||
try json.objectField("theme");
|
||||
try json.write(@tagName(self.theme));
|
||||
|
||||
// Written even when empty, so that the key is discoverable in a file
|
||||
// someone has opened to see what they can put in it.
|
||||
try json.objectField("startup");
|
||||
try json.beginArray();
|
||||
for (self.startup) |tab| {
|
||||
try json.beginObject();
|
||||
// Only what is set, so a hand-edited file stays readable.
|
||||
if (tab.layout.len > 0) {
|
||||
try json.objectField("layout");
|
||||
try json.write(tab.layout);
|
||||
}
|
||||
if (tab.name.len > 0) {
|
||||
try json.objectField("name");
|
||||
try json.write(tab.name);
|
||||
}
|
||||
if (tab.emoji.len > 0) {
|
||||
try json.objectField("emoji");
|
||||
try json.write(tab.emoji);
|
||||
}
|
||||
if (tab.parameters.len > 0) {
|
||||
try json.objectField("parameters");
|
||||
try json.beginObject();
|
||||
for (tab.parameters) |v| {
|
||||
try json.objectField(v.name);
|
||||
try json.write(v.value);
|
||||
}
|
||||
try json.endObject();
|
||||
}
|
||||
try json.endObject();
|
||||
}
|
||||
try json.endArray();
|
||||
try json.endObject();
|
||||
|
||||
// A trailing newline, so the file behaves in an editor.
|
||||
try out.writer.writeByte('\n');
|
||||
return out.toOwnedSlice();
|
||||
}
|
||||
|
||||
/// `$XDG_CONFIG_HOME/playpen/settings.json`, or
|
||||
/// `~/.config/playpen/settings.json` when that isn't set.
|
||||
fn configPath(buf: []u8) ?[:0]const u8 {
|
||||
|
||||
+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);
|
||||
}
|
||||
|
||||
+305
-16
@@ -18,11 +18,13 @@ const Layouts = @import("Layouts.zig");
|
||||
const OpenLayoutDialog = @import("OpenLayoutDialog.zig");
|
||||
const Pane = @import("Pane.zig");
|
||||
const SaveLayoutDialog = @import("SaveLayoutDialog.zig");
|
||||
const Settings = @import("Settings.zig");
|
||||
const SettingsDialog = @import("SettingsDialog.zig");
|
||||
const TabSettingsDialog = @import("TabSettingsDialog.zig");
|
||||
const Terminal = @import("Terminal.zig");
|
||||
const View = @import("View.zig");
|
||||
const appearance = @import("appearance.zig");
|
||||
const emoji = @import("emoji.zig");
|
||||
|
||||
const Window = @This();
|
||||
|
||||
@@ -69,6 +71,21 @@ layout_rows: std.ArrayListUnmanaged(*LayoutRow) = .empty,
|
||||
/// a row and a pane header show the same five states for the same reasons.
|
||||
const Attention = Pane.Attention;
|
||||
|
||||
/// Where a tab came from, when it came from a saved layout.
|
||||
///
|
||||
/// Kept so that "use these tabs at launch" in the settings page has something
|
||||
/// to write down. A live view can be captured as a *shape* — that is what "save
|
||||
/// tab as layout" does — but the shape is not what a startup entry wants; it
|
||||
/// wants the name of the layout and the values it was opened with, and those are
|
||||
/// only knowable at the moment of opening. So they are recorded then.
|
||||
///
|
||||
/// Every string is owned by the window's allocator, since the dialog the values
|
||||
/// were typed into is long gone by the time anyone asks.
|
||||
const Source = struct {
|
||||
layout: []u8,
|
||||
values: []Settings.Value,
|
||||
};
|
||||
|
||||
/// A single tab: a view of one or more panes, plus the sidebar row that
|
||||
/// selects it.
|
||||
const Tab = struct {
|
||||
@@ -96,6 +113,9 @@ const Tab = struct {
|
||||
/// Null means the label tracks the content, which is the default.
|
||||
custom_name: ?[]u8 = null,
|
||||
|
||||
/// The layout this tab was opened from, if any. Null for a plain shell.
|
||||
source: ?Source = null,
|
||||
|
||||
/// The row's right-click menu, parented to this tab's row.
|
||||
menu_popover: *gtk.Popover,
|
||||
|
||||
@@ -268,7 +288,7 @@ pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window {
|
||||
|
||||
appearance.onChanged(&onAppearanceChanged, self);
|
||||
|
||||
try self.newTab();
|
||||
try self.openStartupTabs();
|
||||
|
||||
return self;
|
||||
}
|
||||
@@ -714,25 +734,43 @@ fn onLayoutParameters(
|
||||
openLayout(self, layout, bindings);
|
||||
}
|
||||
|
||||
/// Open a layout in a new tab.
|
||||
/// Open a layout in a new tab and go to it.
|
||||
fn openLayout(self: *Window, layout: *Layouts.Layout, bindings: []const Layouts.Binding) void {
|
||||
const tab = self.newTabEmpty() catch |err| {
|
||||
std.log.err("failed to open tab: {s}", .{@errorName(err)});
|
||||
const tab = self.buildLayoutTab(layout, bindings) catch |err| {
|
||||
std.log.err("failed to open layout \"{s}\": {s}", .{ layout.name, @errorName(err) });
|
||||
return;
|
||||
};
|
||||
|
||||
self.select(tab);
|
||||
}
|
||||
|
||||
/// Build a tab holding `layout`, with `bindings` substituted into it.
|
||||
///
|
||||
/// The tab is left unselected. Opening one from the menu goes to it; the startup
|
||||
/// list opens several and then goes to the first, so which one you land in is
|
||||
/// the caller's decision rather than a side effect of building.
|
||||
fn buildLayoutTab(
|
||||
self: *Window,
|
||||
layout: *Layouts.Layout,
|
||||
bindings: []const Layouts.Binding,
|
||||
) !*Tab {
|
||||
const tab = try self.newTabEmpty();
|
||||
|
||||
tab.view.applyLayout(layout.root, bindings) catch |err| {
|
||||
std.log.err("failed to build layout: {s}", .{@errorName(err)});
|
||||
// A half-built view has no panes to work in and no shell to close,
|
||||
// so drop the tab rather than leave an empty one behind.
|
||||
// A half-built view has no panes to work in and no shell to close, so
|
||||
// drop the tab rather than leave an empty one behind. Discarded rather
|
||||
// than closed: closing the only tab takes the window with it, and at
|
||||
// startup this is reachable before there is another one.
|
||||
if (tab.view.panes.items.len == 0) {
|
||||
self.closeTab(tab);
|
||||
return;
|
||||
self.discardTab(tab);
|
||||
return err;
|
||||
}
|
||||
std.log.err("layout \"{s}\" only partly built: {s}", .{ layout.name, @errorName(err) });
|
||||
};
|
||||
|
||||
self.recordSource(tab, layout.name, bindings);
|
||||
self.refreshLabel(tab);
|
||||
self.select(tab);
|
||||
return tab;
|
||||
}
|
||||
|
||||
/// Edit a saved layout in place: its name, parameters and per-pane scripts.
|
||||
@@ -820,6 +858,227 @@ fn onReloadLayouts(_: *gtk.Button, self: *Window) callconv(.c) void {
|
||||
self.refreshLayoutMenu();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Startup tabs
|
||||
//
|
||||
// The window opens itself out of the settings' startup list: one tab per entry,
|
||||
// each naming a saved layout and the values to fill its parameters in with. It
|
||||
// is deliberately a list of recipes rather than a snapshot of a previous
|
||||
// session — three layouts against three worktrees is a thing you can *write
|
||||
// down*, and a window's worth of live shells is not. Nothing here restores a
|
||||
// scrollback or a running command; it re-runs the arrangement, which is the part
|
||||
// that was tedious to set up by hand every morning.
|
||||
//
|
||||
// A list that opens nothing at all still has to leave a window you can type in,
|
||||
// so a missing layout costs its tab and an empty list falls back to the plain
|
||||
// single-shell window the app opened with before this existed.
|
||||
|
||||
/// Open the tabs the settings ask for, or one plain shell when they ask for
|
||||
/// nothing.
|
||||
fn openStartupTabs(self: *Window) !void {
|
||||
for (Settings.get().startup) |entry| self.openStartupTab(entry);
|
||||
|
||||
if (self.tabs.items.len == 0) {
|
||||
try self.newTab();
|
||||
return;
|
||||
}
|
||||
|
||||
// The first, not the last: a startup list reads top to bottom, and the tab
|
||||
// you want to be looking at is the one you put at the top of it.
|
||||
self.select(self.tabs.items[0]);
|
||||
}
|
||||
|
||||
/// Open one entry. A failure is reported and skipped — the other tabs are still
|
||||
/// worth having, and a window that refused to open because the fourth of six
|
||||
/// layouts had been renamed would be a poor trade.
|
||||
fn openStartupTab(self: *Window, entry: Settings.StartupTab) void {
|
||||
const tab = self.buildStartupTab(entry) catch |err| {
|
||||
std.log.warn("could not open startup tab \"{s}\": {s}", .{
|
||||
if (entry.layout.len > 0) entry.layout else "shell",
|
||||
@errorName(err),
|
||||
});
|
||||
return;
|
||||
} orelse return;
|
||||
|
||||
self.applyStartupChrome(tab, entry);
|
||||
self.refreshLabel(tab);
|
||||
}
|
||||
|
||||
/// The tab for one entry, or null when it names a layout that no longer exists.
|
||||
fn buildStartupTab(self: *Window, entry: Settings.StartupTab) !?*Tab {
|
||||
// No layout named is the plain case, and worth supporting: a startup list
|
||||
// is often two configured tabs and one ordinary shell to work in.
|
||||
if (entry.layout.len == 0) {
|
||||
const tab = try self.newTabEmpty();
|
||||
errdefer self.discardTab(tab);
|
||||
try tab.view.addPane(.plain(.terminal));
|
||||
return tab;
|
||||
}
|
||||
|
||||
const layout = self.layouts.find(entry.layout) orelse {
|
||||
// Renamed or deleted since the list was written. Worth saying out loud:
|
||||
// the alternative is a window that is quietly one tab short.
|
||||
std.log.warn("startup: no layout named \"{s}\"", .{entry.layout});
|
||||
return null;
|
||||
};
|
||||
|
||||
const bindings = try self.startupBindings(layout, entry);
|
||||
defer self.alloc.free(bindings);
|
||||
|
||||
return try self.buildLayoutTab(layout, bindings);
|
||||
}
|
||||
|
||||
/// What to open a layout's parameters with: the values the entry names, then
|
||||
/// every declared parameter it doesn't name, at that parameter's own default.
|
||||
/// The entry's values come first, and `expand` takes the first match, so an
|
||||
/// entry always wins over a default.
|
||||
///
|
||||
/// A value for something the layout doesn't declare is kept rather than dropped.
|
||||
/// A script may refer to `{{anything}}` whether or not the layout declared it,
|
||||
/// and an entry that fills one in is far more likely to know something the
|
||||
/// declaration list has fallen behind on than to be wrong.
|
||||
///
|
||||
/// Every string here is borrowed — from the settings arena or the layouts arena,
|
||||
/// both of which outlive the tab — so only the slice itself is allocated.
|
||||
fn startupBindings(
|
||||
self: *Window,
|
||||
layout: *Layouts.Layout,
|
||||
entry: Settings.StartupTab,
|
||||
) ![]Layouts.Binding {
|
||||
var out: std.ArrayListUnmanaged(Layouts.Binding) = .empty;
|
||||
errdefer out.deinit(self.alloc);
|
||||
try out.ensureTotalCapacity(self.alloc, entry.parameters.len + layout.parameters.len);
|
||||
|
||||
for (entry.parameters) |v| {
|
||||
out.appendAssumeCapacity(.{ .name = v.name, .value = v.value });
|
||||
}
|
||||
|
||||
for (layout.parameters) |p| {
|
||||
if (namesValue(entry.parameters, p.name)) continue;
|
||||
out.appendAssumeCapacity(.{ .name = p.name, .value = p.default });
|
||||
}
|
||||
|
||||
return out.toOwnedSlice(self.alloc);
|
||||
}
|
||||
|
||||
fn namesValue(values: []const Settings.Value, name: []const u8) bool {
|
||||
for (values) |v| {
|
||||
if (std.mem.eql(u8, v.name, name)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// The name and emoji an entry pins on its tab, both behaving exactly as though
|
||||
/// they had been set by hand once it was open.
|
||||
fn applyStartupChrome(self: *Window, tab: *Tab, entry: Settings.StartupTab) void {
|
||||
if (entry.name.len > 0) {
|
||||
tab.custom_name = self.alloc.dupe(u8, entry.name) catch |err| blk: {
|
||||
std.log.warn("could not name startup tab: {s}", .{@errorName(err)});
|
||||
break :blk null;
|
||||
};
|
||||
}
|
||||
|
||||
// Resolved against the emoji table rather than copied, because a row holds
|
||||
// a pointer into that table and nothing else. A glyph that isn't in it is
|
||||
// a hand-edited file naming something this build can't draw.
|
||||
if (entry.emoji.len > 0) {
|
||||
tab.emoji = emoji.lookup(entry.emoji);
|
||||
if (tab.emoji == null) {
|
||||
std.log.warn("startup: \"{s}\" is not an emoji this build knows", .{entry.emoji});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remember what a tab was opened with, so the settings page can write it down
|
||||
/// later. Best effort: failing to record it costs the tab its place in a
|
||||
/// captured list, which is not a reason to refuse to open it.
|
||||
fn recordSource(
|
||||
self: *Window,
|
||||
tab: *Tab,
|
||||
layout_name: []const u8,
|
||||
bindings: []const Layouts.Binding,
|
||||
) void {
|
||||
self.freeSource(tab);
|
||||
tab.source = self.captureSource(layout_name, bindings) catch |err| {
|
||||
std.log.warn("could not record how a tab was opened: {s}", .{@errorName(err)});
|
||||
return;
|
||||
};
|
||||
}
|
||||
|
||||
fn captureSource(
|
||||
self: *Window,
|
||||
layout_name: []const u8,
|
||||
bindings: []const Layouts.Binding,
|
||||
) !Source {
|
||||
const layout = try self.alloc.dupe(u8, layout_name);
|
||||
errdefer self.alloc.free(layout);
|
||||
|
||||
var values: std.ArrayListUnmanaged(Settings.Value) = .empty;
|
||||
errdefer {
|
||||
for (values.items) |v| {
|
||||
self.alloc.free(v.name);
|
||||
self.alloc.free(v.value);
|
||||
}
|
||||
values.deinit(self.alloc);
|
||||
}
|
||||
|
||||
for (bindings) |b| {
|
||||
const name = try self.alloc.dupe(u8, b.name);
|
||||
errdefer self.alloc.free(name);
|
||||
const value = try self.alloc.dupe(u8, b.value);
|
||||
try values.append(self.alloc, .{ .name = name, .value = value });
|
||||
}
|
||||
|
||||
return .{ .layout = layout, .values = try values.toOwnedSlice(self.alloc) };
|
||||
}
|
||||
|
||||
fn freeSource(self: *Window, tab: *Tab) void {
|
||||
const source = tab.source orelse return;
|
||||
for (source.values) |v| {
|
||||
self.alloc.free(v.name);
|
||||
self.alloc.free(v.value);
|
||||
}
|
||||
self.alloc.free(source.values);
|
||||
self.alloc.free(source.layout);
|
||||
tab.source = null;
|
||||
}
|
||||
|
||||
/// Write the open tabs into the settings as the startup list, in sidebar order.
|
||||
///
|
||||
/// What each tab contributes is its recipe — the layout it was opened from and
|
||||
/// the values it was opened with — plus whatever name and emoji it is wearing. A
|
||||
/// tab opened as a plain shell contributes a plain shell. What is deliberately
|
||||
/// not captured is where the shells have wandered to since: that would be a
|
||||
/// snapshot with an expiry date, and the layout it came from is the thing the
|
||||
/// user actually maintains.
|
||||
fn captureStartupTabs(ctx: ?*anyopaque) void {
|
||||
const self: *Window = @ptrCast(@alignCast(ctx.?));
|
||||
|
||||
var entries: std.ArrayListUnmanaged(Settings.StartupTab) = .empty;
|
||||
defer entries.deinit(self.alloc);
|
||||
|
||||
for (self.tabs.items) |tab| {
|
||||
entries.append(self.alloc, .{
|
||||
.layout = if (tab.source) |s| s.layout else "",
|
||||
.name = tab.custom_name orelse "",
|
||||
.emoji = tab.emoji orelse "",
|
||||
.parameters = if (tab.source) |s| s.values else &.{},
|
||||
}) catch |err| {
|
||||
std.log.err("could not capture the open tabs: {s}", .{@errorName(err)});
|
||||
return;
|
||||
};
|
||||
}
|
||||
|
||||
const settings = Settings.get();
|
||||
settings.setStartup(entries.items) catch |err| {
|
||||
std.log.err("could not capture the open tabs: {s}", .{@errorName(err)});
|
||||
return;
|
||||
};
|
||||
settings.save() catch {
|
||||
std.log.err("failed to save settings", .{});
|
||||
};
|
||||
}
|
||||
|
||||
/// Make `tab` the visible one.
|
||||
fn select(self: *Window, tab: *Tab) void {
|
||||
self.updating = true;
|
||||
@@ -842,9 +1101,14 @@ fn indexOf(self: *Window, tab: *Tab) ?usize {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Close a tab, and the window along with it if it was the last one.
|
||||
fn closeTab(self: *Window, tab: *Tab) void {
|
||||
if (self.closing) return;
|
||||
/// Take a tab out of the window and free it, with no view about what should be
|
||||
/// selected next or whether anything is left.
|
||||
///
|
||||
/// `closeTab` is the one to reach for. This is the half of it the startup path
|
||||
/// needs, where a tab that couldn't be built has to go away without taking the
|
||||
/// window down with it — which closing the only tab would do, before there is
|
||||
/// another one to fall back to.
|
||||
fn discardTab(self: *Window, tab: *Tab) void {
|
||||
const index = self.indexOf(tab) orelse return;
|
||||
|
||||
self.stack.remove(tab.view.widget());
|
||||
@@ -861,8 +1125,24 @@ fn closeTab(self: *Window, tab: *Tab) void {
|
||||
_ = self.tabs.orderedRemove(index);
|
||||
|
||||
tab.view.destroy();
|
||||
self.releaseTab(tab);
|
||||
}
|
||||
|
||||
/// Free a tab's own allocations. The view is not one of them — it is destroyed
|
||||
/// by whoever took the tab out of the window, which on the teardown path is not
|
||||
/// the same code.
|
||||
fn releaseTab(self: *Window, tab: *Tab) void {
|
||||
if (tab.custom_name) |name| self.alloc.free(name);
|
||||
self.freeSource(tab);
|
||||
self.alloc.destroy(tab);
|
||||
}
|
||||
|
||||
/// Close a tab, and the window along with it if it was the last one.
|
||||
fn closeTab(self: *Window, tab: *Tab) void {
|
||||
if (self.closing) return;
|
||||
const index = self.indexOf(tab) orelse return;
|
||||
|
||||
self.discardTab(tab);
|
||||
|
||||
if (self.tabs.items.len == 0) {
|
||||
// Teardown of our own state happens in onDestroy.
|
||||
@@ -879,7 +1159,11 @@ fn closeTab(self: *Window, tab: *Tab) void {
|
||||
// Settings
|
||||
|
||||
fn openSettings(self: *Window) void {
|
||||
SettingsDialog.present(self.alloc, self.window.as(gtk.Window)) catch |err| {
|
||||
SettingsDialog.present(self.alloc, self.window.as(gtk.Window), .{
|
||||
.layouts = &self.layouts,
|
||||
.on_capture = &captureStartupTabs,
|
||||
.ctx = self,
|
||||
}) catch |err| {
|
||||
std.log.err("failed to open settings: {s}", .{@errorName(err)});
|
||||
};
|
||||
}
|
||||
@@ -1021,6 +1305,12 @@ fn onDestroy(_: *adw.ApplicationWindow, self: *Window) callconv(.c) void {
|
||||
// otherwise walk a tab list we are about to destroy.
|
||||
appearance.clearOnChanged();
|
||||
|
||||
// The settings page holds this window's layout store and a pointer back to
|
||||
// the window itself. It is modal, so you cannot close the window underneath
|
||||
// it by hand — but a shell exiting can take the last tab and so the window,
|
||||
// which makes this reachable.
|
||||
SettingsDialog.close();
|
||||
|
||||
// Each terminal owns a session, which owns a PTY and its child process.
|
||||
// Dropping them here reaps the children rather than orphaning them.
|
||||
for (self.tabs.items) |tab| {
|
||||
@@ -1028,8 +1318,7 @@ fn onDestroy(_: *adw.ApplicationWindow, self: *Window) callconv(.c) void {
|
||||
// window, so nothing else takes it down before the tab it points at.
|
||||
TabSettingsDialog.closeFor(tab);
|
||||
tab.view.destroy();
|
||||
if (tab.custom_name) |name| self.alloc.free(name);
|
||||
self.alloc.destroy(tab);
|
||||
self.releaseTab(tab);
|
||||
}
|
||||
self.tabs.deinit(self.alloc);
|
||||
|
||||
|
||||
+7
-7
@@ -39,16 +39,15 @@ const css_light = @embedFile("palette-light.css") ++ style;
|
||||
/// doesn't know are colour-dependent — the Cairo-drawn terminal grids.
|
||||
pub const Callback = *const fn (ctx: ?*anyopaque) void;
|
||||
|
||||
var settings: Settings = .{};
|
||||
var provider: ?*gtk.CssProvider = null;
|
||||
var on_changed: ?Callback = null;
|
||||
var on_changed_ctx: ?*anyopaque = null;
|
||||
|
||||
/// Load the saved preference, install the stylesheet, and start following the
|
||||
/// resolved scheme. Called once, before the first window is built.
|
||||
/// Install the stylesheet and start following the resolved scheme. Called once,
|
||||
/// before the first window is built, and after `Settings.init` — the preference
|
||||
/// is read from the process-wide settings rather than loaded again here, so that
|
||||
/// saving a theme can't overwrite the rest of the file.
|
||||
pub fn init() void {
|
||||
settings = .load();
|
||||
|
||||
if (gdk.Display.getDefault()) |display| {
|
||||
const css = gtk.CssProvider.new();
|
||||
provider = css;
|
||||
@@ -72,7 +71,7 @@ pub fn init() void {
|
||||
}
|
||||
|
||||
pub fn currentTheme() Settings.Theme {
|
||||
return settings.theme;
|
||||
return Settings.get().theme;
|
||||
}
|
||||
|
||||
/// Change the preference, persist it, and repaint.
|
||||
@@ -80,6 +79,7 @@ pub fn currentTheme() Settings.Theme {
|
||||
/// The write is best-effort: a settings file we can't write is worth a log
|
||||
/// line, but it is not a reason to refuse the change for this session.
|
||||
pub fn setTheme(to: Settings.Theme) void {
|
||||
const settings = Settings.get();
|
||||
if (settings.theme == to) return;
|
||||
settings.theme = to;
|
||||
|
||||
@@ -95,7 +95,7 @@ pub fn setTheme(to: Settings.Theme) void {
|
||||
/// before it returns — or later, when the desktop changes under a `system`
|
||||
/// preference.
|
||||
fn applyPreference() void {
|
||||
adw.StyleManager.getDefault().setColorScheme(switch (settings.theme) {
|
||||
adw.StyleManager.getDefault().setColorScheme(switch (Settings.get().theme) {
|
||||
.system => .default,
|
||||
.light => .force_light,
|
||||
.dark => .force_dark,
|
||||
|
||||
@@ -49,6 +49,21 @@ pub fn matches(emoji: Emoji, query: []const u8) bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
/// The table's own copy of `glyph`, or null if it isn't one of ours.
|
||||
///
|
||||
/// What comes back is static, so a caller can hold it for as long as it likes
|
||||
/// with nothing to free — which is what lets a glyph read out of a config file
|
||||
/// be handed to something that wants a pointer it can keep. A glyph the table
|
||||
/// doesn't have reads as no choice at all: the picker is where these come from,
|
||||
/// so anything else is a hand-edited file naming something this build can't
|
||||
/// draw at the size the row wants.
|
||||
pub fn lookup(glyph: []const u8) ?[:0]const u8 {
|
||||
for (table) |entry| {
|
||||
if (std.mem.eql(u8, entry.glyph, glyph)) return entry.glyph;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Every emoji the picker offers, in Unicode's order.
|
||||
pub const table = [_]Emoji{
|
||||
// ---- Smileys & Emotion -------------------------------------------
|
||||
|
||||
@@ -9,6 +9,7 @@ const std = @import("std");
|
||||
const adw = @import("adw");
|
||||
const gio = @import("gio");
|
||||
|
||||
const Settings = @import("Settings.zig");
|
||||
const Window = @import("Window.zig");
|
||||
const appearance = @import("appearance.zig");
|
||||
|
||||
@@ -23,6 +24,10 @@ var gpa: std.heap.DebugAllocator(.{}) = .init;
|
||||
pub fn main() u8 {
|
||||
defer _ = gpa.deinit();
|
||||
|
||||
// Before the allocator's own teardown, since the settings arena comes out
|
||||
// of it. A no-op if the app never got as far as activating.
|
||||
defer Settings.deinit();
|
||||
|
||||
// Non-unique so every launch is its own process. The default GApplication
|
||||
// behavior hands off to an already-running instance over D-Bus, which for
|
||||
// a terminal means a second launch silently does nothing visible here and
|
||||
@@ -37,6 +42,10 @@ pub fn main() u8 {
|
||||
}
|
||||
|
||||
fn onActivate(app: *adw.Application, _: ?*anyopaque) callconv(.c) void {
|
||||
// The file both of the next two read from: the scheme, and the tabs the
|
||||
// window opens itself with.
|
||||
Settings.init(gpa.allocator());
|
||||
|
||||
// Before the window, so that the first frame is drawn in the scheme the
|
||||
// user chose rather than repainted into it a moment later.
|
||||
appearance.init();
|
||||
|
||||
@@ -545,6 +545,25 @@ button.playpen-header-button:hover,
|
||||
border-color: @pp_accent_strong;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
The startup list
|
||||
|
||||
One card per tab the app opens itself with. The border is doing real work: a
|
||||
row's parameter fields sit *under* its header line, and without something
|
||||
drawing the boundary they read as belonging to the row below them instead. */
|
||||
|
||||
.playpen-startup-row {
|
||||
padding: 8px;
|
||||
border: 1px solid @pp_border;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* Indented under the header line, which is the other half of the same grouping:
|
||||
these fields are a property of the layout picked above them. */
|
||||
.playpen-startup-params {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
The emoji picker
|
||||
|
||||
|
||||
@@ -344,6 +344,21 @@ pub fn matches(emoji: Emoji, query: []const u8) bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
/// The table's own copy of `glyph`, or null if it isn't one of ours.
|
||||
///
|
||||
/// What comes back is static, so a caller can hold it for as long as it likes
|
||||
/// with nothing to free — which is what lets a glyph read out of a config file
|
||||
/// be handed to something that wants a pointer it can keep. A glyph the table
|
||||
/// doesn't have reads as no choice at all: the picker is where these come from,
|
||||
/// so anything else is a hand-edited file naming something this build can't
|
||||
/// draw at the size the row wants.
|
||||
pub fn lookup(glyph: []const u8) ?[:0]const u8 {
|
||||
for (table) |entry| {
|
||||
if (std.mem.eql(u8, entry.glyph, glyph)) return entry.glyph;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Every emoji the picker offers, in Unicode's order.
|
||||
pub const table = [_]Emoji{
|
||||
'''
|
||||
|
||||
Reference in New Issue
Block a user