From cfcf5f79faeeab24c3ab43a65da1254251a6427d Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:26:42 +0800 Subject: [PATCH] fix(ui): stop filled children squaring off rounded corners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cursor-shape toggles (Block / Bar / Underline) reported in #236 look rough because the selected segment's fill covers the whole corner of the track it caps, and its outer edge is a hard, unantialiased vertical cut. The track's own border arc is drawn correctly and antialiased — it just floats *inside* that square, so the corner reads as a stair-step. The controls were relying on `overflow_hidden` to shape their end segments' fills to the track's rounding. It cannot do that. `gpui::ContentMask` is a bare axis-aligned `Bounds`; `Style::overflow_mask` builds it from the element's bounds shrunk by the border widths and drops `corner_radii` entirely, and every shader applies it as a hard `clip_distances < 0` discard. So the mask only ever cuts a square, and it never antialiases the cut. A container's own corners come from somewhere else — the quad shader's SDF, `saturate(0.5 - distance)` — which is why a plain rounded card renders smooth while anything with a filled child in its corner does not. That divergence is the whole bug, and the reporter's screenshot shows both halves of it: the corner with the selected fill is square, the corner without one is a clean arc. The fill has to carry the radius itself, so it goes down the SDF path too. It sits one border-width inside the track, so the concentric radius is `outer - border`. `ui::rounding` states that rule once, with the constants and the corner-assignment helpers, and unit-tests the invariants (inset is strictly tighter than the outer radius, clamps at zero, only the end segments cap the track). Applied to every place a child paints a fill into a rounded corner: * the segmented controls (the reported one, plus the others `segmented` serves), * the −/value/+ steppers' hover fills — those glyph boxes also had to be pinned to the track's content height, because a padded auto-height box measures 31px against a 22px content box and its rounded corner would land 4½px outside the visible strip, * the theme picker's flush-mounted previews, * the diff overlay's card headers and the row that closes a card. Not reproducible locally: this is a rendering-geometry defect, not a platform one, but it is most visible at a device pixel ratio of 1, where the clip's hard edge is a whole physical pixel. Verified by reading the gpui mask/shader source and the reporter's screenshot pixel by pixel, and by the geometry tests; the on-screen result is left for visual acceptance. Refs #236 --- AGENTS.md | 18 ++++ CHANGELOG.md | 19 ++++ src/ui/diff_overlay.rs | 80 +++++++++++++-- src/ui/mod.rs | 1 + src/ui/rounding.rs | 214 +++++++++++++++++++++++++++++++++++++++++ src/ui/settings.rs | 70 +++++++++++--- 6 files changed, 378 insertions(+), 24 deletions(-) create mode 100644 AGENTS.md create mode 100644 src/ui/rounding.rs diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..fb7296e7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,18 @@ +# Project agent memory + +This file is the project's committed home for project-intrinsic agent knowledge: build, test, release, architecture, and sharp-edge notes that should travel with the code. + +- Add durable project-specific notes here as they are discovered through real work. +- **gpui's `overflow_hidden` does not round-clip.** Its content mask is an + axis-aligned rectangle applied as a hard per-fragment discard, so a child that + paints a background into a rounded container's corner squares that corner off + with no anti-aliasing. Any such child must carry its own radius, inset one + border-width. `src/ui/rounding.rs` holds the rule, the constants and the + tests; read it before adding a filled band or segment to a rounded track. + +## Maintaining this file + +Keep this file for knowledge useful to almost every future agent session in this project. +Do not repeat what the codebase already shows; point to the authoritative file or command instead. +Prefer rewriting or pruning existing entries over appending new ones. +When updating this file, preserve this bar for all agents and keep entries concise. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b3ec11b..6a45a456 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 session" means: paste it into `codex resume`, a bug report, or another tool. (#211) +### Fixed + +- **Rounded UI controls no longer square off their corners** + ([#236](https://github.com/l0ng-ai/tty7/issues/236)) — the cursor-shape + toggles (Block / Bar / Underline) are the clearest case: the selected + segment's fill filled the whole corner of the track it caps, with the track's + own anti-aliased border arc floating *inside* that square. The controls were + relying on `overflow_hidden` to shape those fills to the track's rounding, and + it cannot: gpui's overflow mask is an axis-aligned rectangle with no corner + radii, tested per fragment as a hard discard, so it can only ever cut a square + and never anti-aliases the cut. A container's own corners come from the quad + shader's distance field instead, which is why a plain card looked smooth while + anything with a filled child in the corner did not. Every such fill now + carries its own radius, inset one border-width so it nests inside the border + rather than bulging past it: the segmented controls, the −/value/+ steppers' + hover fills, the theme picker's flush previews, and the diff overlay's card + headers and closing rows. Most visible at a device pixel ratio of 1, where the + hard clip edge is a whole physical pixel wide. + ## [26.7.6] - 2026-07-28 ### Added diff --git a/src/ui/diff_overlay.rs b/src/ui/diff_overlay.rs index 7f1fce4a..f1cd6b89 100644 --- a/src/ui/diff_overlay.rs +++ b/src/ui/diff_overlay.rs @@ -21,7 +21,9 @@ use std::collections::HashSet; use std::path::PathBuf; -use gpui::{AnyElement, FocusHandle, FontWeight, KeyDownEvent, Window, div, prelude::*, px}; +use gpui::{ + AnyElement, FocusHandle, FontWeight, KeyDownEvent, Pixels, Window, div, prelude::*, px, +}; use gpui_component::button::Button; use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex}; @@ -29,6 +31,8 @@ use crate::terminal::git_diff::{ self, AUTO_COLLAPSE_LINES, DiffSnapshot, FileDiff, FileStatus, LineKind, }; use crate::ui::app::Tty7App; +use crate::ui::rounding; +use crate::ui::rounding::RoundedCorners as _; /// What the overlay currently shows: probing, a parsed snapshot, or the /// answer that the cwd stopped being a repo. @@ -562,6 +566,17 @@ impl Tty7App { None => file.path.clone(), }; + // The header paints a solid band flush into the card's corners, and the + // card's `overflow_hidden` cannot round it — that clip is a square, + // unantialiased scissor (issue #236, see `ui::rounding`). So the band + // carries the radius: top two when a body follows it, all four when the + // card is collapsed and the header *is* the card. + let header_corners = rounding::stack_corners( + 0, + if expanded { 2 } else { 1 }, + rounding::CARD_RADIUS, + rounding::HAIRLINE, + ); let mut header = h_flex() .id(("diff-file-header", idx)) .w_full() @@ -569,6 +584,7 @@ impl Tty7App { .gap_2() .px_2p5() .py_1p5() + .rounded_corners(header_corners) .bg(cx.theme().secondary) .when(expandable, |h| { let path = file.path.clone(); @@ -649,13 +665,31 @@ impl Tty7App { .w_full() .border_1() .border_color(cx.theme().border) - .rounded_md() + .rounded(rounding::CARD_RADIUS) + // Overflow backstop only; the bands inside round themselves. .overflow_hidden() .child(header); if expanded { let mut body = v_flex().w_full(); - for hunk in &file.hunks { + // Split every hunk up front so the *last* row is knowable: a diff + // cell paints a tint, and the card's clip is square, so the row that + // ends the card has to draw the bottom corners itself. A truncation + // notice (no fill of its own) takes that job away again. + let hunks: Vec<_> = file + .hunks + .iter() + .map(|hunk| (hunk, split_hunk(&hunk.lines))) + .collect(); + let closing_row = if file.truncated { + None + } else { + hunks + .iter() + .rposition(|(_, rows)| !rows.is_empty()) + .map(|h| (h, hunks[h].1.len() - 1)) + }; + for (h, (hunk, rows)) in hunks.iter().enumerate() { body = body.child( div() .w_full() @@ -668,8 +702,8 @@ impl Tty7App { .truncate() .child(hunk.header.clone()), ); - for row in split_hunk(&hunk.lines) { - body = body.child(self.diff_split_row(&row, cx)); + for (r, row) in rows.iter().enumerate() { + body = body.child(self.diff_split_row(row, closing_row == Some((h, r)), cx)); } } if file.truncated { @@ -694,7 +728,16 @@ impl Tty7App { /// One side-by-side row: the old (left) and new (right) cells with a hairline /// splitter between them. A `None` cell — no counterpart on that side — /// paints a muted placeholder so a pure add/remove reads as one column empty. - fn diff_split_row(&self, row: &SplitRow, cx: &Context) -> AnyElement { + /// + /// `closes_card` marks the row that sits on the card's bottom edge; its two + /// outer cells then round their outer bottom corner, since the card's clip + /// cannot do it for them (see `ui::rounding`). + fn diff_split_row(&self, row: &SplitRow, closes_card: bool, cx: &Context) -> AnyElement { + let radius = if closes_card { + rounding::inner_radius(rounding::CARD_RADIUS, rounding::HAIRLINE) + } else { + px(0.) + }; h_flex() .w_full() // Fixed row height so blank diff lines don't collapse. @@ -702,21 +745,30 @@ impl Tty7App { .items_stretch() .text_xs() .font_family(self.font_family.clone()) - .child(self.diff_split_cell(row.left.as_ref(), Side::Old, cx)) + .child(self.diff_split_cell(row.left.as_ref(), Side::Old, radius, cx)) .child(div().flex_shrink_0().w(px(1.)).bg(cx.theme().border)) - .child(self.diff_split_cell(row.right.as_ref(), Side::New, cx)) + .child(self.diff_split_cell(row.right.as_ref(), Side::New, radius, cx)) .into_any_element() } /// One half of a split row: a right-aligned line-number gutter, then the /// marker and text in the terminal font, tinted green/red when changed. + /// + /// `outer_radius` rounds the cell's own outer bottom corner — non-zero only + /// on the row that closes the card, whose tint would otherwise square that + /// corner off (see `ui::rounding`). fn diff_split_cell( &self, cell: Option<&SplitCell>, side: Side, + outer_radius: Pixels, cx: &Context, ) -> AnyElement { let base = h_flex().flex_1().min_w_0().h_full().items_center(); + let base = match side { + Side::Old => base.rounded_bl(outer_radius), + Side::New => base.rounded_br(outer_radius), + }; let Some(cell) = cell else { return base.bg(cx.theme().muted.opacity(0.3)).into_any_element(); }; @@ -749,17 +801,27 @@ impl Tty7App { /// has no blob to diff a never-added file against, but hiding them would /// read as lost work (agents create files constantly). fn diff_untracked_section(&self, untracked: &[String], cx: &Context) -> AnyElement { + // Same filled-band-in-a-rounded-card shape as `diff_file_card`, so the + // header owns the corners it sits in. The rows below it paint no fill, + // which is why only the top pair is ever non-zero here. + let header_corners = rounding::stack_corners( + 0, + if untracked.is_empty() { 1 } else { 2 }, + rounding::CARD_RADIUS, + rounding::HAIRLINE, + ); let mut section = v_flex() .w_full() .border_1() .border_color(cx.theme().border) - .rounded_md() + .rounded(rounding::CARD_RADIUS) .overflow_hidden() .child( div() .w_full() .px_2p5() .py_1p5() + .rounded_corners(header_corners) .bg(cx.theme().secondary) .text_xs() .text_color(cx.theme().muted_foreground) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 9a3f98f4..27133b5d 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -31,6 +31,7 @@ pub mod remote_connect; pub mod remote_workspace; pub mod reorder; pub mod right_panel; +pub mod rounding; pub mod scrollbar; pub mod settings; pub mod sftp; diff --git a/src/ui/rounding.rs b/src/ui/rounding.rs new file mode 100644 index 00000000..6c481f09 --- /dev/null +++ b/src/ui/rounding.rs @@ -0,0 +1,214 @@ +//! Corner radii for children that paint a fill at the end of a rounded track. +//! +//! # The bug this exists to prevent (issue #236) +//! +//! gpui's `overflow_hidden` does **not** round-clip. [`gpui::ContentMask`] is a +//! plain axis-aligned `Bounds` with no corner radii (`Style::overflow_mask` +//! builds it from the element's bounds, shrunk by the border widths, and throws +//! `corner_radii` away), and every shader tests it as a hard per-fragment +//! `clip_distances < 0` discard. So the mask can only ever cut a square, and it +//! cuts it without a hint of anti-aliasing. +//! +//! A container's *own* rounded corners come from somewhere else entirely: the +//! quad shader's signed-distance field, whose `saturate(0.5 - distance)` gives +//! a clean one-device-pixel edge. That is why a plain rounded card looks +//! smooth. The moment a **child** paints a background into that same corner, +//! the two paths diverge: +//! +//! | | corner comes from | anti-aliased | +//! |---|---|---| +//! | the track's border/fill | quad SDF | yes | +//! | a child's fill in that corner | rectangular content mask | **no** | +//! +//! The child fills the whole square corner, the track's border arc floats +//! *inside* that square, and the child's outer edge is a hard vertical cut. It +//! reads exactly like the report: "anti-aliasing is insufficient". +//! +//! # The rule +//! +//! A child that lands in a corner has to carry its own radius, so its fill is +//! drawn by the SDF path too. It sits one border-width inside the track, so the +//! radius that nests concentrically is `outer - border` — see [`inner_radius`]. +//! +//! The clip is still worth keeping as a backstop for content overflow; it just +//! can't be the thing that shapes a corner. + +use gpui::{Corners, Pixels, Styled, px}; + +/// `rounded_*` one corner at a time is what gpui offers; this takes the whole +/// [`Corners`] the helpers below return, so a caller never has to spell out four +/// setters and risk transposing two of them. +pub(crate) trait RoundedCorners: Styled + Sized { + fn rounded_corners(self, corners: Corners) -> Self { + self.rounded_tl(corners.top_left) + .rounded_tr(corners.top_right) + .rounded_bl(corners.bottom_left) + .rounded_br(corners.bottom_right) + } +} + +impl RoundedCorners for T {} + +/// gpui's `rounded_lg`, resolved. The Tailwind scale is in `rems`, and every +/// radius here has to do arithmetic against a `px` border width, so the tracks +/// this module serves state their radius in pixels. tty7 never calls +/// `set_rem_size`, so this is the same 8px `rounded_lg()` paints. +pub(crate) const TRACK_RADIUS: Pixels = px(8.); + +/// gpui's `rounded_md`, resolved — see [`TRACK_RADIUS`]. +pub(crate) const CARD_RADIUS: Pixels = px(6.); + +/// The hairline every outlined track in the app draws (`border_1()`). +pub(crate) const HAIRLINE: Pixels = px(1.); + +/// The radius a child needs so its fill follows the *inside* of a rounded, +/// bordered track instead of squaring off the corner it sits in. +/// +/// The child's box starts one border-width in from the track's outer edge (that +/// is also where `Style::overflow_mask` puts the clip), so the concentric arc — +/// same centre, tighter by the border — has radius `outer - border`. Any larger +/// and the child bulges past the border and gets square-clipped again; any +/// smaller and a sliver of track shows between the border and the fill. +/// +/// Clamped at zero: a border wider than the radius leaves a square corner, +/// which is what a concentric inset actually gives you there. +pub(crate) fn inner_radius(outer: Pixels, border: Pixels) -> Pixels { + let inset = outer - border; + if inset > px(0.) { inset } else { px(0.) } +} + +/// Which corners segment `i` of `count` owns in a horizontal track. +/// +/// The two end segments cap the track and take its rounding; everything between +/// them is square, because its corners are interior seams. A one-option track +/// is both ends at once, so it takes all four. +/// +/// `count == 0` never renders a segment, but returning square corners keeps the +/// function total rather than making callers guard it. +pub(crate) fn segment_corners( + i: usize, + count: usize, + outer: Pixels, + border: Pixels, +) -> Corners { + let r = inner_radius(outer, border); + let zero = px(0.); + // An index past the end is not an end cap — `i < count` keeps a degenerate + // or out-of-range call square rather than rounding a corner that has no + // segment to draw it. + let first = i < count && i == 0; + let last = i < count && i + 1 == count; + Corners { + top_left: if first { r } else { zero }, + bottom_left: if first { r } else { zero }, + top_right: if last { r } else { zero }, + bottom_right: if last { r } else { zero }, + } +} + +/// [`segment_corners`] for a vertical stack — a card whose children are bands +/// stacked top to bottom. The first band caps the top of the card, the last caps +/// the bottom; a lone band caps both. +pub(crate) fn stack_corners( + i: usize, + count: usize, + outer: Pixels, + border: Pixels, +) -> Corners { + let r = inner_radius(outer, border); + let zero = px(0.); + // An index past the end is not an end cap — `i < count` keeps a degenerate + // or out-of-range call square rather than rounding a corner that has no + // segment to draw it. + let first = i < count && i == 0; + let last = i < count && i + 1 == count; + Corners { + top_left: if first { r } else { zero }, + top_right: if first { r } else { zero }, + bottom_left: if last { r } else { zero }, + bottom_right: if last { r } else { zero }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The whole point: the child's arc is concentric with the border's inner + /// edge, so it is *strictly* tighter than the track's outer radius. A child + /// that reused the track's own radius would bulge past the border and hit + /// the square content mask again — the shape of issue #236. + #[test] + fn inner_radius_insets_by_the_border() { + assert_eq!(inner_radius(px(8.), px(1.)), px(7.)); + assert_eq!(inner_radius(px(6.), px(1.)), px(5.)); + assert!(inner_radius(TRACK_RADIUS, HAIRLINE) < TRACK_RADIUS); + assert!(inner_radius(CARD_RADIUS, HAIRLINE) < CARD_RADIUS); + } + + /// A border at least as wide as the radius eats the curve; the inset must + /// bottom out at a square corner rather than going negative, which gpui + /// would carry straight into the shader. + #[test] + fn inner_radius_never_goes_negative() { + assert_eq!(inner_radius(px(1.), px(1.)), px(0.)); + assert_eq!(inner_radius(px(2.), px(6.)), px(0.)); + } + + #[test] + fn end_segments_cap_the_track_and_the_middle_stays_square() { + let r = inner_radius(TRACK_RADIUS, HAIRLINE); + let zero = px(0.); + + let first = segment_corners(0, 3, TRACK_RADIUS, HAIRLINE); + assert_eq!((first.top_left, first.bottom_left), (r, r)); + assert_eq!((first.top_right, first.bottom_right), (zero, zero)); + + let middle = segment_corners(1, 3, TRACK_RADIUS, HAIRLINE); + assert_eq!(middle, Corners::all(zero)); + + let last = segment_corners(2, 3, TRACK_RADIUS, HAIRLINE); + assert_eq!((last.top_right, last.bottom_right), (r, r)); + assert_eq!((last.top_left, last.bottom_left), (zero, zero)); + } + + /// One option is both ends of the track, so it has to round all four — + /// otherwise a single-segment control squares off both sides. + #[test] + fn a_lone_segment_takes_every_corner() { + let r = inner_radius(TRACK_RADIUS, HAIRLINE); + assert_eq!( + segment_corners(0, 1, TRACK_RADIUS, HAIRLINE), + Corners::all(r) + ); + } + + /// Total for the degenerate count rather than a panic: no segment renders, + /// so no corner is an end. + #[test] + fn an_empty_track_has_no_end_caps() { + assert_eq!( + segment_corners(0, 0, TRACK_RADIUS, HAIRLINE), + Corners::all(px(0.)) + ); + } + + /// A stack caps top and bottom where a segmented track caps left and right — + /// same rule, rotated. + #[test] + fn a_stack_caps_its_first_and_last_band() { + let r = inner_radius(CARD_RADIUS, HAIRLINE); + let zero = px(0.); + + let top = stack_corners(0, 2, CARD_RADIUS, HAIRLINE); + assert_eq!((top.top_left, top.top_right), (r, r)); + assert_eq!((top.bottom_left, top.bottom_right), (zero, zero)); + + let bottom = stack_corners(1, 2, CARD_RADIUS, HAIRLINE); + assert_eq!((bottom.bottom_left, bottom.bottom_right), (r, r)); + assert_eq!((bottom.top_left, bottom.top_right), (zero, zero)); + + // A collapsed card is one band, so its header owns every corner. + assert_eq!(stack_corners(0, 1, CARD_RADIUS, HAIRLINE), Corners::all(r)); + } +} diff --git a/src/ui/settings.rs b/src/ui/settings.rs index db242608..912a3fa8 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -42,6 +42,8 @@ use crate::ui::app::{ }; use crate::ui::host_ops::HostId; use crate::ui::presets; +use crate::ui::rounding; +use crate::ui::rounding::RoundedCorners as _; /// Which section of the settings panel is currently selected in the sidebar. /// Sections are named for the *object* being configured (the appearance, the @@ -1385,10 +1387,11 @@ impl Tty7App { // forward rules) can carry an id derived from their index. let id: SharedString = id.into(); let on_pick = std::rc::Rc::new(on_pick); + let count = options.len(); h_flex() .id(gpui::ElementId::Name(id.clone())) .h(px(24.)) - .rounded_lg() + .rounded(rounding::TRACK_RADIUS) .border_1() .border_color(border) // The track paints its own ground rather than letting the sheet show @@ -1397,12 +1400,21 @@ impl Tty7App { // leaves its ground to whatever it happens to be composited over is // the shape of the bug this whole change is about. .bg(gpui::rgb(sf.base)) - // One track, clipped so the end segments' fills follow its rounding - // instead of squaring off the corners they sit in. + // A backstop for content overflow, and nothing more. This used to be + // what shaped the end segments' fills to the track's rounding, and it + // cannot do that: gpui's overflow mask is a square, unantialiased + // scissor (issue #236, see `ui::rounding`). The segments carry their + // own radii below. .overflow_hidden() .children(options.iter().enumerate().map(|(i, label)| { let active = i == selected; let on_pick = on_pick.clone(); + // The two end segments cap the track, so their fills have to draw + // the corner themselves — one border-width tighter than the + // track's own radius, so the arc nests inside the border instead + // of bulging past it into the square clip. + let corners = + rounding::segment_corners(i, count, rounding::TRACK_RADIUS, rounding::HAIRLINE); h_flex() // A per-segment id keeps each one unique across the several // segmented controls on the page. @@ -1413,6 +1425,7 @@ impl Tty7App { .px_2p5() .text_sm() .cursor_pointer() + .rounded_corners(corners) // Hairlines *between* segments only — the track already owns // its outer edge, and a border on the first segment would // double it. @@ -1471,16 +1484,32 @@ impl Tty7App { .any(|(tag, value)| tag == "liga" && *value != 0) }); - // Unified −/value/+ stepper plus a quiet Reset. - let step = move |id: &'static str, glyph: &'static str, divider: bool| { - div() + // Unified −/value/+ stepper plus a quiet Reset. `slot` is the glyph's + // place in the three-slot track (−│value│+): it draws the internal + // hairline, and — because a hover fill in an end slot would otherwise + // square off the track's corner (issue #236) — the corner radii. + // + // `h_full` rather than `py_1` is load-bearing for that second job. A + // padded, auto-height glyph box measures 31px (14px text × gpui's φ line + // height, plus 8px of padding) against a 22px content box, so the track's + // `items_center` centres it and `overflow_hidden` crops the overhang — + // the fill still reaches the corner, but the box's own rounded corner is + // 4½px outside the visible strip, where it does nothing. Pinning the box + // to the track's content height puts the arc back where the corner is. + let step = move |id: &'static str, glyph: &'static str, slot: usize| { + let corners = + rounding::segment_corners(slot, 3, rounding::TRACK_RADIUS, rounding::HAIRLINE); + h_flex() .id(id) + .items_center() + .justify_center() + .h_full() .px_2p5() - .py_1() .text_sm() .cursor_pointer() .text_color(foreground) - .when(divider, |s| s.border_l_1().border_color(border)) + .when(slot > 0, |s| s.border_l_1().border_color(border)) + .rounded_corners(corners) .hover(|h| h.bg(hover_bg)) .child(glyph) }; @@ -1503,10 +1532,13 @@ impl Tty7App { h_flex() .items_center() .h(control_h) - .rounded_lg() + .rounded(rounding::TRACK_RADIUS) .bg(stepper_bg) .border_1() .border_color(border) + // Overflow backstop only — `step` rounds its own + // corners, because this clip is square (see + // `ui::rounding`). .overflow_hidden() .child(dec) .child( @@ -1527,11 +1559,11 @@ impl Tty7App { .into_any_element() }; let font_size_control = stepper_row( - step("font-dec", "−", false).on_click( + step("font-dec", "−", 0).on_click( cx.listener(|this, _, _w, cx| this.change_font_size(-FONT_SIZE_STEP, cx)), ), format!("{:.0}", font_size), - step("font-inc", "+", true) + step("font-inc", "+", 2) .on_click(cx.listener(|this, _, _w, cx| this.change_font_size(FONT_SIZE_STEP, cx))), Button::new("font-reset") .label("Reset") @@ -1542,11 +1574,11 @@ impl Tty7App { let line_height = self.line_height; let line_height_control = stepper_row( - step("lh-dec", "−", false).on_click( + step("lh-dec", "−", 0).on_click( cx.listener(|this, _, _w, cx| this.change_line_height(-LINE_HEIGHT_STEP, cx)), ), format!("{:.2}", line_height), - step("lh-inc", "+", true).on_click( + step("lh-inc", "+", 2).on_click( cx.listener(|this, _, _w, cx| this.change_line_height(LINE_HEIGHT_STEP, cx)), ), Button::new("lh-reset") @@ -4940,7 +4972,15 @@ impl Tty7App { } let id = p.id.clone(); let is_active = active_id == id; - let preview = self.theme_preview(&p); + // Here the preview sits *flush* inside the card's border (the + // "Current theme" card pads it, so it keeps its own 8px there). Flush + // means its corner has to nest one hairline inside the card's, or it + // bulges past the border into the square overflow clip — the corner + // then reads as a hard step instead of an arc (issue #236). + let preview = self.theme_preview(&p).rounded(rounding::inner_radius( + rounding::TRACK_RADIUS, + rounding::HAIRLINE, + )); let click_id = id.clone(); list = list.child( v_flex() @@ -4954,7 +4994,7 @@ impl Tty7App { // the search box above is sized explicitly. div() .w(px(268.)) - .rounded_lg() + .rounded(rounding::TRACK_RADIUS) .overflow_hidden() .border_1() .border_color(if is_active {