Narrow layouts: vertical ScrollArea, wrapping property rows, icon-only segments and tabs, shrinking status items, unfocused fields show their start
This commit is contained in:
8 files changed
+159
-19
No files matched your search
@@ -2,22 +2,23 @@ use std::rc::Rc;
|
|||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Animation, AnimationExt, AnyElement, App, ElementId, FocusHandle, FontWeight,
|
Animation, AnimationExt, AnyElement, App, ElementId, FocusHandle, FontWeight,
|
||||||
InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce, Role, ScrollHandle,
|
InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, RenderOnce, Role,
|
||||||
SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*,
|
ScrollHandle, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*, px,
|
||||||
|
rems,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::button::label_size;
|
use super::button::label_size;
|
||||||
use crate::{
|
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},
|
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},
|
theme::{ActiveTheme, ControlSize, Elevation, Radius},
|
||||||
typography::Ellipsis,
|
typography::Ellipsis,
|
||||||
};
|
};
|
||||||
|
|
||||||
type OnChange = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
|
type OnChange = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
|
||||||
|
|
||||||
/// 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)]
|
#[derive(IntoElement)]
|
||||||
pub struct SegmentedControl {
|
pub struct SegmentedControl {
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
@@ -94,10 +95,17 @@ impl RenderOnce for SegmentedControl {
|
|||||||
(focus, reveal.into_any_element())
|
(focus, reveal.into_any_element())
|
||||||
})
|
})
|
||||||
.collect();
|
.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 theme = cx.theme();
|
||||||
let colors = &theme.colors;
|
let colors = &theme.colors;
|
||||||
let (text, icon_size) = label_size(self.size);
|
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 duration = motion::duration(motion::SLOW, cx);
|
||||||
let thumb = marker.map(
|
let thumb = marker.map(
|
||||||
|Marker {
|
|Marker {
|
||||||
@@ -181,7 +189,8 @@ impl RenderOnce for SegmentedControl {
|
|||||||
.when_some(icon, |segment, icon| {
|
.when_some(icon, |segment, icon| {
|
||||||
segment.child(Icon::new(icon).size(icon_size).color(fg))
|
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(
|
segment.child(
|
||||||
div()
|
div()
|
||||||
.min_w_0()
|
.min_w_0()
|
||||||
@@ -221,6 +230,26 @@ impl RenderOnce for SegmentedControl {
|
|||||||
.border_1()
|
.border_1()
|
||||||
.border_color(colors.border),
|
.border_color(colors.border),
|
||||||
)
|
)
|
||||||
|
.relative()
|
||||||
|
.child(measure_width(strip))
|
||||||
.child(control)
|
.child(control)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every label and icon in full, with padding: the width that cuts none.
|
||||||
|
fn natural(
|
||||||
|
segments: &[(SharedString, SharedString, Option<IconName>)],
|
||||||
|
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 }
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -215,11 +215,15 @@ impl RenderOnce for PropertyGrid {
|
|||||||
div()
|
div()
|
||||||
.pb_1()
|
.pb_1()
|
||||||
.children(rows.into_iter().map(|(name, editor)| {
|
.children(rows.into_iter().map(|(name, editor)| {
|
||||||
|
// Too narrow for both: the editor goes below.
|
||||||
div()
|
div()
|
||||||
.flex()
|
.flex()
|
||||||
|
.flex_wrap()
|
||||||
.items_center()
|
.items_center()
|
||||||
.gap_3()
|
.gap_x_3()
|
||||||
|
.gap_y_1()
|
||||||
.min_h(row_height)
|
.min_h(row_height)
|
||||||
|
.py_0p5()
|
||||||
.pl_6()
|
.pl_6()
|
||||||
.pr_2()
|
.pr_2()
|
||||||
.child(
|
.child(
|
||||||
@@ -229,7 +233,12 @@ impl RenderOnce for PropertyGrid {
|
|||||||
.text_color(colors.fg_muted)
|
.text_color(colors.fg_muted)
|
||||||
.child(name),
|
.child(name),
|
||||||
)
|
)
|
||||||
.child(div().flex_1().min_w_0().child(editor))
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_1()
|
||||||
|
.min_w(theme.label_width() * 0.85)
|
||||||
|
.child(editor),
|
||||||
|
)
|
||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -242,6 +242,8 @@ impl Element for TextElement {
|
|||||||
let input = self.input.read(cx);
|
let input = self.input.read(cx);
|
||||||
let caret_at = input.position_for(input.display_offset(input.cursor()));
|
let caret_at = input.position_for(input.display_offset(input.cursor()));
|
||||||
let scroll = match caret_at {
|
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),
|
Some(at) => keep_in_view(input, &lines, at, caret_width, bounds, line_height),
|
||||||
None => input.scroll,
|
None => input.scroll,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ mod collapse;
|
|||||||
mod dock;
|
mod dock;
|
||||||
mod floating;
|
mod floating;
|
||||||
mod grid;
|
mod grid;
|
||||||
|
mod narrow;
|
||||||
mod page;
|
mod page;
|
||||||
mod panes;
|
mod panes;
|
||||||
mod scroll;
|
mod scroll;
|
||||||
@@ -22,6 +23,7 @@ pub use dock::{Dock, DockLayout, DockPanel, DockSide, Spot};
|
|||||||
pub use floating::FloatingPanel;
|
pub use floating::FloatingPanel;
|
||||||
pub use grid::{Masonry, SimpleGrid};
|
pub use grid::{Masonry, SimpleGrid};
|
||||||
pub(crate) use grid::{columns_for, fit};
|
pub(crate) use grid::{columns_for, fit};
|
||||||
|
pub(crate) use narrow::{measure_width, text_width};
|
||||||
pub use page::{AppShell, MasterDetail, Page};
|
pub use page::{AppShell, MasterDetail, Page};
|
||||||
pub use panes::{PaneGroup, PaneId, PaneLayout};
|
pub use panes::{PaneGroup, PaneId, PaneLayout};
|
||||||
pub use scroll::{ScrollArea, Scrollbar, on_axis};
|
pub use scroll::{ScrollArea, Scrollbar, on_axis};
|
||||||
|
|||||||
@@ -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<Pixels>) -> impl IntoElement {
|
||||||
|
canvas(
|
||||||
|
move |bounds: Bounds<Pixels>, 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()
|
||||||
|
}
|
||||||
+20
-3
@@ -227,6 +227,7 @@ pub struct ScrollArea {
|
|||||||
id: ElementId,
|
id: ElementId,
|
||||||
base: Div,
|
base: Div,
|
||||||
shadows: bool,
|
shadows: bool,
|
||||||
|
vertical: bool,
|
||||||
body: SmallVec<[AnyElement; 2]>,
|
body: SmallVec<[AnyElement; 2]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,6 +237,7 @@ impl ScrollArea {
|
|||||||
id: id.into(),
|
id: id.into(),
|
||||||
base: div(),
|
base: div(),
|
||||||
shadows: false,
|
shadows: false,
|
||||||
|
vertical: false,
|
||||||
body: SmallVec::new(),
|
body: SmallVec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,6 +247,12 @@ impl ScrollArea {
|
|||||||
self.shadows = true;
|
self.shadows = true;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Scrolls down only; content keeps the area's width.
|
||||||
|
pub fn vertical(mut self) -> Self {
|
||||||
|
self.vertical = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Styled for ScrollArea {
|
impl Styled for ScrollArea {
|
||||||
@@ -300,12 +308,21 @@ impl RenderOnce for ScrollArea {
|
|||||||
div()
|
div()
|
||||||
.id("scroll-body")
|
.id("scroll-body")
|
||||||
.size_full()
|
.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)
|
.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)))
|
.when(self.shadows, |area| area.child(ScrollShadow::new(&handle)))
|
||||||
.child(Scrollbar::new("scrollbar-y", &handle, Axis::Vertical).presence(presence))
|
.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))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+29
-3
@@ -8,8 +8,9 @@ use gpui::{
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
forms::{Choice, Pick, step},
|
forms::{Choice, Pick, step},
|
||||||
|
layout::{measure_width, text_width},
|
||||||
motion::{self, Axis, Marker, glide, measure_item, measure_origin, slide},
|
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},
|
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 theme = cx.theme();
|
||||||
let colors = &theme.colors;
|
let colors = &theme.colors;
|
||||||
let (thickness, duration) = (theme.tab_indicator(), motion::duration(motion::SLOW, cx));
|
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| {
|
.when_some(tab.icon, |item, icon| {
|
||||||
item.child(Icon::new(icon).size(IconSize::Sm).color(fg))
|
item.child(Icon::new(icon).size(IconSize::Sm).color(fg))
|
||||||
})
|
})
|
||||||
.child(tab.label.clone())
|
.when(compact, |item| item.tooltip(Tooltip::text(tab.label.clone())))
|
||||||
.when_some(tab.note.clone(), |item, note| {
|
.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))
|
item.child(div().text_color(colors.fg_subtle).child(note))
|
||||||
})
|
})
|
||||||
.child(measure_item(state.clone(), ix, axis))
|
.child(measure_item(state.clone(), ix, axis))
|
||||||
@@ -258,6 +268,7 @@ impl RenderOnce for Tabs {
|
|||||||
key_pick(to, window, cx);
|
key_pick(to, window, cx);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
.child(measure_width(strip_width))
|
||||||
.child(measure_origin(state, axis))
|
.child(measure_origin(state, axis))
|
||||||
.children(indicator)
|
.children(indicator)
|
||||||
.children(items);
|
.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
|
||||||
|
})
|
||||||
|
}
|
||||||
+11
-4
@@ -6,6 +6,7 @@ use gpui::{
|
|||||||
use smallvec::SmallVec;
|
use smallvec::SmallVec;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
typography::Ellipsis,
|
||||||
primitives::{Icon, IconName, Tooltip},
|
primitives::{Icon, IconName, Tooltip},
|
||||||
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
|
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
|
||||||
};
|
};
|
||||||
@@ -143,7 +144,8 @@ impl Styled for StatusBar {
|
|||||||
impl RenderOnce for StatusBar {
|
impl RenderOnce for StatusBar {
|
||||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
let theme = cx.theme();
|
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
|
self.base
|
||||||
.flex()
|
.flex()
|
||||||
.flex_none()
|
.flex_none()
|
||||||
@@ -156,8 +158,9 @@ impl RenderOnce for StatusBar {
|
|||||||
.border_color(theme.colors.border)
|
.border_color(theme.colors.border)
|
||||||
.text_size(theme.text_size(TextSize::Xs))
|
.text_size(theme.text_size(TextSize::Xs))
|
||||||
.text_color(theme.colors.fg_muted)
|
.text_color(theme.colors.fg_muted)
|
||||||
.child(side().children(self.left))
|
.gap_2()
|
||||||
.child(side().children(self.right))
|
.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()
|
div()
|
||||||
.id(self.id)
|
.id(self.id)
|
||||||
.flex()
|
.flex()
|
||||||
|
.flex_shrink_1()
|
||||||
|
.min_w_0()
|
||||||
.items_center()
|
.items_center()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.h_full()
|
.h_full()
|
||||||
@@ -241,7 +246,9 @@ impl RenderOnce for StatusBarItem {
|
|||||||
.color(theme.colors.fg_muted),
|
.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.tooltip, |item, text| item.tooltip(Tooltip::text(text)))
|
||||||
.when_some(self.on_click, |item, handler| {
|
.when_some(self.on_click, |item, handler| {
|
||||||
let item = item.role(Role::Button);
|
let item = item.role(Role::Button);
|
||||||
|
|||||||
Reference in new issue
Block a user