Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions ui/internal/session/options_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ func (stubView) View() tea.View { return tea.NewView("stub"
func (stubView) SetModel(json.RawMessage) error { return nil }
func (stubView) Reason() Reason { return ReasonClosed }

// allMotionStubView is a View that, like mission.Mission, asks for hover
// motion itself instead of leaving MouseMode at its zero value.
type allMotionStubView struct{ stubView }

func (allMotionStubView) View() tea.View {
v := tea.NewView("stub")
v.MouseMode = tea.MouseModeAllMotion
return v
}

// TestWireMouseSetsCellMotionOnlyWhenOptedIn locks in the actual v2 lever
// for Options.Mouse: there is no ProgramOption for it (see programOptions),
// so this decorator is the only place the option can take effect.
Expand All @@ -26,3 +36,34 @@ func TestWireMouseSetsCellMotionOnlyWhenOptedIn(t *testing.T) {
t.Fatalf("Mouse true: MouseMode = %v, want CellMotion", got)
}
}

// TestMouseViewDefersToAnExplicitMouseMode locks in the other half of
// wireMouse: MouseModeNone is bubbletea's zero value, so a view that sets
// nothing and a view that wants mouse off render identically. This
// decorator only ever wraps a view that opted into Options.Mouse in the
// first place, so it must apply CellMotion only as a default for the
// zero-value case, never clobber a mode the inner view actually chose --
// otherwise a view that asks for AllMotion (mission, to get hover) never
// gets it.
func TestMouseViewDefersToAnExplicitMouseMode(t *testing.T) {
if got := wireMouse(allMotionStubView{}, Options{Mouse: true}).View().MouseMode; got != tea.MouseModeAllMotion {
t.Fatalf("inner AllMotion: MouseMode = %v, want AllMotion", got)
}
if got := wireMouse(stubView{}, Options{Mouse: true}).View().MouseMode; got != tea.MouseModeCellMotion {
t.Fatalf("inner unset: MouseMode = %v, want CellMotion default", got)
}
}

// TestWireMouseDisabledForcesNone ensures Options.Mouse=false is authoritative
// even when the inner view sets an explicit mode: the disabled path forces
// MouseModeNone to prevent views like mission that request AllMotion from
// leaking through. This pins the contract that Options.Mouse is the final word,
// not a default that inner modes can override.
func TestWireMouseDisabledForcesNone(t *testing.T) {
if got := wireMouse(allMotionStubView{}, Options{}).View().MouseMode; got != tea.MouseModeNone {
t.Fatalf("Mouse false with inner AllMotion: MouseMode = %v, want None", got)
}
if got := wireMouse(stubView{}, Options{}).View().MouseMode; got != tea.MouseModeNone {
t.Fatalf("Mouse false with inner unset: MouseMode = %v, want None", got)
}
}
42 changes: 38 additions & 4 deletions ui/internal/session/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,15 @@ func programOptions(ctx context.Context, term *os.File) []tea.ProgramOption {
}
}

// mouseView decorates a View so every frame it paints reports
// mouseView decorates a View so every frame it paints reports at least
// MouseModeCellMotion, without requiring board- or echo-style views to know
// anything about mouse mode themselves.
// anything about mouse mode themselves. MouseModeNone is bubbletea's zero
// value, so a view that never touches MouseMode is indistinguishable from
// one that actively wants mouse off; since this decorator only ever wraps a
// view that opted into Options.Mouse in the first place, that zero value is
// read as "didn't ask" and defaulted to CellMotion, never as a real
// request for none. A view that sets its own mode (mission's AllMotion, for
// hover) is left alone.
type mouseView struct{ view View }

func (m mouseView) Init() tea.Cmd { return m.view.Init() }
Expand All @@ -109,7 +115,35 @@ func (m mouseView) Reason() Reason { return m.view.Reason()

func (m mouseView) View() tea.View {
v := m.view.View()
v.MouseMode = tea.MouseModeCellMotion
if v.MouseMode == tea.MouseModeNone {
v.MouseMode = tea.MouseModeCellMotion
}
return v
}

// noMouseView decorates a View so every frame it paints reports MouseModeNone,
// regardless of what the inner view requests. This ensures Options.Mouse=false
// is authoritative... even when the inner view (like mission) sets an explicit
// mode. Without this, Options.Mouse would be a mere default that inner modes
// could override, defeating the accessibility/preference contract.
type noMouseView struct{ view View }

func (n noMouseView) Init() tea.Cmd { return n.view.Init() }

func (n noMouseView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
updated, cmd := n.view.Update(msg)
if v, ok := updated.(View); ok {
n.view = v
}
return n, cmd
}

func (n noMouseView) SetModel(raw json.RawMessage) error { return n.view.SetModel(raw) }
func (n noMouseView) Reason() Reason { return n.view.Reason() }

func (n noMouseView) View() tea.View {
v := n.view.View()
v.MouseMode = tea.MouseModeNone
return v
}

Expand All @@ -118,7 +152,7 @@ func (m mouseView) View() tea.View {
// when asked.
func wireMouse(view View, opts Options) tea.Model {
if !opts.Mouse {
return view
return noMouseView{view: view}
}
return mouseView{view: view}
}
Expand Down
91 changes: 67 additions & 24 deletions ui/internal/views/mission/changes.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,26 +26,37 @@ const commitBoxInner = sidebarWidth - 4
// Rule under the inactive half (the board's own bottom border). Changes is
// always the active tab; History has no wire state to select it yet
// (renderKeybar's own "History lands in v2" notice covers a click on it).
func renderTabsRow(changedTotal, width int) string {
// hoverHistory paints HoverBg behind the History label's own half only --
// the underline's active/inactive split is untouched by hover.
func renderTabsRow(changedTotal int, hoverHistory bool, width int) string {
on := lipgloss.NewStyle().Background(theme.Bg)
half := width / 2
otherHalf := width - half

historyOn := on
if hoverHistory {
historyOn = on.Background(theme.HoverBg)
}

changesLabel := on.Foreground(theme.Text).Bold(true).Render("Changes") +
on.Foreground(theme.PinkSoft).Render(fmt.Sprintf(" %d", changedTotal))
historyLabel := on.Foreground(theme.Dimmer).Render("History") + on.Foreground(theme.Faint).Render(" v2")
historyLabel := historyOn.Foreground(theme.Dimmer).Render("History") + historyOn.Foreground(theme.Faint).Render(" v2")

top := on.Width(half).Align(lipgloss.Center).Render(changesLabel) +
on.Width(otherHalf).Align(lipgloss.Center).Render(historyLabel)
historyOn.Width(otherHalf).Align(lipgloss.Center).Render(historyLabel)
underline := on.Foreground(theme.Pink).Render(strings.Repeat("─", half)) +
on.Foreground(theme.Rule).Render(strings.Repeat("─", otherHalf))
return top + "\n" + underline
}

// renderFilterRow paints the "❯ filter" box: the typed filter text, or the
// Faint placeholder while empty. The border brightens to Pink while the
// filter itself holds focus, mirroring the summary/description boxes below.
func renderFilterRow(text string, focused bool, width int) string {
// filter itself holds focus; hover gets GutterHoverBar instead (the same
// dimmer-than-Pink tone the summary/description boxes use), never the
// focus color itself -- all three sibling boxes share the one rule that a
// hover reading as already-focused is wrong, focused still wins outright
// when both are true.
func renderFilterRow(text string, focused, hovered bool, width int) string {
inner := width - 4
if inner < 1 {
inner = 1
Expand All @@ -63,9 +74,12 @@ func renderFilterRow(text string, focused bool, width int) string {
bodyStyle = on.Foreground(theme.Faint)
}
line := on.Foreground(theme.Dimmer).Render(theme.GlyphChevron+" ") + bodyStyle.Render(clip(body, textW))
border := theme.Panel
if focused {
var border color.Color = theme.Panel
switch {
case focused:
border = theme.Pink
case hovered:
border = theme.GutterHoverBar
}
return lipgloss.NewStyle().Background(theme.Bg).Border(lipgloss.RoundedBorder()).BorderForeground(border).BorderBackground(theme.Bg).Padding(0, 1).
Render(on.Width(inner).Render(line))
Expand Down Expand Up @@ -185,9 +199,14 @@ func renderChangeRow(c ChangeRow, width int, cursor, hover bool) string {
}

// renderStashStrip is the "Stashed changes · N ❯" row: a notice strip, not
// yet a foldout (a later interaction pass wires the click).
func renderStashStrip(count, width int) string {
on := lipgloss.NewStyle().Background(theme.BgSubtle)
// yet a foldout (a later interaction pass wires the click). hovered swaps
// its whole-strip BgSubtle rest fill for HoverBg.
func renderStashStrip(count int, hovered bool, width int) string {
bg := theme.BgSubtle
if hovered {
bg = theme.HoverBg
}
on := lipgloss.NewStyle().Background(bg)
left := on.Foreground(theme.Dim).Render(fmt.Sprintf("Stashed changes · %d", count))
right := on.Foreground(theme.Dimmer).Render(theme.GlyphChevron)
return justify(on, width, left, right)
Expand All @@ -197,8 +216,11 @@ func renderStashStrip(count, width int) string {
// summary box, the description box, and the commit button, top to bottom.
// Amending overrides the button's own label to "Amend last commit" -- a
// display-only substitution; enabled (the caller's commitEnabled result)
// still gates the button's treatment, amending or not.
func renderCommitBox(width int, summaryView, descriptionView string, amending bool, buttonLabel string, enabled bool) string {
// still gates the button's treatment, amending or not. hoverButton/
// hoverSummary/hoverDescription are each region's own independent hover
// flag (mission.go's mouseMotion never sets more than one at a time, but
// nothing here assumes that).
func renderCommitBox(width int, summaryView, descriptionView string, amending bool, buttonLabel string, enabled, hoverButton, hoverSummary, hoverDescription bool) string {
var lines []string
if amending {
on := lipgloss.NewStyle().Background(theme.Bg)
Expand All @@ -209,22 +231,27 @@ func renderCommitBox(width int, summaryView, descriptionView string, amending bo
// row in the terminal (docs/design/mission/README.md's Terminal
// geometry table).
lines = append(lines, blankRows(width, 1))
lines = append(lines, boxLine(width, summaryView))
lines = append(lines, boxBlock(width, []string{descriptionView, ""}))
lines = append(lines, boxLine(width, summaryView, hoverSummary))
lines = append(lines, boxBlock(width, []string{descriptionView, ""}, hoverDescription))
// The board's own gap between the description box and the button (8px)
// reads as one blank band row -- unlike the summary/description seam,
// which stays flush (docs/design/mission/README.md's Terminal geometry
// table).
lines = append(lines, blankRows(width, 1))
lines = append(lines, renderCommitButton(width, buttonLabel, enabled))
lines = append(lines, renderCommitButton(width, buttonLabel, enabled, hoverButton))
return lipgloss.JoinVertical(lipgloss.Left, lines...)
}

func boxLine(width int, content string) string {
return boxBlock(width, []string{content})
func boxLine(width int, content string, hovered bool) string {
return boxBlock(width, []string{content}, hovered)
}

func boxBlock(width int, contentLines []string) string {
// boxBlock's border brightens to GutterHoverBar (Pink blended half-way
// toward Bg -- the same dimmer-than-full-Pink tone the diff gutter's own
// hover preview already established) while hovered: dimmer than the filter
// box's Pink focus treatment on purpose, so a hovered summary/description
// box never reads as already focused.
func boxBlock(width int, contentLines []string, hovered bool) string {
inner := width - 4
if inner < 1 {
inner = 1
Expand All @@ -234,7 +261,11 @@ func boxBlock(width int, contentLines []string) string {
for i, l := range contentLines {
padded[i] = on.Width(inner).Render(l)
}
return lipgloss.NewStyle().Background(theme.Bg).Border(lipgloss.RoundedBorder()).BorderForeground(theme.Panel).BorderBackground(theme.Bg).Padding(0, 1).
var border color.Color = theme.Panel
if hovered {
border = theme.GutterHoverBar
}
return lipgloss.NewStyle().Background(theme.Bg).Border(lipgloss.RoundedBorder()).BorderForeground(border).BorderBackground(theme.Bg).Padding(0, 1).
Render(strings.Join(padded, "\n"))
}

Expand All @@ -258,10 +289,16 @@ func boxBlock(width int, contentLines []string) string {
// files to <branch>" would otherwise spill it onto a second row -- CodeRabbit
// finding on PR #353). The block is a fixed THREE-row unit in the sidebar's
// own layout now; sidebarHit maps all three rows to the same hit target.
func renderCommitButton(width int, label string, canCommit bool) string {
// hovered brightens the fill to PinkSoft, but only when canCommit: a
// disabled button must never hover, since hover always means "this will do
// something".
func renderCommitButton(width int, label string, canCommit, hovered bool) string {
buttonColor, textColor := theme.Pink, theme.Bg
if !canCommit {
switch {
case !canCommit:
buttonColor, textColor = theme.Panel, theme.Dimmer
case hovered:
buttonColor = theme.PinkSoft
}
capStyle := lipgloss.NewStyle().Width(width).Background(theme.Bg).Foreground(buttonColor)
labelStyle := lipgloss.NewStyle().Width(width).Align(lipgloss.Center).Bold(true).Background(buttonColor).Foreground(textColor)
Expand All @@ -271,11 +308,17 @@ func renderCommitButton(width int, label string, canCommit bool) string {
}

// renderUndoStrip is the WarnBg strip a successful, still-undoable commit
// leaves behind: what got committed, when, and the Undo chip.
func renderUndoStrip(lc LastCommit, width int) string {
// leaves behind: what got committed, when, and the Undo chip. hovered
// brightens the chip's own Panel fill to HoverBg -- the strip's WarnBg line
// around it is untouched, so hover reads as the chip, not the whole row.
func renderUndoStrip(lc LastCommit, hovered bool, width int) string {
on := lipgloss.NewStyle().Background(theme.WarnBg)
left := on.Foreground(theme.Dimmer).Render("Committed "+lc.When+" · ") + on.Foreground(theme.TextSoft).Render(lc.Summary)
right := on.Foreground(theme.Text).Background(theme.Panel).Padding(0, 1).Render("Undo")
chipBg := theme.Panel
if hovered {
chipBg = theme.HoverBg
}
right := on.Foreground(theme.Text).Background(chipBg).Padding(0, 1).Render("Undo")
return justify(on, width, left, right)
}

Expand Down
49 changes: 44 additions & 5 deletions ui/internal/views/mission/mission.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,18 @@ type Mission struct {
hoverDiffLine int
hoverGutter bool

// The sidebar's other hoverable regions, each a plain bool (no index to
// carry): mouseMotion clears every one of these at the top of its switch
// alongside hoverFile/hoverDiffLine, so a pointer that leaves a region
// can never leave its highlight stuck.
hoverCommitButton bool
hoverTabHistory bool
hoverFilterRow bool
hoverCommitSummary bool
hoverCommitDescription bool
hoverStash bool
hoverUndoChip bool

// lastClickPath/lastClickAt pair a file row's two clicks into a double
// click (focuses the diff) the same way the picker's own clickRow does
// for its list; nowFn overrides the clock in tests, nil meaning
Expand Down Expand Up @@ -441,9 +453,9 @@ const sidebarFixedTopRows = 7

func (m *Mission) sidebarFixedTop(width int) string {
return lipgloss.JoinVertical(lipgloss.Left,
renderTabsRow(m.model.ChangedTotal, width),
renderTabsRow(m.model.ChangedTotal, m.hoverTabHistory, width),
blankRows(width, 1),
renderFilterRow(m.filterDisplayText(), m.focus == focusFilter, width),
renderFilterRow(m.filterDisplayText(), m.focus == focusFilter, m.hoverFilterRow, width),
renderMasterRow(m.model.ChangedTotal, m.model.StagedTotal, width),
)
}
Expand All @@ -453,12 +465,12 @@ func (m *Mission) sidebarFixedTop(width int) string {
func (m *Mission) sidebarDocked(width int) string {
var docked []string
if m.model.StashCount > 0 {
docked = append(docked, renderStashStrip(m.model.StashCount, width))
docked = append(docked, renderStashStrip(m.model.StashCount, m.hoverStash, width))
}
docked = append(docked, lipgloss.NewStyle().Background(theme.Bg).Foreground(theme.Rule).Render(strings.Repeat("─", width)))
docked = append(docked, renderCommitBox(width, m.summaryInput.View(), m.descriptionInput.View(), m.amendLocal, m.model.Commit.ButtonLabel, m.commitEnabled()))
docked = append(docked, renderCommitBox(width, m.summaryInput.View(), m.descriptionInput.View(), m.amendLocal, m.model.Commit.ButtonLabel, m.commitEnabled(), m.hoverCommitButton, m.hoverCommitSummary, m.hoverCommitDescription))
if lc := m.model.Commit.LastCommit; lc != nil && lc.Undoable {
docked = append(docked, renderUndoStrip(*lc, width))
docked = append(docked, renderUndoStrip(*lc, m.hoverUndoChip, width))
}
return lipgloss.JoinVertical(lipgloss.Left, docked...)
}
Expand Down Expand Up @@ -613,6 +625,12 @@ func (m *Mission) View() tea.View {

v := tea.NewView(out)
v.AltScreen = true
// The whole board is hover-driven (row/segment/diff-line/modal-row
// treatments all key off mouseMotion), and MouseModeCellMotion only
// reports movement while a button is held, so hover needs AllMotion
// explicitly -- session's wireMouse decorator defers to whatever mode
// is already set here rather than overwriting it.
v.MouseMode = tea.MouseModeAllMotion
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// bubbletea's renderer optimizes trailing styled blanks by erasing to
// end-of-line rather than emitting every styled space, and an erased
// cell paints the TERMINAL's own default background, not whatever SGR
Expand Down Expand Up @@ -1075,6 +1093,13 @@ func (m *Mission) mouseMotion(msg tea.MouseMotionMsg) (tea.Model, tea.Cmd) {
m.hoverFile = -1
m.hoverDiffLine = -1
m.hoverGutter = false
m.hoverCommitButton = false
m.hoverTabHistory = false
m.hoverFilterRow = false
m.hoverCommitSummary = false
m.hoverCommitDescription = false
m.hoverStash = false
m.hoverUndoChip = false
if m.modal != nil {
m.modal.hoverRow = -1
m.modal.hoverAction = false
Expand All @@ -1093,6 +1118,20 @@ func (m *Mission) mouseMotion(msg tea.MouseMotionMsg) (tea.Model, tea.Cmd) {
m.modal.hoverRow = h.idx
case hitModalAction:
m.modal.hoverAction = true
case hitCommitButton:
m.hoverCommitButton = true
case hitTabHistory:
m.hoverTabHistory = true
case hitFilterRow:
m.hoverFilterRow = true
case hitCommitSummary:
m.hoverCommitSummary = true
case hitCommitDescription:
m.hoverCommitDescription = true
case hitStash:
m.hoverStash = true
case hitUndoChip:
m.hoverUndoChip = true
}
return m, nil
}
Expand Down
Loading
Loading