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

feat: diff syntax highlighting.

0ec536da2b6338c4ac6867dcb38d7218545cbb5c
Kevin Kortum <kevinkortum@me.com> · 2026-07-04 21:07

parent 2f358a30

7 files changed

- → Merge-conflict-marker-highlighting-in-the-editor.md +27 −0
@@ -0,0 +1,27 @@
+---
+id: TASK-037
+title: Merge-conflict marker highlighting in the editor
+status: "\U0001F3C1 Done"
+assignee: []
+created_date: '2026-07-05 03:51'
+updated_date: '2026-07-05 04:04'
+labels:
+  - feature
+dependencies: []
+priority: medium
+ordinal: 36000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+When a file has git conflict markers (<<<<<<< HEAD / ======= / >>>>>>> branch, plus ||||||| base for diff3), highlight the marker lines in bold and tint the two version blocks in slightly different background colors. Implemented as a post-pass over scanned spans in buildVisual so it applies to both prose (.md/.txt) and code files. Adds ConflictMarker/ConflictOurs/ConflictTheirs theme colors.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Conflict marker lines (<<<<<<<, |||||||, =======, >>>>>>>) render bold in a distinct color
+- [ ] #2 The 'ours' and 'theirs' blocks between markers get slightly different background tints
+- [ ] #3 Markers only detected inside a conflict block (no false hits on setext ===== or ==highlight==)
+- [ ] #4 Works in both prose and code files; markup-visible invariant preserved
+<!-- AC:END -->
internal/editor/scanner.go +52 −0
@@ -14,7 +14,59 @@ 	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
+
+	// Git merge-conflict markers, anchored at column 0. Git writes exactly seven
+	// marker characters followed by a space and a label (or end of line).
+	conflictOursRe   = regexp.MustCompile(`^<{7}( |$)`)  // <<<<<<< HEAD
+	conflictBaseRe   = regexp.MustCompile(`^\|{7}( |$)`) // ||||||| base (diff3)
+	conflictSepRe    = regexp.MustCompile(`^={7}\s*$`)   // =======
+	conflictTheirsRe = regexp.MustCompile(`^>{7}( |$)`)  // >>>>>>> branch
 )
+
+// conflictPhase tracks which side of a merge conflict a line falls in.
+type conflictPhase int
+
+const (
+	conflictNone   conflictPhase = iota
+	conflictOurs                 // between <<<<<<< and ||||||| / =======
+	conflictBase                 // between ||||||| and ======= (diff3)
+	conflictTheirs               // between ======= and >>>>>>>
+)
+
+// applyConflictHighlight restyles conflict marker lines and tints the two
+// version blocks between them, mutating spans in place. It runs as a post-pass
+// over already-scanned spans so it works for both the prose and code scanners.
+// Marker characters are only recognized as markers once inside a conflict block
+// (opened by <<<<<<<), so a setext ===== underline or a lone ======= elsewhere
+// is never mistaken for a separator. The markup-visible invariant is preserved:
+// marker lines become one whole-line span, content lines only gain a background.
+func applyConflictHighlight(all [][]Span, lines []string, th theme.Theme) {
+	if len(all) != len(lines) {
+		return
+	}
+	marker := lipgloss.NewStyle().Foreground(th.ConflictMarker).Bold(true)
+	phase := conflictNone
+	for i, line := range lines {
+		switch {
+		case phase == conflictNone && conflictOursRe.MatchString(line):
+			phase = conflictOurs
+			all[i] = wholeLine(line, marker)
+		case phase != conflictNone && conflictBaseRe.MatchString(line):
+			phase = conflictBase
+			all[i] = wholeLine(line, marker)
+		case phase != conflictNone && conflictSepRe.MatchString(line):
+			phase = conflictTheirs
+			all[i] = wholeLine(line, marker)
+		case phase != conflictNone && conflictTheirsRe.MatchString(line):
+			phase = conflictNone
+			all[i] = wholeLine(line, marker)
+		case phase == conflictOurs || phase == conflictBase:
+			all[i] = withBackground(all[i], th.ConflictOurs)
+		case phase == conflictTheirs:
+			all[i] = withBackground(all[i], th.ConflictTheirs)
+		}
+	}
+}
 
 // blockState carries cross-line context (fenced code, leading frontmatter).
 type blockState struct {
internal/editor/scanner_test.go +91 −0
@@ -290,3 +290,94 @@ 	if out[7][0].Style.GetForeground() != th.Text {
 		t.Errorf("post-frontmatter line should be plain text")
 	}
 }
