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>
332 lines
7.0 KiB
Go
332 lines
7.0 KiB
Go
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
|
|
entityFieldStatus
|
|
)
|
|
|
|
// EntityForm is the top section of the detail pane for editable entities.
|
|
type EntityForm struct {
|
|
active bool
|
|
kind model.Kind
|
|
itemID string
|
|
primaryLabel string
|
|
secondaryLabel string
|
|
readOnly bool
|
|
hasStatus bool
|
|
status model.ProjectLifecycle
|
|
responsibleID string
|
|
primary textinput.Model
|
|
secondary textarea.Model
|
|
field entityField
|
|
focused bool
|
|
dirty bool
|
|
width int
|
|
height int
|
|
styles entityFormStyles
|
|
}
|
|
|
|
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) 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 }
|
|
|
|
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.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()
|
|
}
|
|
|
|
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
|
|
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() {
|
|
last := entityFieldSecondary
|
|
if f.hasStatus {
|
|
last = entityFieldStatus
|
|
}
|
|
if f.field < last {
|
|
f.field++
|
|
}
|
|
f.applyFocus()
|
|
}
|
|
|
|
func (f *EntityForm) FocusPrev() {
|
|
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()
|
|
f.secondary.Blur()
|
|
return
|
|
}
|
|
switch f.field {
|
|
case entityFieldPrimary:
|
|
f.primary.Focus()
|
|
f.secondary.Blur()
|
|
case entityFieldSecondary:
|
|
f.primary.Blur()
|
|
f.secondary.Focus()
|
|
default:
|
|
f.primary.Blur()
|
|
f.secondary.Blur()
|
|
}
|
|
}
|
|
|
|
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)
|
|
case entityFieldSecondary:
|
|
f.secondary, cmd = f.secondary.Update(msg)
|
|
default:
|
|
return nil
|
|
}
|
|
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)
|
|
}
|
|
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"
|
|
}
|
|
return strings.Join([]string{
|
|
header + ro,
|
|
pLabel,
|
|
f.primary.View(),
|
|
"",
|
|
sLabel,
|
|
f.secondary.View(),
|
|
statusBlock,
|
|
}, "\n")
|
|
}
|
|
|
|
func entityFormBlinkCmd() tea.Cmd {
|
|
return tea.Batch(textinput.Blink, textarea.Blink)
|
|
}
|
|
|
|
// NewEntityFormForTest exposes a form for unit tests.
|
|
func NewEntityFormForTest() EntityForm {
|
|
return newEntityForm()
|
|
}
|