From 5bfde0d38212b539840008f270e9ceb8bf606123 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 6 Nov 2026 19:08:49 +0700 Subject: [PATCH] feat: add separator component --- crates/core/Cargo.toml | 2 + crates/core/src/lib.rs | 3 ++ crates/core/src/separator.rs | 76 ++++++++++++++++++++++++++++++++++ crates/core/tests/separator.rs | 61 +++++++++++++++++++++++++++ docs/CHECKLIST.md | 4 +- examples/gallery/src/main.rs | 39 +++++++++++++++++ 6 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 crates/core/src/separator.rs create mode 100644 crates/core/tests/separator.rs diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 294ccc8..4bf28f9 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -34,6 +34,7 @@ full = [ "scroll_area", "scrollbar", "segmented", + "separator", "spinner", "field", "status_dot", @@ -64,6 +65,7 @@ popover = [] scroll_area = [] scrollbar = ["scroll_area"] segmented = [] +separator = [] spinner = [] field = [] status_dot = [] diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 2adb058..8a97f9e 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -72,6 +72,9 @@ pub mod scrollbar; #[cfg(feature = "segmented")] pub mod segmented; +#[cfg(feature = "separator")] +pub mod separator; + #[cfg(feature = "spinner")] pub mod spinner; diff --git a/crates/core/src/separator.rs b/crates/core/src/separator.rs new file mode 100644 index 0000000..0b46652 --- /dev/null +++ b/crates/core/src/separator.rs @@ -0,0 +1,76 @@ +//! The separator: a hairline rule between sibling content. + +use std::rc::Rc; + +use gpui::prelude::FluentBuilder as _; +use gpui::{App, InteractiveElement as _, IntoElement, RenderOnce, Styled as _, Window, div, px}; +use gpui_native_theme::ActiveTheme as _; + +/// Which axis the rule runs along. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SeparatorOrientation { + /// A horizontal rule between stacked content. + #[default] + Horizontal, + /// A vertical rule between side-by-side content. + Vertical, +} + +type DebugSelector = Rc String>; + +/// A hairline rule that divides sibling content. +#[derive(IntoElement)] +pub struct Separator { + orientation: SeparatorOrientation, + debug_selector: Option, +} + +impl Separator { + /// Creates a horizontal rule. + pub fn horizontal() -> Self { + Self { + orientation: SeparatorOrientation::Horizontal, + debug_selector: None, + } + } + + /// Creates a vertical rule. + pub fn vertical() -> Self { + Self { + orientation: SeparatorOrientation::Vertical, + debug_selector: None, + } + } + + /// Sets which axis the rule runs along. + pub fn orientation(mut self, orientation: SeparatorOrientation) -> Self { + self.orientation = orientation; + self + } + + /// Sets the debug-bounds selector, for headless tests. + pub fn debug_selector(mut self, selector: F) -> Self + where + F: Fn() -> String + 'static, + { + self.debug_selector = Some(Rc::new(selector)); + self + } +} + +impl RenderOnce for Separator { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let border = cx.theme().colors.border; + + div() + .flex_none() + .bg(border) + .when_some(self.debug_selector, |rule, selector| { + rule.debug_selector(move || selector()) + }) + .map(|rule| match self.orientation { + SeparatorOrientation::Horizontal => rule.w_full().h(px(1.)), + SeparatorOrientation::Vertical => rule.self_stretch().w(px(1.)), + }) + } +} diff --git a/crates/core/tests/separator.rs b/crates/core/tests/separator.rs new file mode 100644 index 0000000..5c0cc1f --- /dev/null +++ b/crates/core/tests/separator.rs @@ -0,0 +1,61 @@ +use gpui::{ + AppContext as _, Context, IntoElement, ParentElement as _, Render, Styled as _, TestAppContext, + VisualTestContext, Window, div, px, +}; +use gpui_native_core::separator::{Separator, SeparatorOrientation}; +use gpui_native_theme::Theme; + +struct Harness { + orientation: SeparatorOrientation, +} + +impl Render for Harness { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let separator = Separator::horizontal() + .orientation(self.orientation) + .debug_selector(|| "test-separator".to_string()); + if self.orientation == SeparatorOrientation::Horizontal { + div() + .w(px(200.)) + .h(px(50.)) + .flex() + .flex_col() + .child(separator) + } else { + div().w(px(200.)).h(px(50.)).flex().child(separator) + } + } +} + +fn separator_window(orientation: SeparatorOrientation) -> (TestAppContext, VisualTestContext) { + let mut cx = TestAppContext::single(); + cx.update(Theme::init); + let window = cx.add_window(move |_, _| Harness { orientation }); + let window = window.into(); + cx.update_window(window, |_, window, cx| { + window.draw(cx).clear(cx); + }) + .unwrap(); + let visual = VisualTestContext::from_window(window, &cx); + (cx, visual) +} + +#[test] +fn the_horizontal_rule_spans_its_container_at_hairline_height() { + let (_cx, mut visual) = separator_window(SeparatorOrientation::Horizontal); + let bounds = visual + .debug_bounds("test-separator") + .expect("horizontal separator rendered"); + assert_eq!(bounds.size.width, px(200.)); + assert_eq!(bounds.size.height, px(1.)); +} + +#[test] +fn the_vertical_rule_spans_its_container_at_hairline_width() { + let (_cx, mut visual) = separator_window(SeparatorOrientation::Vertical); + let bounds = visual + .debug_bounds("test-separator") + .expect("vertical separator rendered"); + assert_eq!(bounds.size.width, px(1.)); + assert_eq!(bounds.size.height, px(50.)); +} diff --git a/docs/CHECKLIST.md b/docs/CHECKLIST.md index 740c192..5ed7755 100644 --- a/docs/CHECKLIST.md +++ b/docs/CHECKLIST.md @@ -19,8 +19,6 @@ Gaps surfaced by the signed comparison; ordered by signed's usage. - [ ] **Editable code field** — syntax-highlighted editable multiline field, the code view's highlighter over the input's editing model; signed's repo file browser. -- [ ] **Separator** — standalone horizontal/vertical rule; menu-item - separators stay menu-scoped. - [ ] **Resizable group** — stacked arbitrary panes with drag dividers outside the dock; signed's commit-diff split. - [ ] **Searchable list filter** — searchable collection behind combobox and @@ -81,6 +79,8 @@ Gaps surfaced by the signed comparison; ordered by signed's usage. platform autohide with a `visibility` override - [x] Clipboard button — copies a value, confirms with a brief check mark that reverts itself +- [x] Separator — horizontal and vertical hairline rules; menu separators + stay menu-scoped ### data (`crates/data`) diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 17c3a9b..cc273f4 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -30,6 +30,7 @@ use gpui_native_core::scroll_area::{ScrollArea, ScrollAxes}; use gpui_native_core::scrollbar::{Scrollbar, ScrollbarVisibility}; use gpui_native_core::segmented::{Segmented, SegmentedOption}; use gpui_native_core::select::{Select, SelectOption}; +use gpui_native_core::separator::Separator; use gpui_native_core::slider::Slider; use gpui_native_core::spinner::{Spinner, SpinnerVariant}; use gpui_native_core::status_dot::{StatusDot, StatusDotVariant}; @@ -118,6 +119,7 @@ const CATALOG: &[(&str, &[(&str, &str)])] = &[ ("welcome", "Welcome"), ("sidebar", "Sidebar"), ("resize-handle", "Resize handle"), + ("separator", "Separator"), ], ), ("Theme", &[("theme", "Theme")]), @@ -673,6 +675,12 @@ impl Gallery { }), cx, ); + dock.register_panel( + PanelDescriptor::new("separator", "Separator", |_, cx| { + cx.new(|_| SeparatorDemo).into() + }), + cx, + ); dock.register_panel( PanelDescriptor::new("theme", "Theme", |_, cx| cx.new(|_| ThemeDemo).into()), cx, @@ -1667,6 +1675,37 @@ impl Render for ClipboardDemo { } } +struct SeparatorDemo; + +impl Render for SeparatorDemo { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.theme().colors; + + ScrollArea::new("separator-demo") + .size_full() + .p_4() + .child(demo_section( + "horizontal", + colors, + v_flex() + .gap_2() + .child(div().text_sm().child("Above the rule")) + .child(Separator::horizontal()) + .child(div().text_sm().child("Below the rule")), + )) + .child(demo_section( + "vertical", + colors, + h_flex() + .gap_3() + .h_8() + .child(div().text_sm().child("Left")) + .child(Separator::vertical()) + .child(div().text_sm().child("Right")), + )) + } +} + struct BadgeDemo; impl Render for BadgeDemo {