Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion internal/telemetry/catalogue_events.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion internal/ui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
69 changes: 63 additions & 6 deletions internal/ui/mouse.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const (
hitFeedBlobs
hitToast
hitAttachment
hitDivider
)

// hit is the result of hitTest. idx's meaning depends on zone: a tab index
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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()
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
}

Expand All @@ -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()
}

Expand All @@ -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()
}

Expand Down
43 changes: 43 additions & 0 deletions internal/ui/mouse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
2 changes: 2 additions & 0 deletions internal/ui/telemetrykeys.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,8 @@ func (z hitZone) telemetryTarget() string {
return "feed_blobs"
case hitToast:
return "toast"
case hitDivider:
return "pane_divider"
}
return "nothing"
}
Expand Down
37 changes: 17 additions & 20 deletions internal/ui/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down
Loading