Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
95fea42
fix(native): select text across wrapped rows
mateo-m Aug 26, 2026
adedd5e
fix(react): survive a hot remount of the root
mateo-m Aug 26, 2026
73c00b9
fix(native): drive the live scroll wheel from automation
mateo-m Aug 26, 2026
1b99a30
fix(native): sync env overrides into the addon under bun
mateo-m Aug 26, 2026
9949019
fix(react): run the automation stdio test under the bun runner
mateo-m Aug 26, 2026
efd0623
chore(native): tidy comments and docs after the css value work
mateo-m Aug 26, 2026
192bf13
build(native): speed up png encoding in debug builds
mateo-m Aug 26, 2026
78fcb28
chore: merge feat/scrollbars
mateo-m Aug 26, 2026
63a2bfb
chore: merge feat/scrollbars
mateo-m Aug 26, 2026
e699229
fix(native): keep env overrides in a map instead of setenv
mateo-m Aug 26, 2026
b0d74f9
test(react): wash covers the first glyph of a wrapped row
mateo-m Aug 26, 2026
a5c3726
chore: merge feat/scrollbars
mateo-m Aug 26, 2026
2125bb0
chore: merge feat/scrollbars
mateo-m Aug 26, 2026
7d68585
chore: merge feat/scrollbars
mateo-m Aug 26, 2026
5384911
chore: merge feat/scrollbars
mateo-m Aug 26, 2026
59b494a
chore: merge feat/scrollbars
mateo-m Aug 26, 2026
f198210
chore: merge feat/scrollbars
mateo-m Aug 27, 2026
5301201
chore: merge feat/scrollbars
mateo-m Aug 27, 2026
347ca7e
chore: merge feat/scrollbars
mateo-m Aug 27, 2026
3ffa25d
chore: merge feat/scrollbars
mateo-m Aug 28, 2026
31b6d20
chore: merge feat/scrollbars
mateo-m Aug 29, 2026
17c7c9b
chore: merge feat/scrollbars
mateo-m Aug 29, 2026
bfcd1ef
chore: merge feat/scrollbars
mateo-m Sep 4, 2026
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
11 changes: 11 additions & 0 deletions .changeset/select-across-wrapped-rows.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@gpuix/native": patch
---

Cover the first glyph of a wrapped row in the selection wash.

The wash walks the visual rows of a paragraph with `position_for_index`.
The index at a soft-wrap boundary reports its position on the earlier row,
so each walk started one glyph into the next row and the wash missed that
glyph. A continuation row now stretches back to the leading edge of the
layout.
13 changes: 13 additions & 0 deletions .changeset/survive-a-hot-remount.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@gpuix/react": patch
---

Keep events alive across a `bun --hot` remount.

The map from renderer to container lived in the module, and the native
event callback keeps the module instance that created it. A hot reload
evaluates the module again, so the new tree registered its handlers in a
new map while native events searched the old one, and every click died.
The map now lives on `globalThis`, so both module instances share it. The
`onEvent` option also follows the latest `render()` call instead of the
first one.
14 changes: 14 additions & 0 deletions .changeset/sync-env-overrides-under-bun.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@gpuix/native": patch
"@gpuix/react": patch
---

Push `process.env` overrides through to the Rust side under Bun.

Rust reads overrides such as `GPUIX_SCROLLBARS` at paint. Node writes a
`process.env` assignment through to `setenv`, but Bun only updates its JS
snapshot, so a test that set the variable after start had no effect under
`bun test`. The native module now exports `syncEnvVar`, and the test
renderer copies the known overrides across before every frame flush. The
values land in an override map, not in the real environment, because
`setenv` races `getenv` on the dedicated UI thread of Windows and Linux.
18 changes: 18 additions & 0 deletions packages/native/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,24 @@ lto = true
# that with "mis-aligned LINKEDIT string pool", so the addon fails to load.
strip = "none"

# capture_screenshot encodes a png on every visual test. The encoder is
# too slow at opt-level 0 and vitest kills the worker, so build the
# image crates with opt-level 3 in debug builds too.
[profile.dev.package.png]
opt-level = 3

[profile.dev.package.image]
opt-level = 3

[profile.dev.package.fdeflate]
opt-level = 3

[profile.dev.package.flate2]
opt-level = 3

[profile.dev.package.miniz_oxide]
opt-level = 3

# The style parse benchmark. `harness = false` because it prints its own
# numbers rather than running as a test.
[[bench]]
Expand Down
10 changes: 10 additions & 0 deletions packages/native/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,16 @@ export interface HighlightRect {
height: number
}

