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>
204 lines
4.0 KiB
Go
204 lines
4.0 KiB
Go
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
|
|
}
|