+
+func TestConflictHighlight(t *testing.T) {
+	th := theme.FlexokiDark()
+	lines := []string{
+		"before",
+		"<<<<<<< HEAD",
+		"our change",
+		"=======",
+		"their change",
+		">>>>>>> feature",
+		"after",
+	}
+	all := ScanLines(lines, th)
+	applyConflictHighlight(all, lines, th)
+
+	// Markup-visible invariant: span text still reproduces each raw line.
+	for i, line := range lines {
+		if spanText(all[i]) != line {
+			t.Errorf("line %d: spanText %q != raw %q", i, spanText(all[i]), line)
+		}
+	}
+
+	// The three marker lines are one bold span in the marker color.
+	for _, i := range []int{1, 3, 5} {
+		if len(all[i]) != 1 {
+			t.Fatalf("marker line %d: want 1 span, got %d", i, len(all[i]))
+		}
+		sp := all[i][0]
+		if sp.Style.GetForeground() != th.ConflictMarker || !sp.Style.GetBold() {
+			t.Errorf("marker line %d not bold ConflictMarker (fg=%v bold=%v)",
+				i, sp.Style.GetForeground(), sp.Style.GetBold())
+		}
+	}
+
+	// "ours" content tinted with ConflictOurs.
+	if all[2][0].Style.GetBackground() != th.ConflictOurs {
+		t.Errorf("ours block bg = %v, want ConflictOurs %v", all[2][0].Style.GetBackground(), th.ConflictOurs)
+	}
+	// "theirs" content tinted with ConflictTheirs.
+	if all[4][0].Style.GetBackground() != th.ConflictTheirs {
+		t.Errorf("theirs block bg = %v, want ConflictTheirs %v", all[4][0].Style.GetBackground(), th.ConflictTheirs)
+	}
+	// Lines outside the conflict are untouched (no conflict tint).
+	if bg := all[0][0].Style.GetBackground(); bg == th.ConflictOurs || bg == th.ConflictTheirs {
+		t.Errorf("pre-conflict line should not be tinted, got %v", bg)
+	}
+	if bg := all[6][0].Style.GetBackground(); bg == th.ConflictOurs || bg == th.ConflictTheirs {
+		t.Errorf("post-conflict line should not be tinted, got %v", bg)
+	}
+}
+
+func TestConflictDiff3BaseBlock(t *testing.T) {
+	th := theme.FlexokiDark()
+	lines := []string{
+		"<<<<<<< HEAD",
+		"ours",
+		"||||||| base",
+		"original",
+		"=======",
+		"theirs",
+		">>>>>>> branch",
+	}
+	all := ScanLines(lines, th)
+	applyConflictHighlight(all, lines, th)
+
+	// ||||||| is a marker line.
+	if len(all[2]) != 1 || all[2][0].Style.GetForeground() != th.ConflictMarker {
+		t.Errorf("||||||| base line not marked as a conflict marker")
+	}
+	// The base block shares the "ours" tint.
+	if all[3][0].Style.GetBackground() != th.ConflictOurs {
+		t.Errorf("base block bg = %v, want ConflictOurs", all[3][0].Style.GetBackground())
+	}
+}
+
+func TestConflictSeparatorNotFalselyDetected(t *testing.T) {
+	th := theme.FlexokiDark()
+	// A lone ======= (setext-ish underline) outside a conflict must NOT be
+	// treated as a separator or gain a conflict tint.
+	lines := []string{"Title", "=======", "body"}
+	all := ScanLines(lines, th)
+	applyConflictHighlight(all, lines, th)
+	for i := range lines {
+		if len(all[i]) == 0 {
+			continue
+		}
+		if bg := all[i][0].Style.GetBackground(); bg == th.ConflictOurs || bg == th.ConflictTheirs {
+			t.Errorf("line %d falsely tinted as conflict content", i)
+		}
+	}
+}
internal/editor/wrap.go +1 −0
@@ -98,6 +98,7 @@ 	}
 	if e.spellActive() {
 		all = e.spellPass(all)
 	}