/**
* Records one `process.env` entry for `env_var` readers.
*
* Rust reads overrides such as `GPUIX_SCROLLBARS` at paint. Node writes a
* `process.env` assignment through to `setenv`, but Bun only updates its
* JS snapshot. A caller on Bun must push the value across with this
* function.
*/
export declare function syncEnvVar(key: string, value?: string | undefined | null): void

export interface WindowInsets {
safeArea: EdgeInsets
ime: EdgeInsets
Expand Down
1 change: 1 addition & 0 deletions packages/native/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -578,3 +578,4 @@ if (!nativeBinding) {
module.exports = nativeBinding
module.exports.GpuixRenderer = nativeBinding.GpuixRenderer
module.exports.TestGpuixRenderer = nativeBinding.TestGpuixRenderer
module.exports.syncEnvVar = nativeBinding.syncEnvVar
2 changes: 2 additions & 0 deletions packages/native/src/automation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ pub fn bounds_tracker(
},
)
.absolute()
.top_0()
.left_0()
.size_full()
}

Expand Down
28 changes: 20 additions & 8 deletions packages/native/src/motion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -434,9 +434,9 @@ impl MotionState {
(Some(from), Some(target)) if raw < 1.0 => Some((from, target)),
_ => None,
};
if let Some((from, target)) = ends.filter(|(from, target)| {
from.needs_content() || target.needs_content()
}) {
if let Some((from, target)) =
ends.filter(|(from, target)| from.needs_content() || target.needs_content())
{
let visible = from.mix(target, progress).resolve(old);
let end = target.resolve(new);
// The pixels a start needs so that mixing it toward `end` at
Expand Down Expand Up @@ -624,7 +624,7 @@ mod tests {
"animate": { "cornerShape": "square" },
"transition": { "duration": 1.0, "ease": "linear" }
});
let mut state = MotionState::new(&spec, started).unwrap();
let state = MotionState::new(&spec, started).unwrap();
let frame = state.frame(started + Duration::from_millis(500));
let mut style = StyleDesc::default();
frame.style.apply_to(&mut style);
Expand Down Expand Up @@ -707,8 +707,14 @@ mod tests {
let state = MotionState::new(&description, started).unwrap();

assert_eq!(at(state.frame(started)), Some(0.0));
assert_eq!(at(state.frame(started + Duration::from_millis(500))), Some(100.0));
assert_eq!(at(state.frame(started + Duration::from_secs(1))), Some(200.0));
assert_eq!(
at(state.frame(started + Duration::from_millis(500))),
Some(100.0)
);
assert_eq!(
at(state.frame(started + Duration::from_secs(1))),
Some(200.0)
);
}

#[test]
Expand All @@ -730,7 +736,10 @@ mod tests {
state.sync(&closing, settled).unwrap();

assert_eq!(at(state.frame(settled)), Some(200.0));
assert_eq!(at(state.frame(settled + Duration::from_millis(500))), Some(100.0));
assert_eq!(
at(state.frame(settled + Duration::from_millis(500))),
Some(100.0)
);
assert_eq!(at(state.frame(settled + Duration::from_secs(1))), Some(0.0));
}

Expand All @@ -754,7 +763,10 @@ mod tests {

// Half open when it turned, so the collapse starts at half.
assert_eq!(at(state.frame(turned)), Some(100.0));
assert_eq!(at(state.frame(turned + Duration::from_millis(500))), Some(50.0));
assert_eq!(
at(state.frame(turned + Duration::from_millis(500))),
Some(50.0)
);
}

#[test]
Expand Down
32 changes: 29 additions & 3 deletions packages/native/src/renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,35 @@ fn panic_message(payload: Box<dyn std::any::Any + Send>) -> String {
.unwrap_or_else(|| "unknown panic".to_string())
}

/// JS-side values for environment overrides such as `GPUIX_SCROLLBARS`.
///
/// A map instead of `std::env::set_var`, because `setenv` races `getenv`
/// on another thread, and Windows and Linux paint on a dedicated UI thread.
fn env_overrides() -> &'static std::sync::Mutex<HashMap<String, Option<String>>> {
static OVERRIDES: std::sync::OnceLock<std::sync::Mutex<HashMap<String, Option<String>>>> =
std::sync::OnceLock::new();
OVERRIDES.get_or_init(|| std::sync::Mutex::new(HashMap::new()))
}

