Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,27 @@ module github.com/kryft-dev/cdd

go 1.26.3

require github.com/pelletier/go-toml/v2 v2.4.3 // indirect
require (
charm.land/bubbletea/v2 v2.0.9
charm.land/lipgloss/v2 v2.0.6
github.com/pelletier/go-toml/v2 v2.4.3
github.com/sahilm/fuzzy v0.1.3
)

require (
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect
github.com/charmbracelet/x/ansi v0.11.8 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
github.com/mattn/go-runewidth v0.0.27 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
)
44 changes: 44 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,2 +1,46 @@
charm.land/bubbletea/v2 v2.0.9 h1:DpJCMWKgzQK8SJv4zbKKFHAI10ymWy/evClPFk0k0f8=
charm.land/bubbletea/v2 v2.0.9/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA=
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro=
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0=
github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/sahilm/fuzzy v0.1.3 h1:juByESSS32nVD81vr6tHmKmA/8zde7gE+x5CLxrzXPU=
github.com/sahilm/fuzzy v0.1.3/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
135 changes: 135 additions & 0 deletions internal/picker/layout.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package picker

import "time"

// nameFloor is the smallest a truncated Project name is ever shrunk to.
const nameFloor = 8

// previewFloor is the least remaining width the preview pane needs; below
// it the preview is not drawn at all.
const previewFloor = 30

// caretWidth is the fixed-width caret/gutter column (" " or " › ").
const caretWidth = 3

// Layout is the Picker's per-frame sizing, recomputed from every
// tea.WindowSizeMsg without losing any Model state.
type Layout struct {
// NameWidth is the column width for a Project name, after any
// truncation.
NameWidth int
// StatusWidth is the column width for the status glyph cluster. It
// never shrinks.
StatusWidth int
// TimeWidth is the column width for the relative last-visit time.
TimeWidth int
// ShortTime reports whether the relative time uses its compressed
// form (now, 5m, 2h, 3d, 2w, 3mo, 1y).
ShortTime bool

// ListWidth is the total width given to the grouped list pane.
ListWidth int
// PreviewWidth is the width given to the preview pane, when ShowPreview
// is true.
PreviewWidth int
// ShowPreview reports whether there is room to draw the preview pane.
ShowPreview bool

// ListHeight is the number of rows the list body may draw.
ListHeight int
// ShowLegend reports whether the footer's glyph legend line is drawn.
ShowLegend bool
// ShowKeys reports whether the footer's keys line is drawn.
ShowKeys bool
}

// ComputeLayout derives a Layout from the terminal size and the content
// that must fit in it: the longest Project name, the widest status glyph
// cluster, and the last-visit times of the currently visible rows.
//
// Degradation order (narrowest terminal wins): the list is sized to its
// natural content width capped at 55% of the terminal; if that leaves under
// previewFloor columns for the preview, the preview is dropped and the list
// takes the full width. Within the list's own budget, relative time
// compresses to its short form first, then names truncate with "…" down to
// nameFloor; the status cluster never shrinks and there is no hard minimum.
func ComputeLayout(longestName, widestStatus int, times []time.Time, now time.Time, width, height int) Layout {
l := Layout{
NameWidth: longestName,
StatusWidth: widestStatus,
TimeWidth: widestTime(times, now, false),
}

cap55 := width * 55 / 100
natural := caretWidth + l.NameWidth + 2 + l.StatusWidth + 2 + l.TimeWidth + 1
listBudget := min(natural, max(cap55, 1))

remainder := width - listBudget - 1
l.ShowPreview = remainder >= previewFloor
if l.ShowPreview {
l.PreviewWidth = remainder
}

// The width actually available to the list: the full terminal when the
// preview is not drawn, otherwise the capped budget.
available := width
if l.ShowPreview {
available = listBudget
}

if natural > available {
l.ShortTime = true
l.TimeWidth = widestTime(times, now, true)
natural = caretWidth + l.NameWidth + 2 + l.StatusWidth + 2 + l.TimeWidth + 1
}
if natural > available {
overflow := natural - available
l.NameWidth = max(l.NameWidth-overflow, nameFloor)
}

l.ListWidth = available

l.ShowLegend = height >= 15
l.ShowKeys = height >= 10
fixed := 1 // filter line
if l.ShowLegend {
fixed++ // rule + legend counted as one reserved line pairing with keys below
}
if l.ShowKeys {
fixed++
}
l.ListHeight = max(height-fixed-1, 1) // -1 for the footer rule

return l
}

