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

feat: color link text + ref links/defs, fade markup more, word nav/delete

edadb9ef046ca82939499025b3c364949338d2ab
humdrum <me@humdrum.me> · 2026-06-29 07:33

parent 12724379

feat: color link text + ref links/defs, fade markup more, word nav/delete

- Links: color the readable link text (not the url) as the reference color;
  dim the url + brackets. Add reference-style links [text][ref] and reference
  definitions [ref]: url.
- Markup punctuation fades further (Muted → base-700 on dark, base-300 on light).
- Alt+Left/Right move by word; Alt+Backspace deletes the word before the cursor
  (cmd-based line ops stay terminal-mapped to Home/End and Ctrl+U/K).

5 files changed

internal/editor/editor.go +83 −3
@@ -100,6 +100,74 @@ 		e.Scroll = max
 	}
 }
 
+func isWordSpace(r rune) bool { return r == ' ' || r == '\t' }
+
+// MoveWordLeft moves to the start of the previous word (Alt+Left).
+func (e *Editor) MoveWordLeft() {
+	if e.Cursor.Col == 0 {
+		if e.Cursor.Row > 0 {
+			e.Cursor.Row--
+			e.Cursor.Col = len(e.curLine())
+		}
+	} else {
+		rs := e.curLine()
+		c := e.Cursor.Col
+		for c > 0 && isWordSpace(rs[c-1]) {
+			c--
+		}
+		for c > 0 && !isWordSpace(rs[c-1]) {
+			c--
+		}
+		e.Cursor.Col = c
+	}
+	e.setGoal()
+	e.followCursor()
+}
+
+// MoveWordRight moves to the end of the next word (Alt+Right).
+func (e *Editor) MoveWordRight() {
+	rs := e.curLine()
+	if e.Cursor.Col >= len(rs) {
+		if e.Cursor.Row < len(e.Lines)-1 {
+			e.Cursor.Row++
+			e.Cursor.Col = 0
+		}
+	} else {
+		c := e.Cursor.Col
+		for c < len(rs) && isWordSpace(rs[c]) {
+			c++
+		}
+		for c < len(rs) && !isWordSpace(rs[c]) {
+			c++
+		}
+		e.Cursor.Col = c
+	}
+	e.setGoal()
+	e.followCursor()
+}
+
+// DeleteWordLeft deletes the word before the cursor (Alt+Backspace).
+func (e *Editor) DeleteWordLeft() {
+	if e.Cursor.Col == 0 {
+		e.Backspace() // at line start, fall back to joining lines
+		return
+	}
+	rs := e.curLine()
+	end := e.Cursor.Col
+	start := end
+	for start > 0 && isWordSpace(rs[start-1]) {
+		start--
+	}
+	for start > 0 && !isWordSpace(rs[start-1]) {
+		start--
+	}
+	e.setLine(append(rs[:start], rs[end:]...))
+	e.Cursor.Col = start
+	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()
@@ -306,13 +374,25 @@ 		}
 	case tea.KeyEnter:
 		e.InsertNewline()
 	case tea.KeyBackspace:
-		e.Backspace()
+		if k.Alt {
+			e.DeleteWordLeft()
+		} else {
+			e.Backspace()
+		}
 	case tea.KeyDelete:
 		e.Delete()
 	case tea.KeyLeft:
-		e.MoveLeft()
+		if k.Alt {
+			e.MoveWordLeft()
+		} else {
+			e.MoveLeft()
+		}
 	case tea.KeyRight:
-		e.MoveRight()
+		if k.Alt {
+			e.MoveWordRight()
+		} else {
+			e.MoveRight()
+		}
 	case tea.KeyUp:
 		e.MoveUp()
 	case tea.KeyDown:
internal/editor/editor_test.go +36 −0
@@ -360,3 +360,39 @@ 	if e.Scroll != max {
 		t.Errorf("Scroll = %d, want %d (clamped)", e.Scroll, max)
 	}
 }