/// Reads an override, or the real process environment when JS never set one.
pub(crate) fn env_var(key: &str) -> Option<String> {
if let Some(value) = env_overrides().lock().unwrap().get(key) {
return value.clone();
}
std::env::var(key).ok()
}

/// Records one `process.env` entry for `env_var` readers.
///
/// Rust reads overrides such as `GPUIX_SCROLLBARS` at paint. Node writes a
/// `process.env` assignment through to `setenv`, but Bun only updates its
/// JS snapshot. A caller on Bun must push the value across with this
/// function.
#[napi]
pub fn sync_env_var(key: String, value: Option<String>) {
env_overrides().lock().unwrap().insert(key, value);
}

/// The main GPUI renderer exposed to Node.js.
#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
#[napi]
Expand Down Expand Up @@ -3253,7 +3282,6 @@ impl GpuixView {
}
}


impl GpuixView {
/// Sync focus handles with the current element tree.
/// Creates handles for new focusable elements, subscribes on_focus/on_blur,
Expand Down Expand Up @@ -3465,7 +3493,6 @@ impl gpui::Render for GpuixView {
}
}


// ── Event emission ───────────────────────────────────────────────────

/// Helper to convert a GPUI Point<Pixels> to (f64, f64).
Expand Down Expand Up @@ -3504,7 +3531,6 @@ pub(crate) fn emit_event_full(
}
}


// ── Types ────────────────────────────────────────────────────────────

#[derive(Debug, Clone)]
Expand Down
9 changes: 2 additions & 7 deletions packages/native/src/renderer/frame.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,13 +204,8 @@ pub(super) fn build_element(
highlight_set: ctx.highlight.clone(),
cascade: cascade.clone(),
};
ctx.custom_registry.render(
custom_type,
&element.custom_props,
render_ctx,
window,
cx,
)
ctx.custom_registry
.render(custom_type, &element.custom_props, render_ctx, window, cx)
}
};

