66 "image/color"
77 "os"
88 "os/exec"
9+ "slices"
910 "strings"
1011 "time"
1112
@@ -254,6 +255,7 @@ func newModel(app *board.App, refresh chan struct{}) *model {
254255
255256// openDialog installs a dialog and runs its init command.
256257func (m * model ) openDialog (d dialog ) tea.Cmd {
258+ m .resetDrag () // the dialog captures the release: the drag cannot finish
257259 m .dialog = d
258260 return d .Init ()
259261}
@@ -269,6 +271,15 @@ func (m *model) reload() {
269271 m .projectColors [p .Name ] = projectColorAt (i )
270272 }
271273 m .clampSelection ()
274+ // A refresh can remove the dragged card or shrink the column list;
275+ // re-validate so a drop cannot act on stale state.
276+ if m .dragCardID != "" {
277+ if m .cardByID (m .dragCardID ) == nil {
278+ m .resetDrag ()
279+ } else if m .dragCol >= len (m .columns ) {
280+ m .dragCol = - 1
281+ }
282+ }
272283}
273284
274285// groupCards buckets cards by column, in board order. A card whose column
@@ -313,6 +324,18 @@ func (m *model) selectedCard() *board.Card {
313324 return cards [m .selRow ]
314325}
315326
327+ // cardByID finds a card anywhere on the board.
328+ func (m * model ) cardByID (id string ) * board.Card {
329+ for _ , cards := range m .cards {
330+ for _ , c := range cards {
331+ if c .ID == id {
332+ return c
333+ }
334+ }
335+ }
336+ return nil
337+ }
338+
316339func (m * model ) Init () tea.Cmd {
317340 return tea .Batch (m .waitRefresh (), m .scheduleTick ())
318341}
@@ -522,6 +545,10 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
522545}
523546
524547func (m * model ) handleKey (msg tea.KeyPressMsg ) (tea.Model , tea.Cmd ) {
548+ // A key press cancels any drag in progress (esc cancels a drag) and
549+ // keeps the state clean across paths that lose the release event, like
550+ // tea.ExecProcess (attach, shell) leaving mouse mode.
551+ m .resetDrag ()
525552 switch {
526553 case key .Matches (msg , keys .Quit ):
527554 return m , tea .Quit
@@ -712,12 +739,19 @@ func (m *model) handleClick(msg tea.MouseClickMsg) (tea.Model, tea.Cmd) {
712739}
713740
714741// handleMotion tracks a pressed card being dragged. Cell-motion mode only
715- // reports motion while a button is held, so any motion after a card press
742+ // reports motion while a button is held, so motion after a card press
716743// means a drag; the column under the pointer becomes the drop target.
717744func (m * model ) handleMotion (msg tea.MouseMotionMsg ) {
718745 if m .dragCardID == "" || msg .Button != tea .MouseLeft {
719746 return
720747 }
748+ // Jitter within the pressed card stays a click (double-click keeps
749+ // working): the drag starts once the pointer leaves the card.
750+ if ! m .dragging {
751+ if col , row , ok := m .cardAt (msg .X , msg .Y ); ok && col == m .selCol && row == m .selRow {
752+ return
753+ }
754+ }
721755 m .dragging = true
722756 if col , ok := m .columnAt (msg .X , msg .Y ); ok {
723757 m .dragCol = col
@@ -730,19 +764,31 @@ func (m *model) handleMotion(msg tea.MouseMotionMsg) {
730764// column moves it there. A release without prior motion is a plain click,
731765// already handled by handleClick.
732766func (m * model ) handleRelease (msg tea.MouseReleaseMsg ) (tea.Model , tea.Cmd ) {
733- wasDragging , dst := m .dragging , m .dragCol
734- m .dragCardID , m .dragging , m .dragCol = "" , false , - 1
767+ if msg .Button != tea .MouseLeft {
768+ return m , nil
769+ }
770+ cardID , wasDragging , dst := m .dragCardID , m .dragging , m .dragCol
771+ m .resetDrag ()
735772 if ! wasDragging {
736773 return m , nil
737774 }
738775 m .lastClickCard = "" // a completed drag must not arm double-click
739776 if col , ok := m .columnAt (msg .X , msg .Y ); ok {
740777 dst = col
741778 }
742- cmd := m .moveCardTo ( dst )
779+ cmd := m .moveCard ( cardID , dst )
743780 return m , cmd
744781}
745782
783+ // resetDrag abandons any drag in progress. Called whenever a drag can no
784+ // longer complete cleanly: a dialog opening (it captures the release), a
785+ // key press, or the dragged card disappearing on a refresh.
786+ func (m * model ) resetDrag () {
787+ m .dragCardID = ""
788+ m .dragging = false
789+ m .dragCol = - 1
790+ }
791+
746792// handleWheel moves the selection through the column under the cursor, so
747793// scrolling anywhere on a column walks its cards (the scroll window follows
748794// the selection). Wheel events outside the columns area are ignored.
@@ -787,13 +833,25 @@ func (m *model) createCard(project board.Project, prompt string) tea.Cmd {
787833
788834// moveCardTo moves the selected card to the column at index dst.
789835func (m * model ) moveCardTo (dst int ) tea.Cmd {
790- card := m .selectedCard ()
791- if card == nil || dst == m .selCol || dst < 0 || dst >= len (m .columns ) {
836+ if card := m .selectedCard (); card != nil {
837+ return m .moveCard (card .ID , dst )
838+ }
839+ return nil
840+ }
841+
842+ // moveCard moves a card — by ID, so a board refresh mid-drag cannot
843+ // redirect the move to another card — to the column at index dst. Moving a
844+ // card to the column it already occupies is a no-op.
845+ func (m * model ) moveCard (cardID string , dst int ) tea.Cmd {
846+ if dst < 0 || dst >= len (m .columns ) {
792847 return nil
793848 }
794849 colID := m .columns [dst ].ID
850+ if slices .ContainsFunc (m .cards [colID ], func (c * board.Card ) bool { return c .ID == cardID }) {
851+ return nil
852+ }
795853 return func () tea.Msg {
796- if err := m .app .MoveCard (card . ID , colID ); err != nil {
854+ if err := m .app .MoveCard (cardID , colID ); err != nil {
797855 return flashMsg {text : err .Error (), isErr : true }
798856 }
799857 return cardMovedMsg {colIdx : dst }
0 commit comments