diff --git a/src/collab/remote.rs b/src/collab/remote.rs index 6cb29dc..66b4fba 100644 --- a/src/collab/remote.rs +++ b/src/collab/remote.rs @@ -8,6 +8,7 @@ use gpui::{ use super::peers::Peer; use crate::{ forms::TextInput, + primitives::caret_bar, theme::{ActiveTheme, Radius, TextSize}, typography::LEADING, }; @@ -61,10 +62,9 @@ impl RenderOnce for RemoteCursor { return; } window.with_content_mask(Some(ContentMask { bounds }), |window| { - window.paint_quad(fill( - gpui::Bounds::new(caret.origin, size(bar, caret.size.height)), - color, - )); + let scale = window.scale_factor(); + let bar = caret_bar(caret.origin, bar, caret.size.height, scale); + window.paint_quad(fill(bar, color)); }); let font = gpui::Font { weight: FontWeight::MEDIUM, diff --git a/src/forms/text/element.rs b/src/forms/text/element.rs index 93fd283..b1cab5b 100644 --- a/src/forms/text/element.rs +++ b/src/forms/text/element.rs @@ -8,7 +8,10 @@ use gpui::{ }; use super::{Highlight, Layout, TextInput}; -use crate::theme::ActiveTheme; +use crate::{ + primitives::{caret_bar, caret_width}, + theme::ActiveTheme, +}; pub(crate) struct TextElement { input: Entity, @@ -214,7 +217,10 @@ impl Element for TextElement { ) -> Prepaint { let theme = cx.theme(); let (selection_color, caret_color) = (theme.colors.selection, theme.colors.focus); - let caret_width = theme.caret_width().to_pixels(window.rem_size()); + let caret_width = caret_width( + theme.caret_width().to_pixels(window.rem_size()), + window.scale_factor(), + ); let input = self.input.read(cx); let (text, placeholder, runs) = shown_runs(input, window, cx); let font_size = window.text_style().font_size.to_pixels(window.rem_size()); @@ -262,7 +268,10 @@ impl Element for TextElement { }; let caret = caret_at .filter(|_| focused && caret_on && selection.is_empty()) - .map(|at| Bounds::new(bounds.origin + at - scroll, size(caret_width, line_height))); + .map(|at| { + let at = bounds.origin + at - scroll; + caret_bar(at, caret_width, line_height, window.scale_factor()) + }); self.input.update(cx, |input, _| input.scroll = scroll); Prepaint { lines, diff --git a/src/primitives/caret.rs b/src/primitives/caret.rs new file mode 100644 index 0000000..45c0da4 --- /dev/null +++ b/src/primitives/caret.rs @@ -0,0 +1,65 @@ +use gpui::{Bounds, Pixels, Point, size}; + +fn snap(logical: Pixels, least: f32, scale: f32) -> Pixels { + Pixels::from((logical.as_f32() * scale).round().max(least) / scale) +} + +/// The width a caret paints at: whole device pixels, at least one. +pub(crate) fn caret_width(width: Pixels, scale: f32) -> Pixels { + snap(width, 1.0, scale) +} + +/// A caret bar at `origin`, `width` wide, snapped whole to device pixels so it keeps one width. +pub(crate) fn caret_bar( + origin: Point, + width: Pixels, + height: Pixels, + scale: f32, +) -> Bounds { + Bounds::new( + Point::new(snap(origin.x, f32::MIN, scale), origin.y), + size(caret_width(width, scale), height), + ) +} + +#[cfg(test)] +mod tests { + use gpui::{point, px}; + + use super::caret_bar; + + /// gpui's paint snapping: each edge alone, half toward zero. + fn device(logical: f32, scale: f32) -> f32 { + let value = logical * scale; + (value.abs() - 0.5).ceil().copysign(value) + } + + #[test] + fn a_caret_keeps_one_device_width_wherever_it_stands() { + for scale in [1.0, 1.25, 1.5, 2.0] { + let widths: Vec = (0..200) + .map(|step| { + let bar = caret_bar( + point(px(10.0 + step as f32 * 0.037), px(3.3)), + px(1.5), + px(18.0), + scale, + ); + device(bar.right().as_f32(), scale) - device(bar.left().as_f32(), scale) + }) + .collect(); + let first = widths[0]; + assert!(first >= 1.0, "at {scale}x the caret shows"); + assert!( + widths.iter().all(|width| *width == first), + "at {scale}x the caret took widths {:?}", + widths.iter().fold(Vec::new(), |mut seen, width| { + if !seen.contains(width) { + seen.push(*width); + } + seen + }) + ); + } + } +} diff --git a/src/primitives/mod.rs b/src/primitives/mod.rs index 35964e7..0060ab0 100644 --- a/src/primitives/mod.rs +++ b/src/primitives/mod.rs @@ -1,4 +1,5 @@ mod backdrop; +mod caret; mod disclosure; mod divider; mod files; @@ -17,6 +18,7 @@ mod tooltip; mod tests; pub use backdrop::{Backdrop, Place}; +pub(crate) use caret::{caret_bar, caret_width}; pub use disclosure::Disclosure; pub use divider::Divider; pub use files::IconTheme; diff --git a/src/terminal/paint.rs b/src/terminal/paint.rs index ac79737..188e13f 100644 --- a/src/terminal/paint.rs +++ b/src/terminal/paint.rs @@ -12,6 +12,7 @@ use super::{ links::Target, view::Terminal, }; +use crate::primitives::caret_bar; /// How a terminal paints: font and cell, ink, and the colors of what it marks. pub(crate) struct Look { @@ -161,10 +162,11 @@ pub(crate) fn grid( (CursorShape::HollowBlock, _) | (_, false) => { outline(Bounds::new(origin, cell), look.caret, BorderStyle::Solid) } - (CursorShape::Beam, true) => fill( - Bounds::new(origin, size(look.caret_width, cell.height)), - look.caret, - ), + (CursorShape::Beam, true) => { + let scale = window.scale_factor(); + let bar = caret_bar(origin, look.caret_width, cell.height, scale); + fill(bar, look.caret) + } (CursorShape::Underline, true) => fill( Bounds::new( origin + point(Pixels::ZERO, cell.height - look.caret_width), diff --git a/tasks/github.md b/tasks/github.md index 8f3d577..09bd867 100644 --- a/tasks/github.md +++ b/tasks/github.md @@ -16,7 +16,7 @@ # GitHub round, 2026-10-08 - [x] G10 CI: `cargo fmt` on the files the last commits left unformatted -- [ ] G11 Issue #7: a caret is a whole number of device pixels wide at a device pixel, so it never shows thin then thick; tested +- [x] G11 Issue #7: a caret is a whole number of device pixels wide at a device pixel, so it never shows thin then thick; tested - [ ] G12 Issue #7: an IME asks for its candidate box past the laid-out text; the box clamps to the last layout, as `bounds_for` does; tested - [ ] G13 Issue #12: a floating option list occludes, so the wheel scrolls the list and not the page under it; tested - [ ] G14 Issue #7: BlockEditor scrolls in its own `ScrollArea` with its scrollbar and keeps the caret in view as blocks are added; tested