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

feat: rich status bar — dir, theme, Ln:Col, word count, selection stats (TASK-010)

efdd6b348005b3fd5d77ab5aed57ce9897256dad
Kevin Kortum <kevinkortum@me.com> · 2026-06-29 15:39

parent c69bdbca

feat: rich status bar — dir, theme, Ln:Col, word count, selection stats (TASK-010)

Left shows dir/filename + dirty dot (transient messages take over); right shows
[selection chars/words ·] Ln:Col · word count · theme · ? help indicator.
layoutStatus drops low-priority segments then truncates so the bar never
overflows at narrow widths. Editor WordCount/SelectionStats helpers added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KjNrdAWUdkaxFyGdrPHaBj

5 files changed

- → Status-bar-line-col-and-word-count.md +14 −8
@@ -4,7 +4,7 @@ title: 'Status bar: directory, theme, line:col, word count, help, selection counts'
 status: "\U0001F7E2 In progress"
 assignee: []
 created_date: '2026-06-29 16:26'
-updated_date: '2026-06-29 17:49'
+updated_date: '2026-06-29 22:38'
 labels:
   - feature
   - release-1
@@ -21,11 +21,17 @@ <!-- SECTION:DESCRIPTION:END -->
 
 ## Acceptance Criteria
 <!-- AC:BEGIN -->
-- [ ] #1 Status bar shows Ln:Col (1-based) updating live
-- [ ] #2 Status bar shows a word count
-- [ ] #3 Layout coexists with filename/dirty and respects theme colors
-- [ ] #4 Always shows the current directory and the theme name
-- [ ] #5 Shows a '?' help indicator (tied to the help overlay)
-- [ ] #6 When text is selected, shows the selection's character count and word count
-- [ ] #7 Bar degrades gracefully at narrow widths (no overflow)
+- [x] #1 Status bar shows Ln:Col (1-based) updating live
+- [x] #2 Status bar shows a word count
+- [x] #3 Layout coexists with filename/dirty and respects theme colors
+- [x] #4 Always shows the current directory and the theme name
+- [x] #5 Shows a '?' help indicator (tied to the help overlay)
+- [x] #6 When text is selected, shows the selection's character count and word count
+- [x] #7 Bar degrades gracefully at narrow widths (no overflow)
 <!-- AC:END -->
+
+## Implementation Notes
+
+<!-- SECTION:NOTES:BEGIN -->
+Reworked statusBar (internal/app/app.go): left = dir/filename + dirty dot (transient a.status takes over when set); right = [N chars · M words selected ·] Ln r:c · N words · theme · ?. layoutStatus() right-justifies and drops trailing ' · ' segments then truncates to fit — no overflow at narrow widths. Editor stats helpers WordCount()/SelectionStats() in internal/editor/stats.go. Theme StatusFg/StatusBg preserved. '?' indicator shown; opening the overlay is wired in TASK-011. TDD: 3 editor + 6 app tests; full suite green.
+<!-- SECTION:NOTES:END -->
internal/app/app.go +57 −4
@@ -725,11 +725,64 @@ 	dirty := ""
 	if a.editor.Dirty {
 		dirty = " ●"
 	}
