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

feat: mouse click moves the cursor; preview blends into the themed canvas

2b5abb266ed5fff216c8f1e7de3f5702291dee46
humdrum <me@humdrum.me> · 2026-06-29 07:39

parent edadb9ef

feat: mouse click moves the cursor; preview blends into the themed canvas

- Left-click in the editor moves the cursor: screen (X,Y) is mapped back through
  the canvas margins, top pad, and visual-row scroll to a buffer position
  (editor.MoveToVisual). Wheel scroll unchanged.
- Preview: render glamour with the theme's background and no document margin, so
  the read view blends into the canvas instead of showing glamour's own dark
  panel. App pushes theme.Background into the preview on New and theme cycle.

4 files changed

internal/app/app.go +11 −1
@@ -83,6 +83,7 @@ 		editor:    ed,
 		saveInput: ti,
 	}
 	a.preview = preview.New(a.glamourStyle())
+	a.preview.SetBackground(string(th.Background))
 	return a
 }
 
@@ -132,9 +133,17 @@ 	}
 	return a, nil
 }
 
-// handleMouse scrolls the active view on wheel events without moving the cursor.
+// handleMouse moves the cursor on a left click and scrolls on wheel events.
 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())
+			col := msg.X - a.leftMargin()
+			a.editor.MoveToVisual(vi, col)
+		}
+		return a, nil
+	}
 	var delta int
 	switch msg.Button {
 	case tea.MouseButtonWheelUp:
@@ -343,6 +352,7 @@ func (a *App) cycleTheme() (tea.Model, tea.Cmd) {
 	a.theme = theme.Next(a.theme.Name)
 	a.editor.SetTheme(a.theme)
 	a.preview.SetStyle(a.glamourStyle())
+	a.preview.SetBackground(string(a.theme.Background))
 	// Re-render an open preview so its glamour style follows the new theme
 	// (otherwise it keeps the old light/dark block until the next toggle).
 	if a.mode == ModePreview {
internal/app/app_test.go +19 −0
@@ -546,3 +546,22 @@ 	if a.editor.Cursor.Row != 0 || a.editor.Cursor.Col != 0 {
 		t.Errorf("wheel should not move the cursor; got %+v", a.editor.Cursor)
 	}
 }
+
+func TestMouseClickMovesCursor(t *testing.T) {
+	a := newApp()
+	a.theme = theme.FlexokiDark()
+	a.editor.SetTheme(theme.FlexokiDark())
+	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
+	a.Update(tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionPress,
+		X: lm + 3, Y: a.topPad() + 1})
+	if a.editor.Cursor.Row != 1 {
+		t.Errorf("click row → cursor row %d, want 1", a.editor.Cursor.Row)
+	}
+	if a.editor.Cursor.Col != 3 {
+		t.Errorf("click col → cursor col %d, want 3", a.editor.Cursor.Col)
+	}
+}
internal/editor/editor.go +27 −0
@@ -84,6 +84,33 @@ 	e.setGoal()
 	e.followCursor()
 }
 
+// MoveToVisual places the cursor at absolute visual row vi, col runes into that
+// row — used to map a mouse click (after the caller subtracts margins/scroll) to
+// a buffer position. Indices are clamped to the document.
+func (e *Editor) MoveToVisual(vi, col int) {
+	rows := e.buildVisual()
+	if len(rows) == 0 {
+		return
+	}
+	if vi < 0 {
+		vi = 0
+	}
+	if vi >= len(rows) {
+		vi = len(rows) - 1
+	}
+	vr := rows[vi]
+	if col < 0 {
+		col = 0
+	}
+	if col > vr.runes {
+		col = vr.runes
+	}
+	e.Cursor.Row = vr.logRow
+	e.Cursor.Col = vr.start + col
+	e.setGoal()
+	e.followCursor()
+}
+
 // ScrollBy moves the viewport by delta visual rows (negative = up) without
 // moving the cursor, clamped so it never scrolls past the content.
 func (e *Editor) ScrollBy(delta int) {
internal/preview/preview.go +37 −6
@@ -8,12 +8,14 @@
 	"github.com/charmbracelet/bubbles/viewport"
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/glamour"
+	"github.com/charmbracelet/glamour/styles"
 )
 
 // Model wraps a Glamour renderer and a viewport.
 type Model struct {
 	vp     viewport.Model
 	style  string
+	bg     string // theme background hex, so the preview matches the canvas
 	width  int
 	height int
 }
@@ -29,6 +31,11 @@ 		height: 24,
 	}
 }
 
+// SetBackground sets the document background (a hex like "#100F0F") so the
+// glamour preview blends into the themed canvas instead of showing glamour's
+// own panel color.
+func (m *Model) SetBackground(hex string) { m.bg = hex }
+
 // SetSize resizes the viewport.
 func (m *Model) SetSize(w, h int) {
 	m.width = w
@@ -58,6 +65,15 @@ 	m.vp.GotoTop()
 	return nil
 }
 
+// fileStyle reports whether s names an existing style file on disk.
+func fileStyle(s string) bool {
+	if s == "" {
+		return false
+	}
+	_, err := os.Stat(s)
+	return err == nil
+}
+
 // knownStyles maps glamour's known builtin style names.
 var knownStyles = map[string]bool{
 	"ascii":       true,
@@ -79,14 +95,29 @@ 		width = 80
 	}
 	opts := []glamour.TermRendererOption{glamour.WithWordWrap(width)}
 
-	// If it exists as a file, use it as a path
-	if _, err := os.Stat(m.style); err == nil {
+	switch {
+	case fileStyle(m.style):
+		// An explicit style file wins.
 		opts = append(opts, glamour.WithStylePath(m.style))
-	} else if knownStyles[m.style] {
-		// If it's a known builtin name, use it
+	case m.style == "light" || m.style == "dark" || m.style == "":
+		// The theme-driven styles: take glamour's base config but paint the
+		// document with the theme background (and drop glamour's own margin) so
+		// the preview blends seamlessly into the canvas.
+		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
+		opts = append(opts, glamour.WithStyles(cfg))
+	case knownStyles[m.style]:
+		// A user-chosen named style keeps its own look.
 		opts = append(opts, glamour.WithStandardStyle(m.style))
-	} else {
-		// Otherwise, fall back to "dark"
+	default:
 		opts = append(opts, glamour.WithStandardStyle("dark"))
 	}
 	return glamour.NewTermRenderer(opts...)