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:
+236
-90
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
@@ -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())
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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, "…")
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user