+	applyConflictHighlight(all, e.Lines, e.theme)
 	var rows []vrow
 	for li := range e.Lines {
 		for _, s := range wrapLine(e.Lines[li], e.Width) {
internal/theme/theme.go +5 −0
@@ -25,6 +25,11 @@ 	Accent     lipgloss.Color // frontmatter keys, selection
 	Highlight  lipgloss.Color // ==highlight== background tint
 	Spell      lipgloss.Color // misspelled-word undercurl (red)
 
+	// Merge-conflict highlighting (git markers <<<<<<< ||||||| ======= >>>>>>>).
+	ConflictMarker lipgloss.Color // bold marker lines
+	ConflictOurs   lipgloss.Color // background tint for the "ours"/base block
+	ConflictTheirs lipgloss.Color // background tint for the "theirs" block
+
 	// UI colors.
 	Background lipgloss.Color
 	Muted      lipgloss.Color // markup punctuation, dimmed
internal/theme/theme_test.go +2 −0
@@ -17,6 +17,8 @@ 			"Blockquote": th.Blockquote, "Comment": th.Comment, "Accent": th.Accent,
 			"Highlight": th.Highlight, "Background": th.Background, "Muted": th.Muted,
 			"StatusFg": th.StatusFg, "StatusBg": th.StatusBg, "SelFg": th.SelFg,
 			"SelBg": th.SelBg, "Pointer": th.Pointer,
+			"ConflictMarker": th.ConflictMarker, "ConflictOurs": th.ConflictOurs,
+			"ConflictTheirs": th.ConflictTheirs,
 		}
 		for name, c := range colors {
 			if c == "" {
internal/theme/themes.go +30 −15
@@ -29,11 +29,16 @@ 		Comment:      lipgloss.Color("#DA702C"), // orange-400 — visible meta
 		Accent:       lipgloss.Color("#D0A215"), // yellow-400
 		Highlight:    lipgloss.Color("#3A3517"), // deep olive — ==highlight== bg
 		Spell:        lipgloss.Color("#D14D41"), // red-400 — misspell undercurl
-		StatusFg:     lipgloss.Color("#100F0F"),
-		StatusBg:     lipgloss.Color("#4385BE"),
-		SelFg:        lipgloss.Color("#100F0F"),
-		SelBg:        lipgloss.Color("#D0A215"),
-		Pointer:      lipgloss.Color("#CE5D97"),
+
+		ConflictMarker: lipgloss.Color("#8B7EC8"), // purple-400 — conflict marker lines
+		ConflictOurs:   lipgloss.Color("#1A1E0C"), // green-950 — "ours" block tint
+		ConflictTheirs: lipgloss.Color("#261312"), // red-950 — "theirs" block tint
+
+		StatusFg: lipgloss.Color("#100F0F"),
+		StatusBg: lipgloss.Color("#4385BE"),
+		SelFg:    lipgloss.Color("#100F0F"),
+		SelBg:    lipgloss.Color("#D0A215"),
+		Pointer:  lipgloss.Color("#CE5D97"),
 	}
 }
 
@@ -57,11 +62,16 @@ 		Comment:      lipgloss.Color("#BC5215"), // orange-600 — visible meta
 		Accent:       lipgloss.Color("#AD8301"), // yellow-600
 		Highlight:    lipgloss.Color("#F0E6BE"), // pale yellow — ==highlight== bg
 		Spell:        lipgloss.Color("#AF3029"), // red-600 — misspell undercurl
-		StatusFg:     lipgloss.Color("#FFFCF0"),
-		StatusBg:     lipgloss.Color("#205EA6"),
-		SelFg:        lipgloss.Color("#FFFCF0"),
-		SelBg:        lipgloss.Color("#AD8301"),
-		Pointer:      lipgloss.Color("#A02F6F"),
+
+		ConflictMarker: lipgloss.Color("#5E409D"), // purple-600 — conflict marker lines
+		ConflictOurs:   lipgloss.Color("#EDEECF"), // green-50 — "ours" block tint
+		ConflictTheirs: lipgloss.Color("#FFE1D5"), // red-50 — "theirs" block tint
+
+		StatusFg: lipgloss.Color("#FFFCF0"),
+		StatusBg: lipgloss.Color("#205EA6"),
+		SelFg:    lipgloss.Color("#FFFCF0"),
+		SelBg:    lipgloss.Color("#AD8301"),
+		Pointer:  lipgloss.Color("#A02F6F"),
 	}
 }
 
@@ -85,10 +95,15 @@ 		Comment:      lipgloss.Color("#FFB454"),
 		Accent:       lipgloss.Color("#FFD500"),
 		Highlight:    lipgloss.Color("#3A2E4D"),
 		Spell:        lipgloss.Color("#FF5F87"), // pink-red — misspell undercurl
-		StatusFg:     lipgloss.Color("#16161E"),
-		StatusBg:     lipgloss.Color("#6B50FF"),
-		SelFg:        lipgloss.Color("#16161E"),
-		SelBg:        lipgloss.Color("#FF5FAF"),
-		Pointer:      lipgloss.Color("#00FFA3"),
+
+		ConflictMarker: lipgloss.Color("#B794F6"), // soft purple — conflict marker lines
+		ConflictOurs:   lipgloss.Color("#17251C"), // dark green — "ours" block tint
+		ConflictTheirs: lipgloss.Color("#2A1518"), // dark red — "theirs" block tint
+
+		StatusFg: lipgloss.Color("#16161E"),
+		StatusBg: lipgloss.Color("#6B50FF"),
+		SelFg:    lipgloss.Color("#16161E"),
+		SelBg:    lipgloss.Color("#FF5FAF"),
+		Pointer:  lipgloss.Color("#00FFA3"),
 	}
 }