Skip to content

Commit 0553499

Browse files
charliekclaude
andauthored
Move config to ~/.config/roost/config.conf with externalized keybindings (#7)
* Move config to ~/.config/roost/config.conf with externalized keybindings The hand-edited config file now lives at ~/.config/roost/config.conf (or $XDG_CONFIG_HOME/roost/config.conf) on both macOS and Linux. The SQLite database and IPC socket stay in their platform-native locations (~/Library/Application Support/Roost on macOS, $XDG_DATA_HOME on Linux). Keybindings move out of cmd/roost and into the config file using Ghostty's syntax: keybind = super+j = new_tab # add a second trigger keybind = super+t = unbind # disable a default keybind = super+t = close_tab # reassign Each keybind line is applied on top of the platform defaults; later lines override earlier ones for the same trigger (last-wins). Unknown actions and unparseable triggers are logged and skipped. Implementation notes - internal/config/paths.go: ConfigDir is now XDG-aware on darwin too; ConfigFile() is config.conf instead of config.toml. New LegacyMacConfigFile() points at the pre-cutover path so main.go can log a one-shot migration warning when only the legacy file exists. - internal/config/config.go: adds Keybinds []Keybind to Config and a keybind arm to the parser. parseKeybind splits on the inner =; errors include file:line. - cmd/roost/shortcuts.go (new): triggerToAccel converts Ghostty trigger syntax to a GTK accelerator string; resolveBindings is a pure function that layers user keybinds on top of defaults (unit-tested without a live widget tree). - cmd/roost/app.go: installShortcuts is rewritten as an action-table driven loop. The propagate-false path for clipboard actions is preserved (returning false in the gated callback so an editable widget keeps its native paste). - cmd/roost/main.go: drops the package-global font vars; cfg threads through NewApp -> NewSession. Breaking change: a pre-existing config.toml is not auto-migrated. Roost logs a warning at startup with the move command. The hard cutover is fine because Roost is early-stage. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * gofmt: realign cases-map values in shortcuts_test.go Caught by macOS lint job. The longer "super+bracketleft" entry was already padded to its column; the shorter entries needed to extend to match. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Address PR #7 review feedback: deterministic shortcut order + minor fixes - cmd/roost/app.go: sort the resolved triggers before installing shortcuts. When two distinct triggers in the user's config normalize to the same GTK accel (e.g. `ctrl+t` and `control+t`), Go's random map iteration would otherwise pick the winner non-deterministically. Sorting gives lexicographic last-wins. - cmd/roost/main.go: quote both paths in the legacy-config migration hint with %q so the macOS path (which contains spaces in "Library/Application Support/Roost") is copy-paste safe. - internal/config/paths_test.go: don't hard-code the default ~/.config layout in the macOS branch of TestResolve — a developer with XDG_CONFIG_HOME set would spuriously fail. Compare on basename instead. TestResolveConfigDirRespectsXDG already covers the env-var path explicitly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Validate user keybinds before they replace defaults Closes a UX issue CodeRabbit flagged on #7: with the previous flow, a typo in a user keybind action (e.g. `keybind = ctrl+t = typo`) would write the typo into the resolved trigger map, then get dropped in the install loop's handler-lookup, leaving `ctrl+t` unbound. The default `new_tab` binding was silently lost. Now installShortcuts seeds the resolved set with platform defaults only and merges each user keybind entry-by-entry, validating trigger and action before overwrite. Invalid entries log a warning and the default binding is preserved. `unbind` continues to work as before. resolveBindings stays purely structural — its existing test suite is unchanged and a clarified comment on the unknown-action test pins that contract for future callers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Canonicalize trigger aliases at the install layer Closes the third (and last) of CodeRabbit's PR #7 review threads: the install pipeline keyed its resolved map by literal trigger text, so `keybind = cmd+t = unbind` did NOT remove the macOS default seeded as `super+t` even though both normalize to `<Meta>t`. Same asymmetry for `cmd+t = close_tab` — it ended up alongside `super+t` instead of replacing it. Fix: extract canonicalizeBindings as a pure function that keys the resolved map by the GTK accel form (the output of triggerToAccel). Aliases collapse on insertion, unbind matches by accel, and the production install loop iterates already-canonical accels. The function bundles three jobs that were previously split across shortcuts.go and the install loop in app.go: (1) alias collapse, (2) action validation, (3) trigger validation. The warn callback keeps it pure (no slog import in tests). resolveBindings stays as a structural-merge primitive for the existing test suite, with its docstring updated to point at canonicalizeBindings as the production path. Tests added for the four canonicalization invariants: - super+t default + cmd+t = unbind → {} (alias collapse) - super+t default + cmd+t = close_tab → {<Meta>t: close_tab} - ctrl+t default + ctrl+t = typo → default preserved + warn - ctrl+t default + hyper+t = close → default preserved + warn - ctrl+t default + control+t = unbind → {} (alias unbind) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a0274e3 commit 0553499

13 files changed

Lines changed: 963 additions & 123 deletions

File tree

cmd/roost/app.go

Lines changed: 123 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"log/slog"
88
"os"
99
"runtime"
10+
"sort"
1011
"strconv"
1112
"strings"
1213

@@ -17,6 +18,7 @@ import (
1718
"github.com/diamondburned/gotk4/pkg/glib/v2"
1819
"github.com/diamondburned/gotk4/pkg/gtk/v4"
1920

21+
"github.com/charliek/roost/internal/config"
2022
"github.com/charliek/roost/internal/core"
2123
"github.com/charliek/roost/internal/ghostty"
2224
"github.com/charliek/roost/internal/ipc"
@@ -34,6 +36,11 @@ type App struct {
3436
ws *core.Workspace
3537
home string
3638

39+
// cfg is the parsed user config. Immutable after construction:
40+
// safe to read from any thread during init, then only from the GTK
41+
// main thread. If live-reload lands, this needs to grow a mutex.
42+
cfg config.Config
43+
3744
socketPath string
3845
ipcServer *ipc.Server
3946

@@ -69,10 +76,11 @@ type App struct {
6976
}
7077

7178
// NewApp wires the app together. The window is built in activate.
72-
func NewApp(gtkApp *adw.Application, ws *core.Workspace, home, socketPath string) *App {
79+
func NewApp(gtkApp *adw.Application, ws *core.Workspace, cfg config.Config, home, socketPath string) *App {
7380
return &App{
7481
gtkApp: gtkApp,
7582
ws: ws,
83+
cfg: cfg,
7684
home: home,
7785
socketPath: socketPath,
7886
projectViews: map[int64]*adw.TabView{},
@@ -478,7 +486,7 @@ func (a *App) addTabUI(projectID int64, tab core.Tab) {
478486
"ROOST_TAB_ID=" + strconv.FormatInt(tab.ID, 10),
479487
"ROOST_SOCKET=" + a.socketPath,
480488
}
481-
sess, err := NewSession(a.ws, tab, initialCols, initialRows, env...)
489+
sess, err := NewSession(a.ws, tab, initialCols, initialRows, a.cfg.FontFamily, a.cfg.FontSizePt, env...)
482490
if err != nil {
483491
slog.Error("NewSession", "tab", tab.ID, "err", err)
484492
return
@@ -894,28 +902,26 @@ func (a *App) shutdown() {
894902
// drawing area's key controller — otherwise terminal-focused keys get
895903
// consumed by handleKey and the shortcut never fires.
896904
//
897-
// Per-platform modifier policy:
898-
// - macOS: Cmd is the "primary" app modifier (tab management, cycle).
899-
// Cmd is also the project-management modifier (new project, rename
900-
// project, rename tab, switch project 1..9). Ctrl-1..9 switches
901-
// tabs in the active project.
902-
// - Linux: Ctrl is the primary app modifier. Alt is the project-
903-
// management modifier (mirrors Cmd on macOS). Ctrl-1..9 switches
904-
// tabs.
905+
// Per-platform modifier policy (defaults; overridable via the config
906+
// file's `keybind = trigger=action` lines):
907+
// - macOS: super (Cmd) is the primary app modifier and the
908+
// project-management modifier; ctrl is reserved for tab-switch.
909+
// - Linux: ctrl is the primary app modifier; alt is the project
910+
// modifier; ctrl is also tab-switch.
905911
//
906912
// On macOS gdk-macos translates NSEventModifierFlagCommand directly to
907913
// GDK_META_MASK, so <Meta>x in a trigger reliably matches Cmd-x. The
908914
// <Primary> alias is hardcoded to Control on every platform in GTK4, so
909-
// we substitute the modifier ourselves rather than relying on it.
915+
// the trigger parser substitutes the modifier ourselves.
910916
func (a *App) installShortcuts() {
911917
ctrl := gtk.NewShortcutController()
912918
ctrl.SetScope(gtk.ShortcutScopeGlobal)
913919
ctrl.SetPropagationPhase(gtk.PhaseCapture)
914920

915-
add := func(spec string, fn func()) {
921+
addUnconditional := func(spec string, fn func()) {
916922
t := gtk.NewShortcutTriggerParseString(spec)
917923
if t == nil {
918-
slog.Warn("shortcut: trigger parse failed", "spec", spec)
924+
slog.Warn("shortcut: unparseable accel", "accel", spec)
919925
return
920926
}
921927
action := gtk.NewCallbackAction(func(_ gtk.Widgetter, _ *glib.Variant) (ok bool) {
@@ -924,86 +930,130 @@ func (a *App) installShortcuts() {
924930
})
925931
ctrl.AddShortcut(gtk.NewShortcut(t, action))
926932
}
927-
928-
// addCond is like add but lets the action signal "I didn't
929-
// handle this; let GTK keep propagating." Used for clipboard
930-
// shortcuts so a focused GtkEditable can keep its native
931-
// copy/paste behavior — returning false here lets GTK deliver
932-
// the keystroke to the focused widget after our capture-phase
933-
// controller declines.
934-
addCond := func(spec string, fn func() bool) {
933+
// addGated is the propagate-false variant used by clipboard
934+
// actions: when an editable widget has focus, return false so GTK
935+
// keeps propagating the event to that widget's native copy/paste
936+
// handler. Returning true here would swallow it and break paste
937+
// in the sidebar rename entry.
938+
addGated := func(spec string, fn func()) {
935939
t := gtk.NewShortcutTriggerParseString(spec)
936940
if t == nil {
937-
slog.Warn("shortcut: trigger parse failed", "spec", spec)
941+
slog.Warn("shortcut: unparseable accel", "accel", spec)
938942
return
939943
}
940944
action := gtk.NewCallbackAction(func(_ gtk.Widgetter, _ *glib.Variant) (ok bool) {
941-
return fn()
945+
if a.editableHasFocus() {
946+
return false
947+
}
948+
fn()
949+
return true
942950
})
943951
ctrl.AddShortcut(gtk.NewShortcut(t, action))
944952
}
945953

946-
primary := "<Control>"
947-
projectMod := "<Alt>"
948-
if runtime.GOOS == "darwin" {
949-
primary = "<Meta>"
950-
projectMod = "<Meta>"
954+
type shortcutAction struct {
955+
fn func()
956+
gated bool
951957
}
952-
953-
// Tab management on the primary modifier.
954-
add(primary+"t", a.newTabInActiveProject)
955-
add(primary+"w", a.closeActiveTab)
956-
957-
// Clipboard. Cmd+V on macOS, Alt+V on Linux, Ctrl+Shift+V on both
958-
// (terminal convention) so muscle memory works either way. Bare
959-
// Ctrl+V remains as terminal input. Bare Ctrl+C is left as SIGINT
960-
// — Cmd+C / Alt+C / Ctrl+Shift+C handle copy without overloading
961-
// the SIGINT key.
962-
clipboardMod := "<Alt>"
963-
if runtime.GOOS == "darwin" {
964-
clipboardMod = "<Meta>"
958+
handlers := map[string]shortcutAction{
959+
ActionNewTab: {fn: a.newTabInActiveProject},
960+
ActionCloseTab: {fn: a.closeActiveTab},
961+
ActionRenameTab: {fn: a.renameActiveTab},
962+
ActionCycleTabPrev: {fn: func() { a.cycleTab(-1) }},
963+
ActionCycleTabNext: {fn: func() { a.cycleTab(1) }},
964+
ActionPaste: {fn: a.pasteIntoActive, gated: true},
965+
ActionCopy: {fn: a.copyFromActive, gated: true},
966+
ActionNewProject: {fn: a.newProject},
967+
ActionRenameProject: {fn: a.beginRenameActiveProject},
965968
}
966-
clipboardGuard := func(fn func()) func() bool {
967-
return func() bool {
968-
if a.editableHasFocus() {
969-
// Tell GTK we didn't consume the event so the
970-
// focused entry / text view gets its native
971-
// copy/paste handling.
972-
return false
973-
}
974-
fn()
975-
return true
969+
for i := 1; i <= 9; i++ {
970+
i := i
971+
handlers[switchProjectAction(i)] = shortcutAction{
972+
fn: func() { a.switchProjectByIndex(i - 1) },
973+
}
974+
handlers[switchTabAction(i)] = shortcutAction{
975+
fn: func() { a.switchTabByIndex(i - 1) },
976976
}
977977
}
978-
addCond(clipboardMod+"v", clipboardGuard(a.pasteIntoActive))
979-
addCond("<Control><Shift>v", clipboardGuard(a.pasteIntoActive))
980-
addCond(clipboardMod+"c", clipboardGuard(a.copyFromActive))
981-
addCond("<Control><Shift>c", clipboardGuard(a.copyFromActive))
982978

983-
// Shift+[ produces braceleft on US layouts. GTK matches the
984-
// transformed keyval, so we bind the curly forms (and the bracket
985-
// forms as a safety net for layouts that don't transform).
986-
for _, k := range []string{"braceleft", "bracketleft"} {
987-
add(primary+"<Shift>"+k, func() { a.cycleTab(-1) })
979+
known := make(map[string]bool, len(handlers))
980+
for action := range handlers {
981+
known[action] = true
988982
}
989-
for _, k := range []string{"braceright", "bracketright"} {
990-
add(primary+"<Shift>"+k, func() { a.cycleTab(1) })
983+
resolved := canonicalizeBindings(
984+
defaultBindings(), a.cfg.Keybinds, known,
985+
func(msg, trigger, action string) {
986+
slog.Warn("shortcut: "+msg, "trigger", trigger, "action", action)
987+
},
988+
)
989+
990+
// Sort the canonical accels before installing so the order is
991+
// deterministic — matters only if two installable accels collide
992+
// at the GTK level, but cheap insurance.
993+
accels := make([]string, 0, len(resolved))
994+
for accel := range resolved {
995+
accels = append(accels, accel)
996+
}
997+
sort.Strings(accels)
998+
999+
for _, accel := range accels {
1000+
sa := handlers[resolved[accel]]
1001+
if sa.gated {
1002+
addGated(accel, sa.fn)
1003+
} else {
1004+
addUnconditional(accel, sa.fn)
1005+
}
9911006
}
9921007

993-
// Project / tab management on the project modifier.
994-
add(projectMod+"n", a.newProject)
995-
add(projectMod+"<Shift>r", a.beginRenameActiveProject)
996-
add(projectMod+"r", a.renameActiveTab)
1008+
a.win.AddController(ctrl)
1009+
}
1010+
1011+
// switchProjectAction / switchTabAction synthesize the indexed action
1012+
// names for the project / tab numeric switchers. Kept as small helpers
1013+
// so installShortcuts and defaultBindings stay in sync.
1014+
func switchProjectAction(i int) string { return "switch_project_" + strconv.Itoa(i) }
1015+
func switchTabAction(i int) string { return "switch_tab_" + strconv.Itoa(i) }
9971016

998-
// Numeric switchers: project on the project modifier, tab on Ctrl.
1017+
// defaultBindings returns the platform-default trigger list per action,
1018+
// in Ghostty trigger syntax. installShortcuts layers user `keybind`
1019+
// lines on top via resolveBindings.
1020+
//
1021+
// Linux clipboardMod is "alt" because the existing default has been
1022+
// Alt-V / Alt-C since PR #4; ctrl+shift+v / ctrl+shift+c are kept as
1023+
// secondary triggers. macOS clipboardMod is "super".
1024+
func defaultBindings() map[string][]string {
1025+
primary := "ctrl"
1026+
projectMod := "alt"
1027+
clipboardMod := "alt"
1028+
if runtime.GOOS == "darwin" {
1029+
primary = "super"
1030+
projectMod = "super"
1031+
clipboardMod = "super"
1032+
}
1033+
m := map[string][]string{
1034+
ActionNewTab: {primary + "+t"},
1035+
ActionCloseTab: {primary + "+w"},
1036+
ActionRenameTab: {projectMod + "+r"},
1037+
// Shift-[ produces braceleft on US layouts; bracketleft on
1038+
// layouts that don't transform. Keep both.
1039+
ActionCycleTabPrev: {
1040+
primary + "+shift+braceleft",
1041+
primary + "+shift+bracketleft",
1042+
},
1043+
ActionCycleTabNext: {
1044+
primary + "+shift+braceright",
1045+
primary + "+shift+bracketright",
1046+
},
1047+
ActionPaste: {clipboardMod + "+v", "ctrl+shift+v"},
1048+
ActionCopy: {clipboardMod + "+c", "ctrl+shift+c"},
1049+
ActionNewProject: {projectMod + "+n"},
1050+
ActionRenameProject: {projectMod + "+shift+r"},
1051+
}
9991052
for i := 1; i <= 9; i++ {
1000-
idx := i - 1
1001-
n := strconv.Itoa(i)
1002-
add(projectMod+n, func() { a.switchProjectByIndex(idx) })
1003-
add("<Control>"+n, func() { a.switchTabByIndex(idx) })
1053+
m[switchProjectAction(i)] = []string{projectMod + "+" + strconv.Itoa(i)}
1054+
m[switchTabAction(i)] = []string{"ctrl+" + strconv.Itoa(i)}
10041055
}
1005-
1006-
a.win.AddController(ctrl)
1056+
return m
10071057
}
10081058

10091059
// pageKey returns the stable GObject pointer for an AdwTabPage, used

cmd/roost/main.go

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@
55
package main
66

77
import (
8+
"errors"
9+
"fmt"
10+
"io/fs"
811
"log"
12+
"log/slog"
913
"os"
1014

1115
"github.com/diamondburned/gotk4-adwaita/pkg/adw"
@@ -21,16 +25,6 @@ const (
2125
pad = 8
2226
)
2327

24-
// fontFamily and fontSizePt are populated from the user's config at
25-
// startup (with built-in defaults from internal/config). Globals
26-
// because they're consumed deep inside Session construction; promoting
27-
// them into a struct would mean threading a config object through every
28-
// constructor.
29-
var (
30-
fontFamily = "JetBrains Mono, Monaco, monospace"
31-
fontSizePt = 12
32-
)
33-
3428
func main() {
3529
installLogFilter()
3630

@@ -45,8 +39,7 @@ func main() {
4539
if err != nil {
4640
log.Fatalf("config.Load: %v", err)
4741
}
48-
fontFamily = cfg.FontFamily
49-
fontSizePt = cfg.FontSizePt
42+
warnLegacyMacConfig(paths)
5043

5144
st, err := store.Open(paths.DBPath())
5245
if err != nil {
@@ -64,9 +57,31 @@ func main() {
6457
}
6558

6659
gtkApp := adw.NewApplication("dev.charliek.roost", 0)
67-
app := NewApp(gtkApp, ws, home, paths.SocketPath())
60+
app := NewApp(gtkApp, ws, cfg, home, paths.SocketPath())
6861
gtkApp.ConnectActivate(app.activate)
6962
if code := gtkApp.Run(os.Args); code > 0 {
7063
log.Fatalf("roost exited with code %d", code)
7164
}
7265
}
66+
67+
// warnLegacyMacConfig logs a one-shot migration hint when the user has
68+
// a pre-cutover ~/Library/Application Support/Roost/config.toml but no
69+
// new ~/.config/roost/config.conf. No automatic migration; the move is
70+
// trivial and we don't want to silently rewrite a user's edited file.
71+
func warnLegacyMacConfig(p config.Paths) {
72+
legacy := p.LegacyMacConfigFile()
73+
if _, err := os.Stat(legacy); err != nil {
74+
return
75+
}
76+
if _, err := os.Stat(p.ConfigFile()); err == nil {
77+
return // user has both; assume they migrated
78+
} else if !errors.Is(err, fs.ErrNotExist) {
79+
return
80+
}
81+
// %q quotes both paths so the macOS path with spaces (Library/
82+
// Application Support/Roost/...) is copy-paste safe.
83+
slog.Warn("legacy config detected; not auto-migrating",
84+
"old", legacy,
85+
"new", p.ConfigFile(),
86+
"hint", fmt.Sprintf("mv %q %q", legacy, p.ConfigFile()))
87+
}

cmd/roost/session.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ type Session struct {
128128
//
129129
// extraEnv is forwarded to pty.SpawnShell so callers can inject
130130
// ROOST_TAB_ID + ROOST_SOCKET (or any tab-specific env).
131-
func NewSession(ws *core.Workspace, tab core.Tab, cols, rows uint16, extraEnv ...string) (*Session, error) {
131+
func NewSession(ws *core.Workspace, tab core.Tab, cols, rows uint16, fontFamily string, fontSizePt int, extraEnv ...string) (*Session, error) {
132132
term, err := ghostty.NewTerminal(ghostty.Options{
133133
Cols: cols, Rows: rows, MaxScrollback: 2000,
134134
})

0 commit comments

Comments
 (0)