diff --git a/crates/slag-core/src/host.rs b/crates/slag-core/src/host.rs index 8ae2f07..ed2a8de 100644 --- a/crates/slag-core/src/host.rs +++ b/crates/slag-core/src/host.rs @@ -284,4 +284,15 @@ pub fn register_builtins(module: &mut BuiltInModule) { let Some(route) = key_of(&route) else { return }; with_ctx(|c| state_set(&c.state, "nav", SteelVal::StringV(route.as_str().into()))); }); + + // --- window -------------------------------------------------------------- + + // Requests window fullscreen (#t) or windowed (#f). One-shot: the component + // consumes the slot on its next render, where the platform window is in + // reach. Read `(state-ref 'window-fullscreen)` for the actual state — the + // request and reality can disagree briefly, and the OS can exit fullscreen + // on its own. + module.register_fn("fullscreen!", |want: bool| { + with_ctx(|c| state_set(&c.state, "window-fullscreen-request", SteelVal::BoolV(want))); + }); } diff --git a/crates/slag-core/src/prelude/ui.scm b/crates/slag-core/src/prelude/ui.scm index d0f6a56..b3ca8c9 100644 --- a/crates/slag-core/src/prelude/ui.scm +++ b/crates/slag-core/src/prelude/ui.scm @@ -27,6 +27,8 @@ ;; leaves / widgets text icon avatar spinner skeleton badge kbd progress video button input alert slider checkbox toggle-switch select key-capture + ;; composites + tab-row modal ;; helpers el? el-kind el-props el-children el-handlers prop sym spacer) @@ -154,6 +156,46 @@ (define (video key . args) (make-el 'video (cons '#:key (cons key args)))) +;; ---- composites ------------------------------------------------------------- +;; Pure element helpers. The prelude has no state access (state-ref lives on +;; the app's host module), so these take current values and callbacks. + +;; A row of text tabs: `tabs` is a list of (id "Label") pairs, `active` the +;; currently-selected id, `on-pick` a (lambda (id) ...) fired on click. +;; Compare tab ids with equal?, as always at the FFI boundary. +(define (tab-row active tabs on-pick) + (h-flex #:gap 4 #:w 'full #:pb 8 #:border-b 1 #:border-color 'border + (map (lambda (t) + (let ((id (car t)) (label (cadr t))) + (let ((on (equal? active id))) + (div #:px 12 #:py 6 #:rounded 'md #:cursor-pointer #t + #:bg (and on 'secondary) + #:hover-bg 'secondary + #:on-click (lambda () (on-pick id)) + (text label #:size 'sm + #:weight (if on 'semibold 'medium) + #:color (if on 'foreground 'muted-foreground)))))) + tabs))) + +;; A centered modal: returns the (scrim panel) sibling pair for a screen to +;; splice as its LAST root children (paint order is child order). The scrim +;; dims the window and dismisses on mouse-down; the panel swallows clicks so +;; the scrim never fires for clicks aimed at it. Scrim and panel must stay +;; siblings — nested, a click inside would also reach the dismiss handler. +;; `ww`/`wh` are the viewport size (pass the framework-maintained +;; window-width / window-height state), `w`/`h` the fixed panel size. +(define (modal ww wh w h on-dismiss . children) + (list + (div #:absolute #t #:inset-0 #t #:bg "#000000" #:bg-alpha 0.5 + #:on-click on-dismiss) + (v-flex #:absolute #t + #:left (/ (- ww w) 2) + #:top (/ (- wh h) 2) + #:w w #:h h #:p 16 #:gap 12 + #:rounded 'lg #:bg 'popover #:border 1 #:border-color 'border + #:shadow 'md #:swallow-clicks #t + children))) + ;; ---- interactive widgets ---------------------------------------------------- (define (button id . args) diff --git a/crates/slag-core/src/runtime.rs b/crates/slag-core/src/runtime.rs index f67895d..51e9329 100644 --- a/crates/slag-core/src/runtime.rs +++ b/crates/slag-core/src/runtime.rs @@ -241,6 +241,22 @@ mod tests { assert_eq!(out.last(), Some(&SteelVal::IntV(1))); } + /// The composites are pure element helpers: `tab-row` builds one clickable + /// div per tab, `modal` a (scrim panel) sibling pair for the screen root. + #[test] + fn prelude_composites_build() { + let rt = runtime(bundled_only()); + let out = rt + .run(&format!( + r#"(require "{UI_MODULE}") + (+ (* 10 (length (el-children + (tab-row 'a '((a "A") (b "B")) (lambda (id) id))))) + (length (modal 800 600 400 300 (lambda () #f) (text "hi"))))"# + )) + .expect("composites failed"); + assert_eq!(out.last(), Some(&SteelVal::IntV(22))); + } + /// A component loaded under a prefix must expose its own `render`, which is /// what lets every component share one engine. #[test] diff --git a/crates/slag-gpui/src/component.rs b/crates/slag-gpui/src/component.rs index 7a475a1..545d9dd 100644 --- a/crates/slag-gpui/src/component.rs +++ b/crates/slag-gpui/src/component.rs @@ -844,6 +844,22 @@ impl Render for SchemeComponent { "window-height", SteelVal::NumV(f32::from(window.viewport_size().height) as f64), ); + // Mirror the actual fullscreen state, then honour a pending + // `fullscreen!` request. The request is a one-shot slot consumed here, + // so an OS-initiated exit never fights a stale desire on later renders. + host::state_set( + &self.state, + "window-fullscreen", + SteelVal::BoolV(window.is_fullscreen()), + ); + if let Some(SteelVal::BoolV(want)) = + host::state_get(&self.state, "window-fullscreen-request") + { + host::state_remove(&self.state, "window-fullscreen-request"); + if want != window.is_fullscreen() { + window.toggle_fullscreen(); + } + } let root = self.call_render(); if let Some(el) = &root { diff --git a/crates/slag-gpui/src/materialize.rs b/crates/slag-gpui/src/materialize.rs index dbeae5c..4a967d2 100644 --- a/crates/slag-gpui/src/materialize.rs +++ b/crates/slag-gpui/src/materialize.rs @@ -21,7 +21,7 @@ use std::collections::HashMap; use std::sync::Arc; use gpui::{ - div, img, px, AnyElement, Entity, FontFeatures, Hsla, InteractiveElement, IntoElement, + div, img, px, AnyElement, Div, Entity, FontFeatures, Hsla, InteractiveElement, IntoElement, ObjectFit, ParentElement, RenderImage, ScrollHandle, SharedString, StatefulInteractiveElement, Styled, StyledImage, WeakEntity, Window, }; @@ -532,6 +532,13 @@ fn build_container( } let styled = apply_hover(apply_style(base, &el.style, theme), &el.style, theme); let styled = styled.children(kids); + finish_interactive(styled, el, ctx) +} + +/// The shared interactive tail for boxed elements (containers and video +/// surfaces): tooltip, click handler, swallow-clicks guard. +#[inline(never)] +fn finish_interactive(styled: Div, el: &Element, ctx: &MaterializeCtx) -> AnyElement { // A tooltip needs a stateful element (hover tracking is per-id); the text // doubles as the id, which is unique enough for the labels we hang them on. if let Some(text) = el.tooltip.clone() { @@ -553,7 +560,7 @@ fn build_container( stateful.into_any_element() }; } - // Containers become clickable (via mouse-down, which needs no element id) + // Elements become clickable (via mouse-down, which needs no element id) // when the script attached a handler — that's how hand-rolled triggers // like the user-button card work. if let Some(idx) = el.on_click { @@ -725,7 +732,7 @@ fn build_node( } } Kind::KeyCapture { key, label } => build_key_capture(key, label, &el.style, theme, ctx), - Kind::Video { key } => build_video(key, &el.style, theme, ctx), + Kind::Video { key } => build_video(el, key, theme, ctx), Kind::Avatar { url, fallback } => { let size = match el.style.w { Some(Dim::Px(v)) => v, @@ -979,16 +986,15 @@ fn build_key_capture( /// The frame is looked up in the component-owned map, not fetched from the app, /// so one render pass presents one consistent frame per key. #[inline(never)] -fn build_video(key: &str, style: &Style, theme: &Theme, ctx: &MaterializeCtx) -> AnyElement { - let boxed = apply_style(div(), style, theme).overflow_hidden(); - match ctx.videos.get(key) { - Some(frame) => boxed - .child( - img(frame.clone()) - .size_full() - .object_fit(ObjectFit::Cover), - ) - .into_any_element(), - None => boxed.bg(theme.secondary).into_any_element(), - } +fn build_video(el: &Element, key: &str, theme: &Theme, ctx: &MaterializeCtx) -> AnyElement { + let boxed = apply_style(div(), &el.style, theme).overflow_hidden(); + let boxed = match ctx.videos.get(key) { + Some(frame) => boxed.child( + img(frame.clone()) + .size_full() + .object_fit(ObjectFit::Cover), + ), + None => boxed.bg(theme.secondary), + }; + finish_interactive(boxed, el, ctx) } diff --git a/examples/hello/src/main.rs b/examples/hello/src/main.rs index 6658015..e882c03 100644 --- a/examples/hello/src/main.rs +++ b/examples/hello/src/main.rs @@ -47,6 +47,7 @@ fn main() { let config = Rc::new(UiConfig { env_prefix: "HELLO", icons: Rc::new(|_| None), + video_frames: None, }); // No app context needed: `()` fills the type-erased slot. let hello = cx.new(|cx| {