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
+}