+
+func TestWordNavigationAndDelete(t *testing.T) {
+	e := newEditorWith("hello world foo")
+	e.Cursor = Position{Row: 0, Col: 15} // end
+	e.MoveWordLeft()
+	if e.Cursor.Col != 12 { // start of "foo"
+		t.Errorf("MoveWordLeft → col %d, want 12", e.Cursor.Col)
+	}
+	e.MoveWordLeft()
+	if e.Cursor.Col != 6 { // start of "world"
+		t.Errorf("MoveWordLeft → col %d, want 6", e.Cursor.Col)
+	}
+	e.MoveWordRight()
+	if e.Cursor.Col != 11 { // end of "world"
+		t.Errorf("MoveWordRight → col %d, want 11", e.Cursor.Col)
+	}
+	// delete the word before the cursor ("world")
+	e.DeleteWordLeft()
+	if e.Lines[0] != "hello  foo" {
+		t.Errorf("DeleteWordLeft → %q, want 'hello  foo'", e.Lines[0])
+	}
+}
+
+func TestHandleKeyAltWord(t *testing.T) {
+	e := newEditorWith("alpha beta")
+	e.Cursor = Position{Row: 0, Col: 10}
+	e.HandleKey(tea.KeyMsg{Type: tea.KeyLeft, Alt: true})
+	if e.Cursor.Col != 6 {
+		t.Errorf("Alt+Left → col %d, want 6 (start of beta)", e.Cursor.Col)
+	}
+	e.Cursor.Col = 10 // back to end
+	e.HandleKey(tea.KeyMsg{Type: tea.KeyBackspace, Alt: true})
+	if e.Lines[0] != "alpha " {
+		t.Errorf("Alt+Backspace at end → %q, want 'alpha '", e.Lines[0])
+	}
+}
internal/editor/scanner.go +41 −13
@@ -13,6 +13,7 @@ var (
 	headingRe    = regexp.MustCompile(`^\s*#{1,6}\s`)
 	listRe       = regexp.MustCompile(`^\s*([-*+]|\d+\.)\s`)
 	blockquoteRe = regexp.MustCompile(`^\s*>\s?`)
+	refDefRe     = regexp.MustCompile(`^\s*\[[^\]]+\]:\s*`) // [ref]: url
 )
 
 // blockState carries cross-line context (fenced code, leading frontmatter).
@@ -73,6 +74,15 @@
 	// Comment line: visible, not dimmed.
 	if strings.HasPrefix(trimmed, "<!--") || strings.HasPrefix(trimmed, "%%") {
 		return wholeLine(line, lipgloss.NewStyle().Foreground(th.Comment))
+	}
+
+	// Reference link definition: [ref]: url — dim the [ref]:, color the url.
+	if loc := refDefRe.FindStringIndex(line); loc != nil {
+		spans := []Span{{Text: line[:loc[1]], Style: muted}}
+		if url := line[loc[1]:]; url != "" {
+			spans = append(spans, Span{Text: url, Style: lipgloss.NewStyle().Foreground(th.Link)})
+		}
+		return spans
 	}
 
 	// Heading: dim the hashes, bold + accent the text.
@@ -208,22 +218,40 @@ 			return wrap("[[", string(r[i+2:j]), "]]", muted,
 				lipgloss.NewStyle().Foreground(th.Link)), j + 2 - i, true
 		}
 	}
