diff --git a/crates/core/src/link.rs b/crates/core/src/link.rs new file mode 100644 index 0000000..e2d3631 --- /dev/null +++ b/crates/core/src/link.rs @@ -0,0 +1,122 @@ +use gpui::prelude::FluentBuilder as _; +use gpui::{ + App, BoxShadow, ClickEvent, ElementId, InteractiveElement as _, IntoElement, + ParentElement as _, RenderOnce, Role, SharedString, StatefulInteractiveElement as _, + Styled as _, Window, px, +}; +use gpui_native_theme::ActiveTheme as _; + +use crate::common::h_flex; + +/// The click handler. When set it replaces the default `href` opening. +type ClickHandler = Box; + +/// Link component. +#[derive(IntoElement)] +pub struct Link { + id: ElementId, + label: Option, + href: Option, + disabled: bool, + a11y_label: Option, + on_click: Option, +} + +impl Link { + /// Creates a link without a label or target. + pub fn new(id: I) -> Self + where + I: Into, + { + Self { + id: id.into(), + label: None, + href: None, + disabled: false, + a11y_label: None, + on_click: None, + } + } + + /// Sets the visible label. + pub fn label(mut self, label: L) -> Self + where + L: Into, + { + self.label = Some(label.into()); + self + } + + /// Sets the URL the link opens through the platform. + pub fn href(mut self, href: U) -> Self + where + U: Into, + { + self.href = Some(href.into()); + self + } + + /// Renders the link inert, unfocusable, and dimmed; disabled always wins. + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + /// Overrides the accessibility label, which defaults to the visible label. + pub fn accessibility_label(mut self, label: L) -> Self + where + L: Into, + { + self.a11y_label = Some(label.into()); + self + } + + /// Replaces the default `href` opening with this handler. + pub fn on_click(mut self, handler: F) -> Self + where + F: Fn(&ClickEvent, &mut Window, &mut App) + 'static, + { + self.on_click = Some(Box::new(handler)); + self + } +} + +impl RenderOnce for Link { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors; + let ramps = theme.ramps; + let on_click = self.on_click; + let href = self.href; + let a11y_label = self.a11y_label.or_else(|| self.label.clone()); + + h_flex() + .id(self.id) + .text_sm() + .text_color(colors.accent) + .underline() + .role(Role::Link) + .when_some(a11y_label, |link, label| link.aria_label(label)) + .when(self.disabled, |link| link.opacity(0.5)) + .when(!self.disabled, |link| { + link.cursor_pointer() + .focusable() + .tab_stop(true) + .hover(move |style| style.text_color(ramps.primary.hover)) + .focus_visible(|style| { + style.shadow(vec![ + BoxShadow::new(px(0.), px(0.), colors.ring).spread_radius(px(3.)), + ]) + }) + .on_click(move |event, window, cx| match on_click.as_ref() { + Some(on_click) => on_click(event, window, cx), + None => { + if let Some(href) = href.as_ref() { + cx.open_url(href); + } + } + }) + }) + .when_some(self.label, |link, label| link.child(label)) + } +} diff --git a/crates/core/src/toggle.rs b/crates/core/src/toggle.rs new file mode 100644 index 0000000..9f97d7e --- /dev/null +++ b/crates/core/src/toggle.rs @@ -0,0 +1,128 @@ +use gpui::prelude::FluentBuilder as _; +use gpui::{ + App, BoxShadow, ElementId, InteractiveElement as _, IntoElement, ParentElement as _, + RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _, Toggled, Window, + px, +}; +use gpui_native_theme::ActiveTheme as _; + +use crate::common::h_flex; + +/// The change handler, fired with the next pressed state. +type ChangeHandler = Box; + +/// A toggle component. +#[derive(IntoElement)] +pub struct Toggle { + id: ElementId, + label: Option, + pressed: bool, + disabled: bool, + a11y_label: Option, + on_change: Option, +} + +impl Toggle { + /// Creates an unpressed toggle. + pub fn new(id: I) -> Self + where + I: Into, + { + Self { + id: id.into(), + label: None, + pressed: false, + disabled: false, + a11y_label: None, + on_change: None, + } + } + + /// Sets the visible label. + pub fn label(mut self, label: L) -> Self + where + L: Into, + { + self.label = Some(label.into()); + self + } + + /// Sets the current pressed state; the toggle never owns it. + pub fn pressed(mut self, pressed: bool) -> Self { + self.pressed = pressed; + self + } + + /// Renders the toggle inert, unfocusable, and dimmed; disabled always wins. + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + /// Overrides the accessibility label, which defaults to the visible label. + pub fn accessibility_label(mut self, label: L) -> Self + where + L: Into, + { + self.a11y_label = Some(label.into()); + self + } + + /// Sets the change handler, fired with the next pressed state. + pub fn on_change(mut self, handler: F) -> Self + where + F: Fn(bool, &mut Window, &mut App) + 'static, + { + self.on_change = Some(Box::new(handler)); + self + } +} + +impl RenderOnce for Toggle { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors; + let ramps = theme.ramps; + let on_change = self.on_change; + let next = !self.pressed; + let a11y_label = self.a11y_label.or_else(|| self.label.clone()); + + h_flex() + .id(self.id) + .justify_center() + .h_7() + .px_3() + .rounded(theme.radius.control) + .text_sm() + .text_color(colors.foreground) + .role(Role::Button) + .when_some(a11y_label, |toggle, label| toggle.aria_label(label)) + .aria_toggled(Toggled::from(self.pressed)) + .when(self.pressed, |toggle| { + toggle + .bg(ramps.secondary.hover) + .hover(|style| style.bg(ramps.secondary.pressed)) + }) + .when(!self.pressed, |toggle| { + toggle + .hover(|style| style.bg(colors.muted)) + .active(|style| style.bg(ramps.secondary.hover)) + }) + .when(self.disabled, |toggle| toggle.opacity(0.5)) + .when(!self.disabled, |toggle| { + toggle + .cursor_pointer() + .focusable() + .tab_stop(true) + .focus_visible(|style| { + style.shadow(vec![ + BoxShadow::new(px(0.), px(0.), colors.ring).spread_radius(px(3.)), + ]) + }) + .when_some(on_change, |toggle, on_change| { + toggle.on_click(move |_, window, cx| on_change(next, window, cx)) + }) + }) + .when_some(self.label, |toggle, label| toggle.child(label)) + } +} diff --git a/crates/core/tests/link.rs b/crates/core/tests/link.rs new file mode 100644 index 0000000..6a7fc6a --- /dev/null +++ b/crates/core/tests/link.rs @@ -0,0 +1,133 @@ +use std::{cell::RefCell, rc::Rc}; + +use gpui::{ + AnyWindowHandle, AppContext as _, Context, InputEvent as _, IntoElement, KeyDownEvent, + KeyUpEvent, Keystroke, Modifiers, ParentElement as _, Render, Styled as _, TestAppContext, + VisualTestContext, Window, div, point, px, +}; +use gpui_native_core::link::Link; +use gpui_native_theme::Theme; + +struct Harness { + clicks: Rc>, + disabled: bool, + with_override: bool, +} + +impl Render for Harness { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let clicks = self.clicks.clone(); + div().size_full().flex().flex_col().gap_2().child({ + let mut link = Link::new("test-link-href") + .label("Release notes") + .href("https://example.com/release-notes"); + if self.with_override { + link = link.on_click(move |_, _, _| { + *clicks.borrow_mut() += 1; + }); + } + link.disabled(self.disabled) + }) + } +} + +/// A maximized window with two link rows at the top-left; row 1 spans +/// roughly y 0..21, row 2 y 29..50. +fn link_window( + with_override: bool, + disabled: bool, +) -> (TestAppContext, AnyWindowHandle, Rc>) { + let mut cx = TestAppContext::single(); + cx.update(Theme::init); + let clicks = Rc::new(RefCell::new(0usize)); + let harness_clicks = clicks.clone(); + let window = cx.add_window(move |_, _| Harness { + clicks: harness_clicks, + disabled, + with_override, + }); + let window = window.into(); + cx.update_window(window, |_, window, cx| { + window.draw(cx).clear(cx); + }) + .unwrap(); + (cx, window, clicks) +} + +fn draw(cx: &mut TestAppContext, window: AnyWindowHandle) { + cx.update_window(window, |_, window, cx| { + window.draw(cx).clear(cx); + }) + .unwrap(); +} + +#[test] +fn clicking_a_link_with_href_opens_it_through_the_platform() { + let (cx, window, clicks) = link_window(false, false); + let mut visual = VisualTestContext::from_window(window, &cx); + visual.simulate_click(point(px(20.), px(10.)), Modifiers::default()); + assert_eq!( + visual.opened_url().as_deref(), + Some("https://example.com/release-notes") + ); + assert_eq!(*clicks.borrow(), 0); +} + +#[test] +fn on_click_replaces_the_href_opening() { + let (cx, window, clicks) = link_window(true, false); + let mut visual = VisualTestContext::from_window(window, &cx); + visual.simulate_click(point(px(20.), px(10.)), Modifiers::default()); + assert_eq!(*clicks.borrow(), 1); + assert_eq!(visual.opened_url(), None, "the handler replaces the href"); +} + +#[test] +fn disabled_link_does_nothing() { + let (cx, window, clicks) = link_window(false, true); + let mut visual = VisualTestContext::from_window(window, &cx); + visual.simulate_click(point(px(20.), px(10.)), Modifiers::default()); + assert_eq!(visual.opened_url(), None); + assert_eq!(*clicks.borrow(), 0); +} + +#[test] +fn enter_opens_the_href_while_focused() { + let (mut cx, window, clicks) = link_window(false, false); + let mut visual = VisualTestContext::from_window(window, &cx); + visual.simulate_click(point(px(20.), px(10.)), Modifiers::default()); + assert_eq!( + visual.opened_url().as_deref(), + Some("https://example.com/release-notes"), + "the pointer click itself opens" + ); + + // Repaint with the link focused so its Enter/Space handlers register. + draw(&mut cx, window); + + cx.update_window(window, |_, window, cx| { + window.dispatch_event( + KeyDownEvent { + keystroke: Keystroke::parse("enter").unwrap(), + is_held: false, + prefer_character_input: false, + } + .to_platform_input(), + cx, + ); + window.dispatch_event( + KeyUpEvent { + keystroke: Keystroke::parse("enter").unwrap(), + } + .to_platform_input(), + cx, + ); + }) + .unwrap(); + + assert_eq!(*clicks.borrow(), 0, "Enter re-opens through the platform"); + assert_eq!( + visual.opened_url().as_deref(), + Some("https://example.com/release-notes") + ); +} diff --git a/crates/core/tests/toggle.rs b/crates/core/tests/toggle.rs new file mode 100644 index 0000000..e59a26b --- /dev/null +++ b/crates/core/tests/toggle.rs @@ -0,0 +1,111 @@ +use std::{cell::RefCell, rc::Rc}; + +use gpui::{ + AnyWindowHandle, AppContext as _, Context, IntoElement, KeyDownEvent, KeyUpEvent, Keystroke, + Modifiers, ParentElement as _, Render, Styled as _, TestAppContext, VisualTestContext, Window, + div, point, px, +}; +use gpui_native_core::toggle::Toggle; +use gpui_native_theme::Theme; + +struct Harness { + changes: Rc>>, + pressed: bool, + disabled: bool, +} + +impl Render for Harness { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let changes = self.changes.clone(); + let view = cx.entity(); + div().size_full().flex().flex_col().child( + Toggle::new("test-toggle") + .label("Bold") + .pressed(self.pressed) + .disabled(self.disabled) + .on_change(move |next, _, cx| { + changes.borrow_mut().push(next); + view.update(cx, |harness, cx| { + harness.pressed = next; + cx.notify(); + }); + }), + ) + } +} + +/// A maximized window whose toggle sits in the top-left corner, so +/// `point(px(20.), px(14.))` lands on it. +fn toggle_window( + pressed: bool, + disabled: bool, +) -> (VisualTestContext, AnyWindowHandle, Rc>>) { + let mut cx = TestAppContext::single(); + cx.update(Theme::init); + let changes = Rc::new(RefCell::new(Vec::new())); + let harness_changes = changes.clone(); + let window = cx.add_window(move |_, _| Harness { + changes: harness_changes, + pressed, + disabled, + }); + let window = window.into(); + cx.update_window(window, |_, window, cx| { + window.draw(cx).clear(cx); + }) + .unwrap(); + (VisualTestContext::from_window(window, &cx), window, changes) +} + +fn draw(cx: &mut VisualTestContext, window: AnyWindowHandle) { + cx.update_window(window, |_, window, cx| { + window.draw(cx).clear(cx); + }) + .unwrap(); +} + +#[test] +fn clicking_reports_the_next_pressed_state_through_the_controlled_loop() { + let (mut cx, _window, changes) = toggle_window(false, false); + cx.simulate_click(point(px(20.), px(14.)), Modifiers::default()); + assert_eq!(*changes.borrow(), vec![true]); + + // The second click must report `false`: only if the owner applied the + // first change does the toggle know it is pressed. + cx.simulate_click(point(px(20.), px(14.)), Modifiers::default()); + assert_eq!(*changes.borrow(), vec![true, false]); +} + +#[test] +fn disabled_toggle_does_not_report_changes() { + let (mut cx, _window, changes) = toggle_window(false, true); + cx.simulate_click(point(px(20.), px(14.)), Modifiers::default()); + assert!(changes.borrow().is_empty()); +} + +#[test] +fn enter_and_space_report_the_next_pressed_state_while_focused() { + let (mut cx, window, changes) = toggle_window(false, false); + cx.simulate_click(point(px(20.), px(14.)), Modifiers::default()); + assert_eq!( + *changes.borrow(), + vec![true], + "the pointer click itself presses" + ); + + // Repaint with the toggle focused so its Enter/Space handlers register. + draw(&mut cx, window); + + for key in ["enter", "space"] { + cx.simulate_event(KeyDownEvent { + keystroke: Keystroke::parse(key).unwrap(), + is_held: false, + prefer_character_input: false, + }); + cx.simulate_event(KeyUpEvent { + keystroke: Keystroke::parse(key).unwrap(), + }); + } + + assert_eq!(*changes.borrow(), vec![true, false, true]); +}