fancy theme
This commit is contained in:
@@ -1,2 +1,3 @@
|
|||||||
.pi/cache/
|
.pi/cache/
|
||||||
|
bin/
|
||||||
__pycache__
|
__pycache__
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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})
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
@@ -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), "…"))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user