-	// Link: [text](url) — text stays prose, the url is the colored reference.
+	// Link: [text](url) — the readable text is the colored reference, the url is
+	// dimmed (it's the address), brackets dimmed.
 	if r[i] == '[' {
-		if c := indexRune(r, ']', i+1); c > i && c+1 < len(r) && r[c+1] == '(' {
-			if p := indexRune(r, ')', c+2); p > c {
-				plain := lipgloss.NewStyle().Foreground(th.Text)
-				link := lipgloss.NewStyle().Foreground(th.Link)
-				spans := []Span{{Text: "[", Style: muted}}
-				if text := string(r[i+1 : c]); text != "" {
-					spans = append(spans, Span{Text: text, Style: plain})
+		link := lipgloss.NewStyle().Foreground(th.Link)
+		if c := indexRune(r, ']', i+1); c > i && c+1 < len(r) {
+			// [text](url)
+			if r[c+1] == '(' {
+				if p := indexRune(r, ')', c+2); p > c {
+					spans := []Span{{Text: "[", Style: muted}}
+					if text := string(r[i+1 : c]); text != "" {
+						spans = append(spans, Span{Text: text, Style: link})
+					}
+					spans = append(spans, Span{Text: "](", Style: muted})
+					if url := string(r[c+2 : p]); url != "" {
+						spans = append(spans, Span{Text: url, Style: muted})
+					}
+					spans = append(spans, Span{Text: ")", Style: muted})
+					return spans, p + 1 - i, true
 				}
-				spans = append(spans, Span{Text: "](", Style: muted})
-				if url := string(r[c+2 : p]); url != "" {
-					spans = append(spans, Span{Text: url, Style: link})
+			}
+			// Reference link: [text][ref]
+			if r[c+1] == '[' {
+				if e := indexRune(r, ']', c+2); e > c+1 {
+					spans := []Span{{Text: "[", Style: muted}}
+					if text := string(r[i+1 : c]); text != "" {
+						spans = append(spans, Span{Text: text, Style: link})
+					}
+					spans = append(spans, Span{Text: "][", Style: muted})
+					if ref := string(r[c+2 : e]); ref != "" {
+						spans = append(spans, Span{Text: ref, Style: muted})
+					}
+					spans = append(spans, Span{Text: "]", Style: muted})
+					return spans, e + 1 - i, true
 				}
-				spans = append(spans, Span{Text: ")", Style: muted})
-				return spans, p + 1 - i, true
 			}
 		}
 	}
internal/editor/scanner_test.go +43 −10
@@ -139,26 +139,59 @@ 		t.Error("bold content not Emphasis+bold")
 	}
 }
 
-func TestScanLinkTargetColoredBracketsMuted(t *testing.T) {
+func TestScanLinkTextColoredUrlAndBracketsMuted(t *testing.T) {
 	th := theme.FlexokiDark()
 	out := ScanLines([]string{"see [text](http://x)"}, th)
 	if spanText(out[0]) != "see [text](http://x)" {
 		t.Fatalf("text altered: %q", spanText(out[0]))
 	}
-	var url, brackets bool
+	var linkText, dimmed bool
 	for _, sp := range out[0] {
-		if sp.Text == "http://x" && sp.Style.GetForeground() == th.Link {
-			url = true
+		if sp.Text == "text" && sp.Style.GetForeground() == th.Link {
+			linkText = true
 		}
-		if (sp.Text == "[" || sp.Text == "](" || sp.Text == ")") && sp.Style.GetForeground() == th.Muted {
-			brackets = true
+		if (sp.Text == "http://x" || sp.Text == "[" || sp.Text == "](" || sp.Text == ")") &&
+			sp.Style.GetForeground() == th.Muted {
+			dimmed = true
+		}
+	}
+	if !linkText {
+		t.Error("link text not Link-colored")
+	}
+	if !dimmed {
+		t.Error("url/brackets not muted")
+	}
+}
+
+func TestScanReferenceLink(t *testing.T) {
+	th := theme.FlexokiDark()
+	out := ScanLines([]string{"a [Glamour][glamour] b"}, th)
+	if spanText(out[0]) != "a [Glamour][glamour] b" {
+		t.Fatalf("text altered: %q", spanText(out[0]))
+	}
+	found := false
+	for _, sp := range out[0] {
+		if sp.Text == "Glamour" && sp.Style.GetForeground() == th.Link {
+			found = true
 		}
 	}
-	if !url {
-		t.Error("link target not Link-colored")
+	if !found {
+		t.Error("reference-link text not Link-colored")
 	}
-	if !brackets {
-		t.Error("link brackets not muted")
+}
+
+func TestScanReferenceDefinition(t *testing.T) {
+	th := theme.FlexokiDark()
+	out := ScanLines([]string{"[glamour]: https://github.com/x"}, th)
+	if spanText(out[0]) != "[glamour]: https://github.com/x" {
+		t.Fatalf("text altered: %q", spanText(out[0]))
+	}
+	last := out[0][len(out[0])-1]
+	if last.Text != "https://github.com/x" || last.Style.GetForeground() != th.Link {
+		t.Errorf("ref-def url not Link-colored: %+v", last)
+	}
+	if out[0][0].Style.GetForeground() != th.Muted {
+		t.Error("ref-def [ref]: not muted")
 	}
 }
 
internal/theme/themes.go +2 −2
@@ -18,7 +18,7 @@ 		GlamourStyle: "dark",
 		Background:   lipgloss.Color("#100F0F"), // black
 		Text:         lipgloss.Color("#CECDC3"), // base-200 — prose
 		Emphasis:     lipgloss.Color("#E6E4D9"), // base-100 — brighter for bold/italic
-		Muted:        lipgloss.Color("#878580"), // base-500 — markup, dim but readable
+		Muted:        lipgloss.Color("#575653"), // base-700 — markup, well faded on black
 		Heading:      lipgloss.Color("#4385BE"), // blue-400
 		Code:         lipgloss.Color("#879A39"), // green-400
 		Link:         link,
@@ -45,7 +45,7 @@ 		GlamourStyle: "light",
 		Background:   lipgloss.Color("#FFFCF0"), // paper
 		Text:         lipgloss.Color("#1C1B1A"), // base-950 — prose (leaves room below for Emphasis)
 		Emphasis:     lipgloss.Color("#100F0F"), // black — darker for bold/italic
-		Muted:        lipgloss.Color("#6F6E69"), // base-600 — markup, dim but readable
+		Muted:        lipgloss.Color("#B7B5AC"), // base-300 — markup, well faded on paper
 		Heading:      lipgloss.Color("#205EA6"), // blue-600
 		Code:         lipgloss.Color("#66800B"), // green-600
 		Link:         link,