Modal on Release
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user