// widestTime is the widest rendered relative time among times, using the
// short form when short is true.
func widestTime(times []time.Time, now time.Time, short bool) int {
w := 0
for _, t := range times {
var s string
if short {
s = RelativeTimeShort(t, now)
} else {
s = RelativeTime(t, now)
}
if len(s) > w {
w = len(s)
}
}
return w
}

// truncateName shortens name to width columns, ending in "…" when it does
// not fit, and never going below nameFloor unless name itself is shorter.
func truncateName(name string, width int) string {
r := []rune(name)
if len(r) <= width {
return name
}
if width <= 1 {
return string(r[:max(width, 0)])
}
return string(r[:width-1]) + "…"
}
97 changes: 97 additions & 0 deletions internal/picker/layout_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package picker_test

import (
"testing"
"time"

"github.com/kryft-dev/cdd/internal/picker"
)

func TestComputeLayout_PreviewVisibility(t *testing.T) {
now := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC)
times := []time.Time{now.Add(-2 * time.Hour)}

tests := []struct {
name string
width int
wantPreview bool
}{
{"wide terminal shows preview", 100, true},
{"narrow terminal hides preview", 40, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
lay := picker.ComputeLayout(8, 1, times, now, tt.width, 24)
if lay.ShowPreview != tt.wantPreview {
t.Errorf("ShowPreview = %v, want %v (width %d)", lay.ShowPreview, tt.wantPreview, tt.width)
}
})
}
}

func TestComputeLayout_TimeCompressesBeforeNameTruncates(t *testing.T) {
now := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC)
// "2 weeks ago" (11 cols) is long enough to force compression at a
// tight width, while the Project name should still fit uncompressed.
times := []time.Time{now.Add(-14 * 24 * time.Hour)}

lay := picker.ComputeLayout(12, 1, times, now, 30, 24)

if !lay.ShortTime {
t.Fatalf("ShortTime = false, want true at a tight width")
}
if lay.NameWidth != 12 {
t.Errorf("NameWidth = %d, want 12 (unchanged once time compression is enough)", lay.NameWidth)
}
}

func TestComputeLayout_NameTruncatesToFloorWhenStillTooWide(t *testing.T) {
now := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC)
times := []time.Time{now.Add(-14 * 24 * time.Hour)}

lay := picker.ComputeLayout(40, 1, times, now, 20, 24)

if lay.NameWidth < 8 {
t.Errorf("NameWidth = %d, must never go below the floor of 8", lay.NameWidth)
}
if lay.NameWidth >= 40 {
t.Errorf("NameWidth = %d, want it truncated from 40 at a 20-column width", lay.NameWidth)
}
}

func TestComputeLayout_StatusNeverShrinks(t *testing.T) {
now := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC)
times := []time.Time{now.Add(-14 * 24 * time.Hour)}

lay := picker.ComputeLayout(40, 9, times, now, 15, 24)

if lay.StatusWidth != 9 {
t.Errorf("StatusWidth = %d, want 9 (status never shrinks)", lay.StatusWidth)
}
}

func TestComputeLayout_FooterLines(t *testing.T) {
now := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC)

tests := []struct {
name string
height int
wantLegend bool
wantKeys bool
}{
{"tall terminal shows both", 30, true, true},
{"under 15 rows drops the legend", 14, false, true},
{"under 10 rows drops the keys line too", 9, false, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
lay := picker.ComputeLayout(8, 1, nil, now, 100, tt.height)
if lay.ShowLegend != tt.wantLegend {
t.Errorf("ShowLegend = %v, want %v", lay.ShowLegend, tt.wantLegend)
}
if lay.ShowKeys != tt.wantKeys {
t.Errorf("ShowKeys = %v, want %v", lay.ShowKeys, tt.wantKeys)
}
})
}
}
Loading
Loading