diff --git a/.cursor/skills/office-tui-table/SKILL.md b/.cursor/skills/office-tui-table/SKILL.md new file mode 100644 index 0000000..0568d60 --- /dev/null +++ b/.cursor/skills/office-tui-table/SKILL.md @@ -0,0 +1,75 @@ +--- +name: office-tui-table +description: >- + Build and fix Bubble Tea list tables in cmd/office (DataTable, column layout, + lipgloss cell rendering, pane width). Use when changing office center-pane + tables, column widths, ANSI styling, or list toolbar alignment. +--- +# office TUI table (`cmd/office/ui`) + +## Architecture + +| Piece | File | Role | +|-------|------|------| +| `DataTable` | `table.go` | cursor, sort, viewport, header/row render | +| Column layout | `table_layout.go` | flex vs horizontal-scroll policies | +| Flex policy | `model/table_layout.go` | per-subject flex column key + min width | +| Cell render | `table_render.go` | bubbles/table pattern + ANSI-safe padding | +| Subject row style | `table_projects.go` | optional per-subject cell styles | +| Pane sizing | `scroll.go` | `paneContentWidth`, `paneLipglossWidth` | +| Scrollbar pad | `scrollbar.go` | `padANSIWidth` for styled lines | + +## Layout policies + +1. **Flex (all columns visible)** — register in `model.TableFlexLayoutFor`: + - fixed columns keep `TableColumn.Width` + - one `FlexColumnKey` (e.g. `title`, `email`) absorbs remainder + - `layoutFlexTable` shrinks fixed cols down to `minFixedColumnWidth` before flex min + +2. **Scrolling (default)** — `layoutScrollingTable`: + - horizontal `colScroll`; subset of columns visible + - `distributeColumnWidths` fills pane width + +Add a new flex subject: one entry in `TableFlexLayoutFor` only — do **not** copy `layoutProjectTable`/`layoutUserTable` per subject. + +## Cell rendering (required pattern) + +Never `style.Width(w).Padding(0,1).Render(text)` on styled cells — padding doubles display width and breaks `JoinHorizontal`. + +Use `renderTableCell` from `table_render.go` (matches `charmbracelet/bubbles/table`): + +```go +renderTableCell(outerStyle, plainText, columnWidth) +``` + +- truncate **plain** text with `runewidth.Truncate` inside `renderTableCell` +- inner box: `Width(innerW).Inline(true)` where `innerW = width - outer.GetHorizontalFrameSize()` +- outer style carries padding, background, foreground + +## ANSI rules + +- **Never** `runewidth.Truncate` / `padDisplayWidth` on strings that already contain lipgloss ANSI codes — corrupts escapes and shows garbage like lone `ID`. +- Use `padANSIWidth` (`charmbracelet/x/ansi`) for styled lines (headers, toolbar, scrollbar rows). +- Do not wrap an already full-width styled header in another `lipgloss.Width()` — pad once. + +## Pane width + +Lipgloss bordered panes render **2 cells wider** than `Style.Width`: + +- `paneLipglossWidth(rendered) = rendered - 2` for `.Width()` on pane style +- `paneContentWidth(rendered) = rendered - 4` for table/viewport inner size (border + padding) + +Pass `paneContentWidth(pw.List)` to `DataTable.SetSize` and list toolbar. + +## Tests to add when touching tables + +- `layoutFlexTable` width sum equals pane width +- `TestProjectTableHeaderFitsPaneWidth` / per-line width at 68, 40, 30 +- `TestPadANSIWidthPreservesStyledLine` +- `TestThreePaneRenderedWidthMatchesTerminal` top border + table lines ≤ inner width + +## Anti-patterns + +- Per-subject duplicate layout files (`table_foo.go` with copy-pasted shrink loops) +- Skipping truncation on selected rows (causes multi-line rows in viewport) +- Using `bubbles/table` widget directly — office needs column cursor, multi-select, subject-specific row styles diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 77c0345..d06592a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,7 +15,7 @@ jobs: strategy: fail-fast: false matrix: - go: ["1.22", "stable"] + go: ["1.25", "stable"] steps: - uses: actions/checkout@v4 diff --git a/AGENTS.md b/AGENTS.md index 767b01a..70a7d45 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,7 @@ Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the - `calendar.go`, `projects.go`, `projects_files.go`, `tasks.go`, `tasks_files.go`, `users.go`, `contacts.go`, `opportunities.go`, `cases.go`, `crm_tasks.go`, `apps.go` — one file per subject (or per subject facet), each registers in `init()`. - CLI-only deps (`spf13/cobra`, `joho/godotenv`) stay out of the library. - **TUI — `cmd/office/` as `package main`.** Bubble Tea three-pane browser (module tree, selectable list, markdown preview). Reuses `cmd/internal/bootstrap` for env/auth and the root `onlyoffice` library for all API calls. UI logic in `cmd/office/ui/`; preview/formatting in `cmd/office/preview/`; list loaders in `cmd/office/fetch/`. + - **List table (`DataTable`)** — `cmd/office/ui/table*.go`. Column layout policies live in `cmd/office/model/table_layout.go` (`TableFlexLayoutFor`); cell rendering uses the bubbles/table inline pattern in `table_render.go` (`renderTableCell`, `padANSIWidth`). See `.cursor/skills/office-tui-table/SKILL.md` before changing center-pane tables. - **Shared bootstrap — `cmd/internal/bootstrap/`.** `LoadEnv()` + `NewClient(ctx)` extracted from `oo`; both binaries import it. - **Applications sync — `cmd/oo/applications/`.** README→CRM bridge, CV-specific; kept under `cmd/oo/` so it's clear it's internal to the binary, not a library feature. diff --git a/cmd/office/model/table_layout.go b/cmd/office/model/table_layout.go new file mode 100644 index 0000000..9e27487 --- /dev/null +++ b/cmd/office/model/table_layout.go @@ -0,0 +1,20 @@ +package model + +// TableFlexLayout describes a list table where every column stays visible and +// one flex column absorbs leftover pane width. +type TableFlexLayout struct { + FlexColumnKey string + MinFlexWidth int +} + +// TableFlexLayoutFor returns a flex layout policy for subjects that show all columns. +func TableFlexLayoutFor(subject Subject) (TableFlexLayout, bool) { + switch subject { + case SubjectProjects: + return TableFlexLayout{FlexColumnKey: "title", MinFlexWidth: 12}, true + case SubjectUsers: + return TableFlexLayout{FlexColumnKey: "email", MinFlexWidth: 16}, true + default: + return TableFlexLayout{}, false + } +} diff --git a/cmd/office/model/table_layout_test.go b/cmd/office/model/table_layout_test.go new file mode 100644 index 0000000..6e68111 --- /dev/null +++ b/cmd/office/model/table_layout_test.go @@ -0,0 +1,18 @@ +package model + +import "testing" + +func TestTableFlexLayoutFor(t *testing.T) { + proj, ok := TableFlexLayoutFor(SubjectProjects) + if !ok || proj.FlexColumnKey != "title" || proj.MinFlexWidth != 12 { + t.Fatalf("projects layout=%+v ok=%v", proj, ok) + } + users, ok := TableFlexLayoutFor(SubjectUsers) + if !ok || users.FlexColumnKey != "email" { + t.Fatalf("users layout=%+v", users) + } + _, ok = TableFlexLayoutFor(SubjectTasks) + if ok { + t.Fatal("tasks should use scrolling layout") + } +} diff --git a/cmd/office/ui/entityform_user_test.go b/cmd/office/ui/entityform_user_test.go index 8f9d75b..634af0a 100644 --- a/cmd/office/ui/entityform_user_test.go +++ b/cmd/office/ui/entityform_user_test.go @@ -39,7 +39,11 @@ func TestUserFormFieldCount(t *testing.T) { func TestLayoutUserTableEmailAbsorbsWidth(t *testing.T) { cols := model.BuildColumns(model.SubjectUsers, nil) - lay := layoutUserTable(cols, 100) + flex, ok := model.TableFlexLayoutFor(model.SubjectUsers) + if !ok { + t.Fatal("expected flex layout") + } + lay := layoutFlexTable(cols, 100, flex.FlexColumnKey, flex.MinFlexWidth) sum := 0 emailW := 0 for _, i := range lay.indices { diff --git a/cmd/office/ui/table.go b/cmd/office/ui/table.go index 535cb02..700e765 100644 --- a/cmd/office/ui/table.go +++ b/cmd/office/ui/table.go @@ -400,7 +400,7 @@ func (t *DataTable) styleCell(row, col int, colKey string, selected bool, item m } func (t *DataTable) ensureColVisible() { - if t.spec.Subject == model.SubjectProjects || t.spec.Subject == model.SubjectUsers { + if flex, ok := model.TableFlexLayoutFor(t.spec.Subject); ok && flex.FlexColumnKey != "" { t.colScroll = 0 return } diff --git a/cmd/office/ui/table_layout.go b/cmd/office/ui/table_layout.go index d983d0b..6537903 100644 --- a/cmd/office/ui/table_layout.go +++ b/cmd/office/ui/table_layout.go @@ -15,15 +15,75 @@ func (t *DataTable) computeLayout() columnLayout { if len(t.columns) == 0 || contentW <= 0 { return columnLayout{widths: map[int]int{}} } - if t.spec.Subject == model.SubjectProjects { - return layoutProjectTable(t.columns, contentW) - } - if t.spec.Subject == model.SubjectUsers { - return layoutUserTable(t.columns, contentW) + if flex, ok := model.TableFlexLayoutFor(t.spec.Subject); ok { + return layoutFlexTable(t.columns, contentW, flex.FlexColumnKey, flex.MinFlexWidth) } return layoutScrollingTable(t.columns, t.colScroll, contentW) } +const minFixedColumnWidth = 3 + +// layoutFlexTable shows every column; flexKey absorbs leftover width. +func layoutFlexTable(cols []model.TableColumn, totalW int, flexKey string, minFlexWidth int) columnLayout { + indices := make([]int, len(cols)) + for i := range cols { + indices[i] = i + } + widths := make(map[int]int, len(cols)) + + flexIdx := -1 + fixed := 0 + for i, col := range cols { + if col.Key == flexKey { + flexIdx = i + continue + } + widths[i] = col.Width + fixed += widths[i] + } + if flexIdx < 0 { + return layoutScrollingTable(cols, 0, totalW) + } + + flexW := totalW - fixed + if flexW < minFlexWidth { + shrinkFixedColumns(widths, indices, flexIdx, fixed+minFlexWidth-totalW) + fixed = 0 + for _, i := range indices { + if i != flexIdx { + fixed += widths[i] + } + } + flexW = totalW - fixed + if flexW < minFlexWidth { + flexW = minFlexWidth + } + } + widths[flexIdx] = flexW + normalizeWidthSum(widths, indices, totalW, flexIdx) + return columnLayout{indices: indices, widths: widths} +} + +func shrinkFixedColumns(widths map[int]int, indices []int, flexIdx, need int) { + for need > 0 { + changed := false + for _, i := range indices { + if i == flexIdx || widths[i] <= minFixedColumnWidth { + continue + } + widths[i]-- + need-- + changed = true + if need == 0 { + return + } + } + if !changed { + return + } + } +} + func layoutScrollingTable(cols []model.TableColumn, colScroll, contentW int) columnLayout { indices := pickVisibleColumnIndices(cols, colScroll, contentW) widths := make(map[int]int, len(indices)) diff --git a/cmd/office/ui/table_layout_test.go b/cmd/office/ui/table_layout_test.go new file mode 100644 index 0000000..b8be699 --- /dev/null +++ b/cmd/office/ui/table_layout_test.go @@ -0,0 +1,49 @@ +package ui + +import ( + "testing" + + "github.com/eslider/go-onlyoffice/cmd/office/model" +) + +func TestLayoutFlexTableAllProjectColumnsVisible(t *testing.T) { + cols := model.BuildColumns(model.SubjectProjects, nil) + flex, ok := model.TableFlexLayoutFor(model.SubjectProjects) + if !ok { + t.Fatal("expected flex layout for projects") + } + lay := layoutFlexTable(cols, 80, flex.FlexColumnKey, flex.MinFlexWidth) + if len(lay.indices) != 7 { + t.Fatalf("got %d visible columns, want 7", len(lay.indices)) + } + sum := 0 + for _, i := range lay.indices { + sum += lay.widths[i] + } + if sum != 80 { + t.Fatalf("width sum=%d want 80", sum) + } +} + +func TestLayoutFlexTableUserEmailAbsorbsWidth(t *testing.T) { + cols := model.BuildColumns(model.SubjectUsers, nil) + flex, ok := model.TableFlexLayoutFor(model.SubjectUsers) + if !ok { + t.Fatal("expected flex layout for users") + } + lay := layoutFlexTable(cols, 100, flex.FlexColumnKey, flex.MinFlexWidth) + sum := 0 + emailW := 0 + for _, i := range lay.indices { + sum += lay.widths[i] + if cols[i].Key == "email" { + emailW = lay.widths[i] + } + } + if sum != 100 { + t.Fatalf("sum=%d want 100", sum) + } + if emailW < 20 { + t.Fatalf("email width=%d should absorb remainder", emailW) + } +} diff --git a/cmd/office/ui/table_projects.go b/cmd/office/ui/table_projects.go index 5875266..0ea4481 100644 --- a/cmd/office/ui/table_projects.go +++ b/cmd/office/ui/table_projects.go @@ -5,72 +5,6 @@ import ( "github.com/eslider/go-onlyoffice/cmd/office/model" ) -const ( - projectMinTitleWidth = 12 - projectMinFixedWidth = 3 -) - -// layoutProjectTable shows every project column; the title column absorbs leftover width. -func layoutProjectTable(cols []model.TableColumn, totalW int) columnLayout { - indices := make([]int, len(cols)) - for i := range cols { - indices[i] = i - } - widths := make(map[int]int, len(cols)) - - titleIdx := -1 - fixed := 0 - for i, col := range cols { - if col.Key == "title" { - titleIdx = i - continue - } - widths[i] = col.Width - fixed += widths[i] - } - if titleIdx < 0 { - return layoutScrollingTable(cols, 0, totalW) - } - - titleW := totalW - fixed - if titleW < projectMinTitleWidth { - shrinkProjectFixedColumns(widths, indices, titleIdx, fixed+projectMinTitleWidth-totalW) - fixed = 0 - for _, i := range indices { - if i != titleIdx { - fixed += widths[i] - } - } - titleW = totalW - fixed - if titleW < projectMinTitleWidth { - titleW = projectMinTitleWidth - } - } - widths[titleIdx] = titleW - normalizeWidthSum(widths, indices, totalW, titleIdx) - return columnLayout{indices: indices, widths: widths} -} - -func shrinkProjectFixedColumns(widths map[int]int, indices []int, titleIdx, need int) { - for need > 0 { - changed := false - for _, i := range indices { - if i == titleIdx || widths[i] <= projectMinFixedWidth { - continue - } - widths[i]-- - need-- - changed = true - if need == 0 { - return - } - } - if !changed { - return - } - } -} - func (t *DataTable) projectBaseStyle(item model.Item) lipgloss.Style { if model.ProjectIsOpen(item.Raw) { return lipgloss.NewStyle(). diff --git a/cmd/office/ui/table_projects_test.go b/cmd/office/ui/table_projects_test.go deleted file mode 100644 index 7c15b0d..0000000 --- a/cmd/office/ui/table_projects_test.go +++ /dev/null @@ -1,22 +0,0 @@ -package ui - -import ( - "testing" - - "github.com/eslider/go-onlyoffice/cmd/office/model" -) - -func TestLayoutProjectTableAllColumnsVisible(t *testing.T) { - cols := model.BuildColumns(model.SubjectProjects, nil) - lay := layoutProjectTable(cols, 80) - if len(lay.indices) != 7 { - t.Fatalf("got %d visible columns, want 7", len(lay.indices)) - } - sum := 0 - for _, i := range lay.indices { - sum += lay.widths[i] - } - if sum != 80 { - t.Fatalf("width sum=%d want 80", sum) - } -} diff --git a/cmd/office/ui/table_test.go b/cmd/office/ui/table_test.go index b328837..bb5bc49 100644 --- a/cmd/office/ui/table_test.go +++ b/cmd/office/ui/table_test.go @@ -94,7 +94,11 @@ func TestDistributeColumnWidthsFillsTotal(t *testing.T) { func TestProjectTableTitleAbsorbsWidth(t *testing.T) { cols := model.BuildColumns(model.SubjectProjects, nil) - lay := layoutProjectTable(cols, 120) + flex, ok := model.TableFlexLayoutFor(model.SubjectProjects) + if !ok { + t.Fatal("expected flex layout") + } + lay := layoutFlexTable(cols, 120, flex.FlexColumnKey, flex.MinFlexWidth) if len(lay.indices) != len(cols) { t.Fatalf("expected all %d columns visible, got %d", len(cols), len(lay.indices)) } diff --git a/cmd/office/ui/table_users.go b/cmd/office/ui/table_users.go deleted file mode 100644 index 7b1b9b7..0000000 --- a/cmd/office/ui/table_users.go +++ /dev/null @@ -1,69 +0,0 @@ -package ui - -import "github.com/eslider/go-onlyoffice/cmd/office/model" - -const ( - userMinEmailWidth = 16 - userMinFixedWidth = 3 -) - -// layoutUserTable shows every user column; email absorbs leftover width. -func layoutUserTable(cols []model.TableColumn, totalW int) columnLayout { - indices := make([]int, len(cols)) - for i := range cols { - indices[i] = i - } - widths := make(map[int]int, len(cols)) - - emailIdx := -1 - fixed := 0 - for i, col := range cols { - if col.Key == "email" { - emailIdx = i - continue - } - widths[i] = col.Width - fixed += widths[i] - } - if emailIdx < 0 { - return layoutScrollingTable(cols, 0, totalW) - } - - emailW := totalW - fixed - if emailW < userMinEmailWidth { - shrinkUserFixedColumns(widths, indices, emailIdx, fixed+userMinEmailWidth-totalW) - fixed = 0 - for _, i := range indices { - if i != emailIdx { - fixed += widths[i] - } - } - emailW = totalW - fixed - if emailW < userMinEmailWidth { - emailW = userMinEmailWidth - } - } - widths[emailIdx] = emailW - normalizeWidthSum(widths, indices, totalW, emailIdx) - return columnLayout{indices: indices, widths: widths} -} - -func shrinkUserFixedColumns(widths map[int]int, indices []int, emailIdx, need int) { - for need > 0 { - changed := false - for _, i := range indices { - if i == emailIdx || widths[i] <= userMinFixedWidth { - continue - } - widths[i]-- - need-- - changed = true - if need == 0 { - return - } - } - if !changed { - return - } - } -} diff --git a/go.mod b/go.mod index c8fd2ca..4c7b75e 100644 --- a/go.mod +++ b/go.mod @@ -8,18 +8,21 @@ require ( github.com/charmbracelet/bubbletea v0.25.0 github.com/charmbracelet/glamour v0.8.0 github.com/charmbracelet/lipgloss v0.12.1 + github.com/charmbracelet/x/ansi v0.1.4 github.com/eslider/go-xls/v2 v2.1.0 github.com/google/go-querystring v1.2.0 github.com/joho/godotenv v1.5.1 + github.com/mattn/go-runewidth v0.0.15 + github.com/muesli/termenv v0.16.0 github.com/spf13/cobra v1.10.2 ) require ( github.com/JohannesKaufmann/dom v0.3.1 // indirect github.com/alecthomas/chroma/v2 v2.14.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect - github.com/charmbracelet/x/ansi v0.1.4 // indirect github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect github.com/dlclark/regexp2 v1.11.0 // indirect github.com/gorilla/css v1.0.1 // indirect @@ -27,12 +30,10 @@ require ( github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-isatty v0.0.22 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.15 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect - github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/yuin/goldmark v1.8.2 // indirect diff --git a/go.sum b/go.sum index 7087a1a..e733ad8 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46 github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=