fancy theme

This commit is contained in:
2026-11-04 17:18:09 +01:00
parent e6ca4aef8f
commit d0860c26ad
11 changed files with 351 additions and 44 deletions
+1
View File
@@ -1,2 +1,3 @@
.pi/cache/ .pi/cache/
bin/
__pycache__ __pycache__
+2 -3
View File
@@ -2,7 +2,6 @@
"id": "ddb4f64c", "id": "ddb4f64c",
"title": "Implement the project using the Planka board", "title": "Implement the project using the Planka board",
"tags": [], "tags": [],
"status": "in_progress", "status": "closed",
"created_at": "2026-11-04T15:25:39.707Z", "created_at": "2026-11-04T15:25:39.707Z"
"assigned_to_session": "01a1a70c-fa7a-743f-86e3-469bd89f9d8e"
} }
+1 -1
View File
@@ -2,7 +2,7 @@
"id": "f2ddb00d", "id": "f2ddb00d",
"title": "Celebrate", "title": "Celebrate",
"tags": [], "tags": [],
"status": "open", "status": "closed",
"created_at": "2026-11-04T15:25:41.786Z" "created_at": "2026-11-04T15:25:41.786Z"
} }
+13 -4
View File
@@ -7,12 +7,21 @@ A terminal app for Pi project todos. The Bubble Tea UI browses and searches a pr
Requires Go 1.24 or newer and a terminal. Requires Go 1.24 or newer and a terminal.
```bash ```bash
go run . # inspect the current project go build -o bin/todoapp . # build the executable
go run . --project /path/to/repo # inspect another project ./bin/todoapp # inspect the current project
go test ./... ./bin/todoapp --project /path/to/repo # inspect another project
go test ./... # run unit and compatibility tests
``` ```
If `.pi/todos` does not exist, the app shows an empty state without creating files. Bad todo files are reported as warnings without hiding valid todos. If the project path is invalid or the todo directory cannot be read, it exits with an error. `go run .` works too. `--project` selects the *project root*, not the todo directory. TodoApp always uses that project's `.pi/todos` directory, the Pi todo extension's default.
If `.pi/todos` does not exist, the app shows an empty state without creating files; creating a todo will create the directory. Bad todo files are reported as warnings without hiding valid todos. If the project path is invalid or the todo directory cannot be read, it exits with an error.
The interface uses Lip Gloss for its compact panels, selection, status badges, and warnings. On small terminals it drops the panel borders and shortens hints rather than requiring a fixed width. Set `NO_COLOR=1` (or use `TERM=dumb`) for an uncolored layout; selection and status remain visible in text.
Todo files are named `<8-hex-id>.md`. Each starts with a JSON object (`id`, `title`, `tags`, `status`, `created_at`, optionally `assigned_to_session`), followed by a blank line and optional Markdown body. TodoApp preserves the ID, creation time, and Pi session assignment on edits. An existing `.lock` prevents changes; TodoApp does not steal locks. It does not change Pi's `settings.json`.
Compatibility was checked in both directions: TodoApp read a real file created by Pi's todo extension (kept as `internal/todos/testdata/pi-created.md`), and Pi's todo tool successfully read a temporary file created by TodoApp. The temporary compatibility items were deleted; the project's existing todos were not modified.
## Controls ## Controls
+71 -27
View File
@@ -128,7 +128,7 @@ func (m model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
lines := m.detailLines() lines := m.detailLines()
page := max(1, m.height-9) page := max(1, m.height-11)
last := max(0, len(lines)-page) last := max(0, len(lines)-page)
switch msg.String() { switch msg.String() {
case "esc", "left": case "esc", "left":
@@ -155,9 +155,9 @@ func (m model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
case "down", "j": case "down", "j":
m.selected = min(max(0, len(matches)-1), m.selected+1) m.selected = min(max(0, len(matches)-1), m.selected+1)
case "pgup": case "pgup":
m.selected = max(0, m.selected-max(1, m.height-8)) m.selected = max(0, m.selected-max(1, m.height-11))
case "pgdown": case "pgdown":
m.selected = min(max(0, len(matches)-1), m.selected+max(1, m.height-8)) m.selected = min(max(0, len(matches)-1), m.selected+max(1, m.height-11))
case "home", "g": case "home", "g":
m.selected = 0 m.selected = 0
case "end", "G": case "end", "G":
@@ -205,11 +205,18 @@ func (m model) detailLines() []string {
} }
var lines []string var lines []string
for _, line := range strings.Split(strings.TrimRight(body, "\n"), "\n") { for _, line := range strings.Split(strings.TrimRight(body, "\n"), "\n") {
lines = append(lines, strings.Split(runewidth.Wrap(line, max(1, m.width-2)), "\n")...) lines = append(lines, strings.Split(runewidth.Wrap(line, m.contentWidth()), "\n")...)
} }
return lines return lines
} }
func (m model) contentWidth() int {
if m.width < 50 || m.height < 14 || newTheme().plain {
return max(1, min(m.width, 100))
}
return max(1, min(m.width, 100)-6) // border and horizontal padding
}
func (m model) View() string { func (m model) View() string {
if m.confirm != "" { if m.confirm != "" {
return m.confirmView() return m.confirmView()
@@ -217,49 +224,86 @@ func (m model) View() string {
if m.editor != nil { if m.editor != nil {
return m.editorView() return m.editorView()
} }
width := max(1, m.width) t := newTheme()
width := m.contentWidth()
matches := m.filtered() matches := m.filtered()
if m.detail && len(matches) > 0 { if m.detail && len(matches) > 0 {
todo := m.items[matches[m.selected]] todo := m.items[matches[m.selected]]
lines := m.detailLines() lines := m.detailLines()
page := max(1, m.height-9) page := max(1, m.height-11)
start := min(m.scroll, max(0, len(lines)-page)) start := min(m.scroll, max(0, len(lines)-page))
end := min(start+page, len(lines)) end := min(start+page, len(lines))
header := fmt.Sprintf("TodoApp — %s [%s]\n%s • %s • %s\n\n", safe(todo.Title), todo.ID, safe(todo.Status), safe(strings.Join(todo.Tags, ", ")), todo.CreatedAt) header := t.heading("DETAIL / "+safe(todo.Title), todo.ID+" • "+safe(todo.CreatedAt), width)
return header + strings.Join(lines[start:end], "\n") + fmt.Sprintf("\n\n%d-%d/%d ↑↓/PgUp/PgDn scroll • e edit • x/o close/reopen • Esc back • q quit\n%s", start+1, end, len(lines), noticeLine(m.notice)) meta := t.status(todo.Status) + " " + t.muted.Render(safe(strings.Join(todo.Tags, ", ")))
hint := fmt.Sprintf("%d-%d/%d ↑↓ scroll · e edit · x/o status · Esc back · q quit", start+1, end, len(lines))
if width < 60 {
hint = "↑↓ scroll · e edit · Esc back · q quit"
}
content := header + "\n" + meta + "\n\n" + strings.Join(lines[start:end], "\n") +
fmt.Sprintf("\n\n%s\n", t.hint(hint, width))
if m.notice != "" {
content += t.alert(m.notice, width) + "\n"
}
return t.box(content, m.width, m.height)
} }
var out strings.Builder var out strings.Builder
fmt.Fprintf(&out, "TodoApp — %s\nTodos: %s\n", m.project, m.todosDir) fmt.Fprintln(&out, t.heading("TODOS", fmt.Sprintf("%d shown / %d total · %s", len(matches), len(m.items), safe(m.project)), width))
if len(m.warnings) > 0 { if len(m.warnings) > 0 {
fmt.Fprintf(&out, "Warning: %d unreadable todo(s): %s\n", len(m.warnings), safe(m.warnings[0].Error())) fmt.Fprintln(&out, t.alert(fmt.Sprintf("Warning: %d unreadable todo(s): %s", len(m.warnings), m.warnings[0]), width))
} }
fmt.Fprintf(&out, "\n%d of %d todo(s)\n", len(matches), len(m.items)) fmt.Fprintln(&out, t.muted.Render(runewidth.Truncate(m.todosDir, width, "…")))
out.WriteString("\n")
if len(matches) == 0 { if len(matches) == 0 {
if len(m.items) == 0 { if len(m.items) == 0 {
out.WriteString("No todos yet. Pi stores todo items in this directory.\n") fmt.Fprintln(&out, t.muted.Render("No todos yet. Pi stores todo items in this directory."))
} else { } else {
out.WriteString("No matching todos.\n") fmt.Fprintln(&out, t.muted.Render("No matching todos."))
} }
} else { } else {
page := max(1, m.height-9) page := max(1, m.height-11)
start := max(0, m.selected-page+1) start := max(0, m.selected-page+1)
end := min(start+page, len(matches)) end := min(start+page, len(matches))
for i := start; i < end; i++ { for i := start; i < end; i++ {
todo := m.items[matches[i]] todo := m.items[matches[i]]
prefix := " " status := runewidth.Truncate(safe(todo.Status), max(1, min(16, width/3)), "…")
tags := ""
if width >= 60 {
tags = runewidth.Truncate(safe(strings.Join(todo.Tags, ", ")), min(16, width/5), "…")
}
tagsWidth := 0
if tags != "" {
tagsWidth = 2 + runewidth.StringWidth(tags)
}
title := runewidth.Truncate(safe(todo.Title), max(1, width-16-runewidth.StringWidth(status)-tagsWidth), "…")
line := todo.ID + " [" + status + "] " + title
if tags != "" {
line += " " + tags
}
if i == m.selected { if i == m.selected {
prefix = "> " fmt.Fprintln(&out, t.row(line, true, width))
}
line := fmt.Sprintf("%s%s %s [%s] %s", prefix, todo.ID, safe(todo.Title), safe(todo.Status), safe(strings.Join(todo.Tags, ", ")))
out.WriteString(runewidth.Truncate(line, width, "…") + "\n")
}
}
if m.search {
fmt.Fprintf(&out, "\nSearch: %s_ (Enter/Esc finish, Ctrl+U clear)\n", safe(m.query))
} else { } else {
fmt.Fprintf(&out, "\nSearch: %s (/ to edit, Esc clear)\n", safe(m.query)) row := t.muted.Render(" "+todo.ID) + " " + t.status(status) + " " + title
if tags != "" {
row += " " + t.muted.Render(tags)
} }
out.WriteString("↑↓ navigate • Enter view • n new • e edit • x/o close/reopen • r refresh • q quit\n") fmt.Fprintln(&out, row)
out.WriteString(noticeLine(m.notice)) }
return out.String() }
}
search := "Search: " + safe(m.query)
if m.search {
search += "▏"
}
fmt.Fprintln(&out, "\n"+t.accent.Render(runewidth.Truncate(search, width, "…")))
if m.notice != "" {
fmt.Fprintln(&out, t.alert(m.notice, width))
}
hint := "↑↓ select · Enter view · / search · n new · e edit · x/o status · r refresh · q quit"
if width < 40 {
hint = "↑↓ select · Enter view · q quit"
} else if width < 70 {
hint = "↑↓ move · Enter view · / find · n new · e edit · q quit"
}
fmt.Fprintln(&out, t.hint(hint, width))
return t.box(out.String(), m.width, m.height)
} }
+1 -1
View File
@@ -88,7 +88,7 @@ func TestDetailScrollingAndNavigation(t *testing.T) {
t.Fatalf("expected paged detail: %s", app.View()) t.Fatalf("expected paged detail: %s", app.View())
} }
app = testKey(app, tea.KeyMsg{Type: tea.KeyPgDown}) app = testKey(app, tea.KeyMsg{Type: tea.KeyPgDown})
if app.scroll == 0 || !strings.Contains(app.View(), "line 4") { if app.scroll == 0 || !strings.Contains(app.View(), "line 2") {
t.Fatalf("detail did not scroll: %s", app.View()) t.Fatalf("detail did not scroll: %s", app.View())
} }
app = testKey(app, tea.KeyMsg{Type: tea.KeyEnd}) app = testKey(app, tea.KeyMsg{Type: tea.KeyEnd})
+56 -11
View File
@@ -248,12 +248,17 @@ func (m *model) handleConfirm(key tea.KeyMsg) {
func (m model) editorView() string { func (m model) editorView() string {
e := m.editor e := m.editor
t := newTheme()
width := m.contentWidth()
mode := "New todo" mode := "New todo"
if !e.creating { if !e.creating {
mode = "Edit " + e.original.ID mode = "Edit " + e.original.ID
} }
var out strings.Builder var out strings.Builder
fmt.Fprintf(&out, "TodoApp — %s\n\n", mode) fmt.Fprintln(&out, t.heading("EDITOR", mode, width))
if m.height >= 14 {
out.WriteString("\n")
}
for i, label := range [2]string{"Title", "Tags (comma-separated)"} { for i, label := range [2]string{"Title", "Tags (comma-separated)"} {
prefix := " " prefix := " "
if i == e.field { if i == e.field {
@@ -264,16 +269,30 @@ func (m model) editorView() string {
chars := []rune(value) chars := []rune(value)
value = string(chars[:e.cursor]) + "▏" + string(chars[e.cursor:]) value = string(chars[:e.cursor]) + "▏" + string(chars[e.cursor:])
} }
fmt.Fprintln(&out, runewidth.Truncate(prefix+label+": "+safe(value), max(1, m.width), "…")) labelText := prefix + label + ": "
line := labelText + visibleCaretLine(safe(value), max(1, width-runewidth.StringWidth(labelText)))
line = runewidth.Truncate(line, width, "…")
if i == e.field {
fmt.Fprintln(&out, t.accent.Render(line))
} else {
fmt.Fprintln(&out, t.muted.Render(line))
}
}
if m.height >= 14 {
out.WriteString("\n")
}
if width < 38 {
out.WriteString(t.accent.Render("Body · Enter newline") + "\n")
} else {
out.WriteString(t.accent.Render("Body") + t.muted.Render(" Markdown · Enter adds a line") + "\n")
} }
out.WriteString("\nBody (Markdown; Enter adds a line):\n")
body := e.fields[2] body := e.fields[2]
if e.field == 2 { if e.field == 2 {
chars := []rune(body) chars := []rune(body)
body = string(chars[:e.cursor]) + "▏" + string(chars[e.cursor:]) body = string(chars[:e.cursor]) + "▏" + string(chars[e.cursor:])
} }
lines := strings.Split(safe(body), "\n") lines := strings.Split(safe(body), "\n")
page := max(1, m.height-11) page := max(1, m.height-13)
cursorLine := 0 cursorLine := 0
if e.field == 2 { if e.field == 2 {
cursorLine = strings.Count(string([]rune(e.fields[2])[:e.cursor]), "\n") cursorLine = strings.Count(string([]rune(e.fields[2])[:e.cursor]), "\n")
@@ -283,24 +302,50 @@ func (m model) editorView() string {
start = max(0, len(lines)-page) start = max(0, len(lines)-page)
} }
for _, line := range lines[start:min(start+page, len(lines))] { for _, line := range lines[start:min(start+page, len(lines))] {
fmt.Fprintln(&out, runewidth.Truncate(line, max(1, m.width), "…")) fmt.Fprintln(&out, visibleCaretLine(line, width))
} }
if len(lines) > page { if len(lines) > page && m.height >= 14 {
fmt.Fprintf(&out, "(%d-%d of %d body lines)\n", start+1, min(start+page, len(lines)), len(lines)) fmt.Fprintln(&out, t.muted.Render(fmt.Sprintf("%d-%d / %d body lines", start+1, min(start+page, len(lines)), len(lines))))
} }
out.WriteString(noticeLine(m.notice)) if m.notice != "" {
out.WriteString("\nTab/Shift+Tab fields • ←→ cursor • Ctrl+S save • Esc cancel\n") fmt.Fprintln(&out, t.alert(m.notice, width))
return out.String() }
if m.height >= 14 {
out.WriteString("\n")
}
hint := "Tab/Shift+Tab fields · ←→ cursor · Ctrl+S save · Esc cancel"
if width < 60 {
hint = "Tab fields · Ctrl+S save · Esc cancel"
}
if width < 40 {
hint = "Tab · Ctrl+S save · Esc cancel"
}
out.WriteString(t.hint(hint, width) + "\n")
return t.box(out.String(), m.width, m.height)
} }
func (m model) confirmView() string { func (m model) confirmView() string {
t := newTheme()
question := "Discard unsaved edits?" question := "Discard unsaved edits?"
if m.confirm == "close" { if m.confirm == "close" {
question = "Close this todo?" question = "Close this todo?"
} else if m.confirm == "reopen" { } else if m.confirm == "reopen" {
question = "Reopen this todo?" question = "Reopen this todo?"
} }
return fmt.Sprintf("TodoApp\n\n%s\n\ny confirm • n/Esc cancel\n", question) content := t.heading("CONFIRM", "This action changes the current todo", m.contentWidth()) + "\n\n" +
t.warning.Render(question) + "\n\n" + t.accent.Render("[y] confirm") + " " + t.muted.Render("[n] / Esc cancel") + "\n"
return t.box(content, m.width, m.height)
}
// Keep the insertion cursor visible even when editing a line wider than the terminal.
func visibleCaretLine(line string, width int) string {
if index := strings.Index(line, "▏"); index >= 0 {
before := runewidth.StringWidth(line[:index])
if before >= width-2 {
line = runewidth.TruncateLeft(line, max(0, before-width+4), "…")
}
}
return runewidth.Truncate(line, max(1, width), "…")
} }
func noticeLine(text string) string { func noticeLine(text string) string {
+25
View File
@@ -22,6 +22,31 @@ const fixture = `{
Keep {braces} in the body. Keep {braces} in the body.
` `
func TestActualPiExtensionFixtureRoundTrip(t *testing.T) {
// Captured from a todo created by Pi's todo extension (not hand-written).
data, err := os.ReadFile("testdata/pi-created.md")
if err != nil {
t.Fatal(err)
}
parsed, err := Parse("1ddf1df4", data)
if err != nil || parsed.Title != "TodoApp compatibility probe" || parsed.Status != "open" || len(parsed.Tags) != 2 || parsed.Body != "# Compatibility\n\nCreated by the Pi todo extension for TodoApp tests.\n" {
t.Fatalf("could not read Pi fixture: %+v, %v", parsed, err)
}
dir := t.TempDir()
path := filepath.Join(dir, parsed.ID+".md")
if err := os.WriteFile(path, data, 0600); err != nil {
t.Fatal(err)
}
parsed.Title = "Edited in TodoApp"
if err := Update(dir, parsed); err != nil {
t.Fatal(err)
}
updated, err := Read(dir, parsed.ID)
if err != nil || updated.Title != "Edited in TodoApp" || updated.CreatedAt != parsed.CreatedAt || updated.Body != parsed.Body {
t.Fatalf("Pi fixture round-trip failed: %+v, %v", updated, err)
}
}
func TestParseAndSerializePiFixture(t *testing.T) { func TestParseAndSerializePiFixture(t *testing.T) {
todo, err := Parse("deadbeef", []byte(fixture)) todo, err := Parse("deadbeef", []byte(fixture))
if err != nil { if err != nil {
+14
View File
@@ -0,0 +1,14 @@
{
"id": "1ddf1df4",
"title": "TodoApp compatibility probe",
"tags": [
"compat",
"pi"
],
"status": "open",
"created_at": "2026-11-04T15:58:40.258Z"
}
# Compatibility
Created by the Pi todo extension for TodoApp tests.
+89
View File
@@ -0,0 +1,89 @@
package main
import (
"os"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/mattn/go-runewidth"
)
// A restrained, high-contrast palette. Text always includes its meaning, so
// colors supplement the UI rather than carrying state by themselves.
type uiTheme struct {
title, accent, muted, warning, success, active, selected lipgloss.Style
border lipgloss.Style
plain bool
}
func newTheme() uiTheme {
t := uiTheme{plain: os.Getenv("NO_COLOR") != "" || strings.EqualFold(os.Getenv("TERM"), "dumb")}
if t.plain {
t.title = lipgloss.NewStyle()
t.accent = lipgloss.NewStyle()
t.muted = lipgloss.NewStyle()
t.warning = lipgloss.NewStyle()
t.success = lipgloss.NewStyle()
t.active = lipgloss.NewStyle()
t.selected = lipgloss.NewStyle()
t.border = lipgloss.NewStyle()
return t
}
t.title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#D7E9EF"))
t.accent = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#70D6D1"))
t.muted = lipgloss.NewStyle().Foreground(lipgloss.Color("#8FA6B2"))
t.warning = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFC078"))
t.success = lipgloss.NewStyle().Foreground(lipgloss.Color("#A8DDA2"))
t.active = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFC078"))
t.selected = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#F4FAFC")).Background(lipgloss.Color("#244553"))
t.border = lipgloss.NewStyle().Foreground(lipgloss.Color("#426573"))
return t
}
func (t uiTheme) status(status string) string {
label := "[" + safe(status) + "]"
switch strings.ToLower(status) {
case "closed", "done":
return t.muted.Render(label)
case "open":
return t.success.Render(label)
default:
return t.active.Render(label)
}
}
func (t uiTheme) heading(section string, count string, width int) string {
prefix := "▌ TODOAPP / "
section = runewidth.Truncate(safe(section), max(1, width-runewidth.StringWidth(prefix)), "…")
return t.accent.Render("▌ ") + t.title.Render("TODOAPP") + t.muted.Render(" / "+section) + "\n" +
t.muted.Render(runewidth.Truncate(safe(count), max(1, width), "…"))
}
// box frames content only when the terminal has room for a border. The width
// argument is the terminal width, not the inner content width.
func (t uiTheme) box(content string, width, height int) string {
if width < 50 || height < 14 || t.plain {
return content
}
return t.border.Border(lipgloss.RoundedBorder()).Padding(0, 1).Width(min(width, 100) - 2).Render(content)
}
func (t uiTheme) row(label string, selected bool, width int) string {
prefix := " "
if selected {
prefix = "› "
}
text := runewidth.Truncate(prefix+label, max(1, width), "…")
if selected {
return t.selected.Width(min(width, 100)).Render(text)
}
return text
}
func (t uiTheme) hint(text string, width int) string {
return t.muted.Render(runewidth.Truncate(text, max(1, width), "…"))
}
func (t uiTheme) alert(text string, width int) string {
return t.warning.Render(runewidth.Truncate("! "+safe(text), max(1, width), "…"))
}
+81
View File
@@ -0,0 +1,81 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/charmbracelet/lipgloss"
"todoapp/internal/todos"
)
func assertFits(t *testing.T, view string, width, height int) {
t.Helper()
lines := strings.Split(strings.TrimSuffix(view, "\n"), "\n")
if len(lines) > height {
t.Fatalf("view is %d lines in %d-row terminal:\n%s", len(lines), height, view)
}
for _, line := range lines {
if lipgloss.Width(line) > width {
t.Fatalf("line is %d cells in %d-column terminal: %q", lipgloss.Width(line), width, line)
}
}
}
func TestStyledScreensFitTerminals(t *testing.T) {
for _, size := range [][2]int{{80, 24}, {50, 14}, {32, 10}} {
width, height := size[0], size[1]
app := model{width: width, height: height, project: "/tmp/project", todosDir: "/tmp/project/.pi/todos", items: []todos.Todo{
{ID: "deadbeef", Title: "A long title for a narrow terminal", Status: "open", Tags: []string{"qa"}, Body: "# Long heading\n\nBody text."},
{ID: "cafebabe", Title: "Another todo", Status: "in_progress_with_long_status", Tags: []string{"a very long tag that must be clipped"}, Body: "Second body"},
}}
assertFits(t, app.View(), width, height)
if !strings.Contains(app.View(), "› ") || !strings.Contains(app.View(), "Search:") {
t.Fatal("selection or search missing from styled list")
}
app = testKey(app, runeKey('/'))
app = testKey(app, runeKey('z'))
if !strings.Contains(app.View(), "No matching todos") {
t.Fatal("search empty state missing")
}
assertFits(t, app.View(), width, height)
app.search, app.query = false, ""
app.detail = true
assertFits(t, app.View(), width, height)
app.detail = false
// Render an editor without touching the filesystem.
app.editor = &editor{creating: true, fields: [3]string{"A title that goes well beyond the width of this small window", "qa, pi", "Line one\nLine two"}}
app.editor.selectField(0)
if !strings.Contains(app.View(), "▏") {
t.Fatalf("title cursor hidden at width %d: %s", width, app.View())
}
assertFits(t, app.View(), width, height)
app.editor.selectField(2)
assertFits(t, app.View(), width, height)
app.confirm = "close"
assertFits(t, app.View(), width, height)
}
}
func TestNoColorAndMalformedWarning(t *testing.T) {
t.Setenv("NO_COLOR", "1")
project := t.TempDir()
dir := todos.Dir(project)
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "deadbeef.md"), []byte("invalid"), 0600); err != nil {
t.Fatal(err)
}
app, err := newModel(project)
if err != nil {
t.Fatal(err)
}
view := app.View()
if !strings.Contains(view, "Warning") || !strings.Contains(view, "No todos yet") || strings.Contains(view, "\x1b[") {
t.Fatalf("no-color warning/empty state is unclear: %s", view)
}
assertFits(t, view, 80, 24)
}