feat(office): table detail panes and Alt+1/2/3 layout toggles

Add spreadsheet-style list, split detail pane with forms and document preview,
and Alt+1/2/3 to show/hide panes with even width distribution.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-24 17:33:50 +01:00
co-authored by Cursor
parent 049612e6e6
commit 934af21bc9
25 changed files with 2555 additions and 287 deletions
+16 -5
View File
@@ -244,13 +244,24 @@ func (l *Loader) listProjects(ctx context.Context) ([]model.Item, error) {
if p.Title != nil {
title = *p.Title
}
status := ""
if p.Status != nil {
status = fmt.Sprint(*p.Status)
raw := map[string]any{
"id": id,
"title": title,
}
if p.TaskCount != nil {
raw["taskCount"] = *p.TaskCount
}
if p.TaskCountTotal != nil {
raw["taskCountTotal"] = *p.TaskCountTotal
}
if p.DocumentsCount != nil {
raw["documentsCount"] = *p.DocumentsCount
}
if p.ParticipantCount != nil {
raw["participantCount"] = *p.ParticipantCount
}
items[i] = model.Item{
ID: id, Title: title, Subtitle: status, Kind: model.KindProject,
Raw: map[string]any{"id": id, "title": title, "status": status},
ID: id, Title: title, Kind: model.KindProject, Raw: raw,
}
}
return items, nil
+43
View File
@@ -0,0 +1,43 @@
package fetch
import (
"bytes"
"context"
"fmt"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/eslider/go-onlyoffice/cmd/office/model"
"github.com/eslider/go-onlyoffice/cmd/office/preview"
)
// PreviewMarkdown loads item data and returns markdown for the preview pane.
func (l *Loader) PreviewMarkdown(ctx context.Context, item model.Item) (string, error) {
if l == nil || l.Client == nil {
return "", fmt.Errorf("fetch: client is nil")
}
if item.Kind == model.KindFile {
return l.filePreviewMarkdown(ctx, item)
}
raw, err := l.Detail(ctx, item)
if err != nil {
return "", err
}
return preview.EntityMarkdown(string(item.Kind), raw), nil
}
func (l *Loader) filePreviewMarkdown(ctx context.Context, item model.Item) (string, error) {
if item.ID == "" {
return "", fmt.Errorf("file id missing")
}
name := item.Title
if meta, err := l.Client.GetFile(ctx, item.ID); err == nil && meta != nil {
if t := onlyoffice.FileEntryTitle(meta); t != "" {
name = t
}
}
var buf bytes.Buffer
if _, err := l.Client.DownloadFile(ctx, item.ID, &buf); err != nil {
return "", err
}
return preview.FileBytesToMarkdown(name, buf.Bytes())
}
+43
View File
@@ -0,0 +1,43 @@
package fetch
import (
"context"
"fmt"
"strconv"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
// SaveItem persists editable form fields for supported entity kinds.
func (l *Loader) SaveItem(ctx context.Context, item model.Item, title, description string) error {
if l == nil || l.Client == nil {
return fmt.Errorf("fetch: client is nil")
}
switch item.Kind {
case model.KindTask:
return l.UpdateTask(ctx, item.ID, title, description)
case model.KindProject:
id, err := strconv.Atoi(item.ID)
if err != nil {
return err
}
_, err = l.Client.UpdateProject(onlyoffice.ProjectUpdateRequest{
ID: id,
Title: title,
Description: description,
})
return err
default:
return fmt.Errorf("save not supported for %s", item.Kind)
}
}
// DetailForm loads form field values for the detail pane.
func (l *Loader) DetailForm(ctx context.Context, item model.Item) (model.FormFields, error) {
raw, err := l.Detail(ctx, item)
if err != nil {
return model.FormFields{}, err
}
return model.FormFieldsFromRaw(item.Kind, raw), nil
}
+36
View File
@@ -0,0 +1,36 @@
package fetch
import (
"context"
"fmt"
"strconv"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
// UpdateTask saves title and description for a project task.
func (l *Loader) UpdateTask(ctx context.Context, taskID, title, description string) error {
if l == nil || l.Client == nil {
return fmt.Errorf("fetch: client is nil")
}
id, err := strconv.Atoi(taskID)
if err != nil {
return fmt.Errorf("task id %q: %w", taskID, err)
}
_, err = l.Client.UpdateProjectTask(onlyoffice.ProjectTaskUpdateRequest{
ID: id,
Title: title,
Description: description,
})
return err
}
// TaskFields loads title and description for a project task item.
func (l *Loader) TaskFields(ctx context.Context, item model.Item) (title, description string, err error) {
fields, err := l.DetailForm(ctx, item)
if err != nil {
return "", "", err
}
return fields.Primary, fields.Secondary, nil
}
+68
View File
@@ -0,0 +1,68 @@
//go:build integration
package fetch_test
import (
"context"
"os"
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestIntegrationUpdateTaskTitleDescription(t *testing.T) {
loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectTasks})
if err != nil {
t.Fatal(err)
}
if len(items) == 0 {
t.Skip("no tasks")
}
item := items[0]
title, desc, err := loader.TaskFields(ctx, item)
if err != nil {
t.Fatal(err)
}
restoreTitle, restoreDesc := title, desc
t.Cleanup(func() {
_ = loader.UpdateTask(context.Background(), item.ID, restoreTitle, restoreDesc)
})
newTitle := title + " (office TUI test)"
newDesc := desc + "\n\n_edited by office integration test_"
if err := loader.UpdateTask(ctx, item.ID, newTitle, newDesc); err != nil {
t.Fatal(err)
}
gotTitle, gotDesc, err := loader.TaskFields(ctx, item)
if err != nil {
t.Fatal(err)
}
if gotTitle != newTitle {
t.Fatalf("title: got %q want %q", gotTitle, newTitle)
}
if gotDesc != newDesc {
t.Fatalf("description mismatch")
}
}
func TestIntegrationTaskFieldsFromLiveAPI(t *testing.T) {
if os.Getenv("ONLYOFFICE_URL") == "" && os.Getenv("ONLYOFFICE_HOST") == "" {
t.Skip("ONLYOFFICE_URL not set")
}
loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectTasks})
if err != nil {
t.Fatal(err)
}
if len(items) == 0 {
t.Skip("no tasks")
}
title, desc, err := loader.TaskFields(ctx, items[0])
if err != nil {
t.Fatal(err)
}
if title == "" {
t.Fatal("empty title")
}
_ = desc
}
+1 -1
View File
@@ -26,7 +26,7 @@ func main() {
}
m := ui.NewModel(client)
p := tea.NewProgram(m, tea.WithAltScreen())
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
if _, err := p.Run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
+12 -12
View File
@@ -5,6 +5,7 @@ type ActionID string
const (
ActionView ActionID = "view"
ActionSave ActionID = "save"
ActionDelete ActionID = "delete"
ActionRefresh ActionID = "refresh"
ActionDownload ActionID = "download"
@@ -17,48 +18,47 @@ type ItemAction struct {
Danger bool
}
// ActionsFor returns available operations for an item kind.
// ActionsFor returns available operations for an item kind (detail pane action bar).
func ActionsFor(kind Kind) []ItemAction {
switch kind {
case KindProject:
return []ItemAction{
{ID: ActionView, Label: "View details"},
{ID: ActionDelete, Label: "Delete project", Danger: true},
{ID: ActionSave, Label: "Save"},
{ID: ActionDelete, Label: "Delete", Danger: true},
}
case KindTask, KindCRMTask:
case KindTask:
return []ItemAction{
{ID: ActionView, Label: "View details"},
{ID: ActionSave, Label: "Save"},
{ID: ActionDelete, Label: "Delete task", Danger: true},
}
case KindCRMTask:
return []ItemAction{
{ID: ActionDelete, Label: "Delete", Danger: true},
}
case KindContact:
return []ItemAction{
{ID: ActionView, Label: "View details"},
{ID: ActionDelete, Label: "Delete contact", Danger: true},
}
case KindOpportunity:
return []ItemAction{
{ID: ActionView, Label: "View details"},
{ID: ActionDelete, Label: "Delete deal", Danger: true},
}
case KindCase:
return []ItemAction{
{ID: ActionView, Label: "View details"},
{ID: ActionDelete, Label: "Delete case", Danger: true},
}
case KindMail:
return []ItemAction{
{ID: ActionView, Label: "Read message"},
{ID: ActionDelete, Label: "Delete message", Danger: true},
}
case KindFile:
return []ItemAction{
{ID: ActionView, Label: "Preview file"},
{ID: ActionDownload, Label: "Download"},
{ID: ActionDelete, Label: "Delete file", Danger: true},
}
case KindEvent, KindCalendar, KindUser:
return []ItemAction{{ID: ActionView, Label: "View details"}}
return nil
default:
return []ItemAction{{ID: ActionView, Label: "View details"}}
return nil
}
}
+287
View File
@@ -0,0 +1,287 @@
package model
import (
"fmt"
"sort"
"strconv"
"strings"
)
// TableColumn is one column in the center data table.
type TableColumn struct {
Key string
Title string
Width int
}
var subjectExtraKeys = map[Subject][]string{
SubjectTasks: {"status", "deadline", "responsible"},
SubjectCalendars: {"description"},
SubjectEvents: {"start", "end"},
SubjectContacts: {"primaryEmail", "displayName"},
SubjectPersons: {"primaryEmail", "displayName"},
SubjectCompanies: {"primaryEmail", "displayName"},
SubjectOpportunities: {"stage", "bidValue", "bidCurrency"},
SubjectCases: {"status"},
SubjectCRMTasks: {"status", "deadline"},
SubjectMailInbox: {"from", "date"},
SubjectMailSent: {"to", "date"},
SubjectMailDrafts: {"to", "date"},
SubjectMailTrash: {"from", "date"},
SubjectMailSpam: {"from", "date"},
SubjectUsers: {"email", "displayName"},
}
// BuildColumns derives table columns from the list subject and item payloads.
func BuildColumns(subject Subject, items []Item) []TableColumn {
if subject == SubjectProjects {
return buildProjectColumns(items)
}
cols := []TableColumn{
{Key: "_sel", Title: "✓", Width: 3},
{Key: "id", Title: "ID", Width: 10},
{Key: "title", Title: "Title", Width: 28},
}
if hasSubtitle(items) {
cols = append(cols, TableColumn{Key: "subtitle", Title: "Subtitle", Width: 22})
}
seen := map[string]bool{"_sel": true, "id": true, "title": true, "subtitle": true}
for _, key := range subjectExtraKeys[subject] {
if seen[key] || !columnHasData(items, key) {
continue
}
seen[key] = true
cols = append(cols, TableColumn{Key: key, Title: titleLabel(key), Width: defaultWidth(key)})
}
discovered := discoverRawKeys(items, seen, 8)
for _, key := range discovered {
cols = append(cols, TableColumn{Key: key, Title: titleLabel(key), Width: defaultWidth(key)})
}
sizeColumns(cols, items)
return cols
}
func buildProjectColumns(items []Item) []TableColumn {
cols := []TableColumn{
{Key: "_sel", Title: "✓", Width: 3},
{Key: "id", Title: "ID", Width: 8},
{Key: "title", Title: "Title", Width: 28},
{Key: "tasks", Title: "Tasks (open/closed)", Width: 18},
{Key: "documents", Title: "Documents", Width: 11},
{Key: "users", Title: "Users", Width: 8},
}
sizeColumns(cols, items)
return cols
}
// CellText returns the display string for one table cell.
func CellText(it Item, key string) string {
switch key {
case "_sel":
if it.Selected {
return "●"
}
return "○"
case "id":
return it.ID
case "title":
return it.Title
case "subtitle":
return it.Subtitle
case "tasks":
return formatProjectTasks(it.Raw)
case "documents":
return intRaw(it.Raw, "documentsCount")
case "users":
return intRaw(it.Raw, "participantCount")
case "kind":
return string(it.Kind)
default:
if it.Raw == nil {
return ""
}
return formatAny(it.Raw[key])
}
}
func formatProjectTasks(raw map[string]any) string {
if raw == nil {
return "0/0"
}
open := intRawVal(raw, "taskCount")
total := intRawVal(raw, "taskCountTotal")
closed := total - open
if closed < 0 {
closed = 0
}
return fmt.Sprintf("%d/%d", open, closed)
}
func intRaw(raw map[string]any, key string) string {
if raw == nil {
return ""
}
v := intRawVal(raw, key)
if v == 0 {
if _, ok := raw[key]; !ok {
return ""
}
}
return fmt.Sprintf("%d", v)
}
func intRawVal(raw map[string]any, key string) int {
if raw == nil {
return 0
}
switch v := raw[key].(type) {
case int:
return v
case int64:
return int(v)
case float64:
return int(v)
case string:
n, _ := strconv.Atoi(v)
return n
default:
if raw[key] == nil {
return 0
}
n, _ := strconv.Atoi(fmt.Sprint(raw[key]))
return n
}
}
func hasSubtitle(items []Item) bool {
for _, it := range items {
if it.Subtitle != "" {
return true
}
}
return false
}
func columnHasData(items []Item, key string) bool {
for _, it := range items {
if CellText(it, key) != "" {
return true
}
}
return false
}
func discoverRawKeys(items []Item, seen map[string]bool, limit int) []string {
counts := map[string]int{}
for _, it := range items {
if it.Raw == nil {
continue
}
for k := range it.Raw {
if seen[k] || k == "id" || k == "title" {
continue
}
counts[k]++
}
}
type kv struct {
k string
n int
}
var ranked []kv
for k, n := range counts {
ranked = append(ranked, kv{k, n})
}
sort.Slice(ranked, func(i, j int) bool {
if ranked[i].n == ranked[j].n {
return ranked[i].k < ranked[j].k
}
return ranked[i].n > ranked[j].n
})
out := make([]string, 0, limit)
for _, r := range ranked {
if len(out) >= limit {
break
}
out = append(out, r.k)
}
return out
}
func titleLabel(key string) string {
if key == "" {
return ""
}
parts := strings.Split(key, "_")
for i, p := range parts {
if p == "" {
continue
}
parts[i] = strings.ToUpper(p[:1]) + p[1:]
}
return strings.Join(parts, " ")
}
func defaultWidth(key string) int {
switch key {
case "id", "status", "stage":
return 10
case "start", "end", "deadline", "date":
return 18
case "from", "to", "email", "primaryEmail":
return 22
case "description", "displayName":
return 24
default:
return 14
}
}
func sizeColumns(cols []TableColumn, items []Item) {
for i := range cols {
maxW := runeLen(cols[i].Title)
for _, it := range items {
n := runeLen(CellText(it, cols[i].Key))
if n > maxW {
maxW = n
}
}
maxW += 2
if maxW < 4 {
maxW = 4
}
if maxW > 36 {
maxW = 36
}
cols[i].Width = maxW
}
}
func runeLen(s string) int {
return len([]rune(s))
}
func formatAny(v any) string {
if v == nil {
return ""
}
switch x := v.(type) {
case string:
return x
case bool:
if x {
return "yes"
}
return "no"
case float64:
if x == float64(int64(x)) {
return fmt.Sprintf("%d", int64(x))
}
return fmt.Sprintf("%g", x)
default:
return fmt.Sprint(v)
}
}
+55
View File
@@ -0,0 +1,55 @@
package model
import "testing"
func TestBuildProjectColumns(t *testing.T) {
items := []Item{{
ID: "1", Title: "Alpha", Kind: KindProject,
Raw: map[string]any{
"taskCount": 3, "taskCountTotal": 5,
"documentsCount": 2, "participantCount": 4,
},
}}
cols := BuildColumns(SubjectProjects, items)
want := []string{"_sel", "id", "title", "tasks", "documents", "users"}
if len(cols) != len(want) {
t.Fatalf("got %d columns, want %d", len(cols), len(want))
}
for i, key := range want {
if cols[i].Key != key {
t.Fatalf("col[%d]=%q want %q", i, cols[i].Key, key)
}
}
for _, c := range cols {
if c.Key == "status" || c.Key == "subtitle" {
t.Fatalf("unexpected column %q", c.Key)
}
}
}
func TestFormatProjectTasksOpenClosed(t *testing.T) {
raw := map[string]any{"taskCount": 3, "taskCountTotal": 8}
if got := formatProjectTasks(raw); got != "3/5" {
t.Fatalf("got %q want 3/5", got)
}
}
func TestCellTextProjectDocumentsUsers(t *testing.T) {
it := Item{Raw: map[string]any{"documentsCount": 7, "participantCount": 2}}
if got := CellText(it, "documents"); got != "7" {
t.Fatalf("documents: %q", got)
}
if got := CellText(it, "users"); got != "2" {
t.Fatalf("users: %q", got)
}
}
func TestBuildColumnsIncludesSelectionAndCoreFields(t *testing.T) {
items := []Item{
{ID: "1", Title: "Alpha", Kind: KindTask, Raw: map[string]any{"status": "Open"}},
}
cols := BuildColumns(SubjectTasks, items)
if cols[0].Key != "_sel" {
t.Fatalf("first column should be selection, got %q", cols[0].Key)
}
}
+115
View File
@@ -0,0 +1,115 @@
package model
import "fmt"
// FormFields holds editable/display fields loaded from API detail.
type FormFields struct {
PrimaryLabel string
SecondaryLabel string
Primary string
Secondary string
ReadOnly bool
}
// KindHeading returns a short label for the detail pane header.
func KindHeading(kind Kind, id string) string {
return fmt.Sprintf("%s %s", kindLabel(kind), id)
}
func kindLabel(kind Kind) string {
switch kind {
case KindProject:
return "Project"
case KindTask:
return "Task"
case KindCRMTask:
return "CRM task"
case KindContact:
return "Contact"
case KindOpportunity:
return "Deal"
case KindCase:
return "Case"
case KindMail:
return "Mail"
case KindEvent:
return "Event"
case KindCalendar:
return "Calendar"
case KindFile:
return "Document"
case KindUser:
return "User"
default:
return string(kind)
}
}
// FormFieldsFromRaw maps API detail JSON to form fields for the right pane.
func FormFieldsFromRaw(kind Kind, raw map[string]any) FormFields {
switch kind {
case KindMail:
body := strRaw(raw, "body")
if body == "" {
body = strRaw(raw, "htmlBody")
}
return FormFields{
PrimaryLabel: "Subject", SecondaryLabel: "Body",
Primary: strRaw(raw, "subject"), Secondary: body,
ReadOnly: true,
}
case KindUser:
return FormFields{
PrimaryLabel: "Name", SecondaryLabel: "Email",
Primary: strRaw(raw, "displayName"), Secondary: strRaw(raw, "email"),
ReadOnly: true,
}
case KindContact:
name := strRaw(raw, "displayName")
if name == "" {
name = strRaw(raw, "title")
}
return FormFields{
PrimaryLabel: "Name", SecondaryLabel: "About",
Primary: name, Secondary: strRaw(raw, "about"),
ReadOnly: true,
}
case KindEvent, KindCalendar:
return FormFields{
PrimaryLabel: "Title", SecondaryLabel: "Description",
Primary: strRaw(raw, "title"), Secondary: strRaw(raw, "description"),
ReadOnly: true,
}
default:
title := strRaw(raw, "title")
if title == "" {
title = strRaw(raw, "name")
}
if title == "" {
title = strRaw(raw, "subject")
}
return FormFields{
PrimaryLabel: "Title", SecondaryLabel: "Description",
Primary: title, Secondary: strRaw(raw, "description"),
ReadOnly: kind != KindTask && kind != KindProject,
}
}
}
func strRaw(m map[string]any, key string) string {
if m == nil {
return ""
}
if v, ok := m[key].(string); ok {
return v
}
if m[key] == nil {
return ""
}
return fmt.Sprint(m[key])
}
// IsDocumentKind is true when the right pane should show file content, not a form.
func IsDocumentKind(kind Kind) bool {
return kind == KindFile
}
+20
View File
@@ -0,0 +1,20 @@
package model
import "testing"
func TestFormFieldsFromRawMail(t *testing.T) {
raw := map[string]any{"subject": "Hi", "body": "Hello"}
f := FormFieldsFromRaw(KindMail, raw)
if f.Primary != "Hi" || f.Secondary != "Hello" || !f.ReadOnly {
t.Fatalf("unexpected mail fields: %+v", f)
}
}
func TestIsDocumentKind(t *testing.T) {
if !IsDocumentKind(KindFile) {
t.Fatal("file should be document")
}
if IsDocumentKind(KindTask) {
t.Fatal("task should be form")
}
}
+2 -2
View File
@@ -69,7 +69,7 @@ func TestPrevFocusPane(t *testing.T) {
func TestActionsForContact(t *testing.T) {
acts := model.ActionsFor(model.KindContact)
if len(acts) < 2 {
t.Fatalf("expected view+delete, got %d", len(acts))
if len(acts) != 1 || acts[0].ID != model.ActionDelete {
t.Fatalf("expected delete only, got %v", acts)
}
}
@@ -151,6 +151,31 @@ func TestIntegrationPreviewTaskFromAPI(t *testing.T) {
}
}
func TestIntegrationPreviewFileFromAPI(t *testing.T) {
skipWithoutLiveAPI(t)
if os.Getenv("ONLYOFFICE_PROJECT_ID") == "" {
t.Skip("ONLYOFFICE_PROJECT_ID not set")
}
loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectProjectFiles})
if err != nil {
t.Fatal(err)
}
if len(items) == 0 {
t.Skip("no project files")
}
md, err := loader.PreviewMarkdown(ctx, items[0])
if err != nil {
t.Fatalf("PreviewMarkdown: %v", err)
}
if md == "" {
t.Fatal("empty preview")
}
if !strings.Contains(md, items[0].Title) && !strings.Contains(md, "|") && !strings.Contains(md, "```") {
t.Fatalf("unexpected file preview for %q:\n%s", items[0].Title, md)
}
}
func TestIntegrationPreviewCSVFromDownloadedFile(t *testing.T) {
skipWithoutLiveAPI(t)
if os.Getenv("ONLYOFFICE_PROJECT_ID") == "" {
+373 -222
View File
@@ -2,7 +2,6 @@ package ui
import (
"context"
"fmt"
"strings"
"github.com/charmbracelet/bubbles/viewport"
@@ -11,7 +10,6 @@ import (
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/eslider/go-onlyoffice/cmd/office/fetch"
"github.com/eslider/go-onlyoffice/cmd/office/model"
"github.com/eslider/go-onlyoffice/cmd/office/preview"
)
type listLoadedMsg struct {
@@ -20,9 +18,18 @@ type listLoadedMsg struct {
err error
}
type previewLoadedMsg struct {
text string
err error
type detailLoadedMsg struct {
item model.Item
document bool
fields model.FormFields
markdown string
err error
}
type detailSavedMsg struct {
item model.Item
title, description string
err error
}
type actionDoneMsg struct {
@@ -44,7 +51,6 @@ type Model struct {
hasList bool
items []model.Item
selection *model.Selection
listIdx int
focus model.FocusPane
width int
height int
@@ -52,29 +58,36 @@ type Model struct {
err string
loading bool
menuVP viewport.Model
listVP viewport.Model
previewVP viewport.Model
previewMD string
// Action menu overlay on list pane
actionMode bool
actionIdx int
itemActions []model.ItemAction
listTable DataTable
detail DetailPane
showMenu bool
showList bool
showDetail bool
}
// NewModel constructs the TUI with an authenticated client.
func NewModel(client *onlyoffice.Client) Model {
return Model{
h, w := 24, 80
m := Model{
client: client,
loader: &fetch.Loader{Client: client},
nav: model.DefaultNavTree(),
selection: model.NewSelection(),
focus: model.FocusMenu,
status: "Tab/Shift+Tab: pane · Enter: open/activate · a: actions · q: quit",
status: "Tab: pane · 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.showMenu, m.showList, m.showDetail = true, true, true
m.menuVP.MouseWheelEnabled = true
return m
}
func (m Model) Init() tea.Cmd {
return m.loadNavProjectsCmd()
return tea.Batch(m.loadNavProjectsCmd(), m.detail.BlinkCmd())
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -92,8 +105,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.status = helpText()
return m, nil
}
if m.actionMode && m.focus == model.FocusList {
return m.handleActionKey(key)
if m.scrollFocusedPane(key) {
return m, nil
}
if m.focus == model.FocusPreview {
if cmd, handled := m.handleDetailKey(key, msg); handled {
return m, cmd
}
}
action := KeyAction(key, m.focus)
if action == ActionNone && (key == "up" || key == "k") {
@@ -103,36 +121,75 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case ActionQuit:
return m, tea.Quit
case ActionNextPane:
m.focus = model.NextFocusPane(m.focus)
m.focus = NextVisibleFocus(m.focus, m.paneVis())
m.syncPaneFocus()
return m, nil
case ActionPrevPane:
m.focus = model.PrevFocusPane(m.focus)
m.focus = PrevVisibleFocus(m.focus, m.paneVis())
m.syncPaneFocus()
return m, nil
case ActionMoveUp:
if m.focus == model.FocusPreview {
return m.handleDetailMove(-1)
}
m.moveUp()
m.syncAllContent()
return m, nil
m.syncFocusedPane()
return m, m.onListRowChanged()
case ActionMoveDown:
if m.focus == model.FocusPreview {
return m.handleDetailMove(1)
}
m.moveDown()
m.syncAllContent()
m.syncFocusedPane()
return m, m.onListRowChanged()
case ActionMoveLeft:
if m.focus == model.FocusPreview && m.detail.Zone() == detailZoneActions {
m.detail.MoveAction(-1)
return m, nil
}
if m.focus == model.FocusList && m.hasList {
m.listTable.MoveCol(-1)
}
return m, nil
case ActionMoveRight:
if m.focus == model.FocusPreview && m.detail.Zone() == detailZoneActions {
m.detail.MoveAction(1)
return m, nil
}
if m.focus == model.FocusList && m.hasList {
m.listTable.MoveCol(1)
}
return m, nil
case ActionSort:
if m.focus == model.FocusList && m.hasList {
m.listTable.ToggleSort()
}
return m, nil
case ActionToggleSelect:
if m.hasList {
m.selection.Toggle(&m.items, m.listIdx)
m.syncListContent()
idx := m.listTable.ItemIndex()
if idx >= 0 {
m.selection.Toggle(&m.items, idx)
m.listTable.UpdateItems(m.items)
}
}
return m, nil
case ActionOpenActions:
if m.hasList && len(m.items) > 0 {
m.openActionMenu()
m.syncListContent()
case ActionFocusDetail:
if m.hasList && m.showDetail {
m.focus = model.FocusPreview
m.syncPaneFocus()
m.detail.FocusContent()
return m, m.onListRowChanged()
}
return m, nil
case ActionOpenPreview:
if m.hasList {
m.selection.Toggle(&m.items, m.listIdx)
return m, m.loadPreviewCmd()
}
case ActionToggleMenuPane:
m.togglePane(1)
return m, nil
case ActionToggleListPane:
m.togglePane(2)
return m, nil
case ActionToggleDetailPane:
m.togglePane(3)
return m, nil
case ActionRefresh:
if m.hasList {
@@ -162,11 +219,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
}
if m.focus == model.FocusList && key == "enter" && m.hasList {
m.openActionMenu()
m.syncListContent()
return m, nil
}
case listLoadedMsg:
m.loading = false
@@ -174,29 +226,56 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.err = msg.err.Error()
m.items = nil
m.hasList = false
m.listTable.Clear()
m.detail.Clear()
} else {
m.err = ""
m.items = msg.items
m.listSpec = msg.spec
m.hasList = true
m.listIdx = 0
m.listTable.SetData(m.listSpec, m.items)
}
m.syncListContent()
return m, nil
return m, m.onListRowChanged()
case previewLoadedMsg:
case detailLoadedMsg:
m.loading = false
if msg.err != nil {
m.previewMD = fmt.Sprintf("# Error\n\n%s\n", msg.err.Error())
m.err = msg.err.Error()
m.detail.Clear()
} else {
m.previewMD = msg.text
pw := m.paneLayout()
w := pw.Detail - 4
if w < 20 {
w = 20
}
if msg.document {
m.detail.LoadDocument(msg.item, msg.markdown, w)
} else {
m.detail.LoadForm(msg.item, msg.fields)
}
m.detail.SetFocused(m.focus == model.FocusPreview)
m.err = ""
}
return m, nil
case detailSavedMsg:
m.loading = false
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.detail.SetFocused(m.focus == model.FocusPreview)
m.status = "Saved"
m.err = ""
}
m.syncPreviewContent()
return m, nil
case actionDoneMsg:
m.loading = false
m.actionMode = false
if msg.err != nil {
m.err = msg.err.Error()
} else {
@@ -215,6 +294,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.syncMenuContent()
}
return m, nil
case tea.MouseMsg:
var cmd tea.Cmd
switch m.focus {
case model.FocusMenu:
m.menuVP, cmd = m.menuVP.Update(msg)
case model.FocusList:
cmd = m.listTable.Update(msg)
case model.FocusPreview:
cmd = m.detail.Update(msg)
}
return m, cmd
}
var cmd tea.Cmd
@@ -222,22 +313,72 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case model.FocusMenu:
m.menuVP, cmd = m.menuVP.Update(msg)
case model.FocusList:
m.listVP, cmd = m.listVP.Update(msg)
cmd = m.listTable.Update(msg)
case model.FocusPreview:
m.previewVP, cmd = m.previewVP.Update(msg)
cmd = m.detail.Update(msg)
}
return m, cmd
}
func (m *Model) handleDetailKey(key string, msg tea.KeyMsg) (tea.Cmd, bool) {
switch key {
case "tab", "shift+tab", "backtab":
m.detail.ToggleZone()
return nil, true
case "ctrl+s":
m.loading = true
return m.saveDetailCmd(), true
case "enter":
if m.detail.Zone() == detailZoneActions {
act, ok := m.detail.SelectedAction()
if !ok {
return nil, true
}
m.loading = true
if act.ID == model.ActionSave {
return m.saveDetailCmd(), true
}
return m.executeActionCmd(act.ID, m.detail.Item()), true
}
}
if m.detail.Zone() == detailZoneContent {
return m.detail.Update(msg), true
}
return nil, false
}
func (m Model) handleDetailMove(delta int) (Model, tea.Cmd) {
if m.detail.Zone() == detailZoneActions {
m.detail.MoveAction(delta)
return m, nil
}
if delta < 0 {
m.detail.form.FocusPrev()
} else {
m.detail.form.FocusNext()
}
return m, nil
}
func (m Model) View() string {
if m.width == 0 {
return "Loading…\n"
}
menuW, listW, prevW := LayoutWidths(m.width)
h := m.height - 2
menuStyle := paneStyle(m.focus == model.FocusMenu).Width(menuW).Height(h)
listStyle := paneStyle(m.focus == model.FocusList).Width(listW).Height(h)
prevStyle := paneStyle(m.focus == model.FocusPreview).Width(prevW).Height(h)
pw := m.paneLayout()
h := m.paneHeight() + 2
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()))
}
if pw.Visibility.List {
listStyle := paneStyle(m.focus == model.FocusList).Width(pw.List).Height(h)
parts = append(parts, listStyle.Render(m.listTable.View()))
}
if pw.Visibility.Detail {
prevStyle := paneStyle(m.focus == model.FocusPreview).Width(pw.Detail).Height(h)
parts = append(parts, prevStyle.Render(m.detail.View()))
}
status := m.status
if m.loading {
@@ -248,93 +389,98 @@ func (m Model) View() string {
}
bar := lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(status)
return lipgloss.JoinHorizontal(lipgloss.Top,
menuStyle.Render(m.menuVP.View()),
listStyle.Render(m.listVP.View()),
prevStyle.Render(m.previewVP.View()),
) + "\n" + bar
return lipgloss.JoinHorizontal(lipgloss.Top, parts...) + "\n" + bar
}
func (m Model) paneVis() PaneVisibility {
return PaneVisibility{Menu: m.showMenu, List: m.showList, Detail: m.showDetail}
}
func (m Model) paneLayout() PaneWidths {
return LayoutWidths(m.width, m.paneVis())
}
func (m *Model) syncPaneFocus() {
m.listTable.SetFocused(m.focus == model.FocusList && m.showList)
m.detail.SetFocused(m.focus == model.FocusPreview && m.showDetail)
}
func (m *Model) togglePane(which int) {
switch which {
case 1:
m.showMenu = !m.showMenu
case 2:
m.showList = !m.showList
case 3:
m.showDetail = !m.showDetail
}
if !m.showMenu && !m.showList && !m.showDetail {
switch which {
case 1:
m.showMenu = true
case 2:
m.showList = true
case 3:
m.showDetail = true
}
}
if !paneVisible(m.focus, m.paneVis()) {
m.focus = firstVisibleFocus(m.paneVis())
}
m.layoutViewports()
m.syncAllContent()
m.syncPaneFocus()
}
func (m Model) withList(spec model.ListSpec) (Model, tea.Cmd) {
m.listSpec = spec
m.selection.Clear()
m.listIdx = 0
m.hasList = false
m.actionMode = false
m.focus = model.FocusList
m.listTable.SetFocused(true)
m.detail.Clear()
m.loading = true
return m, m.loadListCmd(spec)
}
func (m *Model) openActionMenu() {
if m.listIdx < 0 || m.listIdx >= len(m.items) {
return
}
m.itemActions = model.ActionsFor(m.items[m.listIdx].Kind)
m.actionIdx = 0
m.actionMode = true
}
func (m *Model) handleActionKey(key string) (tea.Model, tea.Cmd) {
switch key {
case "esc":
m.actionMode = false
m.syncListContent()
return m, nil
case "up", "k":
if m.actionIdx > 0 {
m.actionIdx--
}
m.syncListContent()
return m, nil
case "down", "j":
if m.actionIdx < len(m.itemActions)-1 {
m.actionIdx++
}
m.syncListContent()
return m, nil
case "enter":
if m.listIdx < 0 || m.listIdx >= len(m.items) || m.actionIdx >= len(m.itemActions) {
return m, nil
}
act := m.itemActions[m.actionIdx]
item := m.items[m.listIdx]
m.actionMode = false
if act.ID == model.ActionView {
return m, m.loadPreviewCmd()
}
m.loading = true
return m, m.executeActionCmd(act.ID, item)
}
return m, nil
}
func (m *Model) layoutViewports() {
menuW, listW, prevW := LayoutWidths(m.width)
h := m.height - 4
if h < 4 {
h = 4
pw := m.paneLayout()
h := m.paneHeight()
if pw.Visibility.Menu {
m.menuVP.Width = m.paneInnerWidth(pw.Menu)
m.menuVP.Height = h
}
inner := func(w int) int {
if w > 4 {
return w - 2
}
return w
if pw.Visibility.List {
m.listTable.SetSize(m.paneInnerWidth(pw.List), h)
}
if pw.Visibility.Detail {
m.detail.SetSize(m.paneInnerWidth(pw.Detail), h)
}
m.menuVP = viewport.New(inner(menuW), h)
m.listVP = viewport.New(inner(listW), h)
m.previewVP = viewport.New(inner(prevW), h)
}
func (m *Model) syncAllContent() {
m.syncMenuContent()
m.syncListContent()
m.syncPreviewContent()
m.syncListTable()
}
func (m *Model) syncListTable() {
if !m.hasList {
m.listTable.Clear()
return
}
m.listTable.UpdateItems(m.items)
}
func (m *Model) syncMenuContent() {
var b strings.Builder
b.WriteString("Navigation\n\n")
menuW := m.menuVP.Width
if menuW < 10 {
menuW = 20
}
selectedStyle := lipgloss.NewStyle().
Background(lipgloss.Color("62")).
Foreground(lipgloss.Color("255"))
for i := 0; i < m.nav.VisibleCount(); i++ {
n, _ := m.nav.NodeAtVisible(i)
depth := m.nav.DepthAtVisible(i)
@@ -350,9 +496,7 @@ func (m *Model) syncMenuContent() {
line = prefix + "• " + n.Label
}
if i == m.nav.Cursor() && m.focus == model.FocusMenu {
line = "> " + line
} else {
line = " " + line
line = selectedStyle.Width(menuW).Render(truncateRunes(line, menuW))
}
b.WriteString(line + "\n")
}
@@ -360,78 +504,63 @@ func (m *Model) syncMenuContent() {
syncVPToLine(&m.menuVP, m.nav.Cursor()+2)
}
func (m *Model) syncListContent() {
var b strings.Builder
if !m.hasList {
b.WriteString("List\n\n")
b.WriteString("Select a leaf node in the tree\n")
b.WriteString("(marked with •) and press Enter.\n")
} else {
fmt.Fprintf(&b, "%s (%d)\n\n", m.listSpec.Subject, len(m.items))
if m.actionMode {
item := m.items[m.listIdx]
fmt.Fprintf(&b, "Actions for: %s\n\n", item.Title)
for i, act := range m.itemActions {
cursor := " "
if i == m.actionIdx {
cursor = "> "
}
label := act.Label
if act.Danger {
label = "⚠ " + label
}
fmt.Fprintf(&b, "%s%s\n", cursor, label)
}
b.WriteString("\nEnter: run · Esc: cancel\n")
} else {
for i, it := range m.items {
mark := "[ ]"
if it.Selected {
mark = "[x]"
}
cursor := " "
if i == m.listIdx && m.focus == model.FocusList {
cursor = "> "
}
line := fmt.Sprintf("%s%s %s", cursor, mark, it.Title)
if it.Subtitle != "" {
line += " — " + it.Subtitle
}
b.WriteString(line + "\n")
}
if len(m.items) == 0 && !m.loading {
b.WriteString("(empty)\n")
}
b.WriteString("\nEnter/a: actions · Space: select\n")
}
func truncateRunes(s string, max int) string {
if max <= 0 {
return s
}
m.listVP.SetContent(b.String())
line := m.listIdx + 3
if m.actionMode {
line = m.actionIdx + 4
r := []rune(s)
if len(r) <= max {
return s
}
syncVPToLine(&m.listVP, line)
if max <= 1 {
return string(r[:max])
}
return string(r[:max-1]) + "…"
}
func (m *Model) syncPreviewContent() {
menuW, listW, prevW := LayoutWidths(m.width)
_ = menuW
_ = listW
w := prevW - 4
if w < 20 {
w = 20
func (m *Model) syncFocusedPane() {
switch m.focus {
case model.FocusMenu:
m.syncMenuContent()
case model.FocusList:
m.syncListTable()
}
text, err := preview.RenderMarkdown(m.previewMD, w)
if err != nil {
text = m.previewMD
}
func (m *Model) moveUp() {
switch m.focus {
case model.FocusMenu:
m.nav.MoveUp()
case model.FocusList:
if m.hasList {
m.listTable.MoveRow(-1)
}
}
}
func (m *Model) moveDown() {
switch m.focus {
case model.FocusMenu:
m.nav.MoveDown()
case model.FocusList:
if m.hasList {
m.listTable.MoveRow(1)
}
}
m.previewVP.SetContent(text)
}
func syncVPToLine(vp *viewport.Model, line int) {
if line < 0 {
line = 0
}
max := vp.TotalLineCount()
if max == 0 {
vp.YOffset = 0
return
}
if line >= max {
line = max - 1
}
if line < vp.YOffset {
vp.YOffset = line
} else if line >= vp.YOffset+vp.Height {
@@ -440,31 +569,12 @@ func syncVPToLine(vp *viewport.Model, line int) {
if vp.YOffset < 0 {
vp.YOffset = 0
}
}
func (m *Model) moveUp() {
switch m.focus {
case model.FocusMenu:
m.nav.MoveUp()
case model.FocusList:
if m.hasList && !m.actionMode && m.listIdx > 0 {
m.listIdx--
}
case model.FocusPreview:
m.previewVP.LineUp(1)
maxOff := max - vp.Height
if maxOff < 0 {
maxOff = 0
}
}
func (m *Model) moveDown() {
switch m.focus {
case model.FocusMenu:
m.nav.MoveDown()
case model.FocusList:
if m.hasList && !m.actionMode && m.listIdx < len(m.items)-1 {
m.listIdx++
}
case model.FocusPreview:
m.previewVP.LineDown(1)
if vp.YOffset > maxOff {
vp.YOffset = maxOff
}
}
@@ -476,22 +586,6 @@ func (m *Model) loadListCmd(spec model.ListSpec) tea.Cmd {
}
}
func (m *Model) loadPreviewCmd() tea.Cmd {
if m.listIdx < 0 || m.listIdx >= len(m.items) {
return nil
}
item := m.items[m.listIdx]
return func() tea.Msg {
ctx := context.Background()
raw, err := m.loader.Detail(ctx, item)
if err != nil {
return previewLoadedMsg{err: err}
}
md := preview.EntityMarkdown(string(item.Kind), raw)
return previewLoadedMsg{text: md}
}
}
func (m *Model) executeActionCmd(action model.ActionID, item model.Item) tea.Cmd {
return func() tea.Msg {
ctx := context.Background()
@@ -508,6 +602,63 @@ func (m *Model) loadNavProjectsCmd() tea.Cmd {
}
}
func (m *Model) onListRowChanged() tea.Cmd {
if !m.hasList {
m.detail.Clear()
return nil
}
idx := m.listTable.ItemIndex()
if idx < 0 || idx >= len(m.items) {
m.detail.Clear()
return nil
}
it := m.items[idx]
if m.detail.LoadedID() == it.ID {
return nil
}
m.loading = true
return m.loadDetailCmd(it)
}
func (m *Model) loadDetailCmd(item model.Item) tea.Cmd {
return func() tea.Msg {
ctx := context.Background()
if model.IsDocumentKind(item.Kind) {
md, err := m.loader.PreviewMarkdown(ctx, item)
return detailLoadedMsg{item: item, document: true, markdown: md, err: err}
}
fields, err := m.loader.DetailForm(ctx, item)
return detailLoadedMsg{item: item, fields: fields, err: err}
}
}
func (m *Model) saveDetailCmd() tea.Cmd {
item := m.detail.Item()
title := m.detail.form.Primary()
desc := m.detail.form.Secondary()
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}
}
}
func (m *Model) updateItemAfterSave(item model.Item, title, description string) {
for i := range m.items {
if m.items[i].ID != item.ID {
continue
}
m.items[i].Title = title
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)
return
}
}
func paneStyle(focused bool) lipgloss.Style {
s := lipgloss.NewStyle().Padding(0, 1)
if focused {
@@ -517,5 +668,5 @@ func paneStyle(focused bool) lipgloss.Style {
}
func helpText() string {
return "Tab/Shift+Tab: pane · ↑↓/jk: scroll · Enter: open leaf/actions · Space: select · a: actions · r: refresh · q: quit"
return "Alt+1/2/3: toggle panes · Tab: focus · v: detail · Ctrl+S: save · q: quit"
}
+9 -12
View File
@@ -17,26 +17,23 @@ func TestKeyActionShiftTabPrevPane(t *testing.T) {
if got := ui.KeyAction("shift+tab", model.FocusList); got != ui.ActionPrevPane {
t.Fatalf("got %v", got)
}
if got := ui.KeyAction("backtab", model.FocusPreview); got != ui.ActionPrevPane {
t.Fatalf("got %v", got)
}
func TestKeyActionTabOnPreviewDoesNotSwitchPane(t *testing.T) {
if got := ui.KeyAction("tab", model.FocusPreview); got != ui.ActionNone {
t.Fatalf("tab on preview should stay in pane, got %v", got)
}
}
func TestKeyActionTabNextPane(t *testing.T) {
if got := ui.KeyAction("tab", model.FocusList); got != ui.ActionNextPane {
t.Fatalf("got %v", got)
}
}
func TestKeyActionOpenActions(t *testing.T) {
if got := ui.KeyAction("a", model.FocusList); got != ui.ActionOpenActions {
func TestKeyActionSortAndColumns(t *testing.T) {
if got := ui.KeyAction("s", model.FocusList); got != ui.ActionSort {
t.Fatalf("got %v", got)
}
}
func TestLayoutWidths(t *testing.T) {
menu, list, preview := ui.LayoutWidths(120)
if menu+list+preview > 120 {
menu, list, preview := ui.LayoutWidthsLegacy(120)
if menu+list+preview != 120 {
t.Fatalf("widths exceed total: %d+%d+%d", menu, list, preview)
}
}
+280
View File
@@ -0,0 +1,280 @@
package ui
import (
"strings"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/eslider/go-onlyoffice/cmd/office/model"
"github.com/eslider/go-onlyoffice/cmd/office/preview"
)
const detailContentRatio = 0.72
type detailMode int
const (
detailEmpty detailMode = iota
detailDocument
detailForm
)
type detailZone int
const (
detailZoneContent detailZone = iota
detailZoneActions
)
// DetailPane is the right column: content top, CRUD actions bottom.
type DetailPane struct {
mode detailMode
item model.Item
loadedID string
actions []model.ItemAction
actionIdx int
zone detailZone
focused bool
width int
height int
form EntityForm
docVP viewport.Model
docText string
styles detailStyles
}
type detailStyles struct {
sep lipgloss.Style
action lipgloss.Style
actionOn lipgloss.Style
actionDn lipgloss.Style
empty lipgloss.Style
}
func newDetailPane() DetailPane {
d := DetailPane{
form: newEntityForm(),
docVP: viewport.New(40, 10),
styles: newDetailStyles(),
}
d.docVP.MouseWheelEnabled = true
return d
}
func newDetailStyles() detailStyles {
btn := lipgloss.NewStyle().Padding(0, 1).MarginRight(1)
return detailStyles{
sep: lipgloss.NewStyle().Foreground(lipgloss.Color("238")),
action: btn.Foreground(lipgloss.Color("252")).Background(lipgloss.Color("236")),
actionOn: btn.Bold(true).Foreground(lipgloss.Color("255")).Background(lipgloss.Color("62")),
actionDn: btn.Foreground(lipgloss.Color("255")).Background(lipgloss.Color("52")),
empty: lipgloss.NewStyle().Foreground(lipgloss.Color("241")),
}
}
func (d *DetailPane) Clear() {
d.mode = detailEmpty
d.item = model.Item{}
d.loadedID = ""
d.actions = nil
d.actionIdx = 0
d.docText = ""
d.form.Clear()
d.docVP.SetContent("")
}
func (d *DetailPane) SetFocused(on bool) {
d.focused = on
d.form.SetFocused(on && d.zone == detailZoneContent && d.mode == detailForm)
}
func (d *DetailPane) SetSize(w, h int) {
if w < 8 {
w = 8
}
if h < 8 {
h = 8
}
d.width = w
d.height = h
contentH, _ := d.splitHeights()
d.form.SetSize(w, contentH)
d.docVP.Width = w
d.docVP.Height = contentH
}
func (d *DetailPane) splitHeights() (contentH, actionH int) {
actionH = int(float64(d.height) * (1 - detailContentRatio))
if actionH < 3 {
actionH = 3
}
if actionH > 6 {
actionH = 6
}
contentH = d.height - actionH - 1
if contentH < 4 {
contentH = 4
}
return contentH, actionH
}
func (d *DetailPane) LoadForm(item model.Item, fields model.FormFields) {
d.mode = detailForm
d.item = item
d.loadedID = item.ID
d.actions = model.ActionsFor(item.Kind)
d.actionIdx = 0
d.form.Load(item.Kind, item.ID, fields)
d.layoutContent()
}
func (d *DetailPane) LoadDocument(item model.Item, markdown string, renderWidth int) {
d.mode = detailDocument
d.item = item
d.loadedID = item.ID
d.actions = model.ActionsFor(item.Kind)
d.actionIdx = 0
d.form.Clear()
text, err := preview.RenderMarkdown(markdown, renderWidth)
if err != nil {
text = markdown
}
d.docText = text
d.docVP.SetContent(text)
d.layoutContent()
}
func (d *DetailPane) layoutContent() {
contentH, _ := d.splitHeights()
d.form.SetSize(d.width, contentH)
d.docVP.Width = d.width
d.docVP.Height = contentH
}
func (d *DetailPane) LoadedID() string { return d.loadedID }
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)
}
func (d *DetailPane) ToggleZone() {
if d.zone == detailZoneContent {
d.FocusActions()
} else {
d.FocusContent()
}
}
func (d *DetailPane) MoveAction(delta int) {
if len(d.actions) == 0 {
return
}
d.actionIdx = clampInt(d.actionIdx+delta, 0, len(d.actions)-1)
}
func (d *DetailPane) SelectedAction() (model.ItemAction, bool) {
if d.actionIdx < 0 || d.actionIdx >= len(d.actions) {
return model.ItemAction{}, false
}
return d.actions[d.actionIdx], true
}
func (d *DetailPane) Update(msg tea.Msg) tea.Cmd {
if !d.focused {
return nil
}
if d.zone == detailZoneContent {
switch d.mode {
case detailForm:
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()
}
}
var cmd tea.Cmd
d.docVP, cmd = d.docVP.Update(msg)
return cmd
}
}
return nil
}
func (d DetailPane) View() string {
if d.mode == detailEmpty {
return d.styles.empty.Render("Select a row to preview or edit.")
}
contentH, actionH := d.splitHeights()
var top string
switch d.mode {
case detailForm:
top = lipgloss.NewStyle().Width(d.width).Height(contentH).Render(d.form.View())
case detailDocument:
top = d.docVP.View()
default:
top = ""
}
sep := d.styles.sep.Width(d.width).Render(strings.Repeat("─", max(1, d.width)))
bottom := lipgloss.NewStyle().Width(d.width).Height(actionH).Render(d.renderActions())
return lipgloss.JoinVertical(lipgloss.Left, top, sep, bottom)
}
func (d DetailPane) renderActions() string {
if len(d.actions) == 0 {
hint := "No actions"
if d.focused && d.zone == detailZoneActions {
hint = lipgloss.NewStyle().Background(lipgloss.Color("238")).Render(" No actions ")
}
return d.styles.empty.Render(hint)
}
parts := make([]string, 0, len(d.actions))
for i, act := range d.actions {
style := d.styles.action
if d.focused && d.zone == detailZoneActions && i == d.actionIdx {
if act.Danger {
style = d.styles.actionDn
} else {
style = d.styles.actionOn
}
} else if act.Danger {
style = d.styles.actionDn
}
parts = append(parts, style.Render(act.Label))
}
line := strings.Join(parts, "")
if d.focused && d.zone == detailZoneActions {
line += d.styles.empty.Render(" ←/→ select · Enter run")
}
return line
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func (d DetailPane) BlinkCmd() tea.Cmd {
return entityFormBlinkCmd()
}
// NewDetailPaneForTest exposes detail pane for tests.
func NewDetailPaneForTest() DetailPane {
return newDetailPane()
}
+49
View File
@@ -0,0 +1,49 @@
package ui_test
import (
"strings"
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
"github.com/eslider/go-onlyoffice/cmd/office/ui"
)
func TestEntityFormLoadAndView(t *testing.T) {
form := ui.NewEntityFormForTest()
form.SetSize(40, 16)
form.Load(model.KindTask, "42", model.FormFields{
PrimaryLabel: "Title", SecondaryLabel: "Description",
Primary: "Fix bug", Secondary: "Details here",
})
view := form.View()
if !strings.Contains(view, "Fix bug") {
t.Fatal("view missing title")
}
}
func TestDetailPaneFormAndActions(t *testing.T) {
d := ui.NewDetailPaneForTest()
d.SetSize(50, 20)
d.SetFocused(true)
d.LoadForm(model.Item{ID: "1", Kind: model.KindTask, Title: "T"}, model.FormFields{
PrimaryLabel: "Title", SecondaryLabel: "Description",
Primary: "Hello", Secondary: "World",
})
view := d.View()
if !strings.Contains(view, "Save") {
t.Fatal("expected Save action button")
}
if !strings.Contains(view, "Hello") {
t.Fatal("expected form content")
}
}
func TestDetailPaneDocumentMode(t *testing.T) {
d := ui.NewDetailPaneForTest()
d.SetSize(50, 20)
d.LoadDocument(model.Item{ID: "9", Kind: model.KindFile, Title: "a.txt"}, "# Doc\n\nbody", 40)
view := d.View()
if !strings.Contains(view, "Download") {
t.Fatal("expected Download action")
}
}
+223
View File
@@ -0,0 +1,223 @@
package ui
import (
"strings"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/textarea"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
type entityField int
const (
entityFieldPrimary entityField = iota
entityFieldSecondary
)
// 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
primary textinput.Model
secondary textarea.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
}
func newEntityFormStyles() entityFormStyles {
return 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")),
}
}
func newEntityForm() EntityForm {
primary := textinput.New()
primary.CharLimit = 512
primary.Prompt = "> "
secondary := textarea.New()
secondary.ShowLineNumbers = false
secondary.CharLimit = 8000
secondary.Prompt = "> "
return EntityForm{
primary: primary,
secondary: secondary,
field: entityFieldPrimary,
styles: newEntityFormStyles(),
}
}
func (f *EntityForm) Active() bool { return f.active }
func (f *EntityForm) ItemID() string { return f.itemID }
func (f *EntityForm) Primary() string { return f.primary.Value() }
func (f *EntityForm) Secondary() string { return f.secondary.Value() }
func (f *EntityForm) MarkClean() { f.dirty = false }
func (f *EntityForm) Load(kind model.Kind, itemID string, fields model.FormFields) {
f.active = true
f.kind = kind
f.itemID = itemID
f.primaryLabel = fields.PrimaryLabel
f.secondaryLabel = fields.SecondaryLabel
f.readOnly = fields.ReadOnly
f.primary.SetValue(fields.Primary)
f.secondary.SetValue(fields.Secondary)
f.dirty = false
f.field = entityFieldPrimary
f.applyFocus()
f.layoutFields()
}
func (f *EntityForm) Clear() {
f.active = false
f.itemID = ""
f.primary.SetValue("")
f.secondary.SetValue("")
f.dirty = false
f.primary.Blur()
f.secondary.Blur()
}
func (f *EntityForm) SetFocused(on bool) {
f.focused = on && f.active
if f.focused {
f.applyFocus()
} else {
f.primary.Blur()
f.secondary.Blur()
}
}
func (f *EntityForm) SetSize(w, h int) {
if w < 12 {
w = 12
}
if h < 6 {
h = 6
}
f.width = w
f.height = h
f.layoutFields()
}
func (f *EntityForm) layoutFields() {
if f.width == 0 {
return
}
inner := f.width - 2
if inner < 8 {
inner = 8
}
f.primary.Width = inner
secH := f.height - 6
if secH < 3 {
secH = 3
}
f.secondary.SetWidth(inner)
f.secondary.SetHeight(secH)
}
func (f *EntityForm) FocusNext() {
if f.field < entityFieldSecondary {
f.field++
}
f.applyFocus()
}
func (f *EntityForm) FocusPrev() {
if f.field > entityFieldPrimary {
f.field--
}
f.applyFocus()
}
func (f *EntityForm) applyFocus() {
if f.readOnly {
f.primary.Blur()
f.secondary.Blur()
return
}
switch f.field {
case entityFieldPrimary:
f.primary.Focus()
f.secondary.Blur()
default:
f.primary.Blur()
f.secondary.Focus()
}
}
func (f *EntityForm) Update(msg tea.Msg) tea.Cmd {
if !f.active || !f.focused || f.readOnly {
return nil
}
var cmd tea.Cmd
switch f.field {
case entityFieldPrimary:
f.primary, cmd = f.primary.Update(msg)
default:
f.secondary, cmd = f.secondary.Update(msg)
}
if _, ok := msg.(tea.KeyMsg); ok {
f.dirty = true
}
return cmd
}
func (f EntityForm) View() string {
if !f.active {
return ""
}
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 {
pLabel = f.styles.labelAct.Render(f.primaryLabel)
}
sLabel := f.styles.label.Render(f.secondaryLabel)
if f.focused && f.field == entityFieldSecondary && !f.readOnly {
sLabel = f.styles.labelAct.Render(f.secondaryLabel)
}
ro := ""
if f.readOnly {
ro = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(" (read-only)") + "\n"
}
return strings.Join([]string{
header + ro,
pLabel,
f.primary.View(),
"",
sLabel,
f.secondary.View(),
}, "\n")
}
func entityFormBlinkCmd() tea.Cmd {
return tea.Batch(textinput.Blink, textarea.Blink)
}
// NewEntityFormForTest exposes a form for unit tests.
func NewEntityFormForTest() EntityForm {
return newEntityForm()
}
+33 -33
View File
@@ -9,14 +9,18 @@ const (
ActionNone Action = iota
ActionMoveUp
ActionMoveDown
ActionMoveLeft
ActionMoveRight
ActionSort
ActionToggleSelect
ActionOpenPreview
ActionOpenActions
ActionFocusDetail
ActionToggleMenuPane
ActionToggleListPane
ActionToggleDetailPane
ActionNextPane
ActionPrevPane
ActionRefresh
ActionQuit
ActionOpenVex
)
// KeyAction maps a key string and focused pane to an action.
@@ -25,53 +29,49 @@ func KeyAction(key string, pane model.FocusPane) Action {
case "q", "ctrl+c":
return ActionQuit
case "tab":
return ActionNextPane
if pane != model.FocusPreview {
return ActionNextPane
}
case "shift+tab", "backtab":
return ActionPrevPane
if pane != model.FocusPreview {
return ActionPrevPane
}
case "r":
return ActionRefresh
case "up", "k":
return ActionMoveUp
case "down", "j":
return ActionMoveDown
case "s":
if pane == model.FocusList {
return ActionSort
}
case "left", "h":
return ActionMoveLeft
case "right", "l":
return ActionMoveRight
case " ":
if pane == model.FocusList {
return ActionToggleSelect
}
case "a":
case "v", "p":
if pane == model.FocusList {
return ActionOpenActions
}
case "enter":
if pane == model.FocusList {
return ActionOpenPreview
}
case "v":
if pane == model.FocusList {
return ActionOpenVex
return ActionFocusDetail
}
case "alt+1":
return ActionToggleMenuPane
case "alt+2":
return ActionToggleListPane
case "alt+3":
return ActionToggleDetailPane
}
return ActionNone
}
// LayoutWidths splits total terminal width into menu, list, preview columns.
func LayoutWidths(total int) (menu, list, preview int) {
if total < 80 {
total = 80
}
menu = total / 5
if menu < 22 {
menu = 22
}
preview = total / 3
if preview < 28 {
preview = 28
}
list = total - menu - preview - 2
if list < 24 {
list = 24
}
return menu, list, preview
// LayoutWidthsLegacy is kept for tests that expect the old signature.
func LayoutWidthsLegacy(total int) (menu, list, preview int) {
pw := LayoutWidths(total, defaultPaneVisibility())
return pw.Menu, pw.List, pw.Detail
}
// ResolveMoveUp returns the action for upward navigation keys.
+129
View File
@@ -0,0 +1,129 @@
package ui
import "github.com/eslider/go-onlyoffice/cmd/office/model"
// PaneVisibility controls which columns are shown.
type PaneVisibility struct {
Menu, List, Detail bool
}
// PaneWidths is outer lipgloss width per column (sums to terminal width).
type PaneWidths struct {
Menu, List, Detail int
Visibility PaneVisibility
}
func defaultPaneVisibility() PaneVisibility {
return PaneVisibility{Menu: true, List: true, Detail: true}
}
// LayoutWidths splits total terminal width evenly across visible panes.
func LayoutWidths(total int, vis PaneVisibility) PaneWidths {
if total < 1 {
total = 80
}
v := vis
n := countVisible(v)
if n == 0 {
v = defaultPaneVisibility()
n = 3
}
base := total / n
rem := total % n
out := PaneWidths{Visibility: v}
if v.Menu {
out.Menu = base
if rem > 0 {
out.Menu++
rem--
}
}
if v.List {
out.List = base
if rem > 0 {
out.List++
rem--
}
}
if v.Detail {
out.Detail = base
if rem > 0 {
out.Detail++
rem--
}
}
return out
}
func countVisible(v PaneVisibility) int {
n := 0
if v.Menu {
n++
}
if v.List {
n++
}
if v.Detail {
n++
}
return n
}
// NextVisibleFocus cycles focus forward, skipping hidden panes.
func NextVisibleFocus(cur model.FocusPane, vis PaneVisibility) model.FocusPane {
order := []model.FocusPane{model.FocusMenu, model.FocusList, model.FocusPreview}
start := 0
for i, p := range order {
if p == cur {
start = i
break
}
}
for step := 1; step <= len(order); step++ {
p := order[(start+step)%len(order)]
if paneVisible(p, vis) {
return p
}
}
return cur
}
// PrevVisibleFocus cycles focus backward, skipping hidden panes.
func PrevVisibleFocus(cur model.FocusPane, vis PaneVisibility) model.FocusPane {
order := []model.FocusPane{model.FocusMenu, model.FocusList, model.FocusPreview}
start := 0
for i, p := range order {
if p == cur {
start = i
break
}
}
for step := 1; step <= len(order); step++ {
p := order[(start-step+len(order))%len(order)]
if paneVisible(p, vis) {
return p
}
}
return cur
}
func paneVisible(p model.FocusPane, vis PaneVisibility) bool {
switch p {
case model.FocusMenu:
return vis.Menu
case model.FocusList:
return vis.List
default:
return vis.Detail
}
}
func firstVisibleFocus(vis PaneVisibility) model.FocusPane {
if vis.Menu {
return model.FocusMenu
}
if vis.List {
return model.FocusList
}
return model.FocusPreview
}
+69
View File
@@ -0,0 +1,69 @@
package ui
import (
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestLayoutWidthsAllVisibleUsesFullWidth(t *testing.T) {
vis := PaneVisibility{Menu: true, List: true, Detail: true}
pw := LayoutWidths(120, vis)
sum := pw.Menu + pw.List + pw.Detail
if sum != 120 {
t.Fatalf("sum=%d want 120", sum)
}
}
func TestLayoutWidthsSinglePane(t *testing.T) {
pw := LayoutWidths(100, PaneVisibility{Menu: false, List: true, Detail: false})
if pw.List != 100 {
t.Fatalf("list=%d want 100", pw.List)
}
if pw.Menu != 0 || pw.Detail != 0 {
t.Fatalf("unexpected widths: %+v", pw)
}
}
func TestLayoutWidthsTwoPanes(t *testing.T) {
pw := LayoutWidths(80, PaneVisibility{Menu: true, List: false, Detail: true})
if pw.Menu+pw.Detail != 80 {
t.Fatalf("sum=%d want 80", pw.Menu+pw.Detail)
}
}
func TestNextVisibleFocusSkipsHidden(t *testing.T) {
vis := PaneVisibility{Menu: false, List: true, Detail: true}
if got := NextVisibleFocus(model.FocusList, vis); got != model.FocusPreview {
t.Fatalf("got %v", got)
}
if got := NextVisibleFocus(model.FocusPreview, vis); got != model.FocusList {
t.Fatalf("got %v", got)
}
}
func TestPrevVisibleFocusSkipsHidden(t *testing.T) {
vis := PaneVisibility{Menu: true, List: false, Detail: true}
if got := PrevVisibleFocus(model.FocusPreview, vis); got != model.FocusMenu {
t.Fatalf("got %v", got)
}
}
func TestLayoutWidthsLegacyHelper(t *testing.T) {
menu, list, preview := LayoutWidthsLegacy(120)
if menu+list+preview != 120 {
t.Fatalf("widths exceed total")
}
}
func TestKeyActionTogglePanes(t *testing.T) {
if got := KeyAction("alt+1", model.FocusList); got != ActionToggleMenuPane {
t.Fatalf("got %v", got)
}
if got := KeyAction("alt+2", model.FocusMenu); got != ActionToggleListPane {
t.Fatalf("got %v", got)
}
if got := KeyAction("alt+3", model.FocusList); got != ActionToggleDetailPane {
t.Fatalf("got %v", got)
}
}
+67
View File
@@ -0,0 +1,67 @@
package ui
import (
"github.com/charmbracelet/bubbles/viewport"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func (m *Model) scrollFocusedPane(key string) bool {
switch m.focus {
case model.FocusMenu:
return scrollViewport(&m.menuVP, key)
case model.FocusList:
switch key {
case "pgdown", "pgdn", "f", "ctrl+d":
m.listTable.PageScroll(1)
return true
case "pgup", "b", "ctrl+u":
m.listTable.PageScroll(-1)
return true
}
return false
case model.FocusPreview:
if m.detail.Zone() != detailZoneContent || m.detail.mode != detailDocument {
return false
}
return scrollViewport(&m.detail.docVP, key)
default:
return false
}
}
func (m *Model) paneHeight() int {
h := m.height - 3
if h < 3 {
h = 3
}
return h
}
func (m *Model) paneInnerWidth(outer int) int {
if outer <= 0 {
return 8
}
w := outer - 2 // border only; content fills inner box
if w < 8 {
w = 8
}
return w
}
func scrollViewport(vp *viewport.Model, key string) bool {
switch key {
case "pgdown", "pgdn", "f", "ctrl+d":
vp.ViewDown()
return true
case "pgup", "b", "ctrl+u":
vp.ViewUp()
return true
case "home", "g":
vp.GotoTop()
return true
case "end", "G":
vp.GotoBottom()
return true
}
return false
}
+485
View File
@@ -0,0 +1,485 @@
package ui
import (
"fmt"
"sort"
"strings"
"github.com/charmbracelet/bubbles/viewport"
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 {
title lipgloss.Style
header lipgloss.Style
headerSort lipgloss.Style
cell lipgloss.Style
rowActive lipgloss.Style
colActive lipgloss.Style
cellActive lipgloss.Style
rowSelect lipgloss.Style
cellSelect lipgloss.Style
help lipgloss.Style
}
func defaultTableStyles() tableStyles {
return tableStyles{
title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("252")),
header: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255")).Background(lipgloss.Color("236")).Padding(0, 1),
headerSort: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("229")).Background(lipgloss.Color("62")).Padding(0, 1),
cell: lipgloss.NewStyle().Foreground(lipgloss.Color("252")).Padding(0, 1),
rowActive: lipgloss.NewStyle().Foreground(lipgloss.Color("255")).Background(lipgloss.Color("238")),
colActive: lipgloss.NewStyle().Foreground(lipgloss.Color("255")).Background(lipgloss.Color("237")),
cellActive: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255")).Background(lipgloss.Color("62")),
rowSelect: lipgloss.NewStyle().Foreground(lipgloss.Color("255")).Background(lipgloss.Color("22")),
cellSelect: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255")).Background(lipgloss.Color("34")),
help: lipgloss.NewStyle().Foreground(lipgloss.Color("241")),
}
}
// DataTable is a spreadsheet-style listContent table for the list pane.
type DataTable struct {
spec model.ListSpec
columns []model.TableColumn
items []model.Item
order []int
cursorRow int
cursorCol int
colScroll int
sortCol int
sortAsc bool
focused bool
ready bool
width int
height int
viewport viewport.Model
styles tableStyles
}
func newDataTable() DataTable {
t := DataTable{
sortCol: -1,
styles: defaultTableStyles(),
viewport: viewport.New(40, 10),
}
t.viewport.MouseWheelEnabled = true
return t
}
func (t *DataTable) SetSize(w, h int) {
if w < 8 {
w = 8
}
if h < 3 {
h = 3
}
t.width = w
t.height = h
t.viewport.Width = w
t.viewport.Height = h - 2 // title + header
if t.viewport.Height < 1 {
t.viewport.Height = 1
}
t.refreshViewport()
}
func (t *DataTable) SetFocused(f bool) {
t.focused = f
t.refreshViewport()
}
func (t *DataTable) SetData(spec model.ListSpec, items []model.Item) {
t.spec = spec
t.items = items
t.columns = model.BuildColumns(spec.Subject, items)
t.order = make([]int, len(items))
for i := range t.order {
t.order[i] = i
}
t.cursorRow = 0
t.cursorCol = 0
t.colScroll = 0
t.ready = true
if t.sortCol >= 0 {
t.applySort()
}
t.clampCursor()
t.refreshViewport()
}
func (t *DataTable) Clear() {
t.ready = false
t.items = nil
t.order = nil
t.columns = nil
t.cursorRow = 0
t.cursorCol = 0
t.viewport.SetContent("")
}
func (t *DataTable) ItemIndex() int {
if t.cursorRow < 0 || t.cursorRow >= len(t.order) {
return -1
}
return t.order[t.cursorRow]
}
func (t *DataTable) MoveRow(delta int) {
if !t.ready || len(t.order) == 0 {
return
}
prev := t.cursorRow
t.cursorRow = clampInt(t.cursorRow+delta, 0, len(t.order)-1)
t.syncRowScroll(prev)
t.refreshViewport()
}
func (t *DataTable) MoveCol(delta int) {
if !t.ready || len(t.columns) == 0 {
return
}
t.cursorCol = clampInt(t.cursorCol+delta, 0, len(t.columns)-1)
t.ensureColVisible()
t.refreshViewport()
}
func (t *DataTable) ToggleSort() {
if !t.ready || len(t.columns) == 0 || len(t.order) == 0 {
return
}
if t.sortCol == t.cursorCol {
t.sortAsc = !t.sortAsc
} else {
t.sortCol = t.cursorCol
t.sortAsc = true
}
t.applySort()
t.refreshViewport()
}
func (t *DataTable) applySort() {
if t.sortCol < 0 || t.sortCol >= len(t.columns) {
return
}
key := t.columns[t.sortCol].Key
sort.SliceStable(t.order, func(i, j int) bool {
a := model.CellText(t.items[t.order[i]], key)
b := model.CellText(t.items[t.order[j]], key)
if t.sortAsc {
return strings.ToLower(a) < strings.ToLower(b)
}
return strings.ToLower(a) > strings.ToLower(b)
})
}
func (t *DataTable) PageScroll(delta int) {
if delta > 0 {
t.viewport.ViewDown()
} else {
t.viewport.ViewUp()
}
}
func (t *DataTable) Update(msg tea.Msg) tea.Cmd {
if !t.focused || !t.ready {
return nil
}
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "pgdown", "pgdn", "f", "ctrl+d":
t.viewport.ViewDown()
case "pgup", "b", "ctrl+u":
t.viewport.ViewUp()
}
case tea.MouseMsg:
var cmd tea.Cmd
t.viewport, cmd = t.viewport.Update(msg)
return cmd
}
return nil
}
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)))
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))
}
header := t.renderHeader()
body := t.viewport.View()
content := lipgloss.JoinVertical(lipgloss.Left, title+sortHint, header, body)
return lipgloss.NewStyle().Width(t.width).Render(content)
}
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":
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
}
}
func (t *DataTable) renderHeader() string {
indices, widths := t.visibleLayout()
cells := make([]string, 0, len(indices))
for _, colIdx := range indices {
col := t.columns[colIdx]
w := widths[colIdx]
title := col.Title
if t.sortCol == colIdx {
if t.sortAsc {
title += " ▲"
} else {
title += " ▼"
}
}
text := runewidth.Truncate(title, w, "…")
style := t.styles.header
if t.focused && colIdx == t.cursorCol {
style = t.styles.headerSort
}
cells = append(cells, style.Width(w).MaxWidth(w).Render(text))
}
return lipgloss.JoinHorizontal(lipgloss.Left, cells...)
}
func (t *DataTable) refreshViewport() {
if !t.ready {
return
}
rows := make([]string, 0, len(t.order))
for row := 0; row < len(t.order); row++ {
rows = append(rows, t.renderRow(row))
}
t.viewport.SetContent(strings.Join(rows, "\n"))
t.syncRowScroll(t.cursorRow)
}
func (t *DataTable) renderRow(row int) string {
item := t.items[t.order[row]]
selected := item.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))
}
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)
isRow := row == t.cursorRow
isCol := col == t.cursorCol
isCell := t.focused && isRow && isCol
switch {
case selected && isCell:
return t.styles.cellSelect.Width(width).MaxWidth(width).Render(text)
case selected && isRow:
return t.styles.rowSelect.Width(width).MaxWidth(width).Render(text)
case selected:
return t.styles.rowSelect.Width(width).MaxWidth(width).Render(text)
case isCell:
return t.styles.cellActive.Width(width).MaxWidth(width).Render(text)
case t.focused && isRow:
return t.styles.rowActive.Width(width).MaxWidth(width).Render(text)
case t.focused && isCol:
return t.styles.colActive.Width(width).MaxWidth(width).Render(text)
default:
return base.Render(text)
}
}
func (t *DataTable) ensureColVisible() {
vis := t.pickVisibleColumnIndices()
if len(vis) == 0 {
return
}
first, last := vis[0], vis[len(vis)-1]
if t.cursorCol < first {
t.colScroll = t.cursorCol
}
if t.cursorCol > last {
t.colScroll = t.cursorCol
for t.cursorCol >= 0 {
vis = t.pickVisibleColumnIndices()
if len(vis) == 0 {
break
}
last = vis[len(vis)-1]
if t.cursorCol <= last {
break
}
if t.colScroll < len(t.columns)-1 {
t.colScroll++
} else {
break
}
}
}
}
func (t *DataTable) syncRowScroll(prev int) {
if t.cursorRow < t.viewport.YOffset {
t.viewport.YOffset = t.cursorRow
} else if t.cursorRow >= t.viewport.YOffset+t.viewport.Height {
t.viewport.YOffset = t.cursorRow - t.viewport.Height + 1
}
if t.viewport.YOffset < 0 {
t.viewport.YOffset = 0
}
maxOff := len(t.order) - t.viewport.Height
if maxOff < 0 {
maxOff = 0
}
if t.viewport.YOffset > maxOff {
t.viewport.YOffset = maxOff
}
_ = prev
}
func (t *DataTable) clampCursor() {
if len(t.order) == 0 {
t.cursorRow = 0
} else {
t.cursorRow = clampInt(t.cursorRow, 0, len(t.order)-1)
}
if len(t.columns) == 0 {
t.cursorCol = 0
} else {
t.cursorCol = clampInt(t.cursorCol, 0, len(t.columns)-1)
}
}
func clampInt(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
// tableKeyMatches helps avoid importing key in app.go for sort-only bindings.
func (t *DataTable) UpdateItems(items []model.Item) {
t.items = items
t.clampCursor()
t.refreshViewport()
}
+98
View File
@@ -0,0 +1,98 @@
package ui
import (
"strings"
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func sampleItems() []model.Item {
return []model.Item{
{ID: "2", Title: "Bravo", Subtitle: "b", Kind: model.KindTask, Raw: map[string]any{"status": "Open"}},
{ID: "1", Title: "Alpha", Subtitle: "a", Kind: model.KindTask, Raw: map[string]any{"status": "Done"}},
}
}
func TestDataTableSortByTitle(t *testing.T) {
tbl := newDataTable()
tbl.SetSize(80, 12)
tbl.SetData(model.ListSpec{Subject: model.SubjectTasks}, sampleItems())
tbl.cursorCol = indexOfColumn(tbl, "title")
tbl.ToggleSort()
if model.CellText(tbl.items[tbl.order[0]], "title") != "Alpha" {
t.Fatalf("expected Alpha first after sort, order=%v", tbl.order)
}
}
func TestDataTableMoveColScrolls(t *testing.T) {
tbl := newDataTable()
tbl.SetSize(24, 10)
items := make([]model.Item, 1)
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)
last := len(tbl.columns) - 1
tbl.cursorCol = last
tbl.ensureColVisible()
if tbl.colScroll == 0 && last > 2 {
t.Fatalf("expected horizontal scroll for wide table, colScroll=%d", tbl.colScroll)
}
}
func TestDataTableViewHighlightsActiveCell(t *testing.T) {
tbl := newDataTable()
tbl.SetSize(80, 12)
tbl.SetFocused(true)
tbl.SetData(model.ListSpec{Subject: model.SubjectTasks}, sampleItems())
tbl.cursorRow = 1
tbl.cursorCol = 2
view := tbl.View()
if !strings.Contains(view, "Alpha") {
t.Fatal("view missing row data")
}
if !strings.Contains(view, "Title") {
t.Fatal("view missing header")
}
}
func TestDataTableFillsPaneWidth(t *testing.T) {
tbl := newDataTable()
tbl.SetSize(80, 12)
tbl.SetData(model.ListSpec{Subject: model.SubjectTasks}, sampleItems())
indices, widths := tbl.visibleLayout()
sum := 0
for _, i := range indices {
sum += widths[i]
}
if sum != 80 {
t.Fatalf("visible columns width sum=%d want 80", sum)
}
}
func TestDistributeColumnWidthsFillsTotal(t *testing.T) {
cols := []model.TableColumn{
{Key: "_sel", Width: 3},
{Key: "id", Width: 10},
{Key: "title", Width: 20},
}
indices := []int{0, 1, 2}
w := distributeColumnWidths(33, 80, indices, cols)
sum := w[0] + w[1] + w[2]
if sum != 80 {
t.Fatalf("sum=%d want 80", sum)
}
if w[2] <= 20 {
t.Fatalf("title should expand, got %d", w[2])
}
}
func indexOfColumn(tbl DataTable, key string) int {
for i, c := range tbl.columns {
if c.Key == key {
return i
}
}
return 0
}