-	left := a.status
-	if left == "" {
-		left = "glint"
+	// Left: a transient message takes over; otherwise dir (+ filename).
+	var left string
+	if a.status != "" {
+		left = a.status + dirty
+	} else {
+		dir := filepath.Base(a.currentDir())
+		if a.path != "" {
+			left = dir + "/" + filepath.Base(a.path) + dirty
+		} else {
+			left = dir + "/" + dirty
+		}
+	}
+	// Right: [sel stats ·] Ln r:c · N words · theme · ?  (high→low priority).
+	pos := a.editor.Cursor
+	segs := []string{fmt.Sprintf("Ln %d:%d", pos.Row+1, pos.Col+1)}
+	if c, w, ok := a.editor.SelectionStats(); ok {
+		segs = append(segs, fmt.Sprintf("%d chars · %d words selected", c, w))
+	}
+	segs = append(segs,
+		fmt.Sprintf("%d words", a.editor.WordCount()),
+		a.theme.Name,
+		"?",
+	)
+	return bar.Render(layoutStatus(" "+left, strings.Join(segs, " · ")+" ", maxInt(a.width, 1)))
+}
+
+// layoutStatus left-justifies left and right-justifies right within width,
+// dropping trailing right-hand segments (split on " · ") then truncating to fit
+// at narrow widths so the bar never overflows.
+func layoutStatus(left, right string, width int) string {
+	for lipgloss.Width(left)+lipgloss.Width(right)+1 > width && strings.Contains(right, " · ") {
+		i := strings.LastIndex(right, " · ")
+		right = right[:i] + right[len(right)-1:] // keep the trailing space
+	}
+	if lipgloss.Width(left)+lipgloss.Width(right)+1 > width {
+		right = "" // still too wide: drop the right group entirely
+	}
+	gap := width - lipgloss.Width(left) - lipgloss.Width(right)
+	if gap < 1 {
+		// Left alone overflows: truncate it to the available width.
+		return truncate(left, width)
 	}
-	return bar.Render(fmt.Sprintf(" %s%s ", left, dirty))
+	return left + strings.Repeat(" ", gap) + right
+}
+
+// truncate cuts s to a maximum display width, appending … when shortened.
+func truncate(s string, width int) string {
+	if lipgloss.Width(s) <= width {
+		return s
+	}
+	if width <= 1 {
+		return strings.Repeat(" ", maxInt(width, 0))
+	}
+	r := []rune(s)
+	for len(r) > 0 && lipgloss.Width(string(r))+1 > width {
+		r = r[:len(r)-1]
+	}
+	return string(r) + "…"
 }
 
 func maxInt(a, b int) int {
internal/app/status_test.go +72 −0
@@ -0,0 +1,72 @@
+package app
+
+import (
+	"strings"
+	"testing"
+
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+	"glint/internal/editor"
+)
+
+func statusApp() *App {
+	a := newApp()
+	a.width = 120
+	a.height = 24
+	return a
+}
+
+func TestStatusBarShowsLnCol(t *testing.T) {
+	a := statusApp()
+	a.editor.SetContent([]byte("hello\nworld"))
+	a.editor.Cursor = editor.Position{Row: 1, Col: 2}
+	if got := a.statusBar(); !strings.Contains(got, "Ln 2:3") {
+		t.Fatalf("status bar missing Ln 2:3:\n%s", got)
+	}
+}
+
+func TestStatusBarShowsWordCount(t *testing.T) {
+	a := statusApp()
+	a.editor.SetContent([]byte("one two three"))
+	if got := a.statusBar(); !strings.Contains(got, "3 words") {
+		t.Fatalf("status bar missing word count:\n%s", got)
+	}
+}
+
+func TestStatusBarShowsThemeName(t *testing.T) {
+	a := statusApp()
+	if got := a.statusBar(); !strings.Contains(got, a.theme.Name) {
+		t.Fatalf("status bar missing theme %q:\n%s", a.theme.Name, got)
+	}
+}
+
+func TestStatusBarShowsHelpIndicator(t *testing.T) {
+	a := statusApp()
+	if got := a.statusBar(); !strings.Contains(got, "?") {
+		t.Fatalf("status bar missing help indicator:\n%s", got)
+	}
+}
+
+func TestStatusBarShowsSelectionStats(t *testing.T) {
+	a := statusApp()
+	a.editor.SetContent([]byte("one two three"))
+	for i := 0; i < 7; i++ { // select "one two" via Shift+Right
+		a.editor.HandleKey(tea.KeyMsg{Type: tea.KeyShiftRight})
+	}
+	got := a.statusBar()
+	if !strings.Contains(got, "7 chars") || !strings.Contains(got, "2 words selected") {
+		t.Fatalf("status bar missing selection stats:\n%s", got)
+	}
+}
+
+func TestStatusBarNarrowNoOverflow(t *testing.T) {
+	a := statusApp()
+	a.width = 20
+	a.editor.SetContent([]byte("one two three four five"))
+	a.editor.Cursor = editor.Position{Row: 0, Col: 3}
+	for _, line := range strings.Split(a.statusBar(), "\n") {
+		if w := lipgloss.Width(line); w > 20 {
+			t.Fatalf("status line width %d > 20: %q", w, line)
+		}
+	}
+}
internal/editor/format_test.go +23 −0
@@ -126,3 +126,26 @@ 	if e.Lines[0] != "word" {
 		t.Fatalf("after undo Lines[0] = %q, want %q", e.Lines[0], "word")
 	}
 }
+
+func TestWordCountWholeBuffer(t *testing.T) {
+	e := newEditorWith("one two", "three")
+	if n := e.WordCount(); n != 3 {
+		t.Fatalf("WordCount = %d, want 3", n)
+	}
+}
+
+func TestSelectionStats(t *testing.T) {
+	e := newEditorWith("one two three")
+	sel(e, 0, 0, 0, 7) // "one two"
+	c, w, ok := e.SelectionStats()
+	if !ok || c != 7 || w != 2 {
+		t.Fatalf("SelectionStats = (%d,%d,%v), want (7,2,true)", c, w, ok)
+	}
+}
+
+func TestSelectionStatsNoSelection(t *testing.T) {
+	e := newEditorWith("x")
+	if _, _, ok := e.SelectionStats(); ok {
+		t.Fatal("SelectionStats ok should be false with no selection")
+	}
+}
internal/editor/stats.go +25 −0
@@ -0,0 +1,25 @@
+package editor
+
+import (
+	"strings"
+	"unicode/utf8"
+)
+
+// WordCount returns the whitespace-separated word count of the whole buffer.
+func (e *Editor) WordCount() int {
+	n := 0
+	for _, ln := range e.Lines {
+		n += len(strings.Fields(ln))
+	}
+	return n
+}
+
+// SelectionStats returns the character and word count of the active selection,
+// with ok=false when nothing is selected.
+func (e *Editor) SelectionStats() (chars, words int, ok bool) {
+	if !e.HasSelection() {
+		return 0, 0, false
+	}
+	s := e.SelectedText()
+	return utf8.RuneCountInString(s), len(strings.Fields(s)), true
+}