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,