From 131744888d8f85c9395aa9a5bef86f94e4269b47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Corn=C3=A9=20Dorrestijn?= Date: Fri, 25 Sep 2026 09:14:43 +0200 Subject: [PATCH] ui: drag to resize the side panel Drag the border between the messages pane and the thread, reference or channel-info panel to resize it; double-click restores the half split. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/telemetry.md | 4 +- internal/telemetry/catalogue_events.go | 2 +- internal/ui/model.go | 9 +++- internal/ui/mouse.go | 69 +++++++++++++++++++++++--- internal/ui/mouse_test.go | 43 ++++++++++++++++ internal/ui/telemetrykeys.go | 2 + internal/ui/view.go | 37 +++++++------- 7 files changed, 136 insertions(+), 30 deletions(-) diff --git a/docs/telemetry.md b/docs/telemetry.md index f687cf2..b6c6711 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -895,8 +895,8 @@ which is how a binding nobody uses becomes visible. The clickable regions of the UI. `channel`, `composer`, `feed`, `feed_blobs`, `feed_mark_all`, `info`, -`jump_bottom`, `message`, `nothing`, `reference`, `search`, `sql`, `tab`, -`thread`, `toast` +`jump_bottom`, `message`, `nothing`, `pane_divider`, `reference`, `search`, +`sql`, `tab`, `thread`, `toast` ### Command palette ids diff --git a/internal/telemetry/catalogue_events.go b/internal/telemetry/catalogue_events.go index a5e197e..31c4724 100644 --- a/internal/telemetry/catalogue_events.go +++ b/internal/telemetry/catalogue_events.go @@ -129,7 +129,7 @@ var ActionIDs = []string{ var MouseTargets = []string{ "tab", "channel", "message", "thread", "feed", "search", "reference", "info", "sql", "composer", "jump_bottom", "feed_mark_all", "feed_blobs", "toast", - "nothing", + "pane_divider", "nothing", } // PaletteIDs are the ">" command-palette entries, by stable id rather than by diff --git a/internal/ui/model.go b/internal/ui/model.go index 2765703..cdb85f5 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -193,7 +193,14 @@ type Model struct { // box is in flight; the editor owns the selection itself (see editor.Model), // this only routes motion/release back to it. Lives in mouse.go. composerDrag bool - wrapIdx wrapCache + // sidePaneW is the right detail pane's width once its divider has been + // dragged (0 = half the right area); paneDrag is true while that drag is in + // flight; paneDragOff is the pressed column's distance left of the border, + // so grabbing either side of it doesn't make it jump. All live in mouse.go. + sidePaneW int + paneDrag bool + paneDragOff int + wrapIdx wrapCache // clickCount / lastClick* synthesise double- and triple-clicks: the terminal // only reports individual presses, so handleMouseClick counts presses landing // at (about) the same cell within multiClickInterval. 2 selects a word, 3 a diff --git a/internal/ui/mouse.go b/internal/ui/mouse.go index 866c90d..06902b5 100644 --- a/internal/ui/mouse.go +++ b/internal/ui/mouse.go @@ -41,6 +41,7 @@ const ( hitFeedBlobs hitToast hitAttachment + hitDivider ) // hit is the result of hitTest. idx's meaning depends on zone: a tab index @@ -214,6 +215,15 @@ func (m Model) handleMouseClick(msg tea.MouseClickMsg) (tea.Model, tea.Cmd) { // things. A no-op otherwise. recordClick(h.zone) switch h.zone { + case hitDivider: + m.clearTextSel() + if count == 2 { + // Double-click restores the default half/half split. + return m.setSidePaneW(0) + } + m.paneDrag = true + m.paneDragOff = m.width - m.splitRightPane(m.width-channelsWidth) - msg.X + return m, nil case hitTab: m.clearTextSel() return m.gotoTab(h.idx) @@ -394,6 +404,10 @@ func (m Model) handleMouseRelease(msg tea.MouseReleaseMsg) (tea.Model, tea.Cmd) if msg.Button != tea.MouseLeft { return m, nil } + if m.paneDrag { + m.paneDrag = false + return m, nil + } if m.composerDrag { // Leave the selection live so backspace/delete removes it and typing // replaces it — the point of selecting in an editable field. No clipboard @@ -443,6 +457,9 @@ func (m Model) handleMouseMotion(msg tea.MouseMotionMsg) (tea.Model, tea.Cmd) { m.setInfoHover(-1) return m, nil } + if m.paneDrag && msg.Button == tea.MouseLeft { + return m.setSidePaneW(m.width - msg.X - m.paneDragOff) + } if m.composerDrag && msg.Button == tea.MouseLeft { return m.dragComposerSel(msg.X, msg.Y) } @@ -796,6 +813,9 @@ func (m *Model) hitTest(x, y int) hit { if m.onSQLTab() { return m.hitSQLRow(x, y) } + if m.onPaneDivider(x) { + return hit{zone: hitDivider} + } // Read-only right panes (reference / channel-info) own their full column — // including the rows that sit at the same height as the composer under the // messages pane. Check them before inComposer so a link at the bottom of @@ -807,7 +827,7 @@ func (m *Model) hitTest(x, y int) hit { if rightW < 10 { rightW = 10 } - sideW := splitRightPane(rightW) + sideW := m.splitRightPane(rightW) if x >= channelsWidth+(rightW-sideW) { if m.refOpen { if h := m.hitRefContent(x, y); h.zone != hitNone { @@ -848,7 +868,7 @@ func (m *Model) hitTest(x, y int) hit { } msgsW := rightW if m.threadOpen { - threadW := splitRightPane(rightW) + threadW := m.splitRightPane(rightW) msgsW = rightW - threadW if x >= channelsWidth+msgsW { tx0, top, w, h, yoff := m.threadGeom() @@ -859,6 +879,43 @@ func (m *Model) hitTest(x, y int) hit { return m.hitViewportPost(x, y, top, mx0, w, h, yoff, focusMessages, m.msgRowStarts, len(m.posts)) } +// onPaneDivider reports whether column x is on the border between the messages +// pane and an open side pane — the side pane's left border or the messages +// pane's last column beside it — the handle a drag resizes the split with. +func (m *Model) onPaneDivider(x int) bool { + if !m.threadOpen && !m.refOpen && !m.infoOpen { + return false + } + rightW := m.width - channelsWidth + if rightW < 10 { + rightW = 10 + } + border := m.width - m.splitRightPane(rightW) + return x == border || x == border-1 +} + +// setSidePaneW sets the side pane's width (0 = default half split), clamped the +// way splitRightPane clamps it. Like a terminal resize drag, it re-lays-out every +// frame and defers the content re-render to a settle tick. +func (m Model) setSidePaneW(w int) (tea.Model, tea.Cmd) { + prev := m.sidePaneW + m.sidePaneW = w + if w > 0 { + rightW := m.width - channelsWidth + if rightW < 10 { + rightW = 10 + } + m.sidePaneW = m.splitRightPane(rightW) + } + if m.sidePaneW == prev { + return m, nil + } + m.layoutPanes() + m.resizeInput() + m.resizeGen++ + return m, resizeSettleCmd(m.resizeGen) +} + // hitChannel maps a screen row in the sidebar column to a visible-channel // index, reproducing renderChannelsPane's scroll window so the row the user // sees is the one selected. @@ -1344,7 +1401,7 @@ func (m *Model) composerGeom() (x0, top, width, height, yoff int) { if rightW < 10 { rightW = 10 } - msgsW := rightW - splitRightPane(rightW) + msgsW := rightW - m.splitRightPane(rightW) x0 = channelsWidth + msgsW + 1 } else { x0 = channelsWidth + 1 @@ -1589,7 +1646,7 @@ func (m *Model) threadGeom() (x0, top, width, height, yoff int) { if rightW < 10 { rightW = 10 } - msgsW := rightW - splitRightPane(rightW) + msgsW := rightW - m.splitRightPane(rightW) return channelsWidth + msgsW + 1, tabsHeight + 1, m.threadView.Width(), m.threadView.Height(), m.threadView.YOffset() } @@ -1600,7 +1657,7 @@ func (m *Model) refGeom() (x0, top, width, height, yoff int) { if rightW < 10 { rightW = 10 } - msgsW := rightW - splitRightPane(rightW) + msgsW := rightW - m.splitRightPane(rightW) return channelsWidth + msgsW + 1, tabsHeight + 1, m.refView.Width(), m.refView.Height(), m.refView.YOffset() } @@ -1612,7 +1669,7 @@ func (m *Model) infoGeom() (x0, top, width, height, yoff int) { if rightW < 10 { rightW = 10 } - msgsW := rightW - splitRightPane(rightW) + msgsW := rightW - m.splitRightPane(rightW) return channelsWidth + msgsW + 1, tabsHeight + 1, m.infoView.Width(), m.infoView.Height(), m.infoView.YOffset() } diff --git a/internal/ui/mouse_test.go b/internal/ui/mouse_test.go index 4ebc0c8..188d9e3 100644 --- a/internal/ui/mouse_test.go +++ b/internal/ui/mouse_test.go @@ -837,3 +837,46 @@ func TestInfoDragThenReleaseCopiesSelection(t *testing.T) { t.Fatalf("selectedText=%q want %q", got, needle) } } + +// TestDragPaneDivider: dragging the border between the messages pane and an +// open side pane resizes the side pane; a double-click restores the half split. +func TestDragPaneDivider(t *testing.T) { + m := mouseModel(nil) + m.infoOpen = true + rightW := m.width - channelsWidth + x := m.width - m.splitRightPane(rightW) + for _, dx := range []int{x - 1, x} { + if h := m.hitTest(dx, tabsHeight+2); h.zone != hitDivider { + t.Fatalf("hitTest(%d) = %v, want hitDivider", dx, h.zone) + } + } + next, _ := m.Update(click(tea.MouseLeft, x, tabsHeight+2)) + next, _ = next.(Model).Update(motion(tea.MouseLeft, x-5, tabsHeight+2)) + next, _ = next.(Model).Update(release(tea.MouseLeft, x-5, tabsHeight+2)) + m = next.(Model) + if m.paneDrag { + t.Error("paneDrag still set after release") + } + if got, want := m.splitRightPane(rightW), m.width-(x-5); got != want { + t.Errorf("side pane width = %d, want %d", got, want) + } + + // Dragging past the edge clamps to the minimum pane width. + next, _ = m.Update(click(tea.MouseLeft, x-5, tabsHeight+2)) + next, _ = next.(Model).Update(motion(tea.MouseLeft, m.width-1, tabsHeight+2)) + m = next.(Model) + if got := m.splitRightPane(rightW); got != threadPaneMinWidth { + t.Errorf("clamped width = %d, want %d", got, threadPaneMinWidth) + } + next, _ = m.Update(release(tea.MouseLeft, m.width-1, tabsHeight+2)) + m = next.(Model) + + x = m.width - threadPaneMinWidth + next, _ = m.Update(click(tea.MouseLeft, x, tabsHeight+2)) + next, _ = next.(Model).Update(release(tea.MouseLeft, x, tabsHeight+2)) + next, _ = next.(Model).Update(click(tea.MouseLeft, x, tabsHeight+2)) + m = next.(Model) + if m.sidePaneW != 0 { + t.Errorf("double-click left sidePaneW = %d, want 0", m.sidePaneW) + } +} diff --git a/internal/ui/telemetrykeys.go b/internal/ui/telemetrykeys.go index 2387de5..0985f05 100644 --- a/internal/ui/telemetrykeys.go +++ b/internal/ui/telemetrykeys.go @@ -193,6 +193,8 @@ func (z hitZone) telemetryTarget() string { return "feed_blobs" case hitToast: return "toast" + case hitDivider: + return "pane_divider" } return "nothing" } diff --git a/internal/ui/view.go b/internal/ui/view.go index c7bad24..f8d83d1 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -170,13 +170,13 @@ func (m *Model) layoutPanes() { // The thread sidebar, reference panel and channel-info panel share the single // right slot — at most one is ever open — so each splits rightW the same way. if m.threadOpen { - threadW = splitRightPane(rightW) + threadW = m.splitRightPane(rightW) msgsW = rightW - threadW } else if m.refOpen { - refW = splitRightPane(rightW) + refW = m.splitRightPane(rightW) msgsW = rightW - refW } else if m.infoOpen { - infoW = splitRightPane(rightW) + infoW = m.splitRightPane(rightW) msgsW = rightW - infoW } // The scrollbar overlays the right border column (rendered by @@ -292,11 +292,15 @@ func (m *Model) renderAllPanes() { const threadPaneMinWidth = 24 -// splitRightPane returns the width of the right detail pane (thread or Jira) -// when the right area is rightW wide: half, clamped so neither the detail pane -// nor the messages pane drops below threadPaneMinWidth. -func splitRightPane(rightW int) int { - w := rightW / 2 +// splitRightPane returns the width of the right detail pane (thread, reference +// or channel-info) when the right area is rightW wide: the width the divider +// was dragged to, else half, clamped so neither the detail pane nor the +// messages pane drops below threadPaneMinWidth. +func (m *Model) splitRightPane(rightW int) int { + w := m.sidePaneW + if w <= 0 { + w = rightW / 2 + } if w < threadPaneMinWidth { w = threadPaneMinWidth } @@ -315,14 +319,7 @@ func (m *Model) resizeInput() { if rightW < 10 { rightW = 10 } - threadW := rightW / 2 - if threadW < threadPaneMinWidth { - threadW = threadPaneMinWidth - } - if threadW > rightW-threadPaneMinWidth { - threadW = rightW - threadPaneMinWidth - } - w := threadW - 2 + w := m.splitRightPane(rightW) - 2 if w < 10 { w = 10 } @@ -336,7 +333,7 @@ func (m *Model) resizeInput() { if rightW < 10 { rightW = 10 } - w := rightW - splitRightPane(rightW) - 2 + w := rightW - m.splitRightPane(rightW) - 2 if w < 10 { w = 10 } @@ -1761,13 +1758,13 @@ func (m *Model) renderViewContent() string { refW := 0 infoW := 0 if m.threadOpen { - threadW = splitRightPane(rightW) + threadW = m.splitRightPane(rightW) msgsW = rightW - threadW } else if m.refOpen { - refW = splitRightPane(rightW) + refW = m.splitRightPane(rightW) msgsW = rightW - refW } else if m.infoOpen { - infoW = splitRightPane(rightW) + infoW = m.splitRightPane(rightW) msgsW = rightW - infoW } messagesPane := m.renderMessagesPane(bodyH, msgsW)