// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! The configuration as each pane's foreground program sees it: evaluated //! again with `ctx.foreground` set, for a configuration that reads it, and //! kept, so that `fish` to `vim` and back is evaluated once for each. //! //! A pane holds the entry for its foreground program, and an entry no pane //! holds goes once there are more than `max_entries`, the least recently //! used first. Everything goes on a reload, or a change of appearance: //! what the configuration says may have changed for every one. //! //! What a pane's evaluation says of a field that is not a pane's is not //! applied, and is said once for each field until the next reload. const std = @import("std"); const Allocator = std.mem.Allocator; const struthio = @import("struthio"); const Config = @import("config/Config.zig"); const load = @import("config/load.zig"); const bindings_mod = @import("input/bindings.zig"); const foreground = @import("os/foreground.zig"); const scoped = std.log.scoped(.pane_configs); /// Quiet under test, where a warning fails the run, and what is said is /// what is tested. const log = if (@import("builtin").is_test) struct { fn warn(comptime _: []const u8, _: anytype) void {} fn err(comptime _: []const u8, _: anytype) void {} } else scoped; const PaneConfigs = @This(); pub const max_entries = 64; gpa: Allocator, entries: std.ArrayList(*Entry) = .empty, /// Foreground programs whose evaluation failed, said once each. failed: std.ArrayList(Config.Foreground) = .empty, /// The fields a pane's evaluation was found to give another value than /// the app's, said once each. Names from `Config` itself. warned: std.ArrayList([]const u8) = .empty, /// Counts uses, for least recently used. tick: u64 = 0, pub const Entry = struct { /// The foreground program it was evaluated for; its own strings. key: Config.Foreground, parsed: struthio.Parsed(Config), theme: Config.Theme, /// The bindings that are not global, defaults and all, as /// `bindings.load` makes them. bindings: []bindings_mod.Binding, /// Panes holding it. refs: u32 = 0, used: u64 = 0, pub fn config(e: *const Entry) *const Config { return &e.parsed.value; } }; pub fn init(gpa: Allocator) PaneConfigs { return .{ .gpa = gpa }; } pub fn deinit(pc: *PaneConfigs) void { pc.clear(); pc.entries.deinit(pc.gpa); pc.failed.deinit(pc.gpa); pc.warned.deinit(pc.gpa); } /// Lets go of every entry, and of what has been said: for a new /// configuration. No pane may still hold one. pub fn clear(pc: *PaneConfigs) void { for (pc.entries.items) |e| { std.debug.assert(e.refs == 0); pc.destroy(e); } pc.entries.clearRetainingCapacity(); for (pc.failed.items) |f| foreground.free(pc.gpa, f); pc.failed.clearRetainingCapacity(); pc.warned.clearRetainingCapacity(); } /// The entry for `fg`, held for a pane until `release`: one already made, /// or the configuration `loaded` evaluated again. Null when that fails, /// and then the pane has the app's configuration. pub fn acquire(pc: *PaneConfigs, loaded: *const load.Loaded, fg: Config.Foreground) ?*Entry { pc.tick += 1; for (pc.entries.items) |e| if (foreground.eql(e.key, fg)) { e.refs += 1; e.used = pc.tick; return e; }; for (pc.failed.items) |f| if (foreground.eql(f, fg)) return null; const e = pc.make(loaded, fg) catch |err| { switch (err) { error.OutOfMemory => log.err("the configuration for {s}: out of memory", .{fg.name}), error.Invalid => {}, } if (foreground.dupe(pc.gpa, fg)) |f| pc.failed.append(pc.gpa, f) catch foreground.free(pc.gpa, f) else |_| {} return null; }; pc.entries.append(pc.gpa, e) catch { pc.destroy(e); return null; }; e.refs = 1; e.used = pc.tick; pc.warnDiffering(loaded.config(), e.config()); pc.evict(); return e; } pub fn release(pc: *PaneConfigs, e: *Entry) void { _ = pc; std.debug.assert(e.refs > 0); e.refs -= 1; } fn make(pc: *PaneConfigs, loaded: *const load.Loaded, fg: Config.Foreground) error{ OutOfMemory, Invalid }!*Entry { var diag: struthio.Diagnostic = .{}; var parsed = loaded.evalFor(pc.gpa, fg, &diag) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => { log.warn("the configuration, for a pane running {s} as UID {d}: {f}", .{ fg.name, fg.uid, &diag }); return error.Invalid; }, }; errdefer parsed.deinit(); var field: []const u8 = ""; const theme = parsed.value.theme(&field) catch { log.warn("the configuration, for a pane running {s} as UID {d}: {s}: not a color", .{ fg.name, fg.uid, field }); return error.Invalid; }; const binds = try bindings_mod.load(pc.gpa, parsed.value.keybind); errdefer pc.gpa.free(binds); const key = try foreground.dupe(pc.gpa, fg); errdefer foreground.free(pc.gpa, key); const e = try pc.gpa.create(Entry); e.* = .{ .key = key, .parsed = parsed, .theme = theme, .bindings = binds }; return e; } fn destroy(pc: *PaneConfigs, e: *Entry) void { foreground.free(pc.gpa, e.key); e.parsed.deinit(); pc.gpa.free(e.bindings); pc.gpa.destroy(e); } /// Down to `max_entries`, if it can be: only entries no pane holds go. fn evict(pc: *PaneConfigs) void { while (pc.entries.items.len > max_entries) { var oldest: ?usize = null; for (pc.entries.items, 0..) |e, i| if (e.refs == 0) { if (oldest == null or e.used < pc.entries.items[oldest.?].used) oldest = i; }; const i = oldest orelse return; pc.destroy(pc.entries.swapRemove(i)); } } fn warnDiffering(pc: *PaneConfigs, app: *const Config, pane: *const Config) void { var buf: [64][]const u8 = undefined; for (Config.appFieldsDiffering(app, pane, &buf)) |name| { const said = for (pc.warned.items) |w| { if (std.mem.eql(u8, w, name)) break true; } else false; if (said) continue; log.warn("{s} depends on ctx.foreground, which only a pane's colors, bindings, bell, cursor, clipboard, links, notifications, drag and drop and images can; ignored", .{name}); pc.warned.append(pc.gpa, name) catch {}; } } const testing = std.testing; fn loadString(source: []const u8) !load.Loaded { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(testing.io, .{ .sub_path = "config.struthio", .data = source }); const path = try tmp.dir.realPathFileAlloc(testing.io, "config.struthio", testing.allocator); defer testing.allocator.free(path); var env: std.process.Environ.Map = .init(testing.allocator); defer env.deinit(); var problem: load.Problem = .{}; return load.load(testing.allocator, testing.io, &env, path, .{ .hostname = "test" }, &problem); } test "a pane's evaluation, by its foreground program, kept and shared" { var loaded = try loadString( \\.{ \\ .colors = .{ .background = if (ctx.foreground) |f| (if (f.uid == 0) "#3a0000" else "#000000") else "#1d1f21" }, \\ .keybind = if (ctx.foreground) |f| (if (f.name == "nvim") .{ .{ .keys = "ctrl+shift+c", .action = .unbind } } else .{}) else .{}, \\} ); defer loaded.deinit(); try testing.expect(loaded.readsForeground()); try testing.expectEqualStrings("#1d1f21", loaded.config().colors.background); var pc: PaneConfigs = .init(testing.allocator); defer pc.deinit(); const root = pc.acquire(&loaded, .{ .name = "sudo", .uid = 0 }).?; try testing.expectEqual(@as(u8, 0x3a), root.theme.background.r); const nvim = pc.acquire(&loaded, .{ .name = "nvim", .uid = 1000, .user = "jeff" }).?; try testing.expectEqual(@as(u8, 0), nvim.theme.background.r); try testing.expect(nvim.bindings.len != root.bindings.len); // The same program again is the same entry. const again = pc.acquire(&loaded, .{ .name = "sudo", .uid = 0 }).?; try testing.expectEqual(root, again); try testing.expectEqual(@as(u32, 2), root.refs); pc.release(root); pc.release(again); pc.release(nvim); } test "a configuration that never reads ctx.foreground says so" { var loaded = try loadString(".{ .colors = .{ .background = if (ctx.hostname == \"test\") \"#000000\" else \"#ffffff\" } }"); defer loaded.deinit(); try testing.expect(!loaded.readsForeground()); } test "an evaluation that fails is tried once" { var loaded = try loadString(".{ .colors = .{ .background = if (ctx.foreground) |f| f.user.? else \"#000000\" } }"); defer loaded.deinit(); var pc: PaneConfigs = .init(testing.allocator); defer pc.deinit(); try testing.expectEqual(null, pc.acquire(&loaded, .{ .name = "sh", .uid = 4242 })); try testing.expectEqual(null, pc.acquire(&loaded, .{ .name = "sh", .uid = 4242 })); try testing.expectEqual(@as(usize, 1), pc.failed.items.len); const ok = pc.acquire(&loaded, .{ .name = "sh", .uid = 1000, .user = "#123456" }).?; try testing.expectEqual(@as(u8, 0x12), ok.theme.background.r); pc.release(ok); } test "only entries no pane holds are let go" { var loaded = try loadString(".{ .colors = .{ .background = if (ctx.foreground != null) \"#000000\" else \"#ffffff\" } }"); defer loaded.deinit(); var pc: PaneConfigs = .init(testing.allocator); defer pc.deinit(); const held = pc.acquire(&loaded, .{ .name = "held", .uid = 1 }).?; var names: [max_entries + 4][8]u8 = undefined; for (&names, 0..) |*n, i| { const name = try std.fmt.bufPrint(n, "p{d}", .{i}); pc.release(pc.acquire(&loaded, .{ .name = name, .uid = 1 }).?); } try testing.expectEqual(@as(usize, max_entries), pc.entries.items.len); const kept = for (pc.entries.items) |e| { if (e == held) break true; } else false; try testing.expect(kept); pc.release(held); } test "a field that is not a pane's is not taken from one, and is said" { var loaded = try loadString(".{ .font = .{ .size = if (ctx.foreground != null) 20 else 12 } }"); defer loaded.deinit(); var pc: PaneConfigs = .init(testing.allocator); defer pc.deinit(); const e = pc.acquire(&loaded, .{ .name = "x", .uid = 1 }).?; defer pc.release(e); try testing.expectEqual(@as(usize, 1), pc.warned.items.len); try testing.expectEqualStrings("font", pc.warned.items[0]); }