fix: alt-word motion, preview code backgrounds, daily-in-vault, click offset; add glint keys
99f1a50d29d69ff52a929852c0110f02888c7171
humdrum <me@humdrum.me> · 2026-06-29 07:52
parent 2b5abb26
fix: alt-word motion, preview code backgrounds, daily-in-vault, click offset; add glint keys - Word motion: Option-as-Meta terminals send Option+Arrow as Alt+b/Alt+f; handle those (and Alt+d) as word ops and never insert an Alt-runed key. Ctrl+W deletes the word back; add DeleteWordRight. - Preview: paint code blocks, inline code, chroma, and tables with the theme background (deep-copying chroma to avoid mutating glamour's global) so no dark panels show in the read view. - Daily notes now root at the vault (Vault()), not the working dir, and an absolute daily_subdir is used as-is — lands in the vault from anywhere. - Mouse click row mapping corrected (was landing one row high). - Add 'glint keys' probe to inspect what the terminal sends for each key. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
10 files changed
README.md +4 −1
@@ -40,6 +40,9 @@ | Key | Action |
| --- | --- |
| type / arrows / `Enter` / `Backspace` / `Del` | edit and move (Up/Down move by visual line) |
| mouse wheel | scroll the view (hold `Option`/`Shift` to select text while mouse mode is on) |
+| mouse click | move the cursor |
+| `Alt+←` / `Alt+→` | move by word (also `Alt+b` / `Alt+f`) |
+| `Alt+Backspace` / `Ctrl+W` | delete the word before the cursor (`Alt+d` deletes the word after) |
| `Ctrl+U` / `Ctrl+K` | delete to start / end of line (map `Cmd+Delete`→`Ctrl+U` in your terminal) |
| `Ctrl+S` | save (an unnamed buffer prompts for a name → inbox) |
| `Ctrl+P` | toggle the Glamour read preview |
@@ -59,7 +62,7 @@
```toml
vault_dir = "" # the vault `glint vault` opens from anywhere (e.g. "~/Notes"); unset = current dir
inbox_dir = "" # where `glint new` / save-as land; "" = working dir, relative = under it
-daily_subdir = "Daily" # daily notes live in <working dir>/<daily_subdir>/
+daily_subdir = "Daily" # daily notes live in <vault>/<daily_subdir>/ (absolute path used as-is)
daily_format = "2006-01-02" # Go time layout for daily-note filenames
theme = "auto" # auto | flexoki-light | flexoki-dark | charm (auto detects macOS appearance)
glamour_style = "" # override the preview style; "" follows the theme
internal/app/app.go +3 −1
@@ -138,7 +138,9 @@ func (a *App) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
const step = 3
if msg.Button == tea.MouseButtonLeft {
if msg.Action == tea.MouseActionPress && a.mode == ModeEditor {
- vi := a.editor.Scroll + (msg.Y - a.topPad())
+ // The first editor visual row sits at screen row topPad; a click at
+ // screen row Y is that many rows into the viewport.
+ vi := a.editor.Scroll + msg.Y - a.topPad() + 1
col := msg.X - a.leftMargin()
a.editor.MoveToVisual(vi, col)
}
internal/app/app_test.go +3 −2
@@ -555,9 +555,10 @@ a.Update(tea.WindowSizeMsg{Width: 100, Height: 12})
a.editor.SetContent([]byte("first line\nsecond line\nthird line"))
a.editor.Cursor.Row, a.editor.Cursor.Col = 0, 0
lm := a.leftMargin()
- // click on the 2nd editor row (Y = topPad+1), column 3 within the text
+ // Click the 2nd editor row, column 3. vi = Scroll + Y - topPad + 1, so the
+ // 2nd row (vi=1, Scroll=0) is at screen Y = topPad.
a.Update(tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionPress,
- X: lm + 3, Y: a.topPad() + 1})
+ X: lm + 3, Y: a.topPad()})
if a.editor.Cursor.Row != 1 {
t.Errorf("click row → cursor row %d, want 1", a.editor.Cursor.Row)
}
internal/config/config.go +8 −2
@@ -132,7 +132,13 @@ }
return filepath.Join(c.WorkingDir(), c.InboxDir)
}
-// DailyPath builds the path to the daily note for time t, under the working dir.
+// DailyPath builds the path to the daily note for time t. Daily notes belong to
+// the vault (Vault()), not the working directory, so `glint --daily` lands in
+// the same place from anywhere. An absolute daily_subdir is used as-is.
func (c Config) DailyPath(t time.Time) string {
- return filepath.Join(c.WorkingDir(), c.DailySubdir, t.Format(c.DailyFormat)+".md")
+ name := t.Format(c.DailyFormat) + ".md"
+ if filepath.IsAbs(c.DailySubdir) {
+ return filepath.Join(c.DailySubdir, name)
+ }
+ return filepath.Join(c.Vault(), c.DailySubdir, name)
}
internal/config/config_test.go +17 −0
@@ -186,3 +186,20 @@ if got.VaultDir != "/my/vault" || got.InboxDir != "Inbox" || got.DailyFormat != "20060102" || got.Theme != "charm" {
t.Errorf("round-trip mismatch: %+v", got)
}
}
+
+func TestDailyPathRootsAtVaultNotCwd(t *testing.T) {
+ t.Setenv("GLINT_VAULT", "/work") // working dir (cwd-equivalent)
+ c := Config{VaultDir: "/vault", DailySubdir: "Daily", DailyFormat: "2006-01-02"}
+ got := c.DailyPath(time.Date(2026, 6, 29, 0, 0, 0, 0, time.UTC))
+ want := filepath.Join("/vault", "Daily", "2026-06-29.md")
+ if got != want {
+ t.Errorf("DailyPath = %q, want %q (vault, not working dir)", got, want)
+ }
+ // Absolute daily_subdir is used as-is.
+ c.DailySubdir = "/abs/Journal"
+ got = c.DailyPath(time.Date(2026, 6, 29, 0, 0, 0, 0, time.UTC))
+ want = filepath.Join("/abs/Journal", "2026-06-29.md")
+ if got != want {
+ t.Errorf("absolute DailyPath = %q, want %q", got, want)
+ }
+}
internal/editor/editor.go +45 −3
@@ -195,6 +195,27 @@ e.setGoal()
e.followCursor()
}
+// DeleteWordRight deletes the word after the cursor (Alt+D).
+func (e *Editor) DeleteWordRight() {
+ rs := e.curLine()
+ if e.Cursor.Col >= len(rs) {
+ e.Delete() // at line end, fall back to joining lines
+ return
+ }
+ c := e.Cursor.Col
+ end := c
+ for end < len(rs) && isWordSpace(rs[end]) {
+ end++
+ }
+ for end < len(rs) && !isWordSpace(rs[end]) {
+ end++
+ }
+ e.setLine(append(rs[:c], rs[end:]...))
+ e.Dirty = true
+ e.setGoal()
+ e.followCursor()
+}
+
// KillToLineEnd deletes from the cursor to the end of the line (Ctrl+K).
func (e *Editor) KillToLineEnd() {
rs := e.curLine()
@@ -392,9 +413,28 @@
// HandleKey maps a key message to a buffer operation.
func (e *Editor) HandleKey(k tea.KeyMsg) {
switch k.Type {
- case tea.KeyRunes, tea.KeySpace:
- // Real bubbletea reports a space as KeySpace with Runes == [' '], so the
- // loop already inserts it — do not add a second space here.
+ case tea.KeyRunes:
+ // Option-as-Meta terminals send Option+Left/Right as Alt+b / Alt+f
+ // (readline word motion). Handle those and never insert an Alt-runed key.
+ if k.Alt {
+ switch string(k.Runes) {
+ case "b":
+ e.MoveWordLeft()
+ case "f":
+ e.MoveWordRight()
+ case "d":
+ e.DeleteWordRight()
+ }
+ return
+ }
+ for _, r := range k.Runes {
+ e.InsertRune(r)
+ }
+ case tea.KeySpace:
+ // Real bubbletea reports a space as KeySpace with Runes == [' '].
+ if len(k.Runes) == 0 {
+ e.InsertRune(' ')
+ }
for _, r := range k.Runes {
e.InsertRune(r)
}
@@ -434,6 +474,8 @@ case tea.KeyCtrlU:
e.KillToLineStart()
case tea.KeyCtrlK:
e.KillToLineEnd()
+ case tea.KeyCtrlW:
+ e.DeleteWordLeft()
}
}
internal/editor/editor_test.go +16 −0
@@ -396,3 +396,19 @@ if e.Lines[0] != "alpha " {
t.Errorf("Alt+Backspace at end → %q, want 'alpha '", e.Lines[0])
}
}
+
+func TestHandleKeyAltBFAreWordMotionNotText(t *testing.T) {
+ e := newEditorWith("alpha beta gamma")
+ e.Cursor = Position{Row: 0, Col: 16}
+ e.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("b"), Alt: true})
+ if e.Cursor.Col != 11 {
+ t.Errorf("Alt+b → col %d, want 11 (start of gamma)", e.Cursor.Col)
+ }
+ if e.Lines[0] != "alpha beta gamma" {
+ t.Errorf("Alt+b inserted text: %q", e.Lines[0])
+ }
+ e.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("f"), Alt: true})
+ if e.Cursor.Col != 16 {
+ t.Errorf("Alt+f → col %d, want 16 (end of gamma)", e.Cursor.Col)
+ }
+}
internal/keyprobe/keyprobe.go +39 −0
@@ -0,0 +1,39 @@
+// Package keyprobe is a tiny `glint keys` diagnostic that shows exactly what
+// key events the terminal delivers — useful for discovering what Cmd+Arrow,
+// Alt+Arrow, and friends send so they can be bound.
+package keyprobe
+
+import (
+ "fmt"
+ "strings"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+type model struct{ lines []string }
+
+func (m model) Init() tea.Cmd { return nil }
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ if k, ok := msg.(tea.KeyMsg); ok {
+ if k.Type == tea.KeyCtrlC {
+ return m, tea.Quit
+ }
+ m.lines = append(m.lines, fmt.Sprintf("String=%-12q Type=%-3d Alt=%-5v Runes=%q", k.String(), int(k.Type), k.Alt, string(k.Runes)))
+ if len(m.lines) > 20 {
+ m.lines = m.lines[len(m.lines)-20:]
+ }
+ }
+ return m, nil
+}
+
+func (m model) View() string {
+ return "glint key probe — press keys (Cmd+Arrows, Alt+Arrows, Cmd+Delete, …). Ctrl+C to quit.\n\n" +
+ strings.Join(m.lines, "\n") + "\n"
+}
+
+// Run launches the probe.
+func Run() error {
+ _, err := tea.NewProgram(model{}, tea.WithAltScreen()).Run()
+ return err
+}
internal/preview/preview.go +24 −6
@@ -8,8 +8,31 @@
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/glamour"
+ "github.com/charmbracelet/glamour/ansi"
"github.com/charmbracelet/glamour/styles"
)
+
+// paintCanvasBackground rewrites a glamour style so every block sits on the
+// theme background (and drops glamour's document margin), so code blocks,
+// inline code, and tables don't show their own darker panels in the preview.
+// The chroma config is deep-copied so glamour's shared global isn't mutated.
+func paintCanvasBackground(cfg *ansi.StyleConfig, hex string) {
+ var zero uint
+ cfg.Document.Margin = &zero
+ if hex == "" {
+ return
+ }
+ bg := hex
+ cfg.Document.BackgroundColor = &bg
+ cfg.Code.BackgroundColor = &bg
+ cfg.CodeBlock.BackgroundColor = &bg
+ if cfg.CodeBlock.Chroma != nil {
+ chroma := *cfg.CodeBlock.Chroma
+ chroma.Background.BackgroundColor = &bg
+ cfg.CodeBlock.Chroma = &chroma
+ }
+ cfg.Table.BackgroundColor = &bg
+}
// Model wraps a Glamour renderer and a viewport.
type Model struct {
@@ -107,12 +130,7 @@ cfg := styles.DarkStyleConfig
if m.style == "light" {
cfg = styles.LightStyleConfig
}
- if m.bg != "" {
- bg := m.bg
- cfg.Document.BackgroundColor = &bg
- }
- var zero uint
- cfg.Document.Margin = &zero
+ paintCanvasBackground(&cfg, m.bg)
opts = append(opts, glamour.WithStyles(cfg))
case knownStyles[m.style]:
// A user-chosen named style keeps its own look.
main.go +7 −0
@@ -9,6 +9,7 @@
"glint/internal/app"
"glint/internal/config"
"glint/internal/configui"
+ "glint/internal/keyprobe"
tea "github.com/charmbracelet/bubbletea"
)
@@ -52,6 +53,12 @@ run(a)
return
case "config": // `glint config` — interactive setup walkthrough
if err := configui.Run(); err != nil {
+ fmt.Fprintln(os.Stderr, "glint:", err)
+ os.Exit(1)
+ }
+ return
+ case "keys": // `glint keys` — show what the terminal sends for each key
+ if err := keyprobe.Run(); err != nil {
fmt.Fprintln(os.Stderr, "glint:", err)
os.Exit(1)
}