Modal on Release
ci / test (push) Successful in 36s
ci / release (push) Successful in 1m27s

This commit is contained in:
2026-07-08 16:10:31 -04:00
parent 59e15cf69f
commit 422df7caa4
7 changed files with 183 additions and 56 deletions
BIN
View File
Binary file not shown.
+1
View File
@@ -208,6 +208,7 @@ func (m *Model) closeBlogReader() {
m.blogReading = false
m.blogPost = nil
m.blogViewport.GotoTop()
m.clearMousePress()
}
func (m *Model) requestBlogList() tea.Cmd {
+8
View File
@@ -159,6 +159,13 @@ type Model struct {
lastClickX int
lastClickY int
// mousePress* tracks press→release clicks so actions fire on mouse-up
// (normal click), not mouse-down (which feels like hold-to-activate).
mousePressActive bool
mousePressBtn tea.MouseButton
mousePressX int
mousePressY int
peerMesh *mesh.State
meshTickActive bool
feedMeshVisible bool
@@ -821,6 +828,7 @@ func (m Model) toggleMouse() (Model, tea.Cmd) {
func (m *Model) closeDetail() {
m.detailOpen = false
m.clearMousePress()
m.clearDetail()
}
+141 -38
View File
@@ -8,28 +8,60 @@ import (
const doubleClickWindow = 450 * time.Millisecond
func isLeftClick(ev tea.MouseEvent) bool {
return ev.Button == tea.MouseButtonLeft &&
(ev.Action == tea.MouseActionPress || ev.Action == tea.MouseActionRelease)
func isLeftButton(ev tea.MouseEvent) bool {
return ev.Button == tea.MouseButtonLeft
}
func isRightClick(ev tea.MouseEvent) bool {
return ev.Button == tea.MouseButtonRight &&
(ev.Action == tea.MouseActionPress || ev.Action == tea.MouseActionRelease)
func isRightButton(ev tea.MouseEvent) bool {
return ev.Button == tea.MouseButtonRight
}
func isPrimaryPress(ev tea.MouseEvent) bool {
return isLeftClick(ev) && ev.Action == tea.MouseActionPress
return isLeftButton(ev) && ev.Action == tea.MouseActionPress
}
func isPrimaryRelease(ev tea.MouseEvent) bool {
return isLeftClick(ev) && ev.Action == tea.MouseActionRelease
return isLeftButton(ev) && ev.Action == tea.MouseActionRelease
}
// isCloseClick accepts press or release so terminals that only emit one still work.
func isCloseClick(ev tea.MouseEvent) bool {
return (isLeftClick(ev) || isRightClick(ev)) &&
(ev.Action == tea.MouseActionPress || ev.Action == tea.MouseActionRelease)
func isRightPress(ev tea.MouseEvent) bool {
return isRightButton(ev) && ev.Action == tea.MouseActionPress
}
func isRightRelease(ev tea.MouseEvent) bool {
return isRightButton(ev) && ev.Action == tea.MouseActionRelease
}
func (m *Model) clearMousePress() {
m.mousePressActive = false
m.mousePressBtn = 0
m.mousePressX = 0
m.mousePressY = 0
}
func (m *Model) noteMousePress(ev tea.MouseEvent) {
m.mousePressActive = true
m.mousePressBtn = ev.Button
m.mousePressX = ev.X
m.mousePressY = ev.Y
}
// completedClick is true when this release finishes a press of the same button
// without a large drag (normal click, not hold-and-drag).
func (m *Model) completedClick(ev tea.MouseEvent) bool {
if !m.mousePressActive || m.mousePressBtn != ev.Button {
return false
}
dx := ev.X - m.mousePressX
dy := ev.Y - m.mousePressY
if dx < 0 {
dx = -dx
}
if dy < 0 {
dy = -dy
}
// Allow 1-cell jitter; larger movement = drag, ignore.
return dx <= 1 && dy <= 1
}
func (m *Model) handleMouse(msg tea.MouseMsg) []tea.Cmd {
@@ -69,63 +101,111 @@ func (m *Model) handleMouse(msg tea.MouseMsg) []tea.Cmd {
switch {
case ev.IsWheel():
m.clearMousePress()
cmds = append(cmds, m.handleMouseWheel(ev, ly)...)
case isPrimaryPress(ev):
cmds = append(cmds, m.handleMouseClick(ev, ly)...)
m.noteMousePress(ev)
case isRightClick(ev) && ev.Action == tea.MouseActionPress:
cmds = append(cmds, m.handleMouseRightClick(ev, ly)...)
case isPrimaryRelease(ev):
if m.completedClick(ev) {
cmds = append(cmds, m.handleMouseClick(ev, ly)...)
}
m.clearMousePress()
case isRightPress(ev):
m.noteMousePress(ev)
case isRightRelease(ev):
if m.completedClick(ev) {
cmds = append(cmds, m.handleMouseRightClick(ev, ly)...)
}
m.clearMousePress()
}
return cmds
}
func (m *Model) handleHelpMouse(ev tea.MouseEvent) []tea.Cmd {
if isCloseClick(ev) || ev.IsWheel() {
if ev.IsWheel() {
m.clearMousePress()
m.showHelp = false
return nil
}
if isPrimaryPress(ev) || isRightPress(ev) {
m.noteMousePress(ev)
return nil
}
if (isPrimaryRelease(ev) || isRightRelease(ev)) && m.completedClick(ev) {
m.showHelp = false
}
if isPrimaryRelease(ev) || isRightRelease(ev) {
m.clearMousePress()
}
return nil
}
func (m *Model) handleSearchMouse(ev tea.MouseEvent) []tea.Cmd {
if ev.IsWheel() {
m.clearMousePress()
return nil
}
if isCloseClick(ev) && !m.searchModalRect().Contains(ev.X, ev.Y) {
m.cancelSearch()
if isPrimaryPress(ev) || isRightPress(ev) {
m.noteMousePress(ev)
return nil
}
if !isPrimaryPress(ev) && !isPrimaryRelease(ev) {
return nil
if (isPrimaryRelease(ev) || isRightRelease(ev)) && m.completedClick(ev) {
if !m.searchModalRect().Contains(ev.X, ev.Y) {
m.cancelSearch()
m.clearMousePress()
return nil
}
}
var cmd tea.Cmd
m.searchInput, cmd = m.searchInput.Update(tea.MouseMsg(ev))
return []tea.Cmd{cmd}
if isPrimaryRelease(ev) || isRightRelease(ev) {
m.clearMousePress()
}
if isPrimaryRelease(ev) {
var cmd tea.Cmd
m.searchInput, cmd = m.searchInput.Update(tea.MouseMsg(ev))
return []tea.Cmd{cmd}
}
return nil
}
func (m *Model) handleOperatorMouse(ev tea.MouseEvent) []tea.Cmd {
if ev.IsWheel() {
m.clearMousePress()
return nil
}
if isCloseClick(ev) && !m.operatorModalRect().Contains(ev.X, ev.Y) {
m.cancelOperatorSignIn()
if isPrimaryPress(ev) || isRightPress(ev) {
m.noteMousePress(ev)
return nil
}
if !isPrimaryPress(ev) && !isPrimaryRelease(ev) {
return nil
if (isPrimaryRelease(ev) || isRightRelease(ev)) && m.completedClick(ev) {
if !m.operatorModalRect().Contains(ev.X, ev.Y) {
m.cancelOperatorSignIn()
m.clearMousePress()
return nil
}
}
var cmd tea.Cmd
if m.operatorField == operatorFieldSub {
m.operatorSubInput, cmd = m.operatorSubInput.Update(tea.MouseMsg(ev))
} else {
m.operatorEmailInput, cmd = m.operatorEmailInput.Update(tea.MouseMsg(ev))
if isPrimaryRelease(ev) || isRightRelease(ev) {
m.clearMousePress()
}
return []tea.Cmd{cmd}
if isPrimaryRelease(ev) {
var cmd tea.Cmd
if m.operatorField == operatorFieldSub {
m.operatorSubInput, cmd = m.operatorSubInput.Update(tea.MouseMsg(ev))
} else {
m.operatorEmailInput, cmd = m.operatorEmailInput.Update(tea.MouseMsg(ev))
}
return []tea.Cmd{cmd}
}
return nil
}
func (m *Model) handleDetailMouse(ev tea.MouseEvent) []tea.Cmd {
if ev.IsWheel() {
m.clearMousePress()
steps := 3
if ev.Shift {
steps = 6
@@ -138,7 +218,18 @@ func (m *Model) handleDetailMouse(ev tea.MouseEvent) []tea.Cmd {
return nil
}
if !isCloseClick(ev) {
// Press only arms the click — never close while the button is held down.
if isPrimaryPress(ev) || isRightPress(ev) {
m.noteMousePress(ev)
return nil
}
if !isPrimaryRelease(ev) && !isRightRelease(ev) {
return nil
}
armed := m.completedClick(ev)
m.clearMousePress()
if !armed {
return nil
}
@@ -158,6 +249,7 @@ func (m *Model) handleDetailMouse(ev tea.MouseEvent) []tea.Cmd {
func (m *Model) handleBlogReaderMouse(ev tea.MouseEvent) []tea.Cmd {
if ev.IsWheel() {
m.clearMousePress()
steps := 3
if ev.Shift {
steps = 6
@@ -169,13 +261,24 @@ func (m *Model) handleBlogReaderMouse(ev tea.MouseEvent) []tea.Cmd {
}
return nil
}
if isRightClick(ev) && (ev.Action == tea.MouseActionPress || ev.Action == tea.MouseActionRelease) {
if isPrimaryPress(ev) || isRightPress(ev) {
m.noteMousePress(ev)
return nil
}
if !isPrimaryRelease(ev) && !isRightRelease(ev) {
return nil
}
armed := m.completedClick(ev)
m.clearMousePress()
if !armed {
return nil
}
if isRightButton(ev) {
m.closeBlogReader()
return nil
}
if !isPrimaryPress(ev) {
return nil
}
ly := m.viewLayout()
if tab, ok := tabAt(ev.X, ev.Y, ly); ok {
m.closeBlogReader()
+31 -18
View File
@@ -35,6 +35,12 @@ func leftRelease(x, y int) tea.MouseMsg {
}
}
// clickOutsideDetail simulates a normal press→release click (not hold-to-activate).
func clickAt(m *Model, x, y int) {
m.handleMouse(leftPress(x, y))
m.handleMouse(leftRelease(x, y))
}
func TestDetailCloseHitRectContainsLabel(t *testing.T) {
m := testModel(120, 40)
m.detailOpen = true
@@ -49,7 +55,6 @@ func TestDetailCloseHitRectContainsLabel(t *testing.T) {
t.Fatalf("center not in close hit")
}
if !box.Contains(cx, cy) && !closeR.Expand(0).Contains(cx, cy) {
// Expanded close may extend 1 cell past border — still must close.
t.Logf("close center outside box (expanded hit ok): box=%+v close=%+v", box, closeR)
}
}
@@ -65,12 +70,25 @@ func TestDetailMouseClickOutsideCloses(t *testing.T) {
if box.Contains(ox, oy) {
t.Fatalf("expected outside point inside box")
}
m.handleDetailMouse(tea.MouseEvent(leftPress(ox, oy)))
clickAt(&m, ox, oy)
if m.detailOpen {
t.Fatal("expected detail closed on outside click")
}
}
func TestDetailMousePressAloneDoesNotClose(t *testing.T) {
m := testModel(120, 40)
m.detailOpen = true
box := m.detailModalRect()
m.handleMouse(leftPress(box.Left-2, box.Top))
if !m.detailOpen {
t.Fatal("press alone must not close (no hold-to-activate)")
}
if !m.mousePressActive {
t.Fatal("expected press armed")
}
}
func TestDetailMouseClickCloseButton(t *testing.T) {
m := testModel(120, 40)
m.detailOpen = true
@@ -80,27 +98,17 @@ func TestDetailMouseClickCloseButton(t *testing.T) {
closeR := m.detailCloseHitRect()
cx := closeR.Left + closeR.Width/2
cy := closeR.Top
m.handleDetailMouse(tea.MouseEvent(leftPress(cx, cy)))
clickAt(&m, cx, cy)
if m.detailOpen {
t.Fatal("expected detail closed on ✕ Close click")
}
}
func TestDetailMouseReleaseOutsideCloses(t *testing.T) {
m := testModel(120, 40)
m.detailOpen = true
box := m.detailModalRect()
m.handleDetailMouse(tea.MouseEvent(leftRelease(box.Left-1, box.Top)))
if m.detailOpen {
t.Fatal("expected detail closed on outside release")
}
}
func TestDetailMouseClickInsideDoesNotClose(t *testing.T) {
m := testModel(120, 40)
m.detailOpen = true
body := m.detailBodyHitRect()
m.handleDetailMouse(tea.MouseEvent(leftPress(body.Left+2, body.Top+2)))
clickAt(&m, body.Left+2, body.Top+2)
if !m.detailOpen {
t.Fatal("expected detail to stay open on body click")
}
@@ -113,7 +121,7 @@ func TestSearchMouseClickOutsideCancels(t *testing.T) {
t.Fatal("expected searching")
}
box := m.searchModalRect()
m.handleSearchMouse(tea.MouseEvent(leftPress(box.Left-2, box.Top)))
clickAt(&m, box.Left-2, box.Top)
if m.searching {
t.Fatal("expected search cancelled on outside click")
}
@@ -126,7 +134,7 @@ func TestOperatorMouseClickOutsideCancels(t *testing.T) {
t.Fatal("expected operator signing")
}
box := m.operatorModalRect()
m.handleOperatorMouse(tea.MouseEvent(leftPress(box.Left-2, box.Top)))
clickAt(&m, box.Left-2, box.Top)
if m.operatorSigning {
t.Fatal("expected operator cancelled on outside click")
}
@@ -141,8 +149,13 @@ func TestUpdateRoutesMouseToDetailClose(t *testing.T) {
next, _ := m.Update(leftPress(box.Left-2, box.Top))
mm := next.(Model)
if !mm.detailOpen {
t.Fatal("press alone must not close via Update")
}
next, _ = mm.Update(leftRelease(box.Left-2, box.Top))
mm = next.(Model)
if mm.detailOpen {
t.Fatal("Update should close detail via handleMouse (no early return)")
t.Fatal("release should close detail via handleMouse")
}
}
@@ -163,7 +176,7 @@ func TestSearchOperatorRectsMatchContentSize(t *testing.T) {
func TestHelpMouseDismisses(t *testing.T) {
m := testModel(80, 24)
m.showHelp = true
m.handleHelpMouse(tea.MouseEvent(leftPress(10, 10)))
clickAt(&m, 10, 10)
if m.showHelp {
t.Fatal("expected help dismissed")
}
+1
View File
@@ -48,6 +48,7 @@ func (m *Model) cancelOperatorSignIn() {
m.operatorSigning = false
m.operatorSubInput.Blur()
m.operatorEmailInput.Blur()
m.clearMousePress()
}
func (m *Model) updateOperatorSignIn(msg tea.KeyMsg) (Model, tea.Cmd) {
+1
View File
@@ -27,6 +27,7 @@ func (m *Model) cancelSearch() {
m.searching = false
m.searchInput.Blur()
m.searchInput.SetValue("")
m.clearMousePress()
}
func (m *Model) updateSearch(msg tea.KeyMsg) (Model, tea.Cmd) {