feat(office): users admin UI, table rendering, and user save fixes

Add Users list/detail with ACL toggles and library people update/status APIs.
Fix project table layout and ANSI-safe cell rendering, and decode array
responses from people/status so saves no longer fail on JSON unmarshal.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-25 11:07:22 +01:00
co-authored by Cursor
parent 72844dfe24
commit 40b74eb8ab
45 changed files with 3012 additions and 438 deletions
+236 -90
View File
@@ -65,13 +65,12 @@ type Model struct {
err string
loading bool
menuVP viewport.Model
listTable DataTable
listTable DataTable
listToolbar ListToolbar
detail DetailPane
showMenu bool
showList bool
showDetail bool
filterActive bool
filterSearch FilterSearch
customPaneLayout bool
paneSizes PaneWidths
resize paneResizeState
@@ -86,14 +85,14 @@ func NewModel(client *onlyoffice.Client) Model {
nav: model.DefaultNavTree(),
selection: model.NewSelection(),
focus: model.FocusMenu,
status: "Tab: pane · f: filter · row select loads detail · Ctrl+S save · q quit",
status: "Tab: pane · / filter · Space select row · 💾🗑 when selected · v: detail · q quit",
height: h,
width: w,
}
m.menuVP = viewport.New(m.paneInnerWidth(22), m.paneHeight())
m.listTable = newDataTable()
m.listToolbar = newListToolbar()
m.detail = newDetailPane()
m.filterSearch = newFilterSearch()
m.showMenu, m.showList, m.showDetail = true, true, true
m.menuVP.MouseWheelEnabled = true
return m
@@ -117,20 +116,43 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyMsg:
key := msg.String()
if key == "esc" && m.filterActive {
m.exitFilter()
if key == "esc" && m.focus == model.FocusList && m.listToolbar.Zone() != listZoneTable {
m.listToolbar.FocusTable()
m.syncPaneFocus()
return m, nil
}
if m.filterActive && m.focus == model.FocusPreview && m.showDetail {
if key == "esc" && m.focus == model.FocusList && m.listToolbar.Query() != "" {
m.listToolbar.ClearFilter()
m.nav.ClearFilter()
m.syncMenuContent()
m.syncListTable()
return m, nil
}
if m.focus == model.FocusList && m.listToolbar.Zone() == listZoneFilter {
switch key {
case "tab", "shift+tab", "backtab":
// allow pane switching while filter stays active
// allow pane switching
default:
cmd := m.filterSearch.Update(msg)
cmd := m.listToolbar.Update(msg, m.listToolbarMeta())
m.applyFilter()
return m, cmd
}
}
if m.focus == model.FocusList && m.listToolbar.Zone() == listZoneActions {
meta := m.listToolbarMeta()
switch key {
case "enter":
if act, ok := m.listToolbar.SelectedAction(meta); ok && m.listToolbar.IsActionEnabled(act, meta) {
return m, m.runListToolbarAction(act)
}
return m, nil
case "tab", "shift+tab", "backtab":
// allow pane switching
default:
cmd := m.listToolbar.Update(msg, meta)
return m, cmd
}
}
if key == "?" {
m.status = helpText()
return m, nil
@@ -138,9 +160,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.scrollFocusedPane(key) {
return m, nil
}
if m.filterActive && m.focus == model.FocusPreview {
// filter input owns keys except pane switching handled above
} else if m.focus == model.FocusPreview {
if m.focus == model.FocusPreview {
if cmd, handled := m.handleDetailKey(key, msg); handled {
return m, cmd
}
@@ -155,39 +175,60 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case ActionNextPane:
prev := m.focus
m.focus = NextVisibleFocus(m.focus, m.paneVis())
if m.focus == model.FocusPreview && prev != model.FocusPreview && !m.filterActive {
if m.focus == model.FocusPreview && prev != model.FocusPreview {
m.detail.FocusFirstStop()
}
if m.focus == model.FocusList && prev != model.FocusList {
m.listToolbar.FocusTable()
}
m.syncPaneFocus()
return m, nil
case ActionPrevPane:
prev := m.focus
m.focus = PrevVisibleFocus(m.focus, m.paneVis())
if m.focus == model.FocusPreview && prev != model.FocusPreview && !m.filterActive {
if m.focus == model.FocusPreview && prev != model.FocusPreview {
m.detail.FocusFirstStop()
}
if m.focus == model.FocusList && prev != model.FocusList {
m.listToolbar.FocusTable()
}
m.syncPaneFocus()
return m, nil
case ActionMoveUp:
if m.focus == model.FocusPreview {
return m.handleDetailMove(-1)
}
if m.focus == model.FocusList && m.listToolbar.Zone() != listZoneTable {
return m, nil
}
m.moveUp()
m.syncFocusedPane()
return m, m.onListRowChanged()
if m.focus == model.FocusList {
return m, m.onListRowChanged()
}
return m, nil
case ActionMoveDown:
if m.focus == model.FocusPreview {
return m.handleDetailMove(1)
}
if m.focus == model.FocusList && m.listToolbar.Zone() != listZoneTable {
return m, nil
}
m.moveDown()
m.syncFocusedPane()
return m, m.onListRowChanged()
if m.focus == model.FocusList {
return m, m.onListRowChanged()
}
return m, nil
case ActionMoveLeft:
if m.focus == model.FocusPreview && m.detail.Zone() == detailZoneActions {
m.detail.MoveTabStop(-1)
return m, nil
}
if m.focus == model.FocusList && m.hasList {
if m.focus == model.FocusMenu {
return m.handleMenuHorizontal(-1)
}
if m.focus == model.FocusList && m.hasList && m.listToolbar.Zone() == listZoneTable {
m.listTable.MoveCol(-1)
}
return m, nil
@@ -196,12 +237,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.detail.MoveTabStop(1)
return m, nil
}
if m.focus == model.FocusList && m.hasList {
if m.focus == model.FocusMenu {
return m.handleMenuHorizontal(1)
}
if m.focus == model.FocusList && m.hasList && m.listToolbar.Zone() == listZoneTable {
m.listTable.MoveCol(1)
}
return m, nil
case ActionSort:
if m.focus == model.FocusList && m.hasList {
if m.focus == model.FocusList && m.hasList && m.listToolbar.Zone() == listZoneTable {
m.listTable.ToggleSort()
}
return m, nil
@@ -236,7 +280,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.togglePane(3)
return m, nil
case ActionFilter:
m.enterFilter()
if m.hasList {
m.focus = model.FocusList
m.listToolbar.FocusFilter()
m.syncPaneFocus()
}
return m, nil
case ActionRefresh:
if m.hasList {
@@ -257,13 +305,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.syncMenuContent()
return m, nil
case "right", "l":
if spec, ok := m.nav.CurrentListSpec(); ok {
return m.withList(*spec)
}
m.nav.Activate()
m.syncMenuContent()
return m, nil
}
}
@@ -284,9 +325,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.hasList = true
m.listHasMore = model.SubjectIsMail(msg.spec.Subject) && len(msg.items) >= fetch.MailListPageSize
m.listTable.SetData(m.listSpec, m.items)
if m.filterActive {
m.applyFilter()
}
m.applyFilter()
}
return m, tea.Batch(m.onListRowChanged(), m.maybeLoadMoreList())
@@ -337,8 +376,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else {
m.updateItemAfterSave(msg.item, msg.fields)
m.detail.LoadForm(msg.item, msg.fields)
m.detail.form.MarkClean()
m.detail.SetFocused(m.focus == model.FocusPreview)
m.status = "Saved"
if msg.fields.HasTaskStatus && msg.fields.TaskStatus == model.TaskLifecycleClosed {
m.status = "Closed"
} else {
m.status = "Saved"
}
m.err = ""
}
return m, nil
@@ -350,6 +394,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else {
m.status = msg.message
m.err = ""
m.selection.Clear()
if m.hasList {
m.loading = true
return m, m.loadListCmd(m.listSpec)
@@ -376,11 +421,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case model.FocusMenu:
m.menuVP, cmd = m.menuVP.Update(msg)
case model.FocusList:
cmd = m.listTable.Update(msg)
case model.FocusPreview:
if !m.filterActive {
cmd = m.detail.Update(msg)
if m.listToolbar.Zone() == listZoneTable {
cmd = m.listTable.Update(msg)
}
case model.FocusPreview:
cmd = m.detail.Update(msg)
}
return m, cmd
}
@@ -392,9 +437,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case model.FocusList:
cmd = m.listTable.Update(msg)
case model.FocusPreview:
if !m.filterActive {
cmd = m.detail.Update(msg)
}
cmd = m.detail.Update(msg)
}
return m, cmd
}
@@ -425,6 +468,9 @@ func (m *Model) handleDetailKey(key string, msg tea.KeyMsg) (tea.Cmd, bool) {
if act.ID == model.ActionSave {
return m.saveDetailCmd(), true
}
if act.ID == model.ActionClose && m.detail.Item().Kind == model.KindTask {
return m.closeTaskCmd(), true
}
return m.executeActionCmd(act.ID, m.detail.Item()), true
}
}
@@ -435,7 +481,7 @@ func (m *Model) handleDetailKey(key string, msg tea.KeyMsg) (tea.Cmd, bool) {
}
func (m *Model) routeDetailDocumentMouse(msg tea.MouseMsg) (tea.Cmd, bool) {
if m.filterActive || !m.showDetail {
if !m.showDetail {
return nil, false
}
if !m.detail.IsDocumentContent() && !m.detail.isReadOnlyFormContent() {
@@ -476,7 +522,7 @@ func (m Model) View() string {
h := m.paneHeight() + 2
var parts []string
if pw.Visibility.Menu {
menuStyle := paneStyle(m.focus == model.FocusMenu).Width(pw.Menu).Height(h)
menuStyle := paneStyle(m.focus == model.FocusMenu).Width(paneLipglossWidth(pw.Menu)).Height(h)
menuBody := ApplyVerticalScrollbar(
m.menuVP.View(),
m.menuVP.Width,
@@ -487,18 +533,17 @@ func (m Model) View() string {
parts = append(parts, menuStyle.Render(menuBody))
}
if pw.Visibility.List {
listStyle := paneStyle(m.focus == model.FocusList).Width(pw.List).Height(h)
parts = append(parts, listStyle.Render(m.listTable.View()))
listStyle := paneStyle(m.focus == model.FocusList).Width(paneLipglossWidth(pw.List)).Height(h)
meta := m.listToolbarMeta()
body := lipgloss.JoinVertical(lipgloss.Left,
m.listToolbar.View(meta),
m.listTable.View(),
)
parts = append(parts, listStyle.Render(body))
}
if pw.Visibility.Detail {
prevStyle := paneStyle(m.focus == model.FocusPreview).Width(pw.Detail).Height(h)
var detailBody string
if m.filterActive {
detailBody = m.filterSearch.View()
} else {
detailBody = m.detail.View()
}
parts = append(parts, prevStyle.Render(detailBody))
prevStyle := paneStyle(m.focus == model.FocusPreview).Width(paneLipglossWidth(pw.Detail)).Height(h)
parts = append(parts, prevStyle.Render(m.detail.View()))
}
status := m.status
@@ -526,16 +571,105 @@ func (m Model) paneLayout() PaneWidths {
}
func (m *Model) syncPaneFocus() {
m.listTable.SetFocused(m.focus == model.FocusList && m.showList)
if m.filterActive {
m.filterSearch.SetFocused(m.focus == model.FocusPreview && m.showDetail)
m.detail.SetFocused(false)
return
onList := m.focus == model.FocusList && m.showList
m.listTable.SetFocused(onList && m.listToolbar.Zone() == listZoneTable)
if onList {
m.listToolbar.syncInputFocus()
} else {
m.listToolbar.filter.SetFocused(false)
}
m.filterSearch.SetFocused(false)
m.detail.SetFocused(m.focus == model.FocusPreview && m.showDetail)
}
func (m Model) listToolbarMeta() ListToolbarMeta {
meta := ListToolbarMeta{
Subject: m.listTable.SubjectLabel(),
Count: m.listTable.ItemCount(),
SortLabel: m.listTable.SortHint(),
LoadingMore: m.listTable.LoadingMore(),
SaveEnabled: m.canSaveFromList(),
DeleteEnabled: m.canDeleteFromList(),
}
return meta
}
func (m Model) selectedItems() []model.Item {
var out []model.Item
for _, it := range m.items {
if it.Selected {
out = append(out, it)
}
}
return out
}
func (m Model) canSaveFromList() bool {
selected := m.selectedItems()
if len(selected) != 1 {
return false
}
for _, act := range model.ActionsFor(selected[0].Kind) {
if act.ID == model.ActionSave && m.detail.LoadedID() == selected[0].ID {
return true
}
}
return false
}
func (m Model) canDeleteFromList() bool {
selected := m.selectedItems()
if len(selected) == 0 {
return false
}
for _, it := range selected {
if !actionAvailable(model.ActionsFor(it.Kind), model.ActionDelete) {
return false
}
}
return true
}
func actionAvailable(actions []model.ItemAction, id model.ActionID) bool {
for _, a := range actions {
if a.ID == id {
return true
}
}
return false
}
func (m *Model) runListToolbarAction(action model.ActionID) tea.Cmd {
meta := m.listToolbarMeta()
if !m.listToolbar.IsActionEnabled(action, meta) {
return nil
}
switch action {
case model.ActionSave:
m.loading = true
return m.saveDetailCmd()
case model.ActionDelete:
m.loading = true
return m.deleteSelectedCmd()
default:
return nil
}
}
func (m *Model) deleteSelectedCmd() tea.Cmd {
selected := m.selectedItems()
if len(selected) == 0 {
return nil
}
if len(selected) == 1 {
return m.executeActionCmd(model.ActionDelete, selected[0])
}
cmds := make([]tea.Cmd, len(selected))
for i, it := range selected {
cmds[i] = m.executeActionCmd(model.ActionDelete, it)
}
return tea.Batch(cmds...)
}
func (m *Model) togglePane(which int) {
switch which {
case 1:
@@ -587,11 +721,12 @@ func (m *Model) layoutViewports() {
m.menuVP.Height = h
}
if pw.Visibility.List {
m.listTable.SetSize(m.paneInnerWidth(pw.List), h)
w := m.paneInnerWidth(pw.List)
m.listToolbar.SetWidth(w)
m.listTable.SetSize(w, h-listToolbarHeight)
}
if pw.Visibility.Detail {
m.detail.SetSize(m.paneInnerWidth(pw.Detail), h)
m.filterSearch.SetSize(m.paneInnerWidth(pw.Detail), h)
}
}
@@ -606,34 +741,14 @@ func (m *Model) syncListTable() {
return
}
items := m.items
if m.filterActive {
items = model.FilterItems(m.items, m.filterSearch.Query())
if q := m.listToolbar.Query(); q != "" {
items = model.FilterItems(m.items, q)
}
m.listTable.UpdateItems(items)
}
func (m *Model) enterFilter() {
if !m.showDetail {
m.showDetail = true
m.layoutViewports()
}
m.filterActive = true
m.focus = model.FocusPreview
m.syncPaneFocus()
m.applyFilter()
}
func (m *Model) exitFilter() {
m.filterActive = false
m.filterSearch.Clear()
m.nav.ClearFilter()
m.syncMenuContent()
m.syncListTable()
m.syncPaneFocus()
}
func (m *Model) applyFilter() {
q := m.filterSearch.Query()
q := m.listToolbar.Query()
m.nav.SetFilter(q)
m.syncMenuContent()
m.syncListTable()
@@ -645,8 +760,8 @@ func (m Model) listTableItem() (model.Item, bool) {
return model.Item{}, false
}
items := m.items
if m.filterActive {
items = model.FilterItems(m.items, m.filterSearch.Query())
if q := m.listToolbar.Query(); q != "" {
items = model.FilterItems(m.items, q)
}
if idx >= len(items) {
return model.Item{}, false
@@ -657,7 +772,7 @@ func (m Model) listTableItem() (model.Item, bool) {
func (m *Model) syncMenuContent() {
var b strings.Builder
title := "Navigation"
if m.filterActive && m.filterSearch.Query() != "" {
if m.listToolbar.Query() != "" {
title += " (filtered)"
}
b.WriteString(title + "\n\n")
@@ -705,6 +820,23 @@ func truncateRunes(s string, max int) string {
return string(r[:max-1]) + "…"
}
func (m Model) handleMenuHorizontal(delta int) (Model, tea.Cmd) {
cur := m.nav.Cursor()
switch delta {
case 1:
if spec, ok := m.nav.CurrentListSpec(); ok {
return m.withList(*spec)
}
m.nav.Activate()
case -1:
if m.nav.IsExpandable(cur) && m.nav.IsExpanded(cur) {
m.nav.ToggleExpand(cur)
}
}
m.syncMenuContent()
return m, nil
}
func (m *Model) syncFocusedPane() {
switch m.focus {
case model.FocusMenu:
@@ -790,9 +922,6 @@ func (m *Model) loadNavProjectsCmd() tea.Cmd {
}
func (m *Model) onListRowChanged() tea.Cmd {
if m.filterActive {
return nil
}
if !m.hasList {
m.detail.Clear()
return m.maybeLoadMoreList()
@@ -814,7 +943,7 @@ func (m *Model) onListRowChanged() tea.Cmd {
const mailListLoadThreshold = 3
func (m *Model) maybeLoadMoreList() tea.Cmd {
if !m.hasList || m.listLoadingMore || !m.listHasMore || m.filterActive {
if !m.hasList || m.listLoadingMore || !m.listHasMore {
return nil
}
if !model.SubjectIsMail(m.listSpec.Subject) {
@@ -850,6 +979,17 @@ func (m *Model) loadDetailCmd(item model.Item) tea.Cmd {
}
}
func (m *Model) closeTaskCmd() tea.Cmd {
item := m.detail.Item()
fields := m.detail.form.FormFields()
fields.TaskStatus = model.TaskLifecycleClosed
return func() tea.Msg {
ctx := context.Background()
err := m.loader.CloseTask(ctx, item.ID, fields)
return detailSavedMsg{item: item, fields: fields, err: err}
}
}
func (m *Model) saveDetailCmd() tea.Cmd {
item := m.detail.Item()
fields := m.detail.form.FormFields()
@@ -874,6 +1014,12 @@ func (m *Model) updateItemAfterSave(item model.Item, fields model.FormFields) {
if fields.HasStatus {
m.items[i].Raw["status"] = statusInt(fields.Status)
}
if fields.HasTaskStatus {
m.items[i].Raw["status"] = int(fields.TaskStatus)
}
if fields.ResponsibleID != "" {
m.items[i].Raw["responsibleIds"] = []any{fields.ResponsibleID}
}
m.syncListTable()
return
}
@@ -899,5 +1045,5 @@ func paneStyle(focused bool) lipgloss.Style {
}
func helpText() string {
return "Alt+1/2/3: toggle panes · drag borders to resize · f: filter · Tab: fields/actions/pane · j/k: scroll mail · v: detail · Ctrl+S: save · q: quit"
return "Alt+1/2/3: panes · / filter · Space select · 💾🗑 when selected · v: detail · Ctrl+S: save · q: quit"
}
+41
View File
@@ -0,0 +1,41 @@
package ui
import (
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestDetailTabOrderTaskForm(t *testing.T) {
d := newDetailPane()
d.SetFocused(true)
d.LoadForm(model.Item{ID: "9", Kind: model.KindTask, Title: "T"}, model.FormFields{
PrimaryLabel: "Title", SecondaryLabel: "Description",
Primary: "Alpha", Secondary: "Beta",
HasTaskStatus: true, TaskStatus: model.TaskLifecycleOpen,
HasResponsible: true, ResponsibleID: "u1",
UserChoices: []model.UserOption{{ID: "u1", Name: "Alice"}},
ProjectTitle: "Proj", TimingSummary: "2026-01-01 → 2026-02-01",
})
stops := []entityField{
entityFieldPrimary, entityFieldSecondary, entityFieldStatus, entityFieldResponsible,
}
for i, want := range stops {
if d.form.field != want {
t.Fatalf("stop %d: field=%d want %d", i, d.form.field, want)
}
if d.TabForward() {
t.Fatalf("tab from stop %d should stay in pane", i)
}
}
if d.Zone() != detailZoneActions || d.actionIdx != 0 {
t.Fatalf("expected Save action, zone=%d idx=%d", d.Zone(), d.actionIdx)
}
if d.TabForward() {
t.Fatal("tab from Save should stay")
}
if d.actionIdx != 1 {
t.Fatalf("expected Close action, idx=%d", d.actionIdx)
}
}
+2 -2
View File
@@ -30,8 +30,8 @@ func TestDetailPaneFormAndActions(t *testing.T) {
Primary: "Hello", Secondary: "World",
})
view := d.View()
if !strings.Contains(view, "Save") {
t.Fatal("expected Save action button")
if !strings.Contains(view, "Save") || !strings.Contains(view, "Close") {
t.Fatal("expected Save and Close action buttons")
}
if !strings.Contains(view, "Hello") {
t.Fatal("expected form content")
+297 -70
View File
@@ -16,33 +16,48 @@ const (
entityFieldPrimary entityField = iota
entityFieldSecondary
entityFieldStatus
entityFieldResponsible
)
// EntityForm is the top section of the detail pane for editable entities.
type EntityForm struct {
active bool
kind model.Kind
itemID string
primaryLabel string
secondaryLabel string
readOnly bool
hasStatus bool
status model.ProjectLifecycle
responsibleID string
primary textinput.Model
secondary textarea.Model
field entityField
focused bool
dirty bool
width int
height int
styles entityFormStyles
active bool
kind model.Kind
itemID string
primaryLabel string
secondaryLabel string
readOnly bool
hasStatus bool
status model.ProjectLifecycle
hasTaskStatus bool
taskStatus model.TaskLifecycle
hasResponsible bool
responsibleID string
userChoices []model.UserOption
responsibleIdx int
projectTitle string
timingSummary string
hasUserEdit bool
userEnabled bool
userACL model.UserACLState
groupsText string
userFieldIdx int
primary textinput.Model
secondary textarea.Model
password textinput.Model
field entityField
focused bool
dirty bool
width int
height int
styles entityFormStyles
}
type entityFormStyles struct {
header lipgloss.Style
label lipgloss.Style
labelAct lipgloss.Style
meta lipgloss.Style
}
func newEntityFormStyles() entityFormStyles {
@@ -50,6 +65,7 @@ func newEntityFormStyles() entityFormStyles {
header: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("252")),
label: lipgloss.NewStyle().Foreground(lipgloss.Color("241")),
labelAct: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255")).Background(lipgloss.Color("62")),
meta: lipgloss.NewStyle().Foreground(lipgloss.Color("245")),
}
}
@@ -61,18 +77,30 @@ func newEntityForm() EntityForm {
secondary.ShowLineNumbers = false
secondary.CharLimit = 8000
secondary.Prompt = "> "
password := textinput.New()
password.CharLimit = 128
password.EchoMode = textinput.EchoPassword
password.EchoCharacter = '•'
password.Prompt = "> "
return EntityForm{
primary: primary,
primary: primary,
secondary: secondary,
field: entityFieldPrimary,
styles: newEntityFormStyles(),
password: password,
field: entityFieldPrimary,
styles: newEntityFormStyles(),
}
}
func (f *EntityForm) FieldCount() int {
if f.hasUserEdit {
return f.userFieldCount()
}
n := 2
if f.hasStatus {
n = 3
if f.hasStatus || f.hasTaskStatus {
n++
}
if f.hasResponsible {
n++
}
return n
}
@@ -86,7 +114,18 @@ func (f *EntityForm) FormFields() model.FormFields {
ReadOnly: f.readOnly,
HasStatus: f.hasStatus,
Status: f.status,
HasTaskStatus: f.hasTaskStatus,
TaskStatus: f.taskStatus,
HasResponsible: f.hasResponsible,
ResponsibleID: f.responsibleID,
UserChoices: f.userChoices,
ProjectTitle: f.projectTitle,
TimingSummary: f.timingSummary,
HasUserEdit: f.hasUserEdit,
UserEnabled: f.userEnabled,
UserACL: f.userACL,
GroupsText: f.groupsText,
UserPassword: f.password.Value(),
}
}
@@ -109,29 +148,66 @@ func (f *EntityForm) Load(kind model.Kind, itemID string, fields model.FormField
f.readOnly = fields.ReadOnly
f.hasStatus = fields.HasStatus
f.status = fields.Status
f.hasTaskStatus = fields.HasTaskStatus
f.taskStatus = fields.TaskStatus
f.hasResponsible = fields.HasResponsible
f.userChoices = fields.UserChoices
f.projectTitle = fields.ProjectTitle
f.timingSummary = fields.TimingSummary
f.responsibleID = fields.ResponsibleID
f.responsibleIdx = indexUserChoice(fields.UserChoices, fields.ResponsibleID)
f.hasUserEdit = fields.HasUserEdit
f.userEnabled = fields.UserEnabled
f.userACL = copyUserACLState(fields.UserACL)
f.groupsText = fields.GroupsText
f.userFieldIdx = 0
f.primary.SetValue(fields.Primary)
f.secondary.SetValue(fields.Secondary)
f.password.SetValue("")
f.dirty = false
f.field = entityFieldPrimary
if f.hasStatus && f.readOnly {
f.field = entityFieldStatus
if f.hasUserEdit {
f.userFieldIdx = 0
} else {
f.field = entityFieldPrimary
}
f.applyFocus()
f.layoutFields()
}
func indexUserChoice(choices []model.UserOption, id string) int {
for i, c := range choices {
if c.ID == id {
return i
}
}
return 0
}
func (f *EntityForm) Clear() {
f.active = false
f.itemID = ""
f.hasStatus = false
f.hasTaskStatus = false
f.hasResponsible = false
f.status = model.ProjectLifecycleOpen
f.taskStatus = model.TaskLifecycleOpen
f.responsibleID = ""
f.userChoices = nil
f.responsibleIdx = 0
f.projectTitle = ""
f.timingSummary = ""
f.hasUserEdit = false
f.userEnabled = false
f.userACL = model.UserACLState{}
f.groupsText = ""
f.userFieldIdx = 0
f.primary.SetValue("")
f.secondary.SetValue("")
f.password.SetValue("")
f.dirty = false
f.primary.Blur()
f.secondary.Blur()
f.password.Blur()
}
func (f *EntityForm) SetFocused(on bool) {
@@ -141,6 +217,7 @@ func (f *EntityForm) SetFocused(on bool) {
} else {
f.primary.Blur()
f.secondary.Blur()
f.password.Blur()
}
}
@@ -165,7 +242,28 @@ func (f *EntityForm) layoutFields() {
inner = 8
}
f.primary.Width = inner
secH := f.height - 6
f.password.Width = inner
if f.hasUserEdit {
return
}
metaLines := 0
if f.projectTitle != "" {
metaLines++
}
if f.timingSummary != "" {
metaLines++
}
if metaLines > 0 {
metaLines++ // blank line before meta
}
extra := 6 + metaLines
if f.hasStatus || f.hasTaskStatus {
extra += 2
}
if f.hasResponsible {
extra += 2
}
secH := f.height - extra
if secH < 3 {
secH = 3
}
@@ -173,34 +271,49 @@ func (f *EntityForm) layoutFields() {
f.secondary.SetHeight(secH)
}
func (f *EntityForm) FocusNext() {
last := entityFieldSecondary
if f.hasStatus {
last = entityFieldStatus
func (f *EntityForm) lastField() entityField {
if f.hasResponsible {
return entityFieldResponsible
}
if f.field < last {
if f.hasStatus || f.hasTaskStatus {
return entityFieldStatus
}
return entityFieldSecondary
}
func (f *EntityForm) FocusNext() {
if f.field < f.lastField() {
f.field++
}
f.applyFocus()
}
func (f *EntityForm) FocusPrev() {
first := entityFieldPrimary
if f.field > first {
if f.field > entityFieldPrimary {
f.field--
}
f.applyFocus()
}
func (f *EntityForm) SetFieldIndex(i int) {
if f.hasUserEdit {
f.setUserFieldIndex(i)
return
}
switch i {
case 0:
f.field = entityFieldPrimary
case 1:
f.field = entityFieldSecondary
case 2:
if f.hasStatus {
if f.hasStatus || f.hasTaskStatus {
f.field = entityFieldStatus
} else if f.hasResponsible {
f.field = entityFieldResponsible
}
case 3:
if f.hasResponsible && (f.hasStatus || f.hasTaskStatus) {
f.field = entityFieldResponsible
}
default:
return
@@ -209,7 +322,19 @@ func (f *EntityForm) SetFieldIndex(i int) {
}
func (f *EntityForm) cycleStatus(delta int) {
if !f.hasStatus || f.readOnly {
if f.readOnly {
return
}
if f.hasTaskStatus {
if delta > 0 {
f.taskStatus = f.taskStatus.Next()
} else {
f.taskStatus = f.taskStatus.Prev()
}
f.dirty = true
return
}
if !f.hasStatus {
return
}
if delta > 0 {
@@ -220,7 +345,36 @@ func (f *EntityForm) cycleStatus(delta int) {
f.dirty = true
}
func (f *EntityForm) cycleResponsible(delta int) {
if !f.hasResponsible || f.readOnly || len(f.userChoices) == 0 {
return
}
n := len(f.userChoices)
f.responsibleIdx = (f.responsibleIdx + delta%n + n) % n
f.responsibleID = f.userChoices[f.responsibleIdx].ID
f.dirty = true
}
func (f *EntityForm) responsibleLabel() string {
if f.responsibleID == "" {
return "—"
}
for _, c := range f.userChoices {
if c.ID == f.responsibleID {
if c.Name != "" {
return c.Name
}
return c.ID
}
}
return f.responsibleID
}
func (f *EntityForm) applyFocus() {
if f.hasUserEdit {
f.applyUserFocus()
return
}
if f.readOnly {
f.primary.Blur()
f.secondary.Blur()
@@ -243,17 +397,35 @@ func (f *EntityForm) Update(msg tea.Msg) tea.Cmd {
if !f.active || !f.focused || f.readOnly {
return nil
}
if key, ok := msg.(tea.KeyMsg); ok && f.field == entityFieldStatus {
switch key.String() {
case "left", "h":
f.cycleStatus(-1)
return nil
case "right", "l":
f.cycleStatus(1)
return nil
case " ":
f.cycleStatus(1)
return nil
if f.hasUserEdit {
return f.updateUserForm(msg)
}
if key, ok := msg.(tea.KeyMsg); ok {
switch f.field {
case entityFieldStatus:
switch key.String() {
case "left", "h":
f.cycleStatus(-1)
return nil
case "right", "l":
f.cycleStatus(1)
return nil
case " ":
f.cycleStatus(1)
return nil
}
case entityFieldResponsible:
switch key.String() {
case "left", "h":
f.cycleResponsible(-1)
return nil
case "right", "l":
f.cycleResponsible(1)
return nil
case " ":
f.cycleResponsible(1)
return nil
}
}
}
var cmd tea.Cmd
@@ -275,6 +447,9 @@ func (f EntityForm) View() string {
if !f.active {
return ""
}
if f.hasUserEdit {
return f.userView()
}
header := f.styles.header.Render(model.KindHeading(f.kind, f.itemID))
pLabel := f.styles.label.Render(f.primaryLabel)
if f.focused && f.field == entityFieldPrimary && !f.readOnly {
@@ -284,43 +459,95 @@ func (f EntityForm) View() string {
if f.focused && f.field == entityFieldSecondary && !f.readOnly {
sLabel = f.styles.labelAct.Render(f.secondaryLabel)
}
statusBlock := ""
if f.hasStatus {
stLabel := f.styles.label.Render("Status")
if f.focused && f.field == entityFieldStatus && !f.readOnly {
stLabel = f.styles.labelAct.Render("Status")
}
valStyle := f.styles.label
if f.status == model.ProjectLifecycleClosed {
valStyle = valStyle.Foreground(lipgloss.Color("245"))
} else {
valStyle = valStyle.Foreground(lipgloss.Color("42"))
}
hint := ""
if f.focused && f.field == entityFieldStatus && !f.readOnly {
hint = f.styles.label.Render(" ←/→ toggle")
}
statusBlock = strings.Join([]string{
"",
stLabel,
valStyle.Render(f.status.Label()) + hint,
}, "\n")
}
statusBlock := f.renderStatusBlock()
responsibleBlock := f.renderResponsibleBlock()
metaBlock := f.renderMetaBlock()
ro := ""
if f.readOnly {
ro = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(" (read-only)") + "\n"
}
return strings.Join([]string{
parts := []string{
header + ro,
pLabel,
f.primary.View(),
"",
sLabel,
f.secondary.View(),
statusBlock,
}
if statusBlock != "" {
parts = append(parts, statusBlock)
}
if responsibleBlock != "" {
parts = append(parts, responsibleBlock)
}
if metaBlock != "" {
parts = append(parts, metaBlock)
}
return strings.Join(parts, "\n")
}
func (f EntityForm) renderStatusBlock() string {
if !f.hasStatus && !f.hasTaskStatus {
return ""
}
stLabel := f.styles.label.Render("Status")
if f.focused && f.field == entityFieldStatus && !f.readOnly {
stLabel = f.styles.labelAct.Render("Status")
}
label := f.status.Label()
valStyle := f.styles.label
if f.hasTaskStatus {
label = f.taskStatus.Label()
if f.taskStatus == model.TaskLifecycleClosed {
valStyle = valStyle.Foreground(lipgloss.Color("245"))
} else if f.taskStatus == model.TaskLifecycleOpen {
valStyle = valStyle.Foreground(lipgloss.Color("42"))
}
} else if f.status == model.ProjectLifecycleClosed {
valStyle = valStyle.Foreground(lipgloss.Color("245"))
} else {
valStyle = valStyle.Foreground(lipgloss.Color("42"))
}
hint := ""
if f.focused && f.field == entityFieldStatus && !f.readOnly {
hint = f.styles.label.Render(" ←/→ cycle")
}
return strings.Join([]string{"", stLabel, valStyle.Render(label) + hint}, "\n")
}
func (f EntityForm) renderResponsibleBlock() string {
if !f.hasResponsible {
return ""
}
rLabel := f.styles.label.Render("Responsible")
if f.focused && f.field == entityFieldResponsible && !f.readOnly {
rLabel = f.styles.labelAct.Render("Responsible")
}
hint := ""
if f.focused && f.field == entityFieldResponsible && !f.readOnly && len(f.userChoices) > 0 {
hint = f.styles.label.Render(" ←/→ choose")
}
return strings.Join([]string{
"",
rLabel,
f.styles.meta.Render(f.responsibleLabel()) + hint,
}, "\n")
}
func (f EntityForm) renderMetaBlock() string {
if f.projectTitle == "" && f.timingSummary == "" {
return ""
}
lines := []string{""}
if f.projectTitle != "" {
lines = append(lines, f.styles.label.Render("Project"), f.styles.meta.Render(f.projectTitle))
}
if f.timingSummary != "" {
lines = append(lines, f.styles.label.Render("Timing"), f.styles.meta.Render(f.timingSummary))
}
return strings.Join(lines, "\n")
}
func entityFormBlinkCmd() tea.Cmd {
return tea.Batch(textinput.Blink, textarea.Blink)
}
+161
View File
@@ -0,0 +1,161 @@
package ui
import (
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func (f *EntityForm) userFieldCount() int {
return 2 + len(model.UserACLDefs)
}
func (f *EntityForm) userView() string {
header := f.styles.header.Render(model.KindHeading(f.kind, f.itemID))
lines := []string{
header,
f.styles.label.Render("ID"),
f.styles.meta.Render(f.itemID),
"",
f.renderUserEnabledRow(),
"",
f.renderUserPasswordRow(),
}
for i, def := range model.UserACLDefs {
lines = append(lines, f.renderUserACLRow(i, def.Label, f.userACL.ACLModuleOn(def.Key)))
}
lines = append(lines, "", f.styles.label.Render("Groups"), f.styles.meta.Render(f.groupsText))
return strings.Join(lines, "\n")
}
func (f *EntityForm) renderUserEnabledRow() string {
label := f.styles.label.Render("Account")
if f.focused && f.userFieldIdx == 0 {
label = f.styles.labelAct.Render("Account")
}
val := "Disabled"
style := lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
if f.userEnabled {
val = "Enabled"
style = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
}
hint := ""
if f.focused && f.userFieldIdx == 0 {
hint = f.styles.label.Render(" space toggle")
}
return strings.Join([]string{label, style.Render(val) + hint}, "\n")
}
func (f *EntityForm) renderUserPasswordRow() string {
label := f.styles.label.Render("Password")
if f.focused && f.userFieldIdx == 1 {
label = f.styles.labelAct.Render("Password")
}
return strings.Join([]string{label, f.password.View(), f.styles.meta.Render("(leave blank to keep)")}, "\n")
}
func (f *EntityForm) renderUserACLRow(idx int, title string, on bool) string {
fieldIdx := idx + 2
label := f.styles.label.Render(title)
if f.focused && f.userFieldIdx == fieldIdx {
label = f.styles.labelAct.Render(title)
}
val := "Off"
style := lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
if on {
val = "On"
style = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
}
hint := ""
if f.focused && f.userFieldIdx == fieldIdx {
hint = f.styles.label.Render(" space toggle")
}
return strings.Join([]string{label, style.Render(val) + hint}, "\n")
}
func (f *EntityForm) setUserFieldIndex(i int) {
if i < 0 {
i = 0
}
max := f.userFieldCount() - 1
if i > max {
i = max
}
f.userFieldIdx = i
f.applyFocus()
}
func (f *EntityForm) toggleUserEnabled() {
f.userEnabled = !f.userEnabled
f.dirty = true
}
func (f *EntityForm) toggleUserACL(idx int) {
if idx < 0 || idx >= len(model.UserACLDefs) {
return
}
if idx == 0 {
f.userACL.FullAccess = !f.userACL.FullAccess
if f.userACL.FullAccess {
for k := range f.userACL.Modules {
f.userACL.Modules[k] = true
}
}
f.dirty = true
return
}
def := model.UserACLDefs[idx]
if f.userACL.FullAccess {
f.userACL.FullAccess = false
for k := range f.userACL.Modules {
f.userACL.Modules[k] = true
}
}
f.userACL.Modules[def.Key] = !f.userACL.Modules[def.Key]
f.dirty = true
}
func (f *EntityForm) updateUserForm(msg tea.Msg) tea.Cmd {
if key, ok := msg.(tea.KeyMsg); ok {
switch f.userFieldIdx {
case 0:
switch key.String() {
case " ", "left", "right", "l", "h":
f.toggleUserEnabled()
return nil
}
case 1:
var cmd tea.Cmd
f.password, cmd = f.password.Update(msg)
f.dirty = true
return cmd
default:
switch key.String() {
case " ", "left", "right", "l", "h":
f.toggleUserACL(f.userFieldIdx - 2)
return nil
}
}
}
return nil
}
func (f *EntityForm) applyUserFocus() {
f.primary.Blur()
f.secondary.Blur()
if f.userFieldIdx == 1 {
f.password.Focus()
} else {
f.password.Blur()
}
}
func copyUserACLState(s model.UserACLState) model.UserACLState {
modules := make(map[string]bool, len(s.Modules))
for k, v := range s.Modules {
modules[k] = v
}
return model.UserACLState{FullAccess: s.FullAccess, Modules: modules}
}
+57
View File
@@ -0,0 +1,57 @@
package ui
import (
"strings"
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestUserFormViewShowsIDAndACL(t *testing.T) {
f := NewEntityFormForTest()
f.SetSize(60, 30)
f.Load(model.KindUser, "42", model.FormFields{
HasUserEdit: true,
UserEnabled: true,
UserACL: model.UserACLState{
FullAccess: false,
Modules: map[string]bool{
"documents": true,
},
},
GroupsText: "Admins, Devs",
})
view := f.View()
for _, want := range []string{"User 42", "ID", "42", "Account", "Password", "Full access", "Documents", "Groups", "Admins"} {
if !strings.Contains(view, want) {
t.Fatalf("view missing %q:\n%s", want, view)
}
}
}
func TestUserFormFieldCount(t *testing.T) {
f := NewEntityFormForTest()
f.Load(model.KindUser, "1", model.FormFields{HasUserEdit: true, UserACL: model.UserACLFromRaw(nil)})
if got := f.FieldCount(); got != 2+len(model.UserACLDefs) {
t.Fatalf("field count=%d want %d", got, len(model.UserACLDefs)+2)
}
}
func TestLayoutUserTableEmailAbsorbsWidth(t *testing.T) {
cols := model.BuildColumns(model.SubjectUsers, nil)
lay := layoutUserTable(cols, 100)
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 < 30 {
t.Fatalf("email width=%d should absorb extra space", emailW)
}
}
+12 -1
View File
@@ -25,7 +25,7 @@ type filterSearchStyles struct {
func newFilterSearch() FilterSearch {
in := textinput.New()
in.Prompt = "/ "
in.Placeholder = "Filter navigation and list…"
in.Placeholder = "Filter list…"
in.CharLimit = 256
return FilterSearch{
input: in,
@@ -76,6 +76,17 @@ func (f *FilterSearch) Update(msg tea.Msg) tea.Cmd {
return cmd
}
func (f *FilterSearch) SetInputWidth(inner int) {
if inner < 8 {
inner = 8
}
f.input.Width = inner
}
func (f *FilterSearch) CompactView() string {
return f.input.View()
}
func (f FilterSearch) View() string {
title := f.styles.title.Render("Filter")
body := lipgloss.NewStyle().Width(f.width).Render(f.input.View())
+7 -2
View File
@@ -39,8 +39,13 @@ func KeyAction(key string, pane model.FocusPane) Action {
}
case "r":
return ActionRefresh
case "f":
return ActionFilter
case "f", "/":
if pane == model.FocusList {
return ActionFilter
}
if pane == model.FocusMenu {
return ActionFilter
}
case "up", "k":
return ActionMoveUp
case "down", "j":
+52
View File
@@ -1,9 +1,14 @@
package ui
import (
"regexp"
"strings"
"testing"
"github.com/charmbracelet/lipgloss"
"github.com/eslider/go-onlyoffice/cmd/office/model"
"github.com/mattn/go-runewidth"
"github.com/muesli/termenv"
)
func TestLayoutWidthsAllVisibleUsesFullWidth(t *testing.T) {
@@ -66,6 +71,53 @@ func TestPrevVisibleFocusSkipsHidden(t *testing.T) {
}
}
func TestThreePaneRenderedWidthMatchesTerminal(t *testing.T) {
prev := lipgloss.ColorProfile()
lipgloss.SetColorProfile(termenv.TrueColor)
t.Cleanup(func() { lipgloss.SetColorProfile(prev) })
total := 120
pw := LayoutWidths(total, PaneVisibility{Menu: true, List: true, Detail: true})
h := 12
menu := paneStyle(false).Width(paneLipglossWidth(pw.Menu)).Height(h).Render(strings.Repeat("m", paneContentWidth(pw.Menu)))
inner := paneContentWidth(pw.List)
tbl := newDataTable()
tbl.SetSize(inner, h-listToolbarHeight)
tbl.SetData(model.ListSpec{Subject: model.SubjectProjects}, []model.Item{
{ID: "1", Title: "Alpha", Kind: model.KindProject, Raw: map[string]any{"status": 0}},
})
toolbar := newListToolbar()
toolbar.SetWidth(inner)
body := lipgloss.JoinVertical(lipgloss.Left, toolbar.View(ListToolbarMeta{Subject: "projects", Count: 1}), tbl.View())
list := paneStyle(true).Width(paneLipglossWidth(pw.List)).Height(h).Render(body)
detail := paneStyle(false).Width(paneLipglossWidth(pw.Detail)).Height(h).Render(strings.Repeat("d", paneContentWidth(pw.Detail)))
full := lipgloss.JoinHorizontal(lipgloss.Top, menu, list, detail)
re := regexp.MustCompile(`\x1b\[[0-9;]*m`)
top := strings.Split(full, "\n")[0]
got := runewidth.StringWidth(re.ReplaceAllString(top, ""))
if got != total {
t.Fatalf("top border width=%d want %d", got, total)
}
for _, line := range strings.Split(tbl.View(), "\n") {
if strings.TrimSpace(line) == "" {
continue
}
w := runewidth.StringWidth(re.ReplaceAllString(line, ""))
if w > inner+1 {
t.Fatalf("table line width=%d want <=%d", w, inner+1)
}
}
}
func TestPaneContentWidth(t *testing.T) {
if got := paneContentWidth(72); got != 68 {
t.Fatalf("content width=%d want 68", got)
}
if got := paneLipglossWidth(72); got != 70 {
t.Fatalf("lipgloss width=%d want 70", got)
}
}
func TestLayoutWidthsLegacyHelper(t *testing.T) {
menu, list, preview := LayoutWidthsLegacy(120)
if menu+list+preview != 120 {
+352
View File
@@ -0,0 +1,352 @@
package ui
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/eslider/go-onlyoffice/cmd/office/model"
"github.com/mattn/go-runewidth"
)
const listToolbarHeight = 2
type listZone int
const (
listZoneTable listZone = iota
listZoneFilter
listZoneActions
)
// ListToolbarMeta is the navigation/sort summary shown above the filter row.
type ListToolbarMeta struct {
Subject string
Count int
SortLabel string
LoadingMore bool
SaveEnabled bool
DeleteEnabled bool
}
// ListToolbar is the filter & navigation bar above the center table.
type ListToolbar struct {
filter FilterSearch
width int
zone listZone
actionIdx int
actionRects []toolbarBtnRect
btnX0 int
styles listToolbarStyles
}
type toolbarBtnRect struct {
action model.ActionID
x0, x1 int
}
type listToolbarStyles struct {
title lipgloss.Style
btn lipgloss.Style
btnOn lipgloss.Style
btnOff lipgloss.Style
btnAct lipgloss.Style
btnDn lipgloss.Style
}
func newListToolbar() ListToolbar {
return ListToolbar{
filter: newFilterSearch(),
styles: listToolbarStyles{
title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("252")),
btn: lipgloss.NewStyle().Padding(0, 1),
btnOn: lipgloss.NewStyle().Padding(0, 1).Foreground(lipgloss.Color("255")),
btnOff: lipgloss.NewStyle().Padding(0, 1).Foreground(lipgloss.Color("238")),
btnAct: lipgloss.NewStyle().Padding(0, 1).Bold(true).Foreground(lipgloss.Color("255")).Background(lipgloss.Color("62")),
btnDn: lipgloss.NewStyle().Padding(0, 1).Foreground(lipgloss.Color("255")).Background(lipgloss.Color("52")),
},
}
}
func (b *ListToolbar) SetWidth(w int) {
if w < 12 {
w = 12
}
b.width = w
inner := w - 8
if inner < 12 {
inner = 12
}
b.filter.SetInputWidth(inner)
}
func (b *ListToolbar) Zone() listZone { return b.zone }
func (b *ListToolbar) SetZone(z listZone) { b.zone = z }
func (b *ListToolbar) Query() string { return b.filter.Query() }
func (b *ListToolbar) ClearFilter() { b.filter.Clear() }
func (b *ListToolbar) syncInputFocus() {
b.filter.SetFocused(b.zone == listZoneFilter)
}
func (b *ListToolbar) FocusFilter() {
b.zone = listZoneFilter
b.filter.SetFocused(true)
}
func (b *ListToolbar) FocusTable() {
b.zone = listZoneTable
b.filter.SetFocused(false)
}
func (b *ListToolbar) TabForward(meta ListToolbarMeta) {
switch b.zone {
case listZoneTable:
b.FocusFilter()
case listZoneFilter:
b.zone = listZoneActions
b.actionIdx = 0
b.filter.SetFocused(false)
b.clampActionIdx(meta)
case listZoneActions:
if b.nextEnabledAction(meta, 1) {
return
}
b.FocusTable()
}
}
func (b *ListToolbar) TabBackward(meta ListToolbarMeta) {
switch b.zone {
case listZoneTable:
b.zone = listZoneActions
b.actionIdx = b.lastEnabledAction(meta)
b.filter.SetFocused(false)
case listZoneActions:
if b.nextEnabledAction(meta, -1) {
return
}
b.FocusFilter()
case listZoneFilter:
b.FocusTable()
}
}
func (b *ListToolbar) toolbarActions(meta ListToolbarMeta) []model.ActionID {
var out []model.ActionID
if meta.SaveEnabled {
out = append(out, model.ActionSave)
}
if meta.DeleteEnabled {
out = append(out, model.ActionDelete)
}
return out
}
func (b *ListToolbar) clampActionIdx(meta ListToolbarMeta) {
acts := b.toolbarActions(meta)
if len(acts) == 0 {
b.actionIdx = 0
return
}
if b.actionIdx >= len(acts) {
b.actionIdx = len(acts) - 1
}
if b.actionIdx < 0 {
b.actionIdx = 0
}
}
func (b *ListToolbar) lastEnabledAction(meta ListToolbarMeta) int {
acts := b.toolbarActions(meta)
if len(acts) == 0 {
return 0
}
return len(acts) - 1
}
func (b *ListToolbar) nextEnabledAction(meta ListToolbarMeta, delta int) bool {
acts := b.toolbarActions(meta)
if len(acts) == 0 {
return false
}
next := b.actionIdx + delta
if next >= 0 && next < len(acts) {
b.actionIdx = next
return true
}
return false
}
func (b *ListToolbar) SelectedAction(meta ListToolbarMeta) (model.ActionID, bool) {
acts := b.toolbarActions(meta)
if b.zone != listZoneActions || len(acts) == 0 {
return "", false
}
b.clampActionIdx(meta)
return acts[b.actionIdx], true
}
func (b *ListToolbar) MoveAction(delta int, meta ListToolbarMeta) {
if b.zone != listZoneActions {
return
}
b.nextEnabledAction(meta, delta)
}
func (b *ListToolbar) Update(msg tea.Msg, meta ListToolbarMeta) tea.Cmd {
if b.zone == listZoneFilter {
if key, ok := msg.(tea.KeyMsg); ok {
cmd := b.filter.Update(msg)
switch key.String() {
case "tab":
b.TabForward(meta)
return cmd
case "shift+tab", "backtab":
b.TabBackward(meta)
return cmd
case "enter":
b.zone = listZoneActions
b.actionIdx = 0
b.filter.SetFocused(false)
b.clampActionIdx(meta)
return cmd
}
return cmd
}
return b.filter.Update(msg)
}
if b.zone == listZoneActions {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "left", "h":
b.MoveAction(-1, meta)
case "right", "l":
b.MoveAction(1, meta)
case "tab":
b.TabForward(meta)
case "shift+tab", "backtab":
b.TabBackward(meta)
}
}
}
return nil
}
func (b *ListToolbar) View(meta ListToolbarMeta) string {
title := b.styles.title.Render(meta.Subject)
if meta.Count > 0 || meta.Subject != "" {
title = b.styles.title.Render(formatListToolbarTitle(meta))
}
if meta.LoadingMore {
title += lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(" …")
}
filter := b.filter.CompactView()
buttons, rects := b.renderButtons(meta)
avail := b.width - runewidth.StringWidth(buttons) - 1
if avail < 8 {
avail = 8
}
b.btnX0 = avail + 1
for i := range rects {
rects[i].x0 += b.btnX0
rects[i].x1 += b.btnX0
}
b.actionRects = rects
filterLine := padDisplayWidth(filter, avail) + " " + buttons
if runewidth.StringWidth(filterLine) > b.width {
filterLine = runewidth.Truncate(filterLine, b.width, "")
}
lines := []string{
padANSIWidth(title, b.width),
padANSIWidth(filterLine, b.width),
}
return lipgloss.NewStyle().Width(b.width).MaxWidth(b.width).Render(strings.Join(lines, "\n"))
}
func formatListToolbarTitle(meta ListToolbarMeta) string {
s := meta.Subject
if meta.Count > 0 {
s = fmt.Sprintf("%s (%d)", meta.Subject, meta.Count)
}
if meta.SortLabel != "" {
s += " " + meta.SortLabel
}
return s
}
func (b *ListToolbar) renderButtons(meta ListToolbarMeta) (string, []toolbarBtnRect) {
type btn struct {
action model.ActionID
icon string
enabled bool
danger bool
}
defs := []btn{
{model.ActionSave, "💾", meta.SaveEnabled, false},
{model.ActionDelete, "🗑", meta.DeleteEnabled, true},
}
acts := b.toolbarActions(meta)
rects := make([]toolbarBtnRect, 0, len(defs))
parts := make([]string, 0, len(defs))
x := 0
for i, d := range defs {
active := b.zone == listZoneActions && len(acts) > 0 && b.actionIdx < len(acts) && acts[b.actionIdx] == d.action
var style lipgloss.Style
switch {
case !d.enabled:
style = b.styles.btnOff
case active && d.danger:
style = b.styles.btnDn
case active:
style = b.styles.btnAct
case d.danger:
style = b.styles.btn.Foreground(lipgloss.Color("203"))
default:
style = b.styles.btnOn
}
rendered := style.Render(d.icon)
w := runewidth.StringWidth(rendered)
rects = append(rects, toolbarBtnRect{action: d.action, x0: x, x1: x + w})
x += w
parts = append(parts, rendered)
_ = i
}
return strings.Join(parts, " "), rects
}
func (b *ListToolbar) ActionAt(x int, meta ListToolbarMeta) (model.ActionID, bool) {
_ = meta
if x < 0 {
return "", false
}
for _, r := range b.actionRects {
if x >= r.x0 && x < r.x1 {
action := r.action
if b.IsActionEnabled(action, meta) {
return action, true
}
return "", false
}
}
return "", false
}
func (b *ListToolbar) IsActionEnabled(action model.ActionID, meta ListToolbarMeta) bool {
switch action {
case model.ActionSave:
return meta.SaveEnabled
case model.ActionDelete:
return meta.DeleteEnabled
default:
return false
}
}
+58
View File
@@ -0,0 +1,58 @@
package ui
import (
"strings"
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestListToolbarShowsFilterAndActions(t *testing.T) {
b := newListToolbar()
b.SetWidth(60)
view := b.View(ListToolbarMeta{
Subject: "projects",
Count: 3,
})
if !strings.Contains(view, "projects (3)") {
t.Fatalf("missing subject: %q", view)
}
if !strings.Contains(view, "💾") || !strings.Contains(view, "🗑") {
t.Fatalf("missing action icons: %q", view)
}
}
func TestListToolbarActionsEnabledFromMeta(t *testing.T) {
b := newListToolbar()
on := ListToolbarMeta{SaveEnabled: true, DeleteEnabled: true}
off := ListToolbarMeta{}
if !b.IsActionEnabled(model.ActionSave, on) {
t.Fatal("save should be enabled")
}
if b.IsActionEnabled(model.ActionSave, off) {
t.Fatal("save should be disabled")
}
if !b.IsActionEnabled(model.ActionDelete, on) {
t.Fatal("delete should be enabled")
}
}
func TestCanSaveFromListRequiresSelection(t *testing.T) {
m := Model{
items: []model.Item{
{ID: "1", Title: "A", Kind: model.KindProject, Selected: true},
},
}
if m.canSaveFromList() {
t.Fatal("save should require matching loaded detail")
}
m.detail = newDetailPane()
m.detail.SetSize(40, 20)
m.detail.LoadForm(model.Item{ID: "1", Kind: model.KindProject}, model.FormFields{
PrimaryLabel: "Title", SecondaryLabel: "Description",
Primary: "A", HasStatus: true,
})
if !m.canSaveFromList() {
t.Fatal("save should be enabled with one selected row and loaded detail")
}
}
+31 -4
View File
@@ -8,7 +8,11 @@ import (
func (m *Model) scrollFocusedPane(key string) bool {
switch m.focus {
case model.FocusMenu:
return scrollViewport(&m.menuVP, key)
switch key {
case "pgdown", "pgdn", "ctrl+d", "pgup", "b", "ctrl+u", "home", "g", "end", "G":
return scrollViewport(&m.menuVP, key)
}
return false
case model.FocusList:
switch key {
case "pgdown", "pgdn", "ctrl+d":
@@ -37,17 +41,40 @@ func (m *Model) paneHeight() int {
return h
}
func (m *Model) paneInnerWidth(outer int) int {
if outer <= 0 {
const (
paneBorderChars = 2 // left + right border drawn outside lipgloss Width
panePaddingChars = 2 // Padding(0, 1) on paneStyle
)
// paneLipglossWidth maps an on-screen pane width to lipgloss Style.Width.
// Bordered panes render two cells wider than the Width value.
func paneLipglossWidth(rendered int) int {
if rendered <= 0 {
return 8
}
w := outer - 2 // border only; content fills inner box
w := rendered - paneBorderChars
if w < 8 {
w = 8
}
return w
}
// paneContentWidth is the usable inner width for viewports and tables.
func paneContentWidth(rendered int) int {
if rendered <= 0 {
return 8
}
w := rendered - paneBorderChars - panePaddingChars
if w < 8 {
w = 8
}
return w
}
func (m *Model) paneInnerWidth(rendered int) int {
return paneContentWidth(rendered)
}
func scrollViewport(vp *viewport.Model, key string) bool {
switch key {
case "up", "k":
+48
View File
@@ -0,0 +1,48 @@
package ui
import (
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestScrollFocusedPaneMenuUpDownMovesTree(t *testing.T) {
m := NewModel(nil)
m.focus = model.FocusMenu
m.syncMenuContent()
before := m.nav.Cursor()
if m.scrollFocusedPane("down") {
t.Fatal("down should not be consumed by menu viewport scroll")
}
m.moveDown()
m.syncFocusedPane()
if m.nav.Cursor() != before+1 {
t.Fatalf("cursor=%d want %d", m.nav.Cursor(), before+1)
}
}
func TestScrollFocusedPaneMenuPageKeysScrollViewport(t *testing.T) {
m := NewModel(nil)
m.focus = model.FocusMenu
for i := 0; i < 40; i++ {
m.nav.MoveDown()
}
m.syncMenuContent()
if !m.scrollFocusedPane("pgup") {
t.Fatal("pgup should scroll menu viewport")
}
}
func TestHandleMenuHorizontalRightOpensLeaf(t *testing.T) {
m := NewModel(nil)
m.focus = model.FocusMenu
// cursor starts on Projects leaf
next, cmd := m.handleMenuHorizontal(1)
if cmd == nil {
t.Fatal("expected load list command")
}
if next.listSpec.Subject != model.SubjectProjects {
t.Fatalf("expected projects list, got %+v", next.listSpec)
}
}
+2 -13
View File
@@ -4,7 +4,6 @@ import (
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/mattn/go-runewidth"
)
// ScrollbarMetrics computes thumb position for a vertical scrollbar.
@@ -49,7 +48,7 @@ func ApplyVerticalScrollbar(view string, width, height, totalLines, yOffset int)
thumb := lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
out := make([]string, height)
for row := 0; row < height; row++ {
line := padDisplayWidth(lines[row], contentWidth)
line := padANSIWidth(lines[row], contentWidth)
ch := "│"
style := track
if row >= thumbStart && row < thumbEnd {
@@ -62,15 +61,5 @@ func ApplyVerticalScrollbar(view string, width, height, totalLines, yOffset int)
}
func padDisplayWidth(line string, width int) string {
if width < 1 {
return ""
}
w := runewidth.StringWidth(line)
if w > width {
return runewidth.Truncate(line, width, "")
}
if w < width {
line += strings.Repeat(" ", width-w)
}
return line
return padANSIWidth(line, width)
}
+60 -172
View File
@@ -9,7 +9,6 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/eslider/go-onlyoffice/cmd/office/model"
"github.com/mattn/go-runewidth"
)
type tableStyles struct {
@@ -80,7 +79,7 @@ func (t *DataTable) SetSize(w, h int) {
t.width = w
t.height = h
t.viewport.Width = w
t.viewport.Height = h - 2 // title + header
t.viewport.Height = h - 1 // column header
if t.viewport.Height < 1 {
t.viewport.Height = 1
}
@@ -260,155 +259,63 @@ func (t *DataTable) Update(msg tea.Msg) tea.Cmd {
return nil
}
func (t DataTable) LoadingMore() bool { return t.loadingMore }
func (t DataTable) View() string {
if !t.ready {
return "List\n\nSelect a leaf node in the tree\n(marked with •) and press Enter.\n"
}
title := t.styles.title.Render(fmt.Sprintf("%s (%d)", t.spec.Subject, len(t.items)))
if t.loadingMore {
title += t.styles.help.Render(" …")
}
sortHint := ""
if t.sortCol >= 0 && t.sortCol < len(t.columns) {
dir := "▲"
if !t.sortAsc {
dir = "▼"
}
sortHint = t.styles.help.Render(fmt.Sprintf(" sort: %s %s", t.columns[t.sortCol].Title, dir))
return "\nSelect a leaf in the tree (•) and press Enter.\n"
}
header := t.renderHeader()
if t.hasVerticalScrollbar() {
header = padANSIWidth(header, t.lineContentWidth())
}
body := ApplyVerticalScrollbar(
t.viewport.View(),
t.viewport.Width,
t.width,
t.viewport.Height,
t.viewport.TotalLineCount(),
t.viewport.YOffset,
)
content := lipgloss.JoinVertical(lipgloss.Left, title+sortHint, header, body)
return lipgloss.NewStyle().Width(t.width).Render(content)
return lipgloss.JoinVertical(lipgloss.Left, header, body)
}
func (t DataTable) SortHint() string {
if t.sortCol < 0 || t.sortCol >= len(t.columns) {
return ""
}
dir := "▲"
if !t.sortAsc {
dir = "▼"
}
return fmt.Sprintf("sort: %s %s", t.columns[t.sortCol].Title, dir)
}
func (t DataTable) SubjectLabel() string {
if !t.ready {
return "List"
}
return string(t.spec.Subject)
}
func (t DataTable) ItemCount() int {
return len(t.items)
}
func (t DataTable) hasVerticalScrollbar() bool {
return t.ready && t.viewport.TotalLineCount() > t.viewport.Height
}
func (t DataTable) lineContentWidth() int {
w := t.width
if t.hasVerticalScrollbar() && w > 1 {
w--
}
return w
}
func (t *DataTable) visibleLayout() (indices []int, widths map[int]int) {
widths = make(map[int]int)
if len(t.columns) == 0 || t.width <= 0 {
return nil, widths
}
indices = t.pickVisibleColumnIndices()
if len(indices) == 0 {
return indices, widths
}
minSum := 0
for _, i := range indices {
minSum += t.columns[i].Width
}
widths = distributeColumnWidths(minSum, t.width, indices, t.columns)
return indices, widths
}
func (t *DataTable) pickVisibleColumnIndices() []int {
var out []int
used := 0
for colIdx := t.colScroll; colIdx < len(t.columns); colIdx++ {
w := t.columns[colIdx].Width
if len(out) > 0 && used+w > t.width {
break
}
out = append(out, colIdx)
used += w
}
if len(out) == 0 {
colIdx := t.colScroll
if colIdx < 0 || colIdx >= len(t.columns) {
colIdx = 0
}
out = []int{colIdx}
}
return out
}
// distributeColumnWidths expands or shrinks visible columns to exactly fill total width.
func distributeColumnWidths(minSum, total int, indices []int, cols []model.TableColumn) map[int]int {
out := make(map[int]int, len(indices))
if len(indices) == 0 {
return out
}
if total < len(indices) {
total = len(indices)
}
if minSum <= 0 {
each := total / len(indices)
if each < 1 {
each = 1
}
for _, i := range indices {
out[i] = each
}
fixColumnWidthSum(out, indices, total)
return out
}
for _, i := range indices {
out[i] = cols[i].Width
}
if minSum >= total {
for _, i := range indices {
out[i] = cols[i].Width * total / minSum
if out[i] < 1 {
out[i] = 1
}
}
fixColumnWidthSum(out, indices, total)
return out
}
extra := total - minSum
flex := make([]int, 0, len(indices))
for _, i := range indices {
switch cols[i].Key {
case "title", "subtitle", "description", "displayName", "primaryEmail", "from", "to", "tasks", "type":
flex = append(flex, i)
}
}
if len(flex) == 0 {
flex = append(flex, indices...)
}
flexSum := 0
for _, i := range flex {
flexSum += cols[i].Width
}
if flexSum <= 0 {
flexSum = len(flex)
}
for _, i := range flex {
out[i] += extra * cols[i].Width / flexSum
}
fixColumnWidthSum(out, indices, total)
return out
}
func fixColumnWidthSum(widths map[int]int, indices []int, total int) {
if len(indices) == 0 {
return
}
sum := 0
for _, i := range indices {
sum += widths[i]
}
widths[indices[len(indices)-1]] += total - sum
if widths[indices[len(indices)-1]] < 1 {
widths[indices[len(indices)-1]] = 1
}
}
const cellHPadding = 2 // lipgloss Padding(0, 1) on each side
func truncateCellText(text string, colWidth int) string {
inner := colWidth - cellHPadding
if inner < 1 {
inner = 1
}
if runewidth.StringWidth(text) <= inner {
return text
}
return runewidth.Truncate(text, inner, "...")
lay := t.computeLayout()
return lay.indices, lay.widths
}
func (t *DataTable) renderHeader() string {
@@ -430,7 +337,7 @@ func (t *DataTable) renderHeader() string {
if t.focused && colIdx == t.cursorCol {
style = t.styles.headerSort
}
cells = append(cells, style.Width(w).MaxWidth(w).Render(text))
cells = append(cells, renderTableCell(style, text, w))
}
return lipgloss.JoinHorizontal(lipgloss.Left, cells...)
}
@@ -450,30 +357,24 @@ func (t *DataTable) refreshViewport() {
func (t *DataTable) renderRow(row int) string {
item := t.items[t.order[row]]
selected := item.Selected
fullRow := row == t.cursorRow || selected
indices, widths := t.visibleLayout()
cells := make([]string, 0, len(indices))
for _, colIdx := range indices {
col := t.columns[colIdx]
w := widths[colIdx]
raw := strings.TrimSpace(model.CellText(item, col.Key))
text := raw
if !fullRow {
text = truncateCellText(raw, w)
}
cells = append(cells, t.styleCell(row, colIdx, col.Key, selected, item, text, w, fullRow))
text := truncateCellText(raw, w)
cells = append(cells, t.styleCell(row, colIdx, col.Key, selected, item, text, w))
}
return lipgloss.JoinHorizontal(lipgloss.Left, cells...)
}
func applyCellWidth(s lipgloss.Style, width int, full bool) lipgloss.Style {
if full {
return s
func (t *DataTable) styleCell(row, col int, colKey string, selected bool, item model.Item, text string, width int) string {
if t.spec.Subject == model.SubjectProjects {
style := t.projectCellStyle(row, col, colKey, selected, item)
return renderTableCell(style, text, width)
}
return s.Width(width).MaxWidth(width)
}
func (t *DataTable) styleCell(row, col int, colKey string, selected bool, item model.Item, text string, width int, fullWidth bool) string {
isRow := row == t.cursorRow
isCol := col == t.cursorCol
isCell := t.focused && isRow && isCol
@@ -495,29 +396,16 @@ func (t *DataTable) styleCell(row, col int, colKey string, selected bool, item m
default:
base = t.styles.cell
}
base = t.applyProjectRowStyle(base, item, colKey)
return applyCellWidth(base, width, fullWidth).Render(text)
}
func (t *DataTable) applyProjectRowStyle(base lipgloss.Style, item model.Item, colKey string) lipgloss.Style {
if t.spec.Subject != model.SubjectProjects {
return base
}
open := model.ProjectIsOpen(item.Raw)
if colKey == "status" {
if open {
return base.Foreground(lipgloss.Color("42")).Bold(true)
}
return base.Foreground(lipgloss.Color("245"))
}
if open {
return base.Background(lipgloss.Color("22")).Foreground(lipgloss.Color("255"))
}
return base.Background(lipgloss.Color("238")).Foreground(lipgloss.Color("252"))
return renderTableCell(base, text, width)
}
func (t *DataTable) ensureColVisible() {
vis := t.pickVisibleColumnIndices()
if t.spec.Subject == model.SubjectProjects || t.spec.Subject == model.SubjectUsers {
t.colScroll = 0
return
}
contentW := t.lineContentWidth()
vis := pickVisibleColumnIndices(t.columns, t.colScroll, contentW)
if len(vis) == 0 {
return
}
@@ -528,7 +416,7 @@ func (t *DataTable) ensureColVisible() {
if t.cursorCol > last {
t.colScroll = t.cursorCol
for t.cursorCol >= 0 {
vis = t.pickVisibleColumnIndices()
vis = pickVisibleColumnIndices(t.columns, t.colScroll, contentW)
if len(vis) == 0 {
break
}
+165
View File
@@ -0,0 +1,165 @@
package ui
import (
"github.com/eslider/go-onlyoffice/cmd/office/model"
"github.com/mattn/go-runewidth"
)
type columnLayout struct {
indices []int
widths map[int]int
}
func (t *DataTable) computeLayout() columnLayout {
contentW := t.lineContentWidth()
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)
}
return layoutScrollingTable(t.columns, t.colScroll, contentW)
}
func layoutScrollingTable(cols []model.TableColumn, colScroll, contentW int) columnLayout {
indices := pickVisibleColumnIndices(cols, colScroll, contentW)
widths := make(map[int]int, len(indices))
if len(indices) == 0 {
return columnLayout{indices: indices, widths: widths}
}
minSum := 0
for _, i := range indices {
minSum += cols[i].Width
}
widths = distributeColumnWidths(minSum, contentW, indices, cols)
return columnLayout{indices: indices, widths: widths}
}
func pickVisibleColumnIndices(cols []model.TableColumn, colScroll, contentW int) []int {
var out []int
used := 0
for colIdx := colScroll; colIdx < len(cols); colIdx++ {
w := cols[colIdx].Width
if len(out) > 0 && used+w > contentW {
break
}
out = append(out, colIdx)
used += w
}
if len(out) == 0 {
colIdx := colScroll
if colIdx < 0 || colIdx >= len(cols) {
colIdx = 0
}
out = []int{colIdx}
}
return out
}
// distributeColumnWidths expands or shrinks visible columns to exactly fill total width.
func distributeColumnWidths(minSum, total int, indices []int, cols []model.TableColumn) map[int]int {
out := make(map[int]int, len(indices))
if len(indices) == 0 {
return out
}
if total < len(indices) {
total = len(indices)
}
if minSum <= 0 {
each := total / len(indices)
if each < 1 {
each = 1
}
for _, i := range indices {
out[i] = each
}
normalizeWidthSum(out, indices, total, indices[len(indices)-1])
return out
}
for _, i := range indices {
out[i] = cols[i].Width
}
if minSum >= total {
for _, i := range indices {
out[i] = cols[i].Width * total / minSum
if out[i] < 1 {
out[i] = 1
}
}
normalizeWidthSum(out, indices, total, flexColumnIndex(indices, cols))
return out
}
extra := total - minSum
flex := flexColumnIndices(indices, cols)
flexSum := 0
for _, i := range flex {
flexSum += cols[i].Width
}
if flexSum <= 0 {
flexSum = len(flex)
}
for _, i := range flex {
out[i] += extra * cols[i].Width / flexSum
}
normalizeWidthSum(out, indices, total, flexColumnIndex(indices, cols))
return out
}
func flexColumnIndices(indices []int, cols []model.TableColumn) []int {
flex := make([]int, 0, len(indices))
for _, i := range indices {
switch cols[i].Key {
case "title", "subtitle", "description", "displayName", "primaryEmail", "from", "to", "type":
flex = append(flex, i)
}
}
if len(flex) == 0 {
flex = append(flex, indices...)
}
return flex
}
func flexColumnIndex(indices []int, cols []model.TableColumn) int {
flex := flexColumnIndices(indices, cols)
if len(flex) == 0 {
return indices[len(indices)-1]
}
for _, i := range indices {
if cols[i].Key == "title" {
return i
}
}
return flex[0]
}
func normalizeWidthSum(widths map[int]int, indices []int, total, adjustIdx int) {
if len(indices) == 0 {
return
}
sum := 0
for _, i := range indices {
sum += widths[i]
}
if adjustIdx < 0 {
adjustIdx = indices[len(indices)-1]
}
widths[adjustIdx] += total - sum
if widths[adjustIdx] < 1 {
widths[adjustIdx] = 1
}
}
const cellHPadding = 0 // padding lives in outer lipgloss styles; column width is the full cell width
func truncateCellText(text string, colWidth int) string {
if colWidth < 1 {
colWidth = 1
}
if runewidth.StringWidth(text) <= colWidth {
return text
}
return runewidth.Truncate(text, colWidth, "…")
}
+114
View File
@@ -0,0 +1,114 @@
package ui
import (
"github.com/charmbracelet/lipgloss"
"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().
Background(lipgloss.Color("235")).
Foreground(lipgloss.Color("108"))
}
return lipgloss.NewStyle().
Background(lipgloss.Color("238")).
Foreground(lipgloss.Color("252"))
}
func (t *DataTable) projectCellStyle(row, col int, colKey string, selected bool, item model.Item) lipgloss.Style {
isRow := row == t.cursorRow
isCol := col == t.cursorCol
isCell := t.focused && isRow && isCol
switch {
case selected && isCell:
return t.styles.cellSelect
case selected && isRow:
return t.styles.rowSelect
case selected:
return t.styles.rowSelect
case isCell:
return t.styles.cellActive
case t.focused && (isRow || isCol):
return t.projectStatusStyle(colKey, item, t.projectBaseStyle(item))
default:
return t.projectStatusStyle(colKey, item, t.projectBaseStyle(item))
}
}
func (t *DataTable) projectStatusStyle(colKey string, item model.Item, base lipgloss.Style) lipgloss.Style {
if colKey != "status" {
return base
}
if model.ProjectIsOpen(item.Raw) {
return base.Foreground(lipgloss.Color("42")).Bold(true)
}
return base.Foreground(lipgloss.Color("245"))
}
+22
View File
@@ -0,0 +1,22 @@
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)
}
}
+49
View File
@@ -0,0 +1,49 @@
package ui
import (
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"github.com/mattn/go-runewidth"
)
// renderTableCell renders one column using the bubbles/table pattern:
// truncate plain text, constrain with an inline inner box, then apply the outer style.
func renderTableCell(outer lipgloss.Style, text string, width int) string {
if width < 1 {
width = 1
}
frame := outer.GetHorizontalFrameSize()
innerW := width - frame
if innerW < 1 {
innerW = 1
}
truncated := runewidth.Truncate(text, innerW, "…")
inner := lipgloss.NewStyle().Width(innerW).MaxWidth(innerW).Inline(true)
return outer.Render(inner.Render(truncated))
}
// padANSIWidth pads or truncates a styled line without breaking ANSI sequences.
func padANSIWidth(line string, width int) string {
if width < 1 {
return ""
}
w := ansi.StringWidth(line)
if w > width {
return ansi.Truncate(line, width, "")
}
if w < width {
return line + repeatSpace(width-w)
}
return line
}
func repeatSpace(n int) string {
if n <= 0 {
return ""
}
b := make([]byte, n)
for i := range b {
b[i] = ' '
}
return string(b)
}
+55
View File
@@ -0,0 +1,55 @@
package ui
import (
"regexp"
"strings"
"testing"
"github.com/charmbracelet/lipgloss"
"github.com/eslider/go-onlyoffice/cmd/office/model"
"github.com/mattn/go-runewidth"
"github.com/muesli/termenv"
)
func TestProjectTableHeaderFitsPaneWidth(t *testing.T) {
prev := lipgloss.ColorProfile()
lipgloss.SetColorProfile(termenv.TrueColor)
t.Cleanup(func() { lipgloss.SetColorProfile(prev) })
for _, w := range []int{68, 50, 40, 30} {
tbl := newDataTable()
tbl.SetSize(w, 10)
tbl.SetData(model.ListSpec{Subject: model.SubjectProjects}, []model.Item{
{ID: "1", Title: "Alpha", Kind: model.KindProject, Raw: map[string]any{"status": 0}},
})
tbl.SetFocused(true)
re := regexp.MustCompile(`\x1b\[[0-9;]*m`)
header := tbl.renderHeader()
got := runewidth.StringWidth(re.ReplaceAllString(header, ""))
if got != w {
t.Fatalf("width=%d header display width=%d", w, got)
}
view := tbl.View()
line0 := strings.Split(strings.TrimSuffix(view, "\n"), "\n")[0]
gotView := runewidth.StringWidth(re.ReplaceAllString(line0, ""))
if gotView < w-1 || gotView > w+1 {
t.Fatalf("width=%d view header width=%d", w, gotView)
}
}
}
func TestPadANSIWidthPreservesStyledLine(t *testing.T) {
prev := lipgloss.ColorProfile()
lipgloss.SetColorProfile(termenv.TrueColor)
t.Cleanup(func() { lipgloss.SetColorProfile(prev) })
line := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255")).Render("ID")
padded := padANSIWidth(line, 10)
re := regexp.MustCompile(`\x1b\[[0-9;]*m`)
if runewidth.StringWidth(re.ReplaceAllString(padded, "")) != 10 {
t.Fatalf("expected padded width 10, got %q", padded)
}
if !strings.Contains(padded, "ID") {
t.Fatalf("padding must not destroy cell text: %q", padded)
}
}
+143 -5
View File
@@ -1,11 +1,14 @@
package ui
import (
"regexp"
"strings"
"testing"
"github.com/charmbracelet/lipgloss"
"github.com/eslider/go-onlyoffice/cmd/office/model"
"github.com/mattn/go-runewidth"
"github.com/muesli/termenv"
)
func sampleItems() []model.Item {
@@ -89,13 +92,121 @@ func TestDistributeColumnWidthsFillsTotal(t *testing.T) {
}
}
func TestProjectTableTitleAbsorbsWidth(t *testing.T) {
cols := model.BuildColumns(model.SubjectProjects, nil)
lay := layoutProjectTable(cols, 120)
if len(lay.indices) != len(cols) {
t.Fatalf("expected all %d columns visible, got %d", len(cols), len(lay.indices))
}
sum := 0
titleW := 0
fixedSum := 0
titleIdx := indexOfColumnKey(cols, "title")
for _, i := range lay.indices {
sum += lay.widths[i]
if i == titleIdx {
titleW = lay.widths[i]
continue
}
fixedSum += lay.widths[i]
}
if sum != 120 {
t.Fatalf("sum=%d want 120", sum)
}
if titleW != 120-fixedSum {
t.Fatalf("title width=%d want remainder %d", titleW, 120-fixedSum)
}
if titleW < 40 {
t.Fatalf("title should absorb extra width, got %d", titleW)
}
}
func TestProjectTableFillsPaneWidth(t *testing.T) {
tbl := newDataTable()
tbl.SetSize(80, 12)
tbl.SetData(model.ListSpec{Subject: model.SubjectProjects}, []model.Item{
{ID: "1", Title: "Alpha", Kind: model.KindProject, Raw: map[string]any{"status": 0}},
})
indices, widths := tbl.visibleLayout()
if len(indices) != len(tbl.columns) {
t.Fatalf("expected all columns visible, got %d/%d", len(indices), len(tbl.columns))
}
sum := 0
for _, i := range indices {
sum += widths[i]
}
if sum != 80 {
t.Fatalf("visible columns width sum=%d want 80", sum)
}
}
func TestProjectRowRenderWidth(t *testing.T) {
prev := lipgloss.ColorProfile()
lipgloss.SetColorProfile(termenv.TrueColor)
t.Cleanup(func() { lipgloss.SetColorProfile(prev) })
tbl := newDataTable()
tbl.SetSize(120, 20)
tbl.SetData(model.ListSpec{Subject: model.SubjectProjects}, []model.Item{
{ID: "abc12345", Title: "My Project Title", Kind: model.KindProject, Raw: map[string]any{"status": 0}},
})
re := regexp.MustCompile(`\x1b\[[0-9;]*m`)
_, widths := tbl.visibleLayout()
want := 0
for _, w := range widths {
want += w
}
row := re.ReplaceAllString(tbl.renderRow(0), "")
got := runewidth.StringWidth(row)
if got < want-2 || got > want+2 {
t.Fatalf("row display width=%d want ~%d", got, want)
}
}
func TestProjectRowStaysSingleLineWhenSelected(t *testing.T) {
prev := lipgloss.ColorProfile()
lipgloss.SetColorProfile(termenv.TrueColor)
t.Cleanup(func() { lipgloss.SetColorProfile(prev) })
long := strings.Repeat("X", 200)
tbl := newDataTable()
tbl.SetSize(120, 20)
tbl.SetData(model.ListSpec{Subject: model.SubjectProjects}, []model.Item{
{ID: "1", Title: long, Kind: model.KindProject, Raw: map[string]any{"status": 0}, Selected: true},
})
if strings.Contains(tbl.renderRow(0), "\n") {
t.Fatal("selected project row must not wrap to multiple lines")
}
}
func TestProjectRowStyleUniformBackground(t *testing.T) {
prev := lipgloss.ColorProfile()
lipgloss.SetColorProfile(termenv.TrueColor)
t.Cleanup(func() { lipgloss.SetColorProfile(prev) })
tbl := newDataTable()
item := model.Item{Raw: map[string]any{"status": 0}}
base := tbl.projectBaseStyle(item)
titleStyled := tbl.projectStatusStyle("title", item, base).Render("Open Proj")
statusStyled := tbl.projectStatusStyle("status", item, base).Render(model.ProjectStatusCell(item.Raw))
if !strings.Contains(titleStyled, "\x1b[") {
t.Fatalf("title cell should be styled: %q", titleStyled)
}
if !strings.Contains(statusStyled, "\x1b[") {
t.Fatalf("status cell should be styled: %q", statusStyled)
}
if titleStyled == statusStyled {
t.Fatal("status and title cells should use different foreground styles")
}
}
func TestTruncateCellText(t *testing.T) {
long := strings.Repeat("x", 40)
got := truncateCellText(long, 10)
if runewidth.StringWidth(got) > 10 {
t.Fatalf("truncated width=%d want <=10", runewidth.StringWidth(got))
}
if !strings.HasSuffix(got, "...") {
if !strings.HasSuffix(got, "…") {
t.Fatalf("expected ellipsis suffix, got %q", got)
}
}
@@ -116,8 +227,26 @@ func TestDataTableTruncatesNonSelectedRows(t *testing.T) {
}
tbl.cursorRow = 1
if !strings.Contains(tbl.renderRow(1), long) {
t.Fatal("cursor row should show full title")
if !strings.Contains(tbl.renderRow(1), strings.Repeat("A", 10)) {
t.Fatal("cursor row should show title text within column")
}
}
func TestDataTableRowWidthMatchesColumns(t *testing.T) {
tbl := newDataTable()
tbl.SetSize(80, 12)
tbl.SetData(model.ListSpec{Subject: model.SubjectTasks}, sampleItems())
tbl.SetFocused(true)
_, widths := tbl.visibleLayout()
want := 0
for _, w := range widths {
want += w
}
re := regexp.MustCompile(`\x1b\[[0-9;]*m`)
row := re.ReplaceAllString(tbl.renderRow(0), "")
got := runewidth.StringWidth(row)
if got < want-2 || got > want+2 {
t.Fatalf("row display width=%d want ~%d", got, want)
}
}
@@ -133,8 +262,8 @@ func TestDataTableShowsFullTextWhenSpaceSelected(t *testing.T) {
tbl.cursorRow = 0
rowSelected := tbl.renderRow(1)
if !strings.Contains(rowSelected, long) {
t.Fatal("space-selected row should show full title")
if !strings.Contains(rowSelected, strings.Repeat("B", 10)) {
t.Fatal("space-selected row should show title text within column")
}
}
@@ -146,3 +275,12 @@ func indexOfColumn(tbl DataTable, key string) int {
}
return 0
}
func indexOfColumnKey(cols []model.TableColumn, key string) int {
for i, c := range cols {
if c.Key == key {
return i
}
}
return 0
}
+69
View File
@@ -0,0 +1,69 @@
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
}
}
}