refactor(office): generalize DataTable layout and document TUI table skill

Unify project/user flex column layout into layoutFlexTable with per-subject
policy in model. Add office-tui-table skill and AGENTS notes; fix CI go mod
tidy drift and test matrix Go version to match go.mod 1.25.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-25 11:21:20 +01:00
co-authored by Cursor
parent 40b74eb8ab
commit 38932b0e0e
15 changed files with 246 additions and 169 deletions
+20
View File
@@ -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
}
}
+18
View File
@@ -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")
}
}
+5 -1
View File
@@ -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 {
+1 -1
View File
@@ -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
}
+65 -5
View File
@@ -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))
+49
View File
@@ -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)
}
}
-66
View File
@@ -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().
-22
View File
@@ -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)
}
}
+5 -1
View File
@@ -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))
}
-69
View File
@@ -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
}
}
}