diff --git a/src/buttons/segmented.rs b/src/buttons/segmented.rs index dec7407..ee31d34 100644 --- a/src/buttons/segmented.rs +++ b/src/buttons/segmented.rs @@ -2,22 +2,23 @@ use std::rc::Rc; use gpui::{ Animation, AnimationExt, AnyElement, App, ElementId, FocusHandle, FontWeight, - InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce, Role, ScrollHandle, - SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*, + InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, RenderOnce, Role, + ScrollHandle, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*, px, + rems, }; use super::button::label_size; use crate::{ - layout::{on_axis, reveal_when_focused}, + layout::{measure_width, on_axis, reveal_when_focused, text_width}, motion::{self, Axis, Marker, glide, measure_item, measure_origin, slide}, - primitives::{FocusRing, Icon, IconName, tab_stop}, + primitives::{FocusRing, Icon, IconName, Tooltip, tab_stop}, theme::{ActiveTheme, ControlSize, Elevation, Radius}, typography::Ellipsis, }; type OnChange = Rc; -/// Mutually exclusive segments. A thumb slides to the chosen one. Narrow, the segments give way and their labels end in an ellipsis, down to twice a control's height each; narrower still, the strip scrolls sideways and a focused segment comes into view. +/// Mutually exclusive segments. A thumb slides to the chosen one. Too narrow for every label in full, segments that all have icons show only their icons, each label a tooltip; otherwise labels end in an ellipsis, down to twice a control's height each. Narrower still, the strip scrolls sideways and a focused segment comes into view. #[derive(IntoElement)] pub struct SegmentedControl { id: ElementId, @@ -94,10 +95,17 @@ impl RenderOnce for SegmentedControl { (focus, reveal.into_any_element()) }) .collect(); + // Last frame's strip width decides icons only. + let strip = window.use_keyed_state((self.id.clone(), "strip-width"), cx, |_, _| Pixels::ZERO); + let shown = *strip.read(cx); + let compact = self.segments.iter().all(|(_, _, icon)| icon.is_some()) + && shown > Pixels::ZERO + && shown < natural(&self.segments, self.size, window, cx); let theme = cx.theme(); let colors = &theme.colors; let (text, icon_size) = label_size(self.size); - let (least, count) = (theme.control_height(self.size) * 2.0, values.len()); + let each = if compact { 1.0 } else { 2.0 }; + let (least, count) = (theme.control_height(self.size) * each, values.len()); let duration = motion::duration(motion::SLOW, cx); let thumb = marker.map( |Marker { @@ -181,7 +189,8 @@ impl RenderOnce for SegmentedControl { .when_some(icon, |segment, icon| { segment.child(Icon::new(icon).size(icon_size).color(fg)) }) - .when(!label.is_empty(), |segment| { + .when(compact, |segment| segment.tooltip(Tooltip::text(label.clone()))) + .when(!label.is_empty() && !compact, |segment| { segment.child( div() .min_w_0() @@ -221,6 +230,26 @@ impl RenderOnce for SegmentedControl { .border_1() .border_color(colors.border), ) + .relative() + .child(measure_width(strip)) .child(control) } } + +/// Every label and icon in full, with padding: the width that cuts none. +fn natural( + segments: &[(SharedString, SharedString, Option)], + size: ControlSize, + window: &Window, + cx: &App, +) -> Pixels { + let theme = cx.theme(); + let rem = window.rem_size(); + let (text, icon) = label_size(size); + let pad = theme.control_padding(size).to_pixels(rem) * 2.0 + px(2.0); + let glyph = theme.icon_size(icon).to_pixels(rem) + rems(0.375).to_pixels(rem); + segments.iter().fold(px(6.0), |sum, (_, label, icon)| { + let width = text_width(label, text, FontWeight::SEMIBOLD, window, cx); + sum + pad + width + if icon.is_some() { glyph } else { Pixels::ZERO } + }) +} diff --git a/src/data_display/records.rs b/src/data_display/records.rs index 17e3dab..d88069c 100644 --- a/src/data_display/records.rs +++ b/src/data_display/records.rs @@ -215,11 +215,15 @@ impl RenderOnce for PropertyGrid { div() .pb_1() .children(rows.into_iter().map(|(name, editor)| { + // Too narrow for both: the editor goes below. div() .flex() + .flex_wrap() .items_center() - .gap_3() + .gap_x_3() + .gap_y_1() .min_h(row_height) + .py_0p5() .pl_6() .pr_2() .child( @@ -229,7 +233,12 @@ impl RenderOnce for PropertyGrid { .text_color(colors.fg_muted) .child(name), ) - .child(div().flex_1().min_w_0().child(editor)) + .child( + div() + .flex_1() + .min_w(theme.label_width() * 0.85) + .child(editor), + ) })), ), ) diff --git a/src/forms/text/element.rs b/src/forms/text/element.rs index 0fe3391..93fd283 100644 --- a/src/forms/text/element.rs +++ b/src/forms/text/element.rs @@ -242,6 +242,8 @@ impl Element for TextElement { let input = self.input.read(cx); let caret_at = input.position_for(input.display_offset(input.cursor())); let scroll = match caret_at { + // Unfocused, a line shows its start, as AppKit's fields do. + Some(_) if !focused && !input.is_multi_line() => Point::default(), Some(at) => keep_in_view(input, &lines, at, caret_width, bounds, line_height), None => input.scroll, }; diff --git a/src/layout/mod.rs b/src/layout/mod.rs index 597c58c..770543b 100644 --- a/src/layout/mod.rs +++ b/src/layout/mod.rs @@ -2,6 +2,7 @@ mod collapse; mod dock; mod floating; mod grid; +mod narrow; mod page; mod panes; mod scroll; @@ -22,6 +23,7 @@ pub use dock::{Dock, DockLayout, DockPanel, DockSide, Spot}; pub use floating::FloatingPanel; pub use grid::{Masonry, SimpleGrid}; pub(crate) use grid::{columns_for, fit}; +pub(crate) use narrow::{measure_width, text_width}; pub use page::{AppShell, MasterDetail, Page}; pub use panes::{PaneGroup, PaneId, PaneLayout}; pub use scroll::{ScrollArea, Scrollbar, on_axis}; diff --git a/src/layout/narrow.rs b/src/layout/narrow.rs new file mode 100644 index 0000000..ce64704 --- /dev/null +++ b/src/layout/narrow.rs @@ -0,0 +1,48 @@ +//! What a row of labels needs, and how wide it got. + +use gpui::{ + App, Bounds, Entity, Font, FontWeight, Hsla, IntoElement, Pixels, SharedString, Styled, + TextRun, Window, canvas, +}; + +use crate::theme::{ActiveTheme, TextSize}; + +/// `text` set in `size` and `weight`, in pixels. +pub(crate) fn text_width( + text: &SharedString, + size: TextSize, + weight: FontWeight, + window: &Window, + cx: &App, +) -> Pixels { + let font_size = cx.theme().text_size(size).to_pixels(window.rem_size()); + let run = TextRun { + len: text.len(), + font: Font { weight, ..window.text_style().font() }, + color: Hsla::default(), + background_color: None, + underline: None, + strikethrough: None, + }; + window.text_system().shape_line(text.clone(), font_size, &[run], None).width +} + +/// Keeps `width` at the parent's width; a change redraws once. +pub(crate) fn measure_width(width: Entity) -> impl IntoElement { + canvas( + move |bounds: Bounds, window, cx| { + if *width.read(cx) != bounds.size.width { + width.update(cx, |width, cx| { + *width = bounds.size.width; + cx.notify(); + window.request_animation_frame(); + }); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .top_0() + .left_0() + .size_full() +} diff --git a/src/layout/scroll.rs b/src/layout/scroll.rs index 4fc6526..561f86a 100644 --- a/src/layout/scroll.rs +++ b/src/layout/scroll.rs @@ -227,6 +227,7 @@ pub struct ScrollArea { id: ElementId, base: Div, shadows: bool, + vertical: bool, body: SmallVec<[AnyElement; 2]>, } @@ -236,6 +237,7 @@ impl ScrollArea { id: id.into(), base: div(), shadows: false, + vertical: false, body: SmallVec::new(), } } @@ -245,6 +247,12 @@ impl ScrollArea { self.shadows = true; self } + + /// Scrolls down only; content keeps the area's width. + pub fn vertical(mut self) -> Self { + self.vertical = true; + self + } } impl Styled for ScrollArea { @@ -300,12 +308,21 @@ impl RenderOnce for ScrollArea { div() .id("scroll-body") .size_full() - .overflow_scroll() + .when(self.vertical, |body| body.overflow_y_scroll().overflow_x_hidden()) + .when(!self.vertical, |body| body.overflow_scroll()) .track_scroll(&handle) - .children(self.body), + .map(|body| match self.vertical { + // Children fill and never widen, so rows wrap. + true => body.flex().flex_col().children( + self.body.into_iter().map(|child| div().w_full().min_w_0().child(child)), + ), + false => body.children(self.body), + }), ) .when(self.shadows, |area| area.child(ScrollShadow::new(&handle))) .child(Scrollbar::new("scrollbar-y", &handle, Axis::Vertical).presence(presence)) - .child(Scrollbar::new("scrollbar-x", &handle, Axis::Horizontal).presence(presence)) + .when(!self.vertical, |area| { + area.child(Scrollbar::new("scrollbar-x", &handle, Axis::Horizontal).presence(presence)) + }) } } diff --git a/src/navigation/tabs.rs b/src/navigation/tabs.rs index 8cdcab7..d54eac2 100644 --- a/src/navigation/tabs.rs +++ b/src/navigation/tabs.rs @@ -8,8 +8,9 @@ use gpui::{ use crate::{ forms::{Choice, Pick, step}, + layout::{measure_width, text_width}, motion::{self, Axis, Marker, glide, measure_item, measure_origin, slide}, - primitives::{Icon, tab_stop}, + primitives::{Icon, Tooltip, tab_stop}, theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize}, }; @@ -120,6 +121,14 @@ impl RenderOnce for Tabs { } }) }; + // Last frame's strip width decides icons only. + let strip_width = + window.use_keyed_state((self.id.clone(), "strip-width"), cx, |_, _| Pixels::ZERO); + let shown = *strip_width.read(cx); + let compact = axis == Axis::Horizontal + && tabs.iter().all(|tab| tab.icon.is_some()) + && shown > Pixels::ZERO + && shown < natural(&tabs, window, cx); let theme = cx.theme(); let colors = &theme.colors; let (thickness, duration) = (theme.tab_indicator(), motion::duration(motion::SLOW, cx)); @@ -209,8 +218,9 @@ impl RenderOnce for Tabs { .when_some(tab.icon, |item, icon| { item.child(Icon::new(icon).size(IconSize::Sm).color(fg)) }) - .child(tab.label.clone()) - .when_some(tab.note.clone(), |item, note| { + .when(compact, |item| item.tooltip(Tooltip::text(tab.label.clone()))) + .when(!compact, |item| item.child(tab.label.clone())) + .when_some(tab.note.clone().filter(|_| !compact), |item, note| { item.child(div().text_color(colors.fg_subtle).child(note)) }) .child(measure_item(state.clone(), ix, axis)) @@ -258,6 +268,7 @@ impl RenderOnce for Tabs { key_pick(to, window, cx); } }) + .child(measure_width(strip_width)) .child(measure_origin(state, axis)) .children(indicator) .children(items); @@ -277,3 +288,18 @@ impl RenderOnce for Tabs { } } } + +/// Every tab's icon, label and note in full: the width that cuts none. +fn natural(tabs: &[Choice], window: &Window, cx: &App) -> Pixels { + let theme = cx.theme(); + let rem = window.rem_size(); + let pad = gpui::rems(0.75).to_pixels(rem) * 2.0 + gpui::px(2.0); + let glyph = theme.icon_size(IconSize::Sm).to_pixels(rem) + gpui::rems(0.375).to_pixels(rem); + let gap = gpui::rems(0.25).to_pixels(rem); + tabs.iter().fold(Pixels::ZERO, |sum, tab| { + let text = |text: &SharedString| text_width(text, TextSize::Sm, FontWeight::MEDIUM, window, cx); + let note = tab.note.as_ref().map_or(Pixels::ZERO, |note| text(note) + gpui::rems(0.375).to_pixels(rem)); + let icon = if tab.icon.is_some() { glyph } else { Pixels::ZERO }; + sum + pad + icon + text(&tab.label) + note + gap + }) +} diff --git a/src/shell/bars.rs b/src/shell/bars.rs index f734e26..7d82226 100644 --- a/src/shell/bars.rs +++ b/src/shell/bars.rs @@ -6,6 +6,7 @@ use gpui::{ use smallvec::SmallVec; use crate::{ + typography::Ellipsis, primitives::{Icon, IconName, Tooltip}, theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize}, }; @@ -143,7 +144,8 @@ impl Styled for StatusBar { impl RenderOnce for StatusBar { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let theme = cx.theme(); - let side = || div().flex().items_center().gap_0p5().h_full(); + // Narrow, both sides shrink and labels end in an ellipsis. + let side = || div().flex().items_center().gap_0p5().h_full().min_w_0().overflow_hidden(); self.base .flex() .flex_none() @@ -156,8 +158,9 @@ impl RenderOnce for StatusBar { .border_color(theme.colors.border) .text_size(theme.text_size(TextSize::Xs)) .text_color(theme.colors.fg_muted) - .child(side().children(self.left)) - .child(side().children(self.right)) + .gap_2() + .child(side().flex_shrink_1().children(self.left)) + .child(side().flex_shrink_1().justify_end().children(self.right)) } } @@ -228,6 +231,8 @@ impl RenderOnce for StatusBarItem { div() .id(self.id) .flex() + .flex_shrink_1() + .min_w_0() .items_center() .gap_1() .h_full() @@ -241,7 +246,9 @@ impl RenderOnce for StatusBarItem { .color(theme.colors.fg_muted), ) }) - .when_some(self.label, |item, label| item.child(label)) + .when_some(self.label, |item, label| { + item.child(div().min_w_0().child(Ellipsis::new(label))) + }) .when_some(self.tooltip, |item, text| item.tooltip(Tooltip::text(text))) .when_some(self.on_click, |item, handler| { let item = item.role(Role::Button);