feat(office): mail preview, infinite scroll, pane resize, and scrollbars
Release Please / Release Please (push) Skipped
Tests / Test (Go 1.22) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Release / GoReleaser (push) Failing after 38s
Release Please / Release Please (push) Skipped
Tests / Test (Go 1.22) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Release / GoReleaser (push) Failing after 38s
Polish the office TUI with proportional pane layout, drag resize, live filtering, HTML mail preview, paginated mail lists, overflow scrollbars, flattened navigation, and project save/status fixes; add UpdateProjectStatus. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+282
-51
@@ -18,6 +18,11 @@ type listLoadedMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type listMoreLoadedMsg struct {
|
||||
items []model.Item
|
||||
err error
|
||||
}
|
||||
|
||||
type detailLoadedMsg struct {
|
||||
item model.Item
|
||||
document bool
|
||||
@@ -27,9 +32,9 @@ type detailLoadedMsg struct {
|
||||
}
|
||||
|
||||
type detailSavedMsg struct {
|
||||
item model.Item
|
||||
title, description string
|
||||
err error
|
||||
item model.Item
|
||||
fields model.FormFields
|
||||
err error
|
||||
}
|
||||
|
||||
type actionDoneMsg struct {
|
||||
@@ -51,6 +56,8 @@ type Model struct {
|
||||
hasList bool
|
||||
items []model.Item
|
||||
selection *model.Selection
|
||||
listHasMore bool
|
||||
listLoadingMore bool
|
||||
focus model.FocusPane
|
||||
width int
|
||||
height int
|
||||
@@ -63,6 +70,11 @@ type Model struct {
|
||||
showMenu bool
|
||||
showList bool
|
||||
showDetail bool
|
||||
filterActive bool
|
||||
filterSearch FilterSearch
|
||||
customPaneLayout bool
|
||||
paneSizes PaneWidths
|
||||
resize paneResizeState
|
||||
}
|
||||
|
||||
// NewModel constructs the TUI with an authenticated client.
|
||||
@@ -74,20 +86,21 @@ func NewModel(client *onlyoffice.Client) Model {
|
||||
nav: model.DefaultNavTree(),
|
||||
selection: model.NewSelection(),
|
||||
focus: model.FocusMenu,
|
||||
status: "Tab: pane · row select loads detail · Ctrl+S save · q quit",
|
||||
status: "Tab: pane · f: filter · row select loads detail · Ctrl+S save · q quit",
|
||||
height: h,
|
||||
width: w,
|
||||
}
|
||||
m.menuVP = viewport.New(m.paneInnerWidth(22), m.paneHeight())
|
||||
m.listTable = newDataTable()
|
||||
m.detail = newDetailPane()
|
||||
m.filterSearch = newFilterSearch()
|
||||
m.showMenu, m.showList, m.showDetail = true, true, true
|
||||
m.menuVP.MouseWheelEnabled = true
|
||||
return m
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return tea.Batch(m.loadNavProjectsCmd(), m.detail.BlinkCmd())
|
||||
return tea.Batch(m.loadNavProjectsCmd(), m.detail.BlinkCmd(), filterSearchBlinkCmd())
|
||||
}
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
@@ -95,12 +108,29 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
if m.customPaneLayout {
|
||||
m.paneSizes = FitPaneWidths(m.width, m.paneVis(), m.paneSizes)
|
||||
}
|
||||
m.layoutViewports()
|
||||
m.syncAllContent()
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
key := msg.String()
|
||||
if key == "esc" && m.filterActive {
|
||||
m.exitFilter()
|
||||
return m, nil
|
||||
}
|
||||
if m.filterActive && m.focus == model.FocusPreview && m.showDetail {
|
||||
switch key {
|
||||
case "tab", "shift+tab", "backtab":
|
||||
// allow pane switching while filter stays active
|
||||
default:
|
||||
cmd := m.filterSearch.Update(msg)
|
||||
m.applyFilter()
|
||||
return m, cmd
|
||||
}
|
||||
}
|
||||
if key == "?" {
|
||||
m.status = helpText()
|
||||
return m, nil
|
||||
@@ -108,7 +138,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.scrollFocusedPane(key) {
|
||||
return m, nil
|
||||
}
|
||||
if m.focus == model.FocusPreview {
|
||||
if m.filterActive && m.focus == model.FocusPreview {
|
||||
// filter input owns keys except pane switching handled above
|
||||
} else if m.focus == model.FocusPreview {
|
||||
if cmd, handled := m.handleDetailKey(key, msg); handled {
|
||||
return m, cmd
|
||||
}
|
||||
@@ -121,11 +153,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case ActionQuit:
|
||||
return m, tea.Quit
|
||||
case ActionNextPane:
|
||||
prev := m.focus
|
||||
m.focus = NextVisibleFocus(m.focus, m.paneVis())
|
||||
if m.focus == model.FocusPreview && prev != model.FocusPreview && !m.filterActive {
|
||||
m.detail.FocusFirstStop()
|
||||
}
|
||||
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 {
|
||||
m.detail.FocusFirstStop()
|
||||
}
|
||||
m.syncPaneFocus()
|
||||
return m, nil
|
||||
case ActionMoveUp:
|
||||
@@ -144,7 +184,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, m.onListRowChanged()
|
||||
case ActionMoveLeft:
|
||||
if m.focus == model.FocusPreview && m.detail.Zone() == detailZoneActions {
|
||||
m.detail.MoveAction(-1)
|
||||
m.detail.MoveTabStop(-1)
|
||||
return m, nil
|
||||
}
|
||||
if m.focus == model.FocusList && m.hasList {
|
||||
@@ -153,7 +193,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
case ActionMoveRight:
|
||||
if m.focus == model.FocusPreview && m.detail.Zone() == detailZoneActions {
|
||||
m.detail.MoveAction(1)
|
||||
m.detail.MoveTabStop(1)
|
||||
return m, nil
|
||||
}
|
||||
if m.focus == model.FocusList && m.hasList {
|
||||
@@ -167,10 +207,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
case ActionToggleSelect:
|
||||
if m.hasList {
|
||||
idx := m.listTable.ItemIndex()
|
||||
if idx >= 0 {
|
||||
m.selection.Toggle(&m.items, idx)
|
||||
m.listTable.UpdateItems(m.items)
|
||||
if it, ok := m.listTableItem(); ok {
|
||||
for i := range m.items {
|
||||
if m.items[i].ID == it.ID {
|
||||
m.selection.Toggle(&m.items, i)
|
||||
break
|
||||
}
|
||||
}
|
||||
m.syncListTable()
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
@@ -178,7 +222,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.hasList && m.showDetail {
|
||||
m.focus = model.FocusPreview
|
||||
m.syncPaneFocus()
|
||||
m.detail.FocusContent()
|
||||
m.detail.FocusFirstStop()
|
||||
return m, m.onListRowChanged()
|
||||
}
|
||||
return m, nil
|
||||
@@ -191,6 +235,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case ActionToggleDetailPane:
|
||||
m.togglePane(3)
|
||||
return m, nil
|
||||
case ActionFilter:
|
||||
m.enterFilter()
|
||||
return m, nil
|
||||
case ActionRefresh:
|
||||
if m.hasList {
|
||||
m.loading = true
|
||||
@@ -222,10 +269,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case listLoadedMsg:
|
||||
m.loading = false
|
||||
m.listLoadingMore = false
|
||||
if msg.err != nil {
|
||||
m.err = msg.err.Error()
|
||||
m.items = nil
|
||||
m.hasList = false
|
||||
m.listHasMore = false
|
||||
m.listTable.Clear()
|
||||
m.detail.Clear()
|
||||
} else {
|
||||
@@ -233,9 +282,32 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.items = msg.items
|
||||
m.listSpec = msg.spec
|
||||
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()
|
||||
}
|
||||
}
|
||||
return m, m.onListRowChanged()
|
||||
return m, tea.Batch(m.onListRowChanged(), m.maybeLoadMoreList())
|
||||
|
||||
case listMoreLoadedMsg:
|
||||
m.listLoadingMore = false
|
||||
m.listTable.SetLoadingMore(false)
|
||||
if msg.err != nil {
|
||||
m.err = msg.err.Error()
|
||||
return m, nil
|
||||
}
|
||||
if len(msg.items) == 0 {
|
||||
m.listHasMore = false
|
||||
return m, nil
|
||||
}
|
||||
added := m.listTable.AppendItems(msg.items)
|
||||
if added > 0 {
|
||||
m.items = m.listTable.Items()
|
||||
}
|
||||
m.listHasMore = len(msg.items) >= fetch.MailListPageSize
|
||||
m.err = ""
|
||||
return m, nil
|
||||
|
||||
case detailLoadedMsg:
|
||||
m.loading = false
|
||||
@@ -263,11 +335,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if msg.err != nil {
|
||||
m.err = msg.err.Error()
|
||||
} else {
|
||||
m.updateItemAfterSave(msg.item, msg.title, msg.description)
|
||||
fields, _ := m.loader.DetailForm(context.Background(), msg.item)
|
||||
fields.Primary = msg.title
|
||||
fields.Secondary = msg.description
|
||||
m.detail.LoadForm(msg.item, fields)
|
||||
m.updateItemAfterSave(msg.item, msg.fields)
|
||||
m.detail.LoadForm(msg.item, msg.fields)
|
||||
m.detail.SetFocused(m.focus == model.FocusPreview)
|
||||
m.status = "Saved"
|
||||
m.err = ""
|
||||
@@ -296,6 +365,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
|
||||
case tea.MouseMsg:
|
||||
if m.handlePaneResizeMouse(msg) {
|
||||
return m, nil
|
||||
}
|
||||
if cmd, handled := m.routeDetailDocumentMouse(msg); handled {
|
||||
return m, cmd
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
switch m.focus {
|
||||
case model.FocusMenu:
|
||||
@@ -303,7 +378,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case model.FocusList:
|
||||
cmd = m.listTable.Update(msg)
|
||||
case model.FocusPreview:
|
||||
cmd = m.detail.Update(msg)
|
||||
if !m.filterActive {
|
||||
cmd = m.detail.Update(msg)
|
||||
}
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
@@ -315,7 +392,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case model.FocusList:
|
||||
cmd = m.listTable.Update(msg)
|
||||
case model.FocusPreview:
|
||||
cmd = m.detail.Update(msg)
|
||||
if !m.filterActive {
|
||||
cmd = m.detail.Update(msg)
|
||||
}
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
@@ -323,7 +402,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
func (m *Model) handleDetailKey(key string, msg tea.KeyMsg) (tea.Cmd, bool) {
|
||||
switch key {
|
||||
case "tab", "shift+tab", "backtab":
|
||||
m.detail.ToggleZone()
|
||||
reverse := key != "tab"
|
||||
if reverse {
|
||||
if m.detail.TabBackward() {
|
||||
m.focus = PrevVisibleFocus(m.focus, m.paneVis())
|
||||
}
|
||||
} else if m.detail.TabForward() {
|
||||
m.focus = NextVisibleFocus(m.focus, m.paneVis())
|
||||
}
|
||||
m.syncPaneFocus()
|
||||
return nil, true
|
||||
case "ctrl+s":
|
||||
m.loading = true
|
||||
@@ -347,16 +434,37 @@ func (m *Model) handleDetailKey(key string, msg tea.KeyMsg) (tea.Cmd, bool) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (m *Model) routeDetailDocumentMouse(msg tea.MouseMsg) (tea.Cmd, bool) {
|
||||
if m.filterActive || !m.showDetail {
|
||||
return nil, false
|
||||
}
|
||||
if !m.detail.IsDocumentContent() && !m.detail.isReadOnlyFormContent() {
|
||||
return nil, false
|
||||
}
|
||||
if msg.Y > m.paneHeight()+1 {
|
||||
return nil, false
|
||||
}
|
||||
x0, x1 := DetailPaneXRange(m.paneLayout())
|
||||
if x0 >= x1 || msg.X < x0 || msg.X >= x1 {
|
||||
return nil, false
|
||||
}
|
||||
cmd := m.detail.ScrollDocumentMouse(msg)
|
||||
if msg.Button == tea.MouseButtonWheelUp || msg.Button == tea.MouseButtonWheelDown {
|
||||
return cmd, true
|
||||
}
|
||||
return cmd, false
|
||||
}
|
||||
|
||||
func (m Model) handleDetailMove(delta int) (Model, tea.Cmd) {
|
||||
if m.detail.Zone() == detailZoneActions {
|
||||
m.detail.MoveAction(delta)
|
||||
if m.detail.Zone() == detailZoneContent && (m.detail.IsDocumentContent() || m.detail.isReadOnlyFormContent()) {
|
||||
key := "down"
|
||||
if delta < 0 {
|
||||
key = "up"
|
||||
}
|
||||
m.detail.ScrollDocument(key)
|
||||
return m, nil
|
||||
}
|
||||
if delta < 0 {
|
||||
m.detail.form.FocusPrev()
|
||||
} else {
|
||||
m.detail.form.FocusNext()
|
||||
}
|
||||
m.detail.MoveTabStop(delta)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
@@ -369,7 +477,14 @@ func (m Model) View() string {
|
||||
var parts []string
|
||||
if pw.Visibility.Menu {
|
||||
menuStyle := paneStyle(m.focus == model.FocusMenu).Width(pw.Menu).Height(h)
|
||||
parts = append(parts, menuStyle.Render(m.menuVP.View()))
|
||||
menuBody := ApplyVerticalScrollbar(
|
||||
m.menuVP.View(),
|
||||
m.menuVP.Width,
|
||||
m.menuVP.Height,
|
||||
m.menuVP.TotalLineCount(),
|
||||
m.menuVP.YOffset,
|
||||
)
|
||||
parts = append(parts, menuStyle.Render(menuBody))
|
||||
}
|
||||
if pw.Visibility.List {
|
||||
listStyle := paneStyle(m.focus == model.FocusList).Width(pw.List).Height(h)
|
||||
@@ -377,7 +492,13 @@ func (m Model) View() string {
|
||||
}
|
||||
if pw.Visibility.Detail {
|
||||
prevStyle := paneStyle(m.focus == model.FocusPreview).Width(pw.Detail).Height(h)
|
||||
parts = append(parts, prevStyle.Render(m.detail.View()))
|
||||
var detailBody string
|
||||
if m.filterActive {
|
||||
detailBody = m.filterSearch.View()
|
||||
} else {
|
||||
detailBody = m.detail.View()
|
||||
}
|
||||
parts = append(parts, prevStyle.Render(detailBody))
|
||||
}
|
||||
|
||||
status := m.status
|
||||
@@ -397,11 +518,21 @@ func (m Model) paneVis() PaneVisibility {
|
||||
}
|
||||
|
||||
func (m Model) paneLayout() PaneWidths {
|
||||
return LayoutWidths(m.width, m.paneVis())
|
||||
vis := m.paneVis()
|
||||
if m.customPaneLayout {
|
||||
return FitPaneWidths(m.width, vis, m.paneSizes)
|
||||
}
|
||||
return LayoutWidths(m.width, vis)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
m.filterSearch.SetFocused(false)
|
||||
m.detail.SetFocused(m.focus == model.FocusPreview && m.showDetail)
|
||||
}
|
||||
|
||||
@@ -427,6 +558,9 @@ func (m *Model) togglePane(which int) {
|
||||
if !paneVisible(m.focus, m.paneVis()) {
|
||||
m.focus = firstVisibleFocus(m.paneVis())
|
||||
}
|
||||
if m.customPaneLayout {
|
||||
m.paneSizes = FitPaneWidths(m.width, m.paneVis(), m.paneSizes)
|
||||
}
|
||||
m.layoutViewports()
|
||||
m.syncAllContent()
|
||||
m.syncPaneFocus()
|
||||
@@ -436,6 +570,8 @@ func (m Model) withList(spec model.ListSpec) (Model, tea.Cmd) {
|
||||
m.listSpec = spec
|
||||
m.selection.Clear()
|
||||
m.hasList = false
|
||||
m.listHasMore = false
|
||||
m.listLoadingMore = false
|
||||
m.focus = model.FocusList
|
||||
m.listTable.SetFocused(true)
|
||||
m.detail.Clear()
|
||||
@@ -455,6 +591,7 @@ func (m *Model) layoutViewports() {
|
||||
}
|
||||
if pw.Visibility.Detail {
|
||||
m.detail.SetSize(m.paneInnerWidth(pw.Detail), h)
|
||||
m.filterSearch.SetSize(m.paneInnerWidth(pw.Detail), h)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,12 +605,62 @@ func (m *Model) syncListTable() {
|
||||
m.listTable.Clear()
|
||||
return
|
||||
}
|
||||
m.listTable.UpdateItems(m.items)
|
||||
items := m.items
|
||||
if m.filterActive {
|
||||
items = model.FilterItems(m.items, m.filterSearch.Query())
|
||||
}
|
||||
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()
|
||||
m.nav.SetFilter(q)
|
||||
m.syncMenuContent()
|
||||
m.syncListTable()
|
||||
}
|
||||
|
||||
func (m Model) listTableItem() (model.Item, bool) {
|
||||
idx := m.listTable.ItemIndex()
|
||||
if idx < 0 {
|
||||
return model.Item{}, false
|
||||
}
|
||||
items := m.items
|
||||
if m.filterActive {
|
||||
items = model.FilterItems(m.items, m.filterSearch.Query())
|
||||
}
|
||||
if idx >= len(items) {
|
||||
return model.Item{}, false
|
||||
}
|
||||
return items[idx], true
|
||||
}
|
||||
|
||||
func (m *Model) syncMenuContent() {
|
||||
var b strings.Builder
|
||||
b.WriteString("Navigation\n\n")
|
||||
title := "Navigation"
|
||||
if m.filterActive && m.filterSearch.Query() != "" {
|
||||
title += " (filtered)"
|
||||
}
|
||||
b.WriteString(title + "\n\n")
|
||||
menuW := m.menuVP.Width
|
||||
if menuW < 10 {
|
||||
menuW = 20
|
||||
@@ -603,21 +790,52 @@ func (m *Model) loadNavProjectsCmd() tea.Cmd {
|
||||
}
|
||||
|
||||
func (m *Model) onListRowChanged() tea.Cmd {
|
||||
if m.filterActive {
|
||||
return nil
|
||||
}
|
||||
if !m.hasList {
|
||||
m.detail.Clear()
|
||||
return nil
|
||||
return m.maybeLoadMoreList()
|
||||
}
|
||||
idx := m.listTable.ItemIndex()
|
||||
if idx < 0 || idx >= len(m.items) {
|
||||
it, ok := m.listTableItem()
|
||||
if !ok {
|
||||
m.detail.Clear()
|
||||
return m.maybeLoadMoreList()
|
||||
}
|
||||
var cmds []tea.Cmd
|
||||
cmds = append(cmds, m.maybeLoadMoreList())
|
||||
if m.detail.LoadedID() != it.ID {
|
||||
m.loading = true
|
||||
cmds = append(cmds, m.loadDetailCmd(it))
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
const mailListLoadThreshold = 3
|
||||
|
||||
func (m *Model) maybeLoadMoreList() tea.Cmd {
|
||||
if !m.hasList || m.listLoadingMore || !m.listHasMore || m.filterActive {
|
||||
return nil
|
||||
}
|
||||
it := m.items[idx]
|
||||
if m.detail.LoadedID() == it.ID {
|
||||
if !model.SubjectIsMail(m.listSpec.Subject) {
|
||||
return nil
|
||||
}
|
||||
m.loading = true
|
||||
return m.loadDetailCmd(it)
|
||||
if !m.listTable.NearEnd(mailListLoadThreshold) {
|
||||
return nil
|
||||
}
|
||||
m.listLoadingMore = true
|
||||
m.listTable.SetLoadingMore(true)
|
||||
return m.loadListMoreCmd()
|
||||
}
|
||||
|
||||
func (m *Model) loadListMoreCmd() tea.Cmd {
|
||||
start := len(m.items)
|
||||
spec := m.listSpec
|
||||
return func() tea.Msg {
|
||||
ctx := context.Background()
|
||||
items, err := m.loader.ListMailMore(ctx, spec, start)
|
||||
return listMoreLoadedMsg{items: items, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) loadDetailCmd(item model.Item) tea.Cmd {
|
||||
@@ -634,31 +852,44 @@ func (m *Model) loadDetailCmd(item model.Item) tea.Cmd {
|
||||
|
||||
func (m *Model) saveDetailCmd() tea.Cmd {
|
||||
item := m.detail.Item()
|
||||
title := m.detail.form.Primary()
|
||||
desc := m.detail.form.Secondary()
|
||||
fields := m.detail.form.FormFields()
|
||||
return func() tea.Msg {
|
||||
ctx := context.Background()
|
||||
err := m.loader.SaveItem(ctx, item, title, desc)
|
||||
return detailSavedMsg{item: item, title: title, description: desc, err: err}
|
||||
err := m.loader.SaveItem(ctx, item, fields)
|
||||
return detailSavedMsg{item: item, fields: fields, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) updateItemAfterSave(item model.Item, title, description string) {
|
||||
func (m *Model) updateItemAfterSave(item model.Item, fields model.FormFields) {
|
||||
for i := range m.items {
|
||||
if m.items[i].ID != item.ID {
|
||||
continue
|
||||
}
|
||||
m.items[i].Title = title
|
||||
m.items[i].Title = fields.Primary
|
||||
if m.items[i].Raw == nil {
|
||||
m.items[i].Raw = map[string]any{}
|
||||
}
|
||||
m.items[i].Raw["title"] = title
|
||||
m.items[i].Raw["description"] = description
|
||||
m.listTable.UpdateItems(m.items)
|
||||
m.items[i].Raw["title"] = fields.Primary
|
||||
m.items[i].Raw["description"] = fields.Secondary
|
||||
if fields.HasStatus {
|
||||
m.items[i].Raw["status"] = statusInt(fields.Status)
|
||||
}
|
||||
m.syncListTable()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func statusInt(s model.ProjectLifecycle) int {
|
||||
switch s {
|
||||
case model.ProjectLifecycleClosed:
|
||||
return 2
|
||||
case model.ProjectLifecyclePaused:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func paneStyle(focused bool) lipgloss.Style {
|
||||
s := lipgloss.NewStyle().Padding(0, 1)
|
||||
if focused {
|
||||
@@ -668,5 +899,5 @@ func paneStyle(focused bool) lipgloss.Style {
|
||||
}
|
||||
|
||||
func helpText() string {
|
||||
return "Alt+1/2/3: toggle panes · Tab: focus · v: detail · Ctrl+S: save · q: quit"
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -31,6 +31,12 @@ func TestKeyActionSortAndColumns(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyActionFilter(t *testing.T) {
|
||||
if got := ui.KeyAction("f", model.FocusList); got != ui.ActionFilter {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayoutWidths(t *testing.T) {
|
||||
menu, list, preview := ui.LayoutWidthsLegacy(120)
|
||||
if menu+list+preview != 120 {
|
||||
|
||||
+245
-22
@@ -34,12 +34,14 @@ type DetailPane struct {
|
||||
loadedID string
|
||||
actions []model.ItemAction
|
||||
actionIdx int
|
||||
tabStop int
|
||||
zone detailZone
|
||||
focused bool
|
||||
width int
|
||||
height int
|
||||
form EntityForm
|
||||
docVP viewport.Model
|
||||
formVP viewport.Model
|
||||
docText string
|
||||
styles detailStyles
|
||||
}
|
||||
@@ -56,9 +58,11 @@ func newDetailPane() DetailPane {
|
||||
d := DetailPane{
|
||||
form: newEntityForm(),
|
||||
docVP: viewport.New(40, 10),
|
||||
formVP: viewport.New(40, 10),
|
||||
styles: newDetailStyles(),
|
||||
}
|
||||
d.docVP.MouseWheelEnabled = true
|
||||
d.formVP.MouseWheelEnabled = true
|
||||
return d
|
||||
}
|
||||
|
||||
@@ -79,14 +83,112 @@ func (d *DetailPane) Clear() {
|
||||
d.loadedID = ""
|
||||
d.actions = nil
|
||||
d.actionIdx = 0
|
||||
d.tabStop = 0
|
||||
d.docText = ""
|
||||
d.form.Clear()
|
||||
d.docVP.SetContent("")
|
||||
d.formVP.SetContent("")
|
||||
}
|
||||
|
||||
func (d *DetailPane) SetFocused(on bool) {
|
||||
d.focused = on
|
||||
d.form.SetFocused(on && d.zone == detailZoneContent && d.mode == detailForm)
|
||||
d.applyTabStop(d.tabStop)
|
||||
}
|
||||
|
||||
func (d *DetailPane) FocusFirstStop() {
|
||||
d.tabStop = 0
|
||||
d.applyTabStop(0)
|
||||
}
|
||||
|
||||
func (d *DetailPane) maxTabStop() int {
|
||||
switch d.mode {
|
||||
case detailForm:
|
||||
if d.form.readOnly {
|
||||
if len(d.actions) == 0 {
|
||||
return 0
|
||||
}
|
||||
return len(d.actions) - 1
|
||||
}
|
||||
fields := d.form.FieldCount()
|
||||
if len(d.actions) == 0 {
|
||||
if fields == 0 {
|
||||
return 0
|
||||
}
|
||||
return fields - 1
|
||||
}
|
||||
return fields + len(d.actions) - 1
|
||||
case detailDocument:
|
||||
if len(d.actions) == 0 {
|
||||
return 0
|
||||
}
|
||||
return len(d.actions)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DetailPane) applyTabStop(stop int) {
|
||||
if stop < 0 {
|
||||
stop = 0
|
||||
}
|
||||
max := d.maxTabStop()
|
||||
if stop > max {
|
||||
stop = max
|
||||
}
|
||||
d.tabStop = stop
|
||||
|
||||
switch d.mode {
|
||||
case detailForm:
|
||||
if d.form.readOnly {
|
||||
d.zone = detailZoneActions
|
||||
d.actionIdx = stop
|
||||
d.form.SetFocused(false)
|
||||
return
|
||||
}
|
||||
fields := d.form.FieldCount()
|
||||
if stop < fields {
|
||||
d.zone = detailZoneContent
|
||||
d.form.SetFieldIndex(stop)
|
||||
d.form.SetFocused(d.focused)
|
||||
return
|
||||
}
|
||||
d.zone = detailZoneActions
|
||||
d.actionIdx = stop - fields
|
||||
d.form.SetFocused(false)
|
||||
case detailDocument:
|
||||
if stop == 0 {
|
||||
d.zone = detailZoneContent
|
||||
d.form.SetFocused(false)
|
||||
return
|
||||
}
|
||||
d.zone = detailZoneActions
|
||||
d.actionIdx = stop - 1
|
||||
d.form.SetFocused(false)
|
||||
default:
|
||||
d.zone = detailZoneContent
|
||||
d.form.SetFocused(false)
|
||||
}
|
||||
}
|
||||
|
||||
// TabForward moves to the next field/button. It returns true when focus should leave the pane.
|
||||
func (d *DetailPane) TabForward() bool {
|
||||
max := d.maxTabStop()
|
||||
if d.tabStop < max {
|
||||
d.applyTabStop(d.tabStop + 1)
|
||||
return false
|
||||
}
|
||||
d.FocusFirstStop()
|
||||
return true
|
||||
}
|
||||
|
||||
// TabBackward moves to the previous field/button. It returns true when focus should leave the pane.
|
||||
func (d *DetailPane) TabBackward() bool {
|
||||
if d.tabStop > 0 {
|
||||
d.applyTabStop(d.tabStop - 1)
|
||||
return false
|
||||
}
|
||||
d.applyTabStop(d.maxTabStop())
|
||||
return true
|
||||
}
|
||||
|
||||
func (d *DetailPane) SetSize(w, h int) {
|
||||
@@ -102,6 +204,8 @@ func (d *DetailPane) SetSize(w, h int) {
|
||||
d.form.SetSize(w, contentH)
|
||||
d.docVP.Width = w
|
||||
d.docVP.Height = contentH
|
||||
d.formVP.Width = w
|
||||
d.formVP.Height = contentH
|
||||
}
|
||||
|
||||
func (d *DetailPane) splitHeights() (contentH, actionH int) {
|
||||
@@ -125,7 +229,10 @@ func (d *DetailPane) LoadForm(item model.Item, fields model.FormFields) {
|
||||
d.loadedID = item.ID
|
||||
d.actions = model.ActionsFor(item.Kind)
|
||||
d.actionIdx = 0
|
||||
d.tabStop = 0
|
||||
d.form.Load(item.Kind, item.ID, fields)
|
||||
d.refreshFormViewport()
|
||||
d.applyTabStop(0)
|
||||
d.layoutContent()
|
||||
}
|
||||
|
||||
@@ -135,6 +242,7 @@ func (d *DetailPane) LoadDocument(item model.Item, markdown string, renderWidth
|
||||
d.loadedID = item.ID
|
||||
d.actions = model.ActionsFor(item.Kind)
|
||||
d.actionIdx = 0
|
||||
d.tabStop = 0
|
||||
d.form.Clear()
|
||||
text, err := preview.RenderMarkdown(markdown, renderWidth)
|
||||
if err != nil {
|
||||
@@ -142,6 +250,8 @@ func (d *DetailPane) LoadDocument(item model.Item, markdown string, renderWidth
|
||||
}
|
||||
d.docText = text
|
||||
d.docVP.SetContent(text)
|
||||
d.docVP.GotoTop()
|
||||
d.applyTabStop(0)
|
||||
d.layoutContent()
|
||||
}
|
||||
|
||||
@@ -150,6 +260,17 @@ func (d *DetailPane) layoutContent() {
|
||||
d.form.SetSize(d.width, contentH)
|
||||
d.docVP.Width = d.width
|
||||
d.docVP.Height = contentH
|
||||
d.formVP.Width = d.width
|
||||
d.formVP.Height = contentH
|
||||
d.refreshFormViewport()
|
||||
}
|
||||
|
||||
func (d *DetailPane) refreshFormViewport() {
|
||||
if d.mode != detailForm || !d.form.readOnly {
|
||||
d.formVP.SetContent("")
|
||||
return
|
||||
}
|
||||
d.formVP.SetContent(d.form.View())
|
||||
}
|
||||
|
||||
func (d *DetailPane) LoadedID() string { return d.loadedID }
|
||||
@@ -158,24 +279,47 @@ func (d *DetailPane) Item() model.Item { return d.item }
|
||||
|
||||
func (d *DetailPane) Zone() detailZone { return d.zone }
|
||||
|
||||
func (d *DetailPane) FocusActions() {
|
||||
d.zone = detailZoneActions
|
||||
d.form.SetFocused(false)
|
||||
}
|
||||
|
||||
func (d *DetailPane) FocusContent() {
|
||||
d.zone = detailZoneContent
|
||||
d.form.SetFocused(d.focused && d.mode == detailForm)
|
||||
d.applyTabStop(d.tabStop)
|
||||
if d.zone == detailZoneContent && d.mode == detailForm {
|
||||
d.form.SetFocused(d.focused)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DetailPane) ToggleZone() {
|
||||
if d.zone == detailZoneContent {
|
||||
d.FocusActions()
|
||||
} else {
|
||||
d.FocusContent()
|
||||
func (d *DetailPane) FocusActions() {
|
||||
max := d.maxTabStop()
|
||||
switch d.mode {
|
||||
case detailForm:
|
||||
if d.form.readOnly {
|
||||
d.applyTabStop(max)
|
||||
return
|
||||
}
|
||||
if len(d.actions) > 0 {
|
||||
d.applyTabStop(d.form.FieldCount())
|
||||
}
|
||||
case detailDocument:
|
||||
if len(d.actions) > 0 {
|
||||
d.applyTabStop(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MoveTabStop moves between title, description, and action buttons without leaving the pane.
|
||||
func (d *DetailPane) MoveTabStop(delta int) {
|
||||
if delta == 0 {
|
||||
return
|
||||
}
|
||||
next := d.tabStop + delta
|
||||
if next < 0 {
|
||||
next = 0
|
||||
}
|
||||
max := d.maxTabStop()
|
||||
if next > max {
|
||||
next = max
|
||||
}
|
||||
d.applyTabStop(next)
|
||||
}
|
||||
|
||||
func (d *DetailPane) MoveAction(delta int) {
|
||||
if len(d.actions) == 0 {
|
||||
return
|
||||
@@ -190,6 +334,62 @@ func (d *DetailPane) SelectedAction() (model.ItemAction, bool) {
|
||||
return d.actions[d.actionIdx], true
|
||||
}
|
||||
|
||||
func (d DetailPane) IsDocumentContent() bool {
|
||||
return d.mode == detailDocument
|
||||
}
|
||||
|
||||
func (d DetailPane) isReadOnlyFormContent() bool {
|
||||
return d.mode == detailForm && d.form.readOnly
|
||||
}
|
||||
|
||||
func (d DetailPane) documentContentHeight() int {
|
||||
contentH, _ := d.splitHeights()
|
||||
return contentH
|
||||
}
|
||||
|
||||
// ScrollDocument scrolls the read-only document viewport (mail, file preview).
|
||||
func (d *DetailPane) ScrollDocument(key string) bool {
|
||||
if d.IsDocumentContent() {
|
||||
return scrollViewport(&d.docVP, key)
|
||||
}
|
||||
if d.isReadOnlyFormContent() && d.zone == detailZoneContent {
|
||||
return scrollViewport(&d.formVP, key)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ScrollDocumentMouse applies wheel events to scrollable preview content.
|
||||
func (d *DetailPane) ScrollDocumentMouse(msg tea.MouseMsg) tea.Cmd {
|
||||
var vp *viewport.Model
|
||||
switch {
|
||||
case d.IsDocumentContent():
|
||||
vp = &d.docVP
|
||||
case d.isReadOnlyFormContent():
|
||||
vp = &d.formVP
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
if msg.Y >= d.documentContentHeight() {
|
||||
return nil
|
||||
}
|
||||
if !vp.MouseWheelEnabled || msg.Action != tea.MouseActionPress {
|
||||
return nil
|
||||
}
|
||||
switch msg.Button {
|
||||
case tea.MouseButtonWheelUp, tea.MouseButtonWheelDown:
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
*vp, cmd = vp.Update(msg)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// DocumentYOffset exposes scroll position for tests.
|
||||
func (d DetailPane) DocumentYOffset() int {
|
||||
return d.docVP.YOffset
|
||||
}
|
||||
|
||||
func (d *DetailPane) Update(msg tea.Msg) tea.Cmd {
|
||||
if !d.focused {
|
||||
return nil
|
||||
@@ -197,14 +397,21 @@ func (d *DetailPane) Update(msg tea.Msg) tea.Cmd {
|
||||
if d.zone == detailZoneContent {
|
||||
switch d.mode {
|
||||
case detailForm:
|
||||
if d.form.readOnly {
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
if scrollViewport(&d.formVP, key.String()) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
d.formVP, cmd = d.formVP.Update(msg)
|
||||
return cmd
|
||||
}
|
||||
return d.form.Update(msg)
|
||||
case detailDocument:
|
||||
if _, ok := msg.(tea.KeyMsg); ok {
|
||||
switch msg.(tea.KeyMsg).String() {
|
||||
case "pgdown", "pgdn", "f", "ctrl+d":
|
||||
d.docVP.ViewDown()
|
||||
case "pgup", "b", "ctrl+u":
|
||||
d.docVP.ViewUp()
|
||||
if key, ok := msg.(tea.KeyMsg); ok {
|
||||
if scrollViewport(&d.docVP, key.String()) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
@@ -223,9 +430,25 @@ func (d DetailPane) View() string {
|
||||
var top string
|
||||
switch d.mode {
|
||||
case detailForm:
|
||||
top = lipgloss.NewStyle().Width(d.width).Height(contentH).Render(d.form.View())
|
||||
if d.form.readOnly {
|
||||
top = ApplyVerticalScrollbar(
|
||||
d.formVP.View(),
|
||||
d.formVP.Width,
|
||||
d.formVP.Height,
|
||||
d.formVP.TotalLineCount(),
|
||||
d.formVP.YOffset,
|
||||
)
|
||||
} else {
|
||||
top = lipgloss.NewStyle().Width(d.width).Height(contentH).Render(d.form.View())
|
||||
}
|
||||
case detailDocument:
|
||||
top = d.docVP.View()
|
||||
top = ApplyVerticalScrollbar(
|
||||
d.docVP.View(),
|
||||
d.docVP.Width,
|
||||
d.docVP.Height,
|
||||
d.docVP.TotalLineCount(),
|
||||
d.docVP.YOffset,
|
||||
)
|
||||
default:
|
||||
top = ""
|
||||
}
|
||||
@@ -258,7 +481,7 @@ func (d DetailPane) renderActions() string {
|
||||
}
|
||||
line := strings.Join(parts, "")
|
||||
if d.focused && d.zone == detailZoneActions {
|
||||
line += d.styles.empty.Render(" ←/→ select · Enter run")
|
||||
line += d.styles.empty.Render(" Tab · Enter run")
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
)
|
||||
|
||||
func TestDetailDocumentScrollByKey(t *testing.T) {
|
||||
d := newDetailPane()
|
||||
d.SetSize(50, 20)
|
||||
longBody := "# Mail\n\n"
|
||||
for i := 0; i < 80; i++ {
|
||||
longBody += "line\n"
|
||||
}
|
||||
d.LoadDocument(model.Item{ID: "1", Kind: model.KindMail, Title: "M"}, longBody, 40)
|
||||
d.SetFocused(true)
|
||||
|
||||
if d.DocumentYOffset() != 0 {
|
||||
t.Fatalf("expected top offset 0, got %d", d.DocumentYOffset())
|
||||
}
|
||||
if !d.ScrollDocument("down") {
|
||||
t.Fatal("expected scroll down to succeed")
|
||||
}
|
||||
if d.DocumentYOffset() == 0 {
|
||||
t.Fatal("expected offset after scroll down")
|
||||
}
|
||||
if !d.ScrollDocument("up") {
|
||||
t.Fatal("expected scroll up to succeed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetailDocumentMouseWheel(t *testing.T) {
|
||||
d := newDetailPane()
|
||||
d.SetSize(50, 20)
|
||||
longBody := "# Mail\n\n"
|
||||
for i := 0; i < 80; i++ {
|
||||
longBody += "line\n"
|
||||
}
|
||||
d.LoadDocument(model.Item{ID: "1", Kind: model.KindMail, Title: "M"}, longBody, 40)
|
||||
|
||||
d.ScrollDocumentMouse(tea.MouseMsg{
|
||||
X: 10, Y: 2, Button: tea.MouseButtonWheelDown, Action: tea.MouseActionPress,
|
||||
})
|
||||
if d.DocumentYOffset() == 0 {
|
||||
t.Fatal("expected wheel down to scroll document")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetailPaneXRange(t *testing.T) {
|
||||
pw := PaneWidths{
|
||||
Menu: 12, List: 72, Detail: 36,
|
||||
Visibility: PaneVisibility{Menu: true, List: true, Detail: true},
|
||||
}
|
||||
start, end := DetailPaneXRange(pw)
|
||||
if start != 84 || end != 120 {
|
||||
t.Fatalf("range=%d..%d want 84..120", start, end)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
)
|
||||
|
||||
func TestDetailTabOrderProjectForm(t *testing.T) {
|
||||
d := newDetailPane()
|
||||
d.SetFocused(true)
|
||||
d.LoadForm(model.Item{ID: "1", Kind: model.KindProject, Title: "P"}, model.FormFields{
|
||||
PrimaryLabel: "Title", SecondaryLabel: "Description",
|
||||
Primary: "Alpha", Secondary: "Beta", HasStatus: true,
|
||||
Status: model.ProjectLifecycleOpen,
|
||||
})
|
||||
|
||||
if d.tabStop != 0 || d.form.field != entityFieldPrimary {
|
||||
t.Fatalf("start on title, tabStop=%d field=%d", d.tabStop, d.form.field)
|
||||
}
|
||||
if d.TabForward() {
|
||||
t.Fatal("tab from title should stay in pane")
|
||||
}
|
||||
if d.form.field != entityFieldSecondary {
|
||||
t.Fatalf("second stop should be description, field=%d", d.form.field)
|
||||
}
|
||||
if d.TabForward() {
|
||||
t.Fatal("tab from description should stay in pane")
|
||||
}
|
||||
if d.form.field != entityFieldStatus {
|
||||
t.Fatalf("third stop should be status, field=%d", d.form.field)
|
||||
}
|
||||
if d.TabForward() {
|
||||
t.Fatal("tab from status should stay in pane")
|
||||
}
|
||||
if d.Zone() != detailZoneActions || d.actionIdx != 0 {
|
||||
t.Fatalf("fourth stop should be Save, zone=%d idx=%d", d.Zone(), d.actionIdx)
|
||||
}
|
||||
if d.TabForward() {
|
||||
t.Fatal("tab from Save should stay in pane")
|
||||
}
|
||||
if d.actionIdx != 1 {
|
||||
t.Fatalf("fifth stop should be Delete, idx=%d", d.actionIdx)
|
||||
}
|
||||
if !d.TabForward() {
|
||||
t.Fatal("tab from Delete should leave pane")
|
||||
}
|
||||
if d.tabStop != 0 {
|
||||
t.Fatalf("after leaving pane tab order resets, tabStop=%d", d.tabStop)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetailShiftTabFromTitleLeavesPane(t *testing.T) {
|
||||
d := newDetailPane()
|
||||
d.SetFocused(true)
|
||||
d.LoadForm(model.Item{ID: "1", Kind: model.KindProject}, model.FormFields{
|
||||
PrimaryLabel: "Title", SecondaryLabel: "Description",
|
||||
})
|
||||
if !d.TabBackward() {
|
||||
t.Fatal("shift+tab from title should leave pane")
|
||||
}
|
||||
if d.tabStop != d.maxTabStop() {
|
||||
t.Fatalf("should park on last stop, tabStop=%d max=%d", d.tabStop, d.maxTabStop())
|
||||
}
|
||||
}
|
||||
+112
-4
@@ -15,6 +15,7 @@ type entityField int
|
||||
const (
|
||||
entityFieldPrimary entityField = iota
|
||||
entityFieldSecondary
|
||||
entityFieldStatus
|
||||
)
|
||||
|
||||
// EntityForm is the top section of the detail pane for editable entities.
|
||||
@@ -25,6 +26,9 @@ type EntityForm struct {
|
||||
primaryLabel string
|
||||
secondaryLabel string
|
||||
readOnly bool
|
||||
hasStatus bool
|
||||
status model.ProjectLifecycle
|
||||
responsibleID string
|
||||
primary textinput.Model
|
||||
secondary textarea.Model
|
||||
field entityField
|
||||
@@ -65,6 +69,27 @@ func newEntityForm() EntityForm {
|
||||
}
|
||||
}
|
||||
|
||||
func (f *EntityForm) FieldCount() int {
|
||||
n := 2
|
||||
if f.hasStatus {
|
||||
n = 3
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (f *EntityForm) FormFields() model.FormFields {
|
||||
return model.FormFields{
|
||||
PrimaryLabel: f.primaryLabel,
|
||||
SecondaryLabel: f.secondaryLabel,
|
||||
Primary: f.primary.Value(),
|
||||
Secondary: f.secondary.Value(),
|
||||
ReadOnly: f.readOnly,
|
||||
HasStatus: f.hasStatus,
|
||||
Status: f.status,
|
||||
ResponsibleID: f.responsibleID,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *EntityForm) Active() bool { return f.active }
|
||||
|
||||
func (f *EntityForm) ItemID() string { return f.itemID }
|
||||
@@ -82,10 +107,16 @@ func (f *EntityForm) Load(kind model.Kind, itemID string, fields model.FormField
|
||||
f.primaryLabel = fields.PrimaryLabel
|
||||
f.secondaryLabel = fields.SecondaryLabel
|
||||
f.readOnly = fields.ReadOnly
|
||||
f.hasStatus = fields.HasStatus
|
||||
f.status = fields.Status
|
||||
f.responsibleID = fields.ResponsibleID
|
||||
f.primary.SetValue(fields.Primary)
|
||||
f.secondary.SetValue(fields.Secondary)
|
||||
f.dirty = false
|
||||
f.field = entityFieldPrimary
|
||||
if f.hasStatus && f.readOnly {
|
||||
f.field = entityFieldStatus
|
||||
}
|
||||
f.applyFocus()
|
||||
f.layoutFields()
|
||||
}
|
||||
@@ -93,6 +124,9 @@ func (f *EntityForm) Load(kind model.Kind, itemID string, fields model.FormField
|
||||
func (f *EntityForm) Clear() {
|
||||
f.active = false
|
||||
f.itemID = ""
|
||||
f.hasStatus = false
|
||||
f.status = model.ProjectLifecycleOpen
|
||||
f.responsibleID = ""
|
||||
f.primary.SetValue("")
|
||||
f.secondary.SetValue("")
|
||||
f.dirty = false
|
||||
@@ -140,19 +174,52 @@ func (f *EntityForm) layoutFields() {
|
||||
}
|
||||
|
||||
func (f *EntityForm) FocusNext() {
|
||||
if f.field < entityFieldSecondary {
|
||||
last := entityFieldSecondary
|
||||
if f.hasStatus {
|
||||
last = entityFieldStatus
|
||||
}
|
||||
if f.field < last {
|
||||
f.field++
|
||||
}
|
||||
f.applyFocus()
|
||||
}
|
||||
|
||||
func (f *EntityForm) FocusPrev() {
|
||||
if f.field > entityFieldPrimary {
|
||||
first := entityFieldPrimary
|
||||
if f.field > first {
|
||||
f.field--
|
||||
}
|
||||
f.applyFocus()
|
||||
}
|
||||
|
||||
func (f *EntityForm) SetFieldIndex(i int) {
|
||||
switch i {
|
||||
case 0:
|
||||
f.field = entityFieldPrimary
|
||||
case 1:
|
||||
f.field = entityFieldSecondary
|
||||
case 2:
|
||||
if f.hasStatus {
|
||||
f.field = entityFieldStatus
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
f.applyFocus()
|
||||
}
|
||||
|
||||
func (f *EntityForm) cycleStatus(delta int) {
|
||||
if !f.hasStatus || f.readOnly {
|
||||
return
|
||||
}
|
||||
if delta > 0 {
|
||||
f.status = f.status.Next()
|
||||
} else {
|
||||
f.status = f.status.Prev()
|
||||
}
|
||||
f.dirty = true
|
||||
}
|
||||
|
||||
func (f *EntityForm) applyFocus() {
|
||||
if f.readOnly {
|
||||
f.primary.Blur()
|
||||
@@ -163,9 +230,12 @@ func (f *EntityForm) applyFocus() {
|
||||
case entityFieldPrimary:
|
||||
f.primary.Focus()
|
||||
f.secondary.Blur()
|
||||
default:
|
||||
case entityFieldSecondary:
|
||||
f.primary.Blur()
|
||||
f.secondary.Focus()
|
||||
default:
|
||||
f.primary.Blur()
|
||||
f.secondary.Blur()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,12 +243,27 @@ 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
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
switch f.field {
|
||||
case entityFieldPrimary:
|
||||
f.primary, cmd = f.primary.Update(msg)
|
||||
default:
|
||||
case entityFieldSecondary:
|
||||
f.secondary, cmd = f.secondary.Update(msg)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
if _, ok := msg.(tea.KeyMsg); ok {
|
||||
f.dirty = true
|
||||
@@ -199,6 +284,28 @@ 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")
|
||||
}
|
||||
ro := ""
|
||||
if f.readOnly {
|
||||
ro = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(" (read-only)") + "\n"
|
||||
@@ -210,6 +317,7 @@ func (f EntityForm) View() string {
|
||||
"",
|
||||
sLabel,
|
||||
f.secondary.View(),
|
||||
statusBlock,
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// FilterSearch is the right-pane query input for nav/list filtering.
|
||||
type FilterSearch struct {
|
||||
input textinput.Model
|
||||
width int
|
||||
height int
|
||||
focused bool
|
||||
styles filterSearchStyles
|
||||
}
|
||||
|
||||
type filterSearchStyles struct {
|
||||
title lipgloss.Style
|
||||
hint lipgloss.Style
|
||||
}
|
||||
|
||||
func newFilterSearch() FilterSearch {
|
||||
in := textinput.New()
|
||||
in.Prompt = "/ "
|
||||
in.Placeholder = "Filter navigation and list…"
|
||||
in.CharLimit = 256
|
||||
return FilterSearch{
|
||||
input: in,
|
||||
styles: filterSearchStyles{
|
||||
title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("252")),
|
||||
hint: lipgloss.NewStyle().Foreground(lipgloss.Color("241")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *FilterSearch) Query() string { return f.input.Value() }
|
||||
|
||||
func (f *FilterSearch) SetFocused(on bool) {
|
||||
f.focused = on
|
||||
if on {
|
||||
f.input.Focus()
|
||||
} else {
|
||||
f.input.Blur()
|
||||
}
|
||||
}
|
||||
|
||||
func (f *FilterSearch) SetSize(w, h int) {
|
||||
if w < 12 {
|
||||
w = 12
|
||||
}
|
||||
if h < 6 {
|
||||
h = 6
|
||||
}
|
||||
f.width = w
|
||||
f.height = h
|
||||
inner := w - 2
|
||||
if inner < 8 {
|
||||
inner = 8
|
||||
}
|
||||
f.input.Width = inner
|
||||
}
|
||||
|
||||
func (f *FilterSearch) Clear() {
|
||||
f.input.SetValue("")
|
||||
}
|
||||
|
||||
func (f *FilterSearch) Update(msg tea.Msg) tea.Cmd {
|
||||
if !f.focused {
|
||||
return nil
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
f.input, cmd = f.input.Update(msg)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (f FilterSearch) View() string {
|
||||
title := f.styles.title.Render("Filter")
|
||||
body := lipgloss.NewStyle().Width(f.width).Render(f.input.View())
|
||||
hint := f.styles.hint.Render("Filters left nav and center table · Esc clear · f focus")
|
||||
return strings.Join([]string{title, "", body, "", hint}, "\n")
|
||||
}
|
||||
|
||||
func filterSearchBlinkCmd() tea.Cmd {
|
||||
return textinput.Blink
|
||||
}
|
||||
@@ -17,6 +17,7 @@ const (
|
||||
ActionToggleMenuPane
|
||||
ActionToggleListPane
|
||||
ActionToggleDetailPane
|
||||
ActionFilter
|
||||
ActionNextPane
|
||||
ActionPrevPane
|
||||
ActionRefresh
|
||||
@@ -38,6 +39,8 @@ func KeyAction(key string, pane model.FocusPane) Action {
|
||||
}
|
||||
case "r":
|
||||
return ActionRefresh
|
||||
case "f":
|
||||
return ActionFilter
|
||||
case "up", "k":
|
||||
return ActionMoveUp
|
||||
case "down", "j":
|
||||
|
||||
+38
-22
@@ -17,44 +17,60 @@ func defaultPaneVisibility() PaneVisibility {
|
||||
return PaneVisibility{Menu: true, List: true, Detail: true}
|
||||
}
|
||||
|
||||
// LayoutWidths splits total terminal width evenly across visible panes.
|
||||
const (
|
||||
defaultMenuShare = 10
|
||||
defaultListShare = 60
|
||||
defaultDetailShare = 30
|
||||
)
|
||||
|
||||
// LayoutWidths splits total terminal width across visible panes (default 10% / 60% / 30%).
|
||||
func LayoutWidths(total int, vis PaneVisibility) PaneWidths {
|
||||
if total < 1 {
|
||||
total = 80
|
||||
}
|
||||
v := vis
|
||||
n := countVisible(v)
|
||||
if n == 0 {
|
||||
if countVisible(v) == 0 {
|
||||
v = defaultPaneVisibility()
|
||||
n = 3
|
||||
}
|
||||
base := total / n
|
||||
rem := total % n
|
||||
out := PaneWidths{Visibility: v}
|
||||
shareSum := 0
|
||||
if v.Menu {
|
||||
out.Menu = base
|
||||
if rem > 0 {
|
||||
out.Menu++
|
||||
rem--
|
||||
}
|
||||
shareSum += defaultMenuShare
|
||||
}
|
||||
if v.List {
|
||||
out.List = base
|
||||
if rem > 0 {
|
||||
out.List++
|
||||
rem--
|
||||
}
|
||||
shareSum += defaultListShare
|
||||
}
|
||||
if v.Detail {
|
||||
out.Detail = base
|
||||
if rem > 0 {
|
||||
out.Detail++
|
||||
rem--
|
||||
}
|
||||
shareSum += defaultDetailShare
|
||||
}
|
||||
assign := func(visible bool, share int) int {
|
||||
if !visible || shareSum == 0 {
|
||||
return 0
|
||||
}
|
||||
return total * share / shareSum
|
||||
}
|
||||
out := PaneWidths{Visibility: v}
|
||||
out.Menu = assign(v.Menu, defaultMenuShare)
|
||||
out.List = assign(v.List, defaultListShare)
|
||||
out.Detail = assign(v.Detail, defaultDetailShare)
|
||||
fixPaneWidthSum(&out, total)
|
||||
return out
|
||||
}
|
||||
|
||||
// DetailPaneXRange returns the [start, end) column span of the detail pane.
|
||||
func DetailPaneXRange(pw PaneWidths) (start, end int) {
|
||||
if !pw.Visibility.Detail {
|
||||
return 0, 0
|
||||
}
|
||||
if pw.Visibility.Menu {
|
||||
start += pw.Menu
|
||||
}
|
||||
if pw.Visibility.List {
|
||||
start += pw.List
|
||||
}
|
||||
end = start + pw.Detail
|
||||
return start, end
|
||||
}
|
||||
|
||||
func countVisible(v PaneVisibility) int {
|
||||
n := 0
|
||||
if v.Menu {
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
const (
|
||||
minPaneOuterWidth = 14
|
||||
resizeHitSlop = 1
|
||||
)
|
||||
|
||||
type paneResizeState struct {
|
||||
active bool
|
||||
divider int
|
||||
anchorX int
|
||||
anchorPW PaneWidths
|
||||
}
|
||||
|
||||
// FitPaneWidths scales stored pane widths to fill total width for visible panes.
|
||||
func FitPaneWidths(total int, vis PaneVisibility, sizes PaneWidths) PaneWidths {
|
||||
if total < 1 {
|
||||
total = 80
|
||||
}
|
||||
out := PaneWidths{Visibility: vis}
|
||||
if countVisible(vis) == 0 {
|
||||
return LayoutWidths(total, vis)
|
||||
}
|
||||
sum := 0
|
||||
if vis.Menu {
|
||||
sum += sizes.Menu
|
||||
}
|
||||
if vis.List {
|
||||
sum += sizes.List
|
||||
}
|
||||
if vis.Detail {
|
||||
sum += sizes.Detail
|
||||
}
|
||||
if sum <= 0 {
|
||||
return LayoutWidths(total, vis)
|
||||
}
|
||||
assign := func(visible bool, stored int) int {
|
||||
if !visible {
|
||||
return 0
|
||||
}
|
||||
return stored * total / sum
|
||||
}
|
||||
out.Menu = assign(vis.Menu, sizes.Menu)
|
||||
out.List = assign(vis.List, sizes.List)
|
||||
out.Detail = assign(vis.Detail, sizes.Detail)
|
||||
fixPaneWidthSum(&out, total)
|
||||
return out
|
||||
}
|
||||
|
||||
func fixPaneWidthSum(pw *PaneWidths, total int) {
|
||||
if pw == nil || total < 1 {
|
||||
return
|
||||
}
|
||||
type slot struct {
|
||||
visible bool
|
||||
width *int
|
||||
}
|
||||
slots := []slot{
|
||||
{pw.Visibility.Menu, &pw.Menu},
|
||||
{pw.Visibility.List, &pw.List},
|
||||
{pw.Visibility.Detail, &pw.Detail},
|
||||
}
|
||||
sum := 0
|
||||
var last *int
|
||||
for _, s := range slots {
|
||||
if !s.visible {
|
||||
continue
|
||||
}
|
||||
sum += *s.width
|
||||
last = s.width
|
||||
}
|
||||
if last == nil {
|
||||
return
|
||||
}
|
||||
*last += total - sum
|
||||
if *last < minPaneOuterWidth {
|
||||
*last = minPaneOuterWidth
|
||||
}
|
||||
}
|
||||
|
||||
// DividerPositions returns x coordinates of vertical pane separators.
|
||||
func DividerPositions(pw PaneWidths) []int {
|
||||
var out []int
|
||||
x := 0
|
||||
if pw.Visibility.Menu {
|
||||
x += pw.Menu
|
||||
if pw.Visibility.List || pw.Visibility.Detail {
|
||||
out = append(out, x)
|
||||
}
|
||||
}
|
||||
if pw.Visibility.List {
|
||||
x += pw.List
|
||||
if pw.Visibility.Detail {
|
||||
out = append(out, x)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DividerAt returns the divider index under x, or -1.
|
||||
func DividerAt(x int, pw PaneWidths) int {
|
||||
for i, pos := range DividerPositions(pw) {
|
||||
if x >= pos-resizeHitSlop && x <= pos+resizeHitSlop {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// DragPaneDivider adjusts adjacent pane widths by deltaX pixels.
|
||||
func DragPaneDivider(pw PaneWidths, divider, deltaX int) (PaneWidths, bool) {
|
||||
if deltaX == 0 {
|
||||
return pw, false
|
||||
}
|
||||
changed := false
|
||||
switch divider {
|
||||
case 0:
|
||||
if pw.Visibility.Menu && pw.Visibility.List {
|
||||
if resizePair(&pw.Menu, &pw.List, deltaX) {
|
||||
changed = true
|
||||
}
|
||||
} else if pw.Visibility.Menu && pw.Visibility.Detail {
|
||||
if resizePair(&pw.Menu, &pw.Detail, deltaX) {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
case 1:
|
||||
if pw.Visibility.List && pw.Visibility.Detail {
|
||||
if resizePair(&pw.List, &pw.Detail, deltaX) {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return pw, changed
|
||||
}
|
||||
|
||||
func resizePair(left, right *int, delta int) bool {
|
||||
if left == nil || right == nil {
|
||||
return false
|
||||
}
|
||||
newLeft := *left + delta
|
||||
newRight := *right - delta
|
||||
if newLeft < minPaneOuterWidth || newRight < minPaneOuterWidth {
|
||||
return false
|
||||
}
|
||||
*left = newLeft
|
||||
*right = newRight
|
||||
return true
|
||||
}
|
||||
|
||||
func (m Model) paneResizeMaxY() int {
|
||||
return m.paneHeight() + 1
|
||||
}
|
||||
|
||||
func (m *Model) handlePaneResizeMouse(msg tea.MouseMsg) bool {
|
||||
if msg.Y >= m.paneResizeMaxY() {
|
||||
if m.resize.active && msg.Action == tea.MouseActionRelease {
|
||||
m.resize.active = false
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
pw := m.paneLayout()
|
||||
switch msg.Action {
|
||||
case tea.MouseActionPress:
|
||||
if msg.Button != tea.MouseButtonLeft {
|
||||
return false
|
||||
}
|
||||
if div := DividerAt(msg.X, pw); div >= 0 {
|
||||
m.paneSizes = pw
|
||||
m.customPaneLayout = true
|
||||
m.resize = paneResizeState{
|
||||
active: true,
|
||||
divider: div,
|
||||
anchorX: msg.X,
|
||||
anchorPW: pw,
|
||||
}
|
||||
return true
|
||||
}
|
||||
case tea.MouseActionRelease:
|
||||
if m.resize.active {
|
||||
m.resize.active = false
|
||||
return true
|
||||
}
|
||||
case tea.MouseActionMotion:
|
||||
if m.resize.active {
|
||||
delta := msg.X - m.resize.anchorX
|
||||
next, ok := DragPaneDivider(m.resize.anchorPW, m.resize.divider, delta)
|
||||
if ok {
|
||||
m.paneSizes = next
|
||||
m.layoutViewports()
|
||||
m.syncAllContent()
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
return m.resize.active
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package ui
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDividerAtBetweenMenuAndList(t *testing.T) {
|
||||
pw := PaneWidths{
|
||||
Menu: 30, List: 50, Detail: 40,
|
||||
Visibility: PaneVisibility{Menu: true, List: true, Detail: true},
|
||||
}
|
||||
if got := DividerAt(30, pw); got != 0 {
|
||||
t.Fatalf("divider at 30=%d want 0", got)
|
||||
}
|
||||
if got := DividerAt(80, pw); got != 1 {
|
||||
t.Fatalf("divider at 80=%d want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDragPaneDividerRespectsMinimum(t *testing.T) {
|
||||
pw := PaneWidths{
|
||||
Menu: 40, List: 40, Detail: 40,
|
||||
Visibility: PaneVisibility{Menu: true, List: true, Detail: true},
|
||||
}
|
||||
next, ok := DragPaneDivider(pw, 0, -30)
|
||||
if ok {
|
||||
t.Fatalf("expected resize blocked by minimum, got %+v", next)
|
||||
}
|
||||
next, ok = DragPaneDivider(pw, 0, 5)
|
||||
if !ok || next.Menu != 45 || next.List != 35 {
|
||||
t.Fatalf("resize failed: ok=%v %+v", ok, next)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFitPaneWidthsScalesToTotal(t *testing.T) {
|
||||
vis := PaneVisibility{Menu: true, List: true, Detail: true}
|
||||
sizes := PaneWidths{Menu: 20, List: 30, Detail: 50, Visibility: vis}
|
||||
out := FitPaneWidths(120, vis, sizes)
|
||||
sum := out.Menu + out.List + out.Detail
|
||||
if sum != 120 {
|
||||
t.Fatalf("sum=%d want 120", sum)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,9 @@ func TestLayoutWidthsAllVisibleUsesFullWidth(t *testing.T) {
|
||||
if sum != 120 {
|
||||
t.Fatalf("sum=%d want 120", sum)
|
||||
}
|
||||
if pw.Menu != 12 || pw.List != 72 || pw.Detail != 36 {
|
||||
t.Fatalf("menu=%d list=%d detail=%d want 12/72/36 (10/60/30)", pw.Menu, pw.List, pw.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayoutWidthsSinglePane(t *testing.T) {
|
||||
@@ -30,6 +33,20 @@ func TestLayoutWidthsTwoPanes(t *testing.T) {
|
||||
if pw.Menu+pw.Detail != 80 {
|
||||
t.Fatalf("sum=%d want 80", pw.Menu+pw.Detail)
|
||||
}
|
||||
if pw.Menu != 20 || pw.Detail != 60 {
|
||||
t.Fatalf("menu=%d detail=%d want 20/60 (10/30 of pair)", pw.Menu, pw.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetailPaneXRangeSkipsHidden(t *testing.T) {
|
||||
pw := PaneWidths{
|
||||
Menu: 20, List: 60, Detail: 0,
|
||||
Visibility: PaneVisibility{Menu: true, List: true, Detail: false},
|
||||
}
|
||||
start, end := DetailPaneXRange(pw)
|
||||
if start != 0 || end != 0 {
|
||||
t.Fatalf("hidden detail should have empty range, got %d..%d", start, end)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextVisibleFocusSkipsHidden(t *testing.T) {
|
||||
|
||||
+10
-4
@@ -11,7 +11,7 @@ func (m *Model) scrollFocusedPane(key string) bool {
|
||||
return scrollViewport(&m.menuVP, key)
|
||||
case model.FocusList:
|
||||
switch key {
|
||||
case "pgdown", "pgdn", "f", "ctrl+d":
|
||||
case "pgdown", "pgdn", "ctrl+d":
|
||||
m.listTable.PageScroll(1)
|
||||
return true
|
||||
case "pgup", "b", "ctrl+u":
|
||||
@@ -20,10 +20,10 @@ func (m *Model) scrollFocusedPane(key string) bool {
|
||||
}
|
||||
return false
|
||||
case model.FocusPreview:
|
||||
if m.detail.Zone() != detailZoneContent || m.detail.mode != detailDocument {
|
||||
if m.detail.Zone() != detailZoneContent {
|
||||
return false
|
||||
}
|
||||
return scrollViewport(&m.detail.docVP, key)
|
||||
return m.detail.ScrollDocument(key)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -50,7 +50,13 @@ func (m *Model) paneInnerWidth(outer int) int {
|
||||
|
||||
func scrollViewport(vp *viewport.Model, key string) bool {
|
||||
switch key {
|
||||
case "pgdown", "pgdn", "f", "ctrl+d":
|
||||
case "up", "k":
|
||||
vp.LineUp(1)
|
||||
return true
|
||||
case "down", "j":
|
||||
vp.LineDown(1)
|
||||
return true
|
||||
case "pgdown", "pgdn", "ctrl+d":
|
||||
vp.ViewDown()
|
||||
return true
|
||||
case "pgup", "b", "ctrl+u":
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/mattn/go-runewidth"
|
||||
)
|
||||
|
||||
// ScrollbarMetrics computes thumb position for a vertical scrollbar.
|
||||
func ScrollbarMetrics(totalLines, viewportHeight, yOffset int) (show bool, thumbStart, thumbEnd int) {
|
||||
if totalLines <= viewportHeight || viewportHeight < 1 {
|
||||
return false, 0, 0
|
||||
}
|
||||
maxOff := totalLines - viewportHeight
|
||||
if maxOff < 0 {
|
||||
maxOff = 0
|
||||
}
|
||||
thumbSize := viewportHeight * viewportHeight / totalLines
|
||||
if thumbSize < 1 {
|
||||
thumbSize = 1
|
||||
}
|
||||
if thumbSize > viewportHeight {
|
||||
thumbSize = viewportHeight
|
||||
}
|
||||
travel := viewportHeight - thumbSize
|
||||
thumbStart = 0
|
||||
if maxOff > 0 {
|
||||
thumbStart = yOffset * travel / maxOff
|
||||
}
|
||||
return true, thumbStart, thumbStart + thumbSize
|
||||
}
|
||||
|
||||
// ApplyVerticalScrollbar draws a 1-column scrollbar on the right when content overflows.
|
||||
func ApplyVerticalScrollbar(view string, width, height, totalLines, yOffset int) string {
|
||||
show, thumbStart, thumbEnd := ScrollbarMetrics(totalLines, height, yOffset)
|
||||
if !show || width < 2 {
|
||||
return view
|
||||
}
|
||||
contentWidth := width - 1
|
||||
lines := strings.Split(view, "\n")
|
||||
for len(lines) < height {
|
||||
lines = append(lines, "")
|
||||
}
|
||||
if len(lines) > height {
|
||||
lines = lines[:height]
|
||||
}
|
||||
track := lipgloss.NewStyle().Foreground(lipgloss.Color("238"))
|
||||
thumb := lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
|
||||
out := make([]string, height)
|
||||
for row := 0; row < height; row++ {
|
||||
line := padDisplayWidth(lines[row], contentWidth)
|
||||
ch := "│"
|
||||
style := track
|
||||
if row >= thumbStart && row < thumbEnd {
|
||||
ch = "█"
|
||||
style = thumb
|
||||
}
|
||||
out[row] = line + style.Render(ch)
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestScrollbarMetricsHiddenWhenFits(t *testing.T) {
|
||||
show, _, _ := ScrollbarMetrics(5, 10, 0)
|
||||
if show {
|
||||
t.Fatal("expected no scrollbar when content fits")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrollbarMetricsVisibleWhenOverflow(t *testing.T) {
|
||||
show, start, end := ScrollbarMetrics(100, 10, 50)
|
||||
if !show || end <= start {
|
||||
t.Fatalf("expected thumb, got %d..%d", start, end)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyVerticalScrollbarAddsColumn(t *testing.T) {
|
||||
view := "line1\nline2\nline3"
|
||||
out := ApplyVerticalScrollbar(view, 12, 3, 10, 0)
|
||||
lines := strings.Split(out, "\n")
|
||||
if len(lines) != 3 {
|
||||
t.Fatalf("lines=%d", len(lines))
|
||||
}
|
||||
if !strings.HasSuffix(lines[0], "█") && !strings.HasSuffix(lines[0], "│") {
|
||||
t.Fatalf("expected scrollbar glyph: %q", lines[0])
|
||||
}
|
||||
}
|
||||
+125
-15
@@ -53,6 +53,7 @@ type DataTable struct {
|
||||
sortAsc bool
|
||||
focused bool
|
||||
ready bool
|
||||
loadingMore bool
|
||||
width int
|
||||
height int
|
||||
viewport viewport.Model
|
||||
@@ -127,6 +128,62 @@ func (t *DataTable) ItemIndex() int {
|
||||
return t.order[t.cursorRow]
|
||||
}
|
||||
|
||||
// CursorRow is the visual row index in the current table order.
|
||||
func (t DataTable) CursorRow() int {
|
||||
return t.cursorRow
|
||||
}
|
||||
|
||||
// Items returns the backing slice (after appends).
|
||||
func (t DataTable) Items() []model.Item {
|
||||
return t.items
|
||||
}
|
||||
|
||||
// NearEnd reports whether the cursor is within threshold rows of the last row.
|
||||
func (t DataTable) NearEnd(threshold int) bool {
|
||||
if !t.ready || len(t.order) == 0 {
|
||||
return false
|
||||
}
|
||||
return t.cursorRow >= len(t.order)-threshold
|
||||
}
|
||||
|
||||
func (t *DataTable) SetLoadingMore(on bool) {
|
||||
t.loadingMore = on
|
||||
}
|
||||
|
||||
// AppendItems adds new rows, skipping duplicate IDs. Returns how many were added.
|
||||
func (t *DataTable) AppendItems(items []model.Item) int {
|
||||
if !t.ready || len(items) == 0 {
|
||||
return 0
|
||||
}
|
||||
seen := make(map[string]struct{}, len(t.items))
|
||||
for _, it := range t.items {
|
||||
if it.ID != "" {
|
||||
seen[it.ID] = struct{}{}
|
||||
}
|
||||
}
|
||||
added := 0
|
||||
for _, it := range items {
|
||||
if it.ID != "" {
|
||||
if _, ok := seen[it.ID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[it.ID] = struct{}{}
|
||||
}
|
||||
idx := len(t.items)
|
||||
t.items = append(t.items, it)
|
||||
t.order = append(t.order, idx)
|
||||
added++
|
||||
}
|
||||
if added > 0 {
|
||||
if t.sortCol >= 0 {
|
||||
t.applySort()
|
||||
}
|
||||
t.clampCursor()
|
||||
t.refreshViewport()
|
||||
}
|
||||
return added
|
||||
}
|
||||
|
||||
func (t *DataTable) MoveRow(delta int) {
|
||||
if !t.ready || len(t.order) == 0 {
|
||||
return
|
||||
@@ -190,7 +247,7 @@ func (t *DataTable) Update(msg tea.Msg) tea.Cmd {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "pgdown", "pgdn", "f", "ctrl+d":
|
||||
case "pgdown", "pgdn", "ctrl+d":
|
||||
t.viewport.ViewDown()
|
||||
case "pgup", "b", "ctrl+u":
|
||||
t.viewport.ViewUp()
|
||||
@@ -208,6 +265,9 @@ func (t DataTable) View() string {
|
||||
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 := "▲"
|
||||
@@ -217,7 +277,13 @@ func (t DataTable) View() string {
|
||||
sortHint = t.styles.help.Render(fmt.Sprintf(" sort: %s %s", t.columns[t.sortCol].Title, dir))
|
||||
}
|
||||
header := t.renderHeader()
|
||||
body := t.viewport.View()
|
||||
body := ApplyVerticalScrollbar(
|
||||
t.viewport.View(),
|
||||
t.viewport.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)
|
||||
}
|
||||
@@ -297,7 +363,7 @@ func distributeColumnWidths(minSum, total int, indices []int, cols []model.Table
|
||||
flex := make([]int, 0, len(indices))
|
||||
for _, i := range indices {
|
||||
switch cols[i].Key {
|
||||
case "title", "subtitle", "description", "displayName", "primaryEmail", "from", "to", "tasks":
|
||||
case "title", "subtitle", "description", "displayName", "primaryEmail", "from", "to", "tasks", "type":
|
||||
flex = append(flex, i)
|
||||
}
|
||||
}
|
||||
@@ -332,6 +398,19 @@ func fixColumnWidthSum(widths map[int]int, indices []int, total int) {
|
||||
}
|
||||
}
|
||||
|
||||
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, "...")
|
||||
}
|
||||
|
||||
func (t *DataTable) renderHeader() string {
|
||||
indices, widths := t.visibleLayout()
|
||||
cells := make([]string, 0, len(indices))
|
||||
@@ -346,7 +425,7 @@ func (t *DataTable) renderHeader() string {
|
||||
title += " ▼"
|
||||
}
|
||||
}
|
||||
text := runewidth.Truncate(title, w, "…")
|
||||
text := truncateCellText(title, w)
|
||||
style := t.styles.header
|
||||
if t.focused && colIdx == t.cursorCol {
|
||||
style = t.styles.headerSort
|
||||
@@ -371,39 +450,70 @@ 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]
|
||||
text := runewidth.Truncate(model.CellText(item, col.Key), w, "…")
|
||||
cells = append(cells, t.styleCell(row, colIdx, selected, text, w))
|
||||
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))
|
||||
}
|
||||
return lipgloss.JoinHorizontal(lipgloss.Left, cells...)
|
||||
}
|
||||
|
||||
func (t *DataTable) styleCell(row, col int, selected bool, text string, width int) string {
|
||||
base := t.styles.cell.Width(width).MaxWidth(width)
|
||||
func applyCellWidth(s lipgloss.Style, width int, full bool) lipgloss.Style {
|
||||
if full {
|
||||
return s
|
||||
}
|
||||
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
|
||||
|
||||
var base lipgloss.Style
|
||||
switch {
|
||||
case selected && isCell:
|
||||
return t.styles.cellSelect.Width(width).MaxWidth(width).Render(text)
|
||||
base = t.styles.cellSelect
|
||||
case selected && isRow:
|
||||
return t.styles.rowSelect.Width(width).MaxWidth(width).Render(text)
|
||||
base = t.styles.rowSelect
|
||||
case selected:
|
||||
return t.styles.rowSelect.Width(width).MaxWidth(width).Render(text)
|
||||
base = t.styles.rowSelect
|
||||
case isCell:
|
||||
return t.styles.cellActive.Width(width).MaxWidth(width).Render(text)
|
||||
base = t.styles.cellActive
|
||||
case t.focused && isRow:
|
||||
return t.styles.rowActive.Width(width).MaxWidth(width).Render(text)
|
||||
base = t.styles.rowActive
|
||||
case t.focused && isCol:
|
||||
return t.styles.colActive.Width(width).MaxWidth(width).Render(text)
|
||||
base = t.styles.colActive
|
||||
default:
|
||||
return base.Render(text)
|
||||
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"))
|
||||
}
|
||||
|
||||
func (t *DataTable) ensureColVisible() {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
)
|
||||
|
||||
func TestDataTableAppendItemsDedupes(t *testing.T) {
|
||||
tbl := newDataTable()
|
||||
tbl.SetSize(40, 10)
|
||||
tbl.SetData(model.ListSpec{Subject: model.SubjectMailInbox}, []model.Item{
|
||||
{ID: "1", Title: "A", Kind: model.KindMail},
|
||||
})
|
||||
added := tbl.AppendItems([]model.Item{
|
||||
{ID: "1", Title: "dup", Kind: model.KindMail},
|
||||
{ID: "2", Title: "B", Kind: model.KindMail},
|
||||
})
|
||||
if added != 1 || len(tbl.Items()) != 2 {
|
||||
t.Fatalf("added=%d len=%d", added, len(tbl.Items()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDataTableNearEnd(t *testing.T) {
|
||||
tbl := newDataTable()
|
||||
tbl.SetSize(40, 10)
|
||||
items := make([]model.Item, 10)
|
||||
for i := range items {
|
||||
items[i] = model.Item{ID: string(rune('0' + i)), Title: "x", Kind: model.KindMail}
|
||||
}
|
||||
tbl.SetData(model.ListSpec{Subject: model.SubjectMailInbox}, items)
|
||||
tbl.cursorRow = 7
|
||||
if !tbl.NearEnd(3) {
|
||||
t.Fatal("expected near end")
|
||||
}
|
||||
tbl.cursorRow = 0
|
||||
if tbl.NearEnd(3) {
|
||||
t.Fatal("expected not near end")
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
"github.com/mattn/go-runewidth"
|
||||
)
|
||||
|
||||
func sampleItems() []model.Item {
|
||||
@@ -32,7 +33,7 @@ func TestDataTableMoveColScrolls(t *testing.T) {
|
||||
items[0] = model.Item{ID: "1", Title: "One", Raw: map[string]any{
|
||||
"alpha": "a", "beta": "b", "gamma": "c", "delta": "d",
|
||||
}}
|
||||
tbl.SetData(model.ListSpec{Subject: model.SubjectTasks}, items)
|
||||
tbl.SetData(model.ListSpec{Subject: model.SubjectContacts}, items)
|
||||
last := len(tbl.columns) - 1
|
||||
tbl.cursorCol = last
|
||||
tbl.ensureColVisible()
|
||||
@@ -88,6 +89,55 @@ func TestDistributeColumnWidthsFillsTotal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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, "...") {
|
||||
t.Fatalf("expected ellipsis suffix, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDataTableTruncatesNonSelectedRows(t *testing.T) {
|
||||
long := strings.Repeat("A", 60)
|
||||
items := []model.Item{
|
||||
{ID: "1", Title: "Short"},
|
||||
{ID: "2", Title: long},
|
||||
}
|
||||
tbl := newDataTable()
|
||||
tbl.SetSize(50, 10)
|
||||
tbl.SetData(model.ListSpec{Subject: model.SubjectTasks}, items)
|
||||
tbl.cursorRow = 0
|
||||
|
||||
if strings.Contains(tbl.renderRow(1), long) {
|
||||
t.Fatal("non-cursor row should truncate long title")
|
||||
}
|
||||
|
||||
tbl.cursorRow = 1
|
||||
if !strings.Contains(tbl.renderRow(1), long) {
|
||||
t.Fatal("cursor row should show full title")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDataTableShowsFullTextWhenSpaceSelected(t *testing.T) {
|
||||
long := strings.Repeat("B", 60)
|
||||
items := []model.Item{
|
||||
{ID: "1", Title: "Short"},
|
||||
{ID: "2", Title: long, Selected: true},
|
||||
}
|
||||
tbl := newDataTable()
|
||||
tbl.SetSize(50, 10)
|
||||
tbl.SetData(model.ListSpec{Subject: model.SubjectTasks}, items)
|
||||
tbl.cursorRow = 0
|
||||
|
||||
rowSelected := tbl.renderRow(1)
|
||||
if !strings.Contains(rowSelected, long) {
|
||||
t.Fatal("space-selected row should show full title")
|
||||
}
|
||||
}
|
||||
|
||||
func indexOfColumn(tbl DataTable, key string) int {
|
||||
for i, c := range tbl.columns {
|
||||
if c.Key == key {
|
||||
|
||||
Reference in New Issue
Block a user