Expand Down
6 changes: 3 additions & 3 deletions packages/native/src/renderer/scrollbar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,9 @@ pub(crate) enum Mode {
impl Mode {
/// The mode for this window, with the environment override on top.
pub(crate) fn current(cx: &App) -> Self {
match std::env::var("GPUIX_SCROLLBARS").as_deref() {
Ok("overlay") => Mode::Overlay,
Ok("classic") => Mode::Classic,
match crate::renderer::env_var("GPUIX_SCROLLBARS").as_deref() {
Some("overlay") => Mode::Overlay,
Some("classic") => Mode::Classic,
_ if cx.should_auto_hide_scrollbars() => Mode::Overlay,
_ => Mode::Classic,
}
Expand Down
22 changes: 17 additions & 5 deletions packages/native/src/style.rs
Original file line number Diff line number Diff line change
Expand Up @@ -675,7 +675,10 @@ mod tests {
let written = serde_json::to_value(StyleDesc::default()).unwrap();
let written = written.as_object().unwrap();
for name in written.keys() {
assert!(FIELDS.contains(&name.as_str()), "`{name}` is written but never read");
assert!(
FIELDS.contains(&name.as_str()),
"`{name}` is written but never read"
);
}
assert_eq!(written.len(), FIELDS.len());
}
Expand All @@ -698,7 +701,10 @@ mod tests {
assert_eq!(style.gap, Some(Numeric::Text("var(--gap)".to_owned())));
assert_eq!(style.width, Some(Numeric::Text("100%".to_owned())));
assert_eq!(style.height, Some(Numeric::Text("auto".to_owned())));
assert_eq!(style.font_weight, Some(FontWeightValue::Str("bold".to_owned())));
assert_eq!(
style.font_weight,
Some(FontWeightValue::Str("bold".to_owned()))
);
assert_eq!(style.line_clamp, None);
assert_eq!(style.hover.unwrap().color.as_deref(), Some("red"));
}
Expand All @@ -724,12 +730,16 @@ mod tests {
let style: StyleDesc =
serde_json::from_str(r#"{ "gap": 4, "gap": 8, "--pad": 1, "--pad": 2 }"#).unwrap();
assert_eq!(style.gap, Some(Numeric::Number(8.0)));
assert_eq!(declared_variables(&style), vec![("--pad".to_owned(), "2".to_owned())]);
assert_eq!(
declared_variables(&style),
vec![("--pad".to_owned(), "2".to_owned())]
);
}

#[test]
fn the_boxed_read_and_the_ordinary_read_agree() {
let json = r#"{ "gap": 8, "color": "red", "--pad": "4px", "hover": { "gap": 2 }, "nope": 1 }"#;
let json =
r#"{ "gap": 8, "color": "red", "--pad": "4px", "hover": { "gap": 2 }, "nope": 1 }"#;
assert_eq!(
*StyleDesc::from_json_boxed(json).unwrap(),
serde_json::from_str::<StyleDesc>(json).unwrap()
Expand All @@ -748,7 +758,9 @@ mod tests {
font_size: Some(Numeric::Number(14.0)),
max_width: Some(Numeric::Number(320.0)),
user_select: Some("none".to_owned()),
custom: [("--pad".to_owned(), serde_json::json!("8px"))].into_iter().collect(),
custom: [("--pad".to_owned(), serde_json::json!("8px"))]
.into_iter()
.collect(),
hover: Some(Box::new(StyleDesc {
background_color: Some("#fff".to_owned()),
..Default::default()
Expand Down
47 changes: 33 additions & 14 deletions packages/native/src/style/vars.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,7 @@ pub(crate) struct Scope<'a> {
}

impl<'a> Scope<'a> {
pub fn new(
variables: &'a Variables,
current_color: Rgba,
dark: bool,
rem_size: f32,
) -> Self {
pub fn new(variables: &'a Variables, current_color: Rgba, dark: bool, rem_size: f32) -> Self {
Self {
variables,
current_color,
Expand Down Expand Up @@ -541,7 +536,10 @@ mod tests {
fn a_bare_number_needs_no_resolving() {
let variables = scope_of(&[]);
let scope = Scope::new(&variables, Rgba::BLACK, false, 16.0);
assert_eq!(scope.number(&Some(crate::style::Numeric::Number(8.0))), Some(8.0));
assert_eq!(
scope.number(&Some(crate::style::Numeric::Number(8.0))),
Some(8.0)
);
assert!(!scope.used_a_variable());
}

Expand Down Expand Up @@ -612,10 +610,22 @@ mod tests {
use crate::style::{DimensionValue, Numeric};
let text = |t: &str| Some(Numeric::Text(t.to_string()));

assert_eq!(dimension(Some(Numeric::Number(200.0)), &[]), Some(DimensionValue::Pixels(200.0)));
assert_eq!(dimension(text("200px"), &[]), Some(DimensionValue::Pixels(200.0)));
assert_eq!(dimension(text("6rem"), &[]), Some(DimensionValue::Pixels(96.0)));
assert_eq!(dimension(text("calc(100px + 2rem)"), &[]), Some(DimensionValue::Pixels(132.0)));
assert_eq!(
dimension(Some(Numeric::Number(200.0)), &[]),
Some(DimensionValue::Pixels(200.0))
);
assert_eq!(
dimension(text("200px"), &[]),
Some(DimensionValue::Pixels(200.0))
);
assert_eq!(
dimension(text("6rem"), &[]),
Some(DimensionValue::Pixels(96.0))
);
assert_eq!(
dimension(text("calc(100px + 2rem)"), &[]),
Some(DimensionValue::Pixels(132.0))
);
assert_eq!(
dimension(text("calc(var(--spacing) * 30)"), &[("--spacing", "4px")]),
Some(DimensionValue::Pixels(120.0))
Expand All @@ -627,10 +637,16 @@ mod tests {
use crate::style::{DimensionValue, Numeric};
let text = |t: &str| Some(Numeric::Text(t.to_string()));

assert_eq!(dimension(text("50%"), &[]), Some(DimensionValue::Percentage(0.5)));
assert_eq!(
dimension(text("50%"), &[]),
Some(DimensionValue::Percentage(0.5))
);
assert_eq!(dimension(text("auto"), &[]), Some(DimensionValue::Auto));
assert_eq!(dimension(text("AUTO"), &[]), Some(DimensionValue::Auto));
assert_eq!(dimension(text("var(--w)"), &[("--w", "auto")]), Some(DimensionValue::Auto));
assert_eq!(
dimension(text("var(--w)"), &[("--w", "auto")]),
Some(DimensionValue::Auto)
);
}

#[test]
Expand All @@ -649,6 +665,9 @@ mod tests {
#[test]
fn an_absent_declaration_stays_absent() {
let variables = scope_of(&[]);
assert_eq!(Scope::new(&variables, Rgba::BLACK, false, 16.0).number(&None), None);
assert_eq!(
Scope::new(&variables, Rgba::BLACK, false, 16.0).number(&None),
None
);
}
}
Loading
Loading