▍ humdrum codex / glint v1.0.2
license AGPL-3.0

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)
 			}