diff --git a/internal/ui/dialog/dialog.go b/internal/ui/dialog/dialog.go index dc5ce3913d..b601d32c9d 100644 --- a/internal/ui/dialog/dialog.go +++ b/internal/ui/dialog/dialog.go @@ -20,6 +20,8 @@ const ( titleContentHeight = 1 // inputContentHeight is the height of the input content line. inputContentHeight = 1 + // mouseScrollLines is the number of lines to scroll on mouse wheel events. + mouseScrollLines = 3 ) // CloseKey is the default key binding to close dialogs. diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go index 76ec4dbbf2..0c4e0ccf4b 100644 --- a/internal/ui/dialog/models.go +++ b/internal/ui/dialog/models.go @@ -89,9 +89,10 @@ type Models struct { Previous key.Binding Close key.Binding } - list *ModelsList - input textinput.Model - help help.Model + list *ModelsList + input textinput.Model + help help.Model + listScreenY int } var _ Dialog = (*Models)(nil) @@ -164,6 +165,43 @@ func (m *Models) ID() string { // HandleMsg implements Dialog. func (m *Models) HandleMsg(msg tea.Msg) Action { switch msg := msg.(type) { + case tea.MouseClickMsg: + idx, _ := m.list.ItemIndexAtPosition(0, msg.Y-m.listScreenY) + if idx >= 0 { + m.list.SetSelected(idx) + selectedItem := m.list.SelectedItem() + if selectedItem == nil { + break + } + modelItem, ok := selectedItem.(*ModelItem) + if !ok { + break + } + return ActionSelectModel{ + Provider: modelItem.prov, + Model: modelItem.SelectedModel(), + ModelType: modelItem.SelectedModelType(), + } + } + case tea.MouseMotionMsg: + idx, _ := m.list.ItemIndexAtPosition(0, msg.Y-m.listScreenY) + if idx >= 0 { + m.list.SetSelected(idx) + } + case tea.MouseWheelMsg: + switch msg.Button { + case tea.MouseWheelUp: + m.list.ScrollBy(-mouseScrollLines) + case tea.MouseWheelDown: + m.list.ScrollBy(mouseScrollLines) + } + start, end := m.list.VisibleItemIndices() + sel := m.list.Selected() + if sel < start { + m.list.SetSelected(start) + } else if sel > end { + m.list.SetSelected(end) + } case tea.KeyPressMsg: switch { case key.Matches(msg, m.keyMap.Close): @@ -295,6 +333,28 @@ func (m *Models) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { rc.Help = m.help.View(m) + // Compute list screen Y for mouse hover detection. + view := rc.Render() + viewWidth, viewHeight := lipgloss.Size(view) + var dialogMinY int + if m.isOnboarding { + blRect := common.BottomLeftRect(area, viewWidth, viewHeight) + dialogMinY = blRect.Min.Y + } else { + centerRect := common.CenterRect(area, viewWidth, viewHeight) + dialogMinY = centerRect.Min.Y + } + // heightOffset includes dialogViewFrameSize (top+bottom) and help. + // List Y from dialog top = borderTop+paddingTop + title + input. + listYFromDialogTop := heightOffset - + t.Dialog.View.GetBorderBottomSize() - t.Dialog.View.GetPaddingBottom() - + t.Dialog.HelpView.GetVerticalFrameSize() + if m.isOnboarding { + // Onboarding renders without dialog view wrapping. + listYFromDialogTop -= t.Dialog.View.GetBorderTopSize() + t.Dialog.View.GetPaddingTop() + } + m.listScreenY = dialogMinY + listYFromDialogTop + cur := m.Cursor() if m.isOnboarding { diff --git a/internal/ui/dialog/sessions.go b/internal/ui/dialog/sessions.go index 3b0a033218..d75b455daf 100644 --- a/internal/ui/dialog/sessions.go +++ b/internal/ui/dialog/sessions.go @@ -36,6 +36,7 @@ type Session struct { input textinput.Model selectedSessionInx int sessions []session.Session + listScreenY int sessionsMode sessionsMode @@ -141,6 +142,41 @@ func (s *Session) ID() string { // HandleMsg implements Dialog. func (s *Session) HandleMsg(msg tea.Msg) Action { switch msg := msg.(type) { + case tea.MouseClickMsg: + if s.sessionsMode == sessionsModeNormal { + idx, _ := s.list.ItemIndexAtPosition(0, msg.Y-s.listScreenY) + if idx >= 0 { + s.list.SetSelected(idx) + if item := s.list.SelectedItem(); item != nil { + sessionItem := item.(*SessionItem) + return ActionSelectSession{sessionItem.Session} + } + } + } + case tea.MouseMotionMsg: + if s.sessionsMode == sessionsModeNormal { + idx, _ := s.list.ItemIndexAtPosition(0, msg.Y-s.listScreenY) + if idx >= 0 { + s.list.SetSelected(idx) + } + } + case tea.MouseWheelMsg: + if s.sessionsMode == sessionsModeNormal { + switch msg.Button { + case tea.MouseWheelUp: + s.list.ScrollBy(-mouseScrollLines) + case tea.MouseWheelDown: + s.list.ScrollBy(mouseScrollLines) + } + start, end := s.list.VisibleItemIndices() + sel := s.list.Selected() + if sel < start { + s.list.SetSelected(start) + } else if sel > end { + s.list.SetSelected(end) + } + } + return nil case tea.KeyPressMsg: switch s.sessionsMode { case sessionsModeDeleting: @@ -243,14 +279,6 @@ func (s *Session) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { s.list.SetSize(listWidth, listHeight) s.help.SetWidth(innerWidth) - // This makes it so we do not scroll the list if we don't have to - start, end := s.list.VisibleItemIndices() - - // if selected index is outside visible range, scroll to it - if s.selectedSessionInx < start || s.selectedSessionInx > end { - s.list.ScrollToSelected() - } - var cur *tea.Cursor rc := NewRenderContext(t, width) rc.Title = "Sessions" @@ -321,6 +349,14 @@ func (s *Session) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { view := rc.Render() + // Compute list screen Y for mouse hover detection. + viewWidth, viewHeight := lipgloss.Size(view) + centerRect := common.CenterRect(area, viewWidth, viewHeight) + listYFromDialogTop := heightOffset - + t.Dialog.View.GetBorderBottomSize() - t.Dialog.View.GetPaddingBottom() - + t.Dialog.HelpView.GetVerticalFrameSize() + s.listScreenY = centerRect.Min.Y + listYFromDialogTop + DrawCenterCursor(scr, area, view, cur) return cur } diff --git a/internal/ui/list/filterable.go b/internal/ui/list/filterable.go index 1c66cf0f13..b26263a698 100644 --- a/internal/ui/list/filterable.go +++ b/internal/ui/list/filterable.go @@ -120,6 +120,23 @@ func (f *FilterableList) FilteredItems() []Item { // Render renders the filterable list. func (f *FilterableList) Render() string { - f.List.SetItems(f.FilteredItems()...) + filtered := f.FilteredItems() + if !f.itemsMatch(filtered) { + f.List.SetItems(filtered...) + } return f.List.Render() } + +// itemsMatch reports whether the current list items match the given slice +// by pointer identity and length. +func (f *FilterableList) itemsMatch(items []Item) bool { + if f.List.Len() != len(items) { + return false + } + for i, item := range items { + if f.List.ItemAt(i) != item { + return false + } + } + return true +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 9dfe722796..76a7de4742 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -771,7 +771,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.MouseClickMsg: // Pass mouse events to dialogs first if any are open. if m.dialog.HasDialogs() { - m.dialog.Update(msg) + if cmd := m.handleDialogMsg(msg); cmd != nil { + cmds = append(cmds, cmd) + } return m, tea.Batch(cmds...) } @@ -798,7 +800,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.MouseMotionMsg: // Pass mouse events to dialogs first if any are open. if m.dialog.HasDialogs() { - m.dialog.Update(msg) + if cmd := m.handleDialogMsg(msg); cmd != nil { + cmds = append(cmds, cmd) + } return m, tea.Batch(cmds...) } @@ -836,7 +840,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.MouseReleaseMsg: // Pass mouse events to dialogs first if any are open. if m.dialog.HasDialogs() { - m.dialog.Update(msg) + if cmd := m.handleDialogMsg(msg); cmd != nil { + cmds = append(cmds, cmd) + } return m, tea.Batch(cmds...) } @@ -858,7 +864,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.MouseWheelMsg: // Pass mouse events to dialogs first if any are open. if m.dialog.HasDialogs() { - m.dialog.Update(msg) + if cmd := m.handleDialogMsg(msg); cmd != nil { + cmds = append(cmds, cmd) + } return m, tea.Batch(cmds...) } @@ -2334,7 +2342,11 @@ func (m *UI) View() tea.View { if !m.isTransparent { v.BackgroundColor = m.com.Styles.Background } - v.MouseMode = tea.MouseModeCellMotion + if m.dialog.HasDialogs() { + v.MouseMode = tea.MouseModeAllMotion + } else { + v.MouseMode = tea.MouseModeCellMotion + } v.ReportFocus = m.caps.ReportFocusEvents v.WindowTitle = "crush " + home.Short(m.com.Workspace.WorkingDir())