diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c9e244bc..aa52c1691 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- The Pulls screen opens on an index of the project's pull requests across the whole screen, and a pull request opens in its place instead of beside the list. A back button over its title returns to the index as you left it; beside it, the number opens a menu of the other pull requests, and previous and next walk the index's filter. On the Files tab only the file tree sits beside the diff, which gains the width the list took. The Overview puts the description beside a column with the reviewers, checks, mergeability and the checkout the branch sits in; the tab strip ends with the pull request's size. Open in Session and Merge stay in the header, and the hand-off, Approve, Refresh and Open on GitHub move to the ⋯ menu. On the index, j and k or the arrows move, Enter opens and / goes to the filter; on a pull request, Escape goes back and Alt with an arrow steps to the previous or next one. +- The Pulls screen opens on an index of the project's pull requests across the whole screen, and a pull request opens in its place instead of beside the list. A back button over its title returns to the index as you left it; beside it, the number opens a menu of the other pull requests, and previous and next walk the index's filter. On the Files tab only the file tree sits beside the diff, which gains the width the list took. The Overview puts the description beside a column with the reviewers, checks, mergeability and the checkout the branch sits in; the tab strip ends with the pull request's size. Open in Session and Merge stay in the header, with Resolve conflicts or Fix checks beside them when the pull request needs one, and Approve, Refresh and Open on GitHub move to the ⋯ menu. The screen lines up on the sidebar's spacing, its buttons are the size of the rest of the window's, and the code a review thread is about sits in a block of its own, apart from the comments. On the index, j and k or the arrows move, Enter opens and / goes to the filter; on a pull request, Escape goes back and Alt with an arrow steps to the previous or next one. - A session's browser is no longer closed without asking. When no pane shows it and nobody has used it for five minutes, a toast asks whether to close it: Close closes it, and Keep open, or swiping the toast away, keeps it for another five minutes. Unanswered, the toast stays up and the browser stays open, so a page left open over lunch is still there when you come back. Using the browser, or the agent using it, takes the toast down. Before, the browser was closed after five minutes away from its session, and its page, tabs and login were lost. ### Fixed +- Code quoted in a review thread on the Pulls screen shows its indentation instead of a box for every tab. - A file input in the session browser, such as a profile picture upload, opens the system's file chooser when you click it, and takes several files when the input allows it. Before, the click did nothing. - Leave on a page in the session browser that asks before you go no longer reloads the page instead on a busy machine; the browser goes where it was going. - An agent clicking a point in the session browser no longer misses it after you move lich to another monitor, resize the browser pane or change its zoom. Its screenshots are now in the same pixels as its clicks, which a HiDPI monitor or a page zoom had made up to 1.5 times larger, and a click at a point read off a screenshot taken before the pane changed size is refused, telling the agent to take a new one. diff --git a/CLAUDE.md b/CLAUDE.md index ef7316b0c..37a0a347e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,6 +26,8 @@ lives in the code, `docs/` and `CHANGELOG.md` — never restate any of it here. - **Translations**: two independent languages, the interface and the prompt language (what lich and lich-plugin hand agents). `.claude/skills/lich-i18n/` maps where a string lands and every point a new language touches, and the rules for writing a message. +- **How the window looks**: `native/DESIGN.md`, the tokens, button sizes, spacing and screen layout. Read it + before drawing or changing UI; a pattern corrected in review lands there in the same PR. - **User-facing feature history**: `CHANGELOG.md`. ## Rules of the codebase diff --git a/native/DESIGN.md b/native/DESIGN.md new file mode 100644 index 000000000..7bf20a318 --- /dev/null +++ b/native/DESIGN.md @@ -0,0 +1,174 @@ +# lich: design language + +How the native window looks and behaves. The tokens live in `ui/theme.go`, the components in `ui/`, and +`ui-gallery` shows every one of them. When this file and the code disagree, the code is a holdout to migrate, +unless a line here says the code is the exception. + +Read it before drawing a new screen or changing an old one. A pattern the maintainer corrected in review +belongs here, in the same PR as the fix, so the next screen starts right. + +## The one idea: open surfaces, not boxes + +Elements are defined by space, hover and islands, not by enclosure. + +- **Rows, not cards.** A list item has no border and no fill of its own. Hover and selection paint it. +- **Panels are islands.** The window's regions (sidebar, stage, dock) are rounded surfaces floating on + `Canvas`, separated by the `gutter` (6px, `cmd/lich-native/main.go`) instead of a hairline. The top bar + and the footer sit on the canvas itself, unboxed. +- **Hairlines are seams inside an island.** `th.Separator` splits a panel's own parts (a screen's header + from its body, two panes). It never wraps a list item, a badge or a button at rest. Inputs are the one + exception: a field needs an edge to read as editable. +- **Badges are text and glyph**, never a filled pill. +- **State is fill, not chrome.** Selection is a flat `Accent` fill: no side bar, no ring, no shadow. + +A change that adds a border, a pill or a nested surface is probably wrong. Reach for spacing or a hover state +first. + +## Foundations + +### Units + +- Every length is `ui.Space(n)`, Tailwind's spacing scale (`Space(2.5)` is `p-2.5`, 10dp). A `unit.Dp` + literal is for a measure off the scale (a 1px border, a mockup's odd value), named in a constant. +- Text sizes are `ui.TextXS` (12), `ui.TextSM` (14) and `ui.TextBase` (16). A size off the scale is a named + `unit.Sp` constant beside the screen that uses it (`pullsTitleSize`, `settingsTitle`). +- Radius is `ui.RadiusSM` (4), `ui.RadiusMD` (6), `ui.RadiusLG` (8); dialogs alone use 12. Controls and + rows are `RadiusMD`; islands are `RadiusLG`. +- A screen's geometry lives in one `var` block at the top of its file, each value named and, when it comes + from a mockup or a Tailwind class, saying which. Never a bare number inside a layout. + +### Color + +Never a hex or OKLCH value in a widget: always a `Theme` token. Themes are JSON (`docs/themes.md`). + +| Token | Role | +| --- | --- | +| `Canvas` | the ground the islands float on | +| `Background` | full screens inside the stage (Settings, Pulls) and code blocks set inside a raised surface | +| `Sidebar`, `Card`, `Popover` | islands and raised parts: sidebar, dock, a thread or comment box, menus, dialogs | +| `Accent` | hover and selection fill | +| `MutedForeground` | secondary text, icons at rest, paths, meta | +| `Border` | seams and input edges | +| `Primary` | the one high-emphasis button of a view | +| `Foreground` | primary text | + +A translucent token goes through `th.Over` before it is painted, and a widget that paints a background +hands its children `th.On(background)`. Gio blends in linear light; `Over` composites the way a browser +does. A tint that must read on a known surface is an opaque `ui.MixOKLab(surface, tone, weight)`. + +Interaction fills: rest transparent, hover `Alpha(Accent, 0.5)` to `0.6`, active or selected `Accent`. + +### Semantic color + +The only chromatic color. It encodes state and never decorates. + +| Token | Meaning | +| --- | --- | +| `TonePass` | done, passed, added lines | +| `ToneWait` | waiting on the user, running, outdated | +| `Destructive` | destructive action, failure, removed lines | + +Added and removed lines in a code excerpt tint their row with `MixOKLab(Background, tone, 0.12)` and color +their text with the tone itself. + +### Typography + +- **Onest** for all interface text, **the system monospace** for code, paths, diffs and dense meta. +- Body is `TextSM`, meta and paths `TextXS`, headings named per screen. +- Text a person reads goes through `i18n.T`; nothing literal (see the `lich-i18n` skill). +- Text from the outside world (a diff hunk, a PR body, a commit message) is cleaned before it is shaped: + tabs become four spaces, because the fonts have no glyph for a tab and draw a missing-glyph box. + +### Icons + +Lucide through `icons.Lucide`, at `Space(3.5)` or `Space(4)`; `Space(3)` inside dense meta. Provider marks +and file-type logos have their own sets in `ui/icons`. + +## Controls + +### Buttons + +`th.Button` with a variant and a size; never a hand-drawn clickable for something that is a button. + +| Size | Height | Use | +| --- | --- | --- | +| `SizeSM` / `SizeIconSM` | 32 | **the default for actions on a screen**: headers, thread and comment actions, dialogs | +| `SizeDefault` / `SizeIcon` | 36 | a form's main row, beside a 36px input | +| `SizeXS` / `SizeIconXS` | 24 | inline inside dense rows only (a file row, a binary preview) | +| `SizeLG` | 40 | rare; a lone call to action | + +Buttons side by side share one size. A row that mixes 24px and 32px buttons is a bug. + +Variants: `VariantDefault` (primary fill) for the one action a view exists for, `VariantSecondary` for the +next most likely, `VariantOutline` for an action that should stand out from ghost ones, `VariantGhost` for +the rest, `VariantDestructive` for what cannot be undone. A disabled button says why in a tooltip. + +### What goes where + +- **The action a state calls for is a button, in reach.** When something is wrong and lich can hand it to an + agent (Resolve conflicts, Fix checks), the button sits in the header beside the main actions, not in an + overflow menu. +- **The `⋯` menu holds the routine and the rare**: Approve, Refresh, Open on GitHub. Never the action the + current state calls for. +- A menu never opens or ends on a separator, and never shows two in a row. + +### Tooltips + +`th.Tooltip`, in a few words: what an icon-only control does, or why a control is disabled. Several lines +about the thing under the pointer use the card variant. + +## Screens + +### The island's edge + +Every island keeps its content `Space(2)` (8px) from its rounded edge, on all four sides: the sidebar +(`sidebar.go`, `UniformInset(Space(2))`), the Settings nav (`settings_nav.go`) and the Pulls header strip +(`pullsToolbarPad`). Controls and rows sit flush on that edge, box first: the sidebar's New session button, +the Settings search field, a nav row, the Pulls back button and tree toggle all start at 8px. + +Text inside a row is indented by the row's own padding, not by the island: `Space(2.5)` (10px) on a sidebar +card (`sidebar_card.go`) and a Settings nav row (`navRowPadX`), so a row's label lands at 18px from the +island's edge and its hover fill spans the 8px edge to edge. + +Rows in a list stand `Space(0.5)` to `Space(1.5)` apart; groups of rows `Space(2)`. + +The geometry is shared; the look is not. Every screen lays out on the sidebar's edge, padding and hover +shape, and keeps what makes it itself: the Pulls list keeps its seams between rows, the Settings pane its +reading column. Matching a screen to the sidebar means its spacing, never its decoration. + +### Header and strips + +A screen's header and every strip under it (a toolbar, a file list's top bar) sit on the island's 8px edge. +The first control's box, not its glyph, sits on that edge, so an icon button's glyph lands at its own +padding inside it. + +Each header line is a flex row: what the screen is about on the left, flexing; its actions on the right, +`Rigid`, separated by `ui.Gap(2)`. When the actions and the title no longer fit on one line, the actions take +their own line above the title rather than squeezing it. + +A `th.Separator` closes the header off from the body. + +### Body + +A reading pane, a column of prose and settings rows under a title, is the one place that leaves the 8px +edge: the Settings pane sits at `settingsPanePadX` / `settingsPanePadY` (40px / 32px), its close button in +the corner at `settingsCloseInset` (14px). Every other screen body follows the sidebar: its content at +18px from the island's edge, the 8px edge plus a row's 10px (`pullsPadX` in Pulls). + +### Boxes inside a screen + +A thread, a pending comment or a composer is a box on `Sidebar` with `RadiusMD` and `Space(3)` padding. Its +children draw on `th.On(th.Sidebar)`. + +Code quoted inside such a box (a diff hunk) never shares the box's fill: it sits in its own block on +`Background`, `RadiusSM`, `Space(2)` padding, so code and prose read as two things. + +Vertical rhythm inside a box: `Space(2)` above a code block, `Space(2.5)` between comments and before the +action row. + +## Fixed by decision: do not "fix" these + +- **The bundled themes stay achromatic.** A colored palette is a custom theme, not a widget's color. +- **The layout**: top tabs, left sidebar, terminal, right dock, footer. +- **Semantic color and the diff language badges**: they carry meaning. +- **Buttons do not move when pressed**; only their color changes. diff --git a/native/cmd/lich-native/pulls_conversation.go b/native/cmd/lich-native/pulls_conversation.go index e6769f05a..39cf13cb9 100644 --- a/native/cmd/lich-native/pulls_conversation.go +++ b/native/cmd/lich-native/pulls_conversation.go @@ -325,21 +325,45 @@ func lineAnchor(start, line int) string { // the thread is about. const hunkLines = 4 +// hunkTint is how strongly an added or removed line tints its row, and +// hunkTab what a tab expands to: the mono font has no glyph for one. +const ( + hunkTint = 0.12 + hunkTab = " " +) + +// hunkTail sits the code on the window's background, apart from the +// comments on the thread's surface. func hunkTail(gtx C, th *ui.Theme, hunk string) D { lines := strings.Split(strings.TrimRight(hunk, "\n"), "\n") lines = lines[max(0, len(lines)-hunkLines):] var children []layout.FlexChild for _, line := range lines { - fg := th.MutedForeground - switch { - case strings.HasPrefix(line, "+"): - fg = th.TonePass - case strings.HasPrefix(line, "-"): - fg = th.Destructive - } - children = append(children, layout.Rigid(func(gtx C) D { return truncated(gtx, th.Text(ui.TextXS, line).Mono().In(fg)) })) + children = append(children, layout.Rigid(func(gtx C) D { return hunkLine(gtx, th, line) })) } - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) + return layout.Background{}.Layout(gtx, + func(gtx C) D { return ui.Fill(gtx, th.Background, ui.RadiusSM) }, + func(gtx C) D { + return layout.UniformInset(ui.Space(2)).Layout(gtx, func(gtx C) D { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) + }) + }) +} + +func hunkLine(gtx C, th *ui.Theme, line string) D { + fg, tint := th.MutedForeground, th.Background + switch { + case strings.HasPrefix(line, "+"): + fg, tint = th.TonePass, ui.MixOKLab(th.Background, th.TonePass, hunkTint) + case strings.HasPrefix(line, "-"): + fg, tint = th.Destructive, ui.MixOKLab(th.Background, th.Destructive, hunkTint) + } + gtx.Constraints.Min.X = gtx.Constraints.Max.X + return layout.Background{}.Layout(gtx, + func(gtx C) D { return ui.Fill(gtx, tint, 0) }, + func(gtx C) D { + return truncated(gtx, th.Text(ui.TextXS, strings.ReplaceAll(line, "\t", hunkTab)).Mono().In(fg)) + }) } // threadActions is Reply and Resolve, with Send to session at the end, or @@ -366,9 +390,9 @@ func (v *pullView) threadActions(gtx C, th *ui.Theme, p *pullsScreen, d backend. label = i18n.T("pulls.reviewThread.reopen") } replyB := th.Button(reply, i18n.T("pulls.reviewThread.reply")) - replyB.Variant, replyB.Size = ui.VariantOutline, ui.SizeXS + replyB.Variant, replyB.Size = ui.VariantOutline, ui.SizeSM resolveB := th.Button(resolve, label) - resolveB.Variant, resolveB.Size, resolveB.Disabled = ui.VariantGhost, ui.SizeXS, busy == "resolve:"+t.ID + resolveB.Variant, resolveB.Size, resolveB.Disabled = ui.VariantGhost, ui.SizeSM, busy == "resolve:"+t.ID return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Rigid(replyB.Layout), ui.Gap(2), layout.Rigid(resolveB.Layout), layout.Flexed(1, func(gtx C) D { return D{Size: image.Pt(gtx.Constraints.Min.X, 0)} }), @@ -391,7 +415,7 @@ func (v *pullView) toSessionButton(gtx C, th *ui.Theme, p *pullsScreen, d backen label, tip = i18n.T("pulls.threadSend.send"), i18n.T("pulls.threadSend.sendTip", i18n.Args{"name": p.sessionLabel(id)}) } b := th.Button(click, label) - b.Icon, b.Variant, b.Size, b.Disabled = icPullSession, ui.VariantGhost, ui.SizeXS, busy + b.Icon, b.Variant, b.Size, b.Disabled = icPullSession, ui.VariantGhost, ui.SizeSM, busy return v.withTip(gtx, th, p, "toSession:"+t.ID, tip, b.Layout) } diff --git a/native/cmd/lich-native/pulls_list.go b/native/cmd/lich-native/pulls_list.go index 07640d3bc..7f9ae3161 100644 --- a/native/cmd/lich-native/pulls_list.go +++ b/native/cmd/lich-native/pulls_list.go @@ -210,30 +210,33 @@ func (p *pullsScreen) row(gtx C, th *ui.Theme, pr backend.PRSummary, now time.Ti } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx C) D { - return layout.Background{}.Layout(gtx, - func(gtx C) D { return ui.Fill(gtx, bg, 0) }, - func(gtx C) D { - on := th.On(bg) - return layout.Inset{Left: pullsPadX, Right: pullsPadX, Top: ui.Space(2.5), Bottom: ui.Space(2.5)}.Layout(gtx, func(gtx C) D { - return layout.Flex{Alignment: layout.Middle}.Layout(gtx, - layout.Flexed(1, func(gtx C) D { - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(func(gtx C) D { return rowTitle(gtx, on, pr) }), - ui.VGap(1), - layout.Rigid(func(gtx C) D { return rowMeta(gtx, on, pr, now) }), - ) - }), - ui.Gap(4), - layout.Rigid(func(gtx C) D { return rowSide(gtx, on, pr, checkoutName(checkout)) }), - ) - }) - }) + return layout.Inset{Left: pullsRowEdge, Right: pullsRowEdge}.Layout(gtx, func(gtx C) D { + return layout.Background{}.Layout(gtx, + func(gtx C) D { return ui.Fill(gtx, bg, ui.RadiusMD) }, + func(gtx C) D { return rowContent(gtx, th.On(bg), pr, checkoutName(checkout), now) }) + }) }), layout.Rigid(th.Separator), ) }) } +func rowContent(gtx C, th *ui.Theme, pr backend.PRSummary, checkout string, now time.Time) D { + return layout.Inset{Left: pullsRowPadX, Right: pullsRowPadX, Top: ui.Space(2.5), Bottom: ui.Space(2.5)}.Layout(gtx, func(gtx C) D { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Flexed(1, func(gtx C) D { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx C) D { return rowTitle(gtx, th, pr) }), + ui.VGap(1), + layout.Rigid(func(gtx C) D { return rowMeta(gtx, th, pr, now) }), + ) + }), + ui.Gap(4), + layout.Rigid(func(gtx C) D { return rowSide(gtx, th, pr, checkout) }), + ) + }) +} + func rowTitle(gtx C, th *ui.Theme, pr backend.PRSummary) D { return layout.Flex{}.Layout(gtx, layout.Rigid(th.Text(ui.TextSM, fmt.Sprintf("#%d", pr.Number)).Mono().Muted().Layout), diff --git a/native/cmd/lich-native/pulls_panels.go b/native/cmd/lich-native/pulls_panels.go index 484437bc8..1a2785dfa 100644 --- a/native/cmd/lich-native/pulls_panels.go +++ b/native/cmd/lich-native/pulls_panels.go @@ -14,9 +14,9 @@ import ( "github.com/lichdotdev/lich/native/ui" ) -// actions is the header's buttons, left to right: the session, the pending -// review while one is written, Merge, and ⋯ with the hand-off, Approve, -// Refresh and Open on GitHub. +// actions is the header's buttons, left to right: the hand-off a failing or +// conflicting pull request offers, the session, the pending review while one +// is written, Merge, and ⋯ with Approve, Refresh and Open on GitHub. func (v *pullView) actions(gtx C, th *ui.Theme, p *pullsScreen, d backend.PRDetail, act *pullsAction) D { busy := v.busyNow() blocked := "" @@ -27,29 +27,25 @@ func (v *pullView) actions(gtx C, th *ui.Theme, p *pullsScreen, d backend.PRDeta act.session = &d } reviewBlocked := mergeReasonForReview(d) - items := []ui.WrapChild{ui.WrapItem(func(gtx C) D { return v.sessionButton(gtx, th, p, d, busy, blocked) })} + var items []ui.WrapChild + if handoff := v.handoffButton(gtx, th, p, d, busy, blocked, act); handoff != nil { + items = append(items, ui.WrapItem(handoff)) + } + items = append(items, ui.WrapItem(func(gtx C) D { return v.sessionButton(gtx, th, p, d, busy, blocked) })) if len(v.pendingOf(d.URL).snapshot()) > 0 { items = append(items, ui.WrapItem(func(gtx C) D { return v.reviewButton(gtx, th, p, d, reviewBlocked) })) } items = append(items, ui.WrapItem(func(gtx C) D { return v.mergeButton(gtx, th, p, d, busy, act) }), - ui.WrapItem(func(gtx C) D { return v.moreButton(gtx, th, p, d, busy, blocked, reviewBlocked, act) }), + ui.WrapItem(func(gtx C) D { return v.moreButton(gtx, th, p, d, busy, reviewBlocked, act) }), ) return ui.Wrap{GapX: ui.Space(2), GapY: ui.Space(2), Justify: ui.JustifyEnd}.Layout(gtx, items...) } -// moreButton is ⋯: the hand-off a failing or conflicting pull request offers, -// Approve while no review is being written, Refresh and Open on GitHub. -func (v *pullView) moreButton(gtx C, th *ui.Theme, p *pullsScreen, d backend.PRDetail, busy, blocked, reviewBlocked string, act *pullsAction) D { - var conflicts []string - if conflictsWithBase(d) { - conflicts = p.store.conflictFiles(p.project.Path, &d).val - } +// moreButton is ⋯: Approve while no review is being written, Refresh and +// Open on GitHub. +func (v *pullView) moreButton(gtx C, th *ui.Theme, p *pullsScreen, d backend.PRDetail, busy, reviewBlocked string, act *pullsAction) D { var entries []ui.MenuEntry - if label, prompt, ok := pullHandoff(d, conflicts); ok { - entries = append(entries, ui.MenuEntry{Label: label, Icon: icPullWrench, Disabled: blocked != "" || busy != "", - OnClick: func() { act.session, act.handoff = &d, prompt }}) - } if len(v.pendingOf(d.URL).snapshot()) == 0 { label := i18n.T("pulls.pullRequestView.approve") switch { @@ -75,6 +71,28 @@ func (v *pullView) moreButton(gtx C, th *ui.Theme, p *pullsScreen, d backend.PRD }) } +// handoffButton is Resolve conflicts or Fix checks, handing the pull request +// to its session; nil when nothing is wrong with it. +func (v *pullView) handoffButton(gtx C, th *ui.Theme, p *pullsScreen, d backend.PRDetail, busy, blocked string, act *pullsAction) layout.Widget { + var conflicts []string + if conflictsWithBase(d) { + conflicts = p.store.conflictFiles(p.project.Path, &d).val + } + label, prompt, ok := pullHandoff(d, conflicts) + if !ok { + return nil + } + disabled := blocked != "" || busy != "" + if v.handoff.Clicked(gtx) && !disabled { + act.session, act.handoff = &d, prompt + } + return func(gtx C) D { + b := th.Button(&v.handoff, label) + b.Icon, b.Variant, b.Size, b.Disabled = icPullWrench, ui.VariantOutline, ui.SizeSM, disabled + return v.withTip(gtx, th, p, "handoff", blocked, b.Layout) + } +} + // sessionButton is Open in Session, or Go to session when a session already // sits in the branch's checkout. func (v *pullView) sessionButton(gtx C, th *ui.Theme, p *pullsScreen, d backend.PRDetail, busy, blocked string) D { @@ -491,7 +509,8 @@ func (v *pullView) checkRows(gtx C, th *ui.Theme, d backend.PRDetail, act *pulls return rows } -// panelRow is a tab's px-6 py-2 row, hover-filled while it does something. +// panelRow is a tab's py-2 row, hover-filled like a sidebar card while it +// does something. func panelRow(gtx C, th *ui.Theme, click *widget.Clickable, live bool, content func(gtx C, th *ui.Theme) D) D { gtx.Constraints.Min.X = gtx.Constraints.Max.X return click.Layout(gtx, func(gtx C) D { @@ -499,13 +518,15 @@ func panelRow(gtx C, th *ui.Theme, click *widget.Clickable, live bool, content f if live && click.Hovered() { bg = th.Over(ui.Alpha(th.Accent, ghostHoverFill)) } - return layout.Background{}.Layout(gtx, - func(gtx C) D { return ui.Fill(gtx, bg, 0) }, - func(gtx C) D { - return layout.Inset{Left: pullsPadX, Right: pullsPadX, Top: ui.Space(2), Bottom: ui.Space(2)}.Layout(gtx, func(gtx C) D { - return content(gtx, th.On(bg)) + return layout.Inset{Left: pullsRowEdge, Right: pullsRowEdge}.Layout(gtx, func(gtx C) D { + return layout.Background{}.Layout(gtx, + func(gtx C) D { return ui.Fill(gtx, bg, ui.RadiusMD) }, + func(gtx C) D { + return layout.Inset{Left: pullsRowPadX, Right: pullsRowPadX, Top: ui.Space(2), Bottom: ui.Space(2)}.Layout(gtx, func(gtx C) D { + return content(gtx, th.On(bg)) + }) }) - }) + }) }) } diff --git a/native/cmd/lich-native/pulls_view.go b/native/cmd/lich-native/pulls_view.go index f544f6c4a..974d55468 100644 --- a/native/cmd/lich-native/pulls_view.go +++ b/native/cmd/lich-native/pulls_view.go @@ -18,9 +18,13 @@ import ( "github.com/lichdotdev/lich/native/ui/icons" ) -// The PR island's padding: PullRequestView's px-6 under the toolbar strip. +// The PR island's padding under the toolbar strip: the sidebar's, an 8px +// island edge plus a row's 10px, so text lands where a session card's does +// and a row's hover fill stops at the edge. var ( - pullsPadX = ui.Space(6) + pullsRowEdge = ui.Space(2) + pullsRowPadX = ui.Space(2.5) + pullsPadX = pullsRowEdge + pullsRowPadX pullsTitleSize = unit.Sp(18) pullsToolbarPad = ui.Space(2) // pullsHeadingTop centers the title's first line on the h-8 buttons @@ -57,7 +61,7 @@ func (a *pullsAction) open(url string) { a.external = url } // pullView is PullRequestView: the toolbar, the title, the status line, the // tabs and the open tab's panel. type pullView struct { - session, moreClick widget.Clickable + session, handoff, moreClick widget.Clickable moreMenu ui.MenuState mergeClick widget.Clickable mergeMenu ui.MenuState @@ -242,7 +246,7 @@ func (v *pullView) navClicks(gtx C, p *pullsScreen, rows []backend.PRSummary) { func (v *pullView) crumb(gtx C, th *ui.Theme, p *pullsScreen) D { rows := p.visible(p.store.list(p.project.Path, p.state).val) at := position(rows, p.selected) - return layout.Inset{Left: pullsPadX - pullsToolbarPad - ui.Space(1), Bottom: ui.Space(1)}.Layout(gtx, func(gtx C) D { + return layout.Inset{Bottom: ui.Space(1)}.Layout(gtx, func(gtx C) D { gtx.Constraints.Min.X = gtx.Constraints.Max.X return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Rigid(func(gtx C) D { @@ -341,9 +345,9 @@ func (v *pullView) recordActions(gtx C, th *ui.Theme, p *pullsScreen, d backend. return macro.Stop(), size } -// heading is the title over its stats, its left edge on the body's px-6. +// heading is the title over its stats, its left edge on the toolbar strip's. func (v *pullView) heading(gtx C, th *ui.Theme, p *pullsScreen, d backend.PRDetail, act *pullsAction) D { - return layout.Inset{Left: pullsPadX - pullsToolbarPad, Right: ui.Space(4), Top: pullsHeadingTop}.Layout(gtx, func(gtx C) D { + return layout.Inset{Right: ui.Space(4), Top: pullsHeadingTop}.Layout(gtx, func(gtx C) D { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx C) D { return v.pullTitle(gtx, th, p, d, act) }), ui.VGap(3), @@ -355,7 +359,7 @@ func (v *pullView) heading(gtx C, th *ui.Theme, p *pullsScreen, d backend.PRDeta func (v *pullView) body(gtx C, th *ui.Theme, p *pullsScreen, d backend.PRDetail, act *pullsAction) D { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx C) D { - return layout.Inset{Left: pullsPadX, Right: pullsPadX}.Layout(gtx, func(gtx C) D { + return layout.Inset{Left: pullsToolbarPad, Right: pullsToolbarPad}.Layout(gtx, func(gtx C) D { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, ui.VGap(4), layout.Rigid(func(gtx C) D { return v.tabStrip(gtx, th, p, d) }),