66 "image/color"
77 "os"
88 "os/exec"
9+ "slices"
910 "strings"
1011 "time"
1112
@@ -136,6 +137,7 @@ type keyMap struct {
136137 Diff key.Binding
137138 MoveFwd key.Binding
138139 MoveBack key.Binding
140+ MoveTo key.Binding
139141 Delete key.Binding
140142 Projects key.Binding
141143 Prompt key.Binding
@@ -157,6 +159,7 @@ var keys = keyMap{
157159 Diff : key .NewBinding (key .WithKeys ("d" ), key .WithHelp ("d" , "view diff" )),
158160 MoveFwd : key .NewBinding (key .WithKeys ("]" , "shift+right" , "L" ), key .WithHelp ("]" , "move card forward" )),
159161 MoveBack : key .NewBinding (key .WithKeys ("[" , "shift+left" , "H" ), key .WithHelp ("[" , "move card back" )),
162+ MoveTo : key .NewBinding (key .WithKeys ("1" , "2" , "3" , "4" , "5" , "6" , "7" , "8" , "9" ), key .WithHelp ("1-9" , "move card to column N" )),
160163 Delete : key .NewBinding (key .WithKeys ("x" , "backspace" , "delete" ), key .WithHelp ("x" , "delete card" )),
161164 Projects : key .NewBinding (key .WithKeys ("p" ), key .WithHelp ("p" , "manage projects" )),
162165 Prompt : key .NewBinding (key .WithKeys ("e" ), key .WithHelp ("e" , "edit column prompt" )),
@@ -229,6 +232,14 @@ type model struct {
229232 // lastClick* back double-click-to-attach on cards.
230233 lastClickCard string
231234 lastClickTime time.Time
235+
236+ // drag* back drag-and-drop card moves: dragCardID is the pressed card
237+ // (a drag candidate), dragging turns true on the first motion event
238+ // (cell-motion mode only reports motion while a button is held), and
239+ // dragCol is the drop target column under the pointer (-1 when none).
240+ dragCardID string
241+ dragging bool
242+ dragCol int
232243}
233244
234245func newModel (app * board.App , refresh chan struct {}) * model {
@@ -244,6 +255,7 @@ func newModel(app *board.App, refresh chan struct{}) *model {
244255
245256// openDialog installs a dialog and runs its init command.
246257func (m * model ) openDialog (d dialog ) tea.Cmd {
258+ m .resetDrag () // the dialog captures the release: the drag cannot finish
247259 m .dialog = d
248260 return d .Init ()
249261}
@@ -259,6 +271,15 @@ func (m *model) reload() {
259271 m .projectColors [p .Name ] = projectColorAt (i )
260272 }
261273 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+ }
262283}
263284
264285// groupCards buckets cards by column, in board order. A card whose column
@@ -303,6 +324,18 @@ func (m *model) selectedCard() *board.Card {
303324 return cards [m .selRow ]
304325}
305326
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+
306339func (m * model ) Init () tea.Cmd {
307340 return tea .Batch (m .waitRefresh (), m .scheduleTick ())
308341}
@@ -499,6 +532,11 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
499532 return m .handleKey (msg )
500533 case tea.MouseClickMsg :
501534 return m .handleClick (msg )
535+ case tea.MouseMotionMsg :
536+ m .handleMotion (msg )
537+ return m , nil
538+ case tea.MouseReleaseMsg :
539+ return m .handleRelease (msg )
502540 case tea.MouseWheelMsg :
503541 m .handleWheel (msg )
504542 return m , nil
@@ -507,6 +545,10 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
507545}
508546
509547func (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 ()
510552 switch {
511553 case key .Matches (msg , keys .Quit ):
512554 return m , tea .Quit
@@ -544,10 +586,13 @@ func (m *model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
544586 }
545587
546588 case key .Matches (msg , keys .MoveFwd ):
547- cmd := m .moveCard ( 1 )
589+ cmd := m .moveCardTo ( m . selCol + 1 )
548590 return m , cmd
549591 case key .Matches (msg , keys .MoveBack ):
550- cmd := m .moveCard (- 1 )
592+ cmd := m .moveCardTo (m .selCol - 1 )
593+ return m , cmd
594+ case key .Matches (msg , keys .MoveTo ):
595+ cmd := m .moveCardTo (int (msg .String ()[0 ] - '1' ))
551596 return m , cmd
552597
553598 case key .Matches (msg , keys .Delete ):
@@ -686,9 +731,64 @@ func (m *model) handleClick(msg tea.MouseClickMsg) (tea.Model, tea.Cmd) {
686731 }
687732 m .lastClickCard = card .ID
688733 m .lastClickTime = time .Now ()
734+ // The pressed card is a drag candidate: motion before the release turns
735+ // the click into a drag (see handleMotion/handleRelease).
736+ m .dragCardID = card .ID
737+ m .dragCol = col
689738 return m , nil
690739}
691740
741+ // handleMotion tracks a pressed card being dragged. Cell-motion mode only
742+ // reports motion while a button is held, so motion after a card press
743+ // means a drag; the column under the pointer becomes the drop target.
744+ func (m * model ) handleMotion (msg tea.MouseMotionMsg ) {
745+ if m .dragCardID == "" || msg .Button != tea .MouseLeft {
746+ return
747+ }
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+ }
755+ m .dragging = true
756+ if col , ok := m .columnAt (msg .X , msg .Y ); ok {
757+ m .dragCol = col
758+ } else {
759+ m .dragCol = - 1
760+ }
761+ }
762+
763+ // handleRelease completes a drag-and-drop: dropping a card on another
764+ // column moves it there. A release without prior motion is a plain click,
765+ // already handled by handleClick.
766+ func (m * model ) handleRelease (msg tea.MouseReleaseMsg ) (tea.Model , tea.Cmd ) {
767+ if msg .Button != tea .MouseLeft {
768+ return m , nil
769+ }
770+ cardID , wasDragging , dst := m .dragCardID , m .dragging , m .dragCol
771+ m .resetDrag ()
772+ if ! wasDragging {
773+ return m , nil
774+ }
775+ m .lastClickCard = "" // a completed drag must not arm double-click
776+ if col , ok := m .columnAt (msg .X , msg .Y ); ok {
777+ dst = col
778+ }
779+ cmd := m .moveCard (cardID , dst )
780+ return m , cmd
781+ }
782+
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+
692792// handleWheel moves the selection through the column under the cursor, so
693793// scrolling anywhere on a column walks its cards (the scroll window follows
694794// the selection). Wheel events outside the columns area are ignored.
@@ -731,18 +831,27 @@ func (m *model) createCard(project board.Project, prompt string) tea.Cmd {
731831 }
732832}
733833
734- func ( m * model ) moveCard ( direction int ) tea. Cmd {
735- card := m . selectedCard ()
736- if card = = nil {
737- return nil
834+ // moveCardTo moves the selected card to the column at index dst.
835+ func ( m * model ) moveCardTo ( dst int ) tea. Cmd {
836+ if card := m . selectedCard (); card ! = nil {
837+ return m . moveCard ( card . ID , dst )
738838 }
739- dst := m .selCol + direction
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 {
740846 if dst < 0 || dst >= len (m .columns ) {
741847 return nil
742848 }
743849 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+ }
744853 return func () tea.Msg {
745- if err := m .app .MoveCard (card . ID , colID ); err != nil {
854+ if err := m .app .MoveCard (cardID , colID ); err != nil {
746855 return flashMsg {text : err .Error (), isErr : true }
747856 }
748857 return cardMovedMsg {colIdx : dst }
0 commit comments