mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-21 22:54:11 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34efce6410 |
No files matched your search
@@ -41,7 +41,11 @@ fn app_harness(kind: &str, mode: &str, depth: u32) -> Harness<'static, Photocraf
|
||||
s.execute("edit.fill", json!({"color": "#6a3020"})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
s.execute(&format!("layer.newAdjustmentLayer.{kind}"), json!({})).unwrap();
|
||||
PhotocraftApp::new(s, crate::Services::default())
|
||||
let mut app = PhotocraftApp::new(s, crate::Services::default());
|
||||
// The default Properties group gives way to Layers (#147) and scrolls taller editors;
|
||||
// these tests drive every control without scrolling, so they size it like a user would.
|
||||
app.ui.dock.heights.insert(crate::dock::Group::Properties, 560.0);
|
||||
app
|
||||
});
|
||||
h.run_steps(8);
|
||||
h
|
||||
|
||||
+106
-10
@@ -29,6 +29,8 @@ pub enum Group {
|
||||
Color,
|
||||
/// Properties | Adjustments.
|
||||
Properties,
|
||||
/// Character | Paragraph (not in the default workspace; Window › Character opens it, #150).
|
||||
Character,
|
||||
/// Navigator | Histogram | Info.
|
||||
Navigator,
|
||||
/// History | Actions | Layer Comps.
|
||||
@@ -39,29 +41,54 @@ pub enum Group {
|
||||
|
||||
impl Group {
|
||||
/// Photoshop Essentials order, top to bottom.
|
||||
pub const ALL: [Group; 5] = [Group::Color, Group::Properties, Group::Navigator, Group::History, Group::Layers];
|
||||
pub const ALL: [Group; 6] = [Group::Color, Group::Properties, Group::Character, Group::Navigator, Group::History, Group::Layers];
|
||||
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
Group::Color => "color",
|
||||
Group::Properties => "properties",
|
||||
Group::Character => "character",
|
||||
Group::Navigator => "navigator",
|
||||
Group::History => "history",
|
||||
Group::Layers => "layers",
|
||||
}
|
||||
}
|
||||
|
||||
/// Height (points, tab strip included) a group gets until the user resizes it.
|
||||
/// Height (points, tab strip included) a group gets until the user resizes it, when the
|
||||
/// column has room (see [`DockLayout::heights_for`]).
|
||||
pub fn default_height(self) -> f32 {
|
||||
match self {
|
||||
Group::Color => 200.0,
|
||||
Group::Properties => 340.0,
|
||||
Group::Navigator => 230.0,
|
||||
Group::History => 220.0,
|
||||
Group::Color => 190.0,
|
||||
Group::Properties => 250.0,
|
||||
Group::Character => 270.0,
|
||||
Group::Navigator => 210.0,
|
||||
Group::History => 200.0,
|
||||
Group::Layers => 320.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// What a group left at its default height gives way down to so the filler (Layers) keeps
|
||||
/// [`Group::preferred_fill`] in a short column (#147). Content taller than this scrolls.
|
||||
pub fn compact_height(self) -> f32 {
|
||||
match self {
|
||||
Group::Color => 130.0,
|
||||
Group::Properties => 160.0,
|
||||
Group::Character => 160.0,
|
||||
Group::Navigator => 140.0,
|
||||
Group::History => 130.0,
|
||||
Group::Layers => 200.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The height the filling group asks for before default-sized groups above it get their
|
||||
/// full defaults: Layers wants room for about ten rows at 900 pt (#147).
|
||||
pub fn preferred_fill(self) -> f32 {
|
||||
match self {
|
||||
Group::Layers => 500.0,
|
||||
g => g.min_height(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The smallest height an expanded group can be dragged or squeezed to.
|
||||
pub fn min_height(self) -> f32 {
|
||||
match self {
|
||||
@@ -76,6 +103,7 @@ impl Group {
|
||||
Group::Color if pro => &["Color", "Swatches", "Gradients", "Patterns"],
|
||||
Group::Color => &["Swatches", "Color", "Gradients", "Patterns"],
|
||||
Group::Properties => &["Properties", "Adjustments"],
|
||||
Group::Character => &["Character", "Paragraph"],
|
||||
Group::Navigator => &["Navigator", "Histogram", "Info"],
|
||||
Group::History => &["History", "Actions", "Layer Comps"],
|
||||
Group::Layers => &["Layers", "Channels", "Paths"],
|
||||
@@ -91,6 +119,7 @@ impl Group {
|
||||
match self {
|
||||
Group::Color => &mut tabs.color,
|
||||
Group::Properties => &mut tabs.properties,
|
||||
Group::Character => &mut tabs.character,
|
||||
Group::Navigator => &mut tabs.navigator,
|
||||
Group::History => &mut tabs.history,
|
||||
Group::Layers => &mut tabs.layers,
|
||||
@@ -106,6 +135,7 @@ impl Group {
|
||||
match self {
|
||||
Group::Color => panels.color,
|
||||
Group::Properties => panels.properties,
|
||||
Group::Character => panels.character,
|
||||
Group::Navigator => panels.navigator,
|
||||
Group::History => panels.history,
|
||||
Group::Layers => panels.layers,
|
||||
@@ -116,6 +146,7 @@ impl Group {
|
||||
match self {
|
||||
Group::Color => &mut panels.color,
|
||||
Group::Properties => &mut panels.properties,
|
||||
Group::Character => &mut panels.character,
|
||||
Group::Navigator => &mut panels.navigator,
|
||||
Group::History => &mut panels.history,
|
||||
Group::Layers => &mut panels.layers,
|
||||
@@ -127,7 +158,8 @@ impl Group {
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct DockLayout {
|
||||
/// Top-to-bottom order; groups missing here keep their default place after the listed ones.
|
||||
/// Top-to-bottom order; a group missing here (say one added after the layout was saved)
|
||||
/// goes just below the nearest group that precedes it by default (or last).
|
||||
pub order: Vec<Group>,
|
||||
/// Heights the user dragged groups to (points, tab strip included). Unset = default.
|
||||
pub heights: BTreeMap<Group, f32>,
|
||||
@@ -144,11 +176,23 @@ impl DockLayout {
|
||||
/// Every group once, in display order.
|
||||
pub fn order(&self) -> Vec<Group> {
|
||||
let mut out: Vec<Group> = Vec::with_capacity(Group::ALL.len());
|
||||
for g in self.order.iter().chain(Group::ALL.iter()) {
|
||||
for g in &self.order {
|
||||
if !out.contains(g) {
|
||||
out.push(*g);
|
||||
}
|
||||
}
|
||||
// Missing groups slot in below their default predecessor, so a group new to an old
|
||||
// saved layout (Character) never lands below Layers and takes over as the filler.
|
||||
for (i, g) in Group::ALL.iter().enumerate() {
|
||||
if out.contains(g) {
|
||||
continue;
|
||||
}
|
||||
let prev = Group::ALL.iter().take(i).rev().find_map(|p| out.iter().position(|x| x == p));
|
||||
match prev {
|
||||
Some(at) => out.insert(at + 1, *g),
|
||||
None => out.push(*g),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -185,7 +229,9 @@ impl DockLayout {
|
||||
|
||||
/// Lay out the `shown` groups (in display order) in a column `avail` points tall with
|
||||
/// `strip`-high tab strips. Returns each group's height. The last expanded group fills the
|
||||
/// rest; when the column is too short the others give way down to their minimum heights.
|
||||
/// rest. Groups the user never resized give way first, down to their compact heights, so
|
||||
/// the filler gets its preferred height (Layers: ~10 rows, #147); when the column is still
|
||||
/// too short every group gives way down to its minimum height.
|
||||
pub fn heights_for(&self, shown: &[Group], avail: f32, strip: f32) -> Vec<(Group, f32)> {
|
||||
let avail = if avail.is_finite() { avail.max(0.0) } else { 0.0 };
|
||||
let filler = shown.iter().rposition(|g| !self.is_collapsed(*g));
|
||||
@@ -205,6 +251,23 @@ impl DockLayout {
|
||||
if let Some(f) = filler {
|
||||
let gaps = GAP * shown.len().saturating_sub(1) as f32;
|
||||
let min_fill = shown.get(f).map_or(0.0, |g| g.min_height());
|
||||
let pref_fill = shown.get(f).map_or(0.0, |g| g.preferred_fill());
|
||||
let used: f32 = hs.iter().sum::<f32>() + gaps;
|
||||
let mut deficit = (used + pref_fill - avail).max(0.0);
|
||||
for i in (0..f).rev() {
|
||||
if deficit <= 0.0 {
|
||||
break;
|
||||
}
|
||||
let Some(g) = shown.get(i) else { continue };
|
||||
if self.is_collapsed(*g) || self.heights.contains_key(g) {
|
||||
continue;
|
||||
}
|
||||
if let Some(h) = hs.get_mut(i) {
|
||||
let give = (*h - g.compact_height()).max(0.0).min(deficit);
|
||||
*h -= give;
|
||||
deficit -= give;
|
||||
}
|
||||
}
|
||||
let used: f32 = hs.iter().sum::<f32>() + gaps;
|
||||
let mut deficit = (used + min_fill - avail).max(0.0);
|
||||
// Squeeze the expanded groups nearest the filler first.
|
||||
@@ -269,6 +332,27 @@ fn rects_id() -> egui::Id {
|
||||
egui::Id::new("dock-group-rects")
|
||||
}
|
||||
|
||||
/// A group's tab strip as drawn last frame (screen points), for tests and automation.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct StripRects {
|
||||
pub group: Group,
|
||||
/// `(tab index, rect)` of the tabs on the strip (the others are in the chevron menu).
|
||||
pub tabs: Vec<(usize, Rect)>,
|
||||
/// The panel menu button.
|
||||
pub menu: Rect,
|
||||
/// The » overflow button, when some tabs didn't fit.
|
||||
pub chevron: Option<Rect>,
|
||||
}
|
||||
|
||||
/// The tab strips drawn last frame.
|
||||
pub fn last_strips(ctx: &egui::Context) -> Vec<StripRects> {
|
||||
ctx.data(|d| d.get_temp::<Vec<StripRects>>(strips_id())).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn strips_id() -> egui::Id {
|
||||
egui::Id::new("dock-strip-rects")
|
||||
}
|
||||
|
||||
/// Draw the `shown` groups (any order; the layout decides) filling `ui`. `body` draws one
|
||||
/// group's tab content.
|
||||
pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut body: impl FnMut(&mut PhotocraftApp, &mut egui::Ui, Group, usize)) {
|
||||
@@ -281,6 +365,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod
|
||||
let rects = rects_after_layout(&heights, area);
|
||||
let mut actions: Vec<Action> = Vec::new();
|
||||
let mut dragging: Option<Group> = None;
|
||||
let mut strips: Vec<StripRects> = Vec::with_capacity(rects.len());
|
||||
for (i, (g, rect)) in rects.iter().copied().enumerate() {
|
||||
let collapsed = app.ui.dock.is_collapsed(g);
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().id_salt(("dock-group", g.key())).max_rect(rect));
|
||||
@@ -302,6 +387,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod
|
||||
.show(ui, |ui| body(app, ui, g, tab));
|
||||
}
|
||||
});
|
||||
strips.push(StripRects { group: g, tabs: resp.tabs.clone(), menu: resp.menu.rect, chevron: resp.chevron });
|
||||
// The body may switch tabs itself (Adjustments jumps back to Properties).
|
||||
if sel != before {
|
||||
*g.tab_mut(&mut app.ui.dock_tabs) = sel.min(tabs.len().saturating_sub(1));
|
||||
@@ -368,7 +454,10 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod
|
||||
};
|
||||
ui.painter().line_segment([pos2(area.left(), line_y), pos2(area.right(), line_y)], Stroke::new(3.0, t.accent));
|
||||
}
|
||||
ui.ctx().data_mut(|d| d.insert_temp(rects_id(), rects));
|
||||
ui.ctx().data_mut(|d| {
|
||||
d.insert_temp(rects_id(), rects);
|
||||
d.insert_temp(strips_id(), strips);
|
||||
});
|
||||
ui.advance_cursor_after_rect(area);
|
||||
for a in actions {
|
||||
match a {
|
||||
@@ -422,6 +511,13 @@ fn resize(layout: &mut DockLayout, heights: &[(Group, f32)], i: usize, dy: f32)
|
||||
let filler = heights.iter().rposition(|(g, _)| !layout.is_collapsed(*g));
|
||||
let Some(j) = heights.iter().enumerate().skip(i + 1).find(|(_, (n, _))| !layout.is_collapsed(*n)).map(|(j, _)| j) else { return };
|
||||
let Some(&(n, nh)) = heights.get(j) else { return };
|
||||
// The first drag pins the other groups at the heights they show, so groups still at their
|
||||
// defaults (which give way to the filler) don't shift while this one is resized.
|
||||
for (k, (o, oh)) in heights.iter().enumerate() {
|
||||
if Some(k) != filler && !layout.is_collapsed(*o) {
|
||||
layout.heights.entry(*o).or_insert(*oh);
|
||||
}
|
||||
}
|
||||
let new_h = (h + dy).clamp(g.min_height(), (h + nh - n.min_height()).max(g.min_height()));
|
||||
layout.heights.insert(g, new_h);
|
||||
if Some(j) != filler {
|
||||
|
||||
@@ -12,15 +12,15 @@ const ESSENTIALS: [Group; 3] = [Group::Color, Group::Properties, Group::Layers];
|
||||
#[test]
|
||||
fn last_expanded_group_fills_the_column() {
|
||||
let l = DockLayout::default();
|
||||
let hs = l.heights_for(&ESSENTIALS, 800.0, 28.0);
|
||||
let hs = l.heights_for(&ESSENTIALS, 1200.0, 28.0);
|
||||
assert_eq!(hs[0], (Group::Color, Group::Color.default_height()));
|
||||
assert_eq!(hs[1], (Group::Properties, Group::Properties.default_height()));
|
||||
let total: f32 = hs.iter().map(|(_, h)| h).sum::<f32>() + 2.0 * GAP;
|
||||
assert!((total - 800.0).abs() < 1e-3, "{hs:?}");
|
||||
assert!((total - 1200.0).abs() < 1e-3, "{hs:?}");
|
||||
// Collapsed groups shrink to the tab strip; the one above Layers keeps its height.
|
||||
let mut l = DockLayout::default();
|
||||
l.set_collapsed(Group::Color, true);
|
||||
let hs = l.heights_for(&ESSENTIALS, 800.0, 28.0);
|
||||
let hs = l.heights_for(&ESSENTIALS, 1000.0, 28.0);
|
||||
assert_eq!(hs[0].1, 28.0);
|
||||
assert_eq!(hs[1].1, Group::Properties.default_height());
|
||||
// Layers collapsed: Properties becomes the filler.
|
||||
@@ -33,10 +33,10 @@ fn last_expanded_group_fills_the_column() {
|
||||
#[test]
|
||||
fn short_columns_squeeze_groups_down_to_their_minimum_and_never_go_negative() {
|
||||
let l = DockLayout::default();
|
||||
let hs = l.heights_for(&ESSENTIALS, 500.0, 28.0);
|
||||
let hs = l.heights_for(&ESSENTIALS, 400.0, 28.0);
|
||||
assert_eq!(hs[2].1, Group::Layers.min_height(), "Layers keeps its minimum: {hs:?}");
|
||||
assert_eq!(hs[0].1, Group::Color.default_height(), "the group farthest from Layers gives way last");
|
||||
assert!(hs[1].1 < Group::Properties.default_height());
|
||||
assert_eq!(hs[0].1, Group::Color.compact_height(), "the group farthest from Layers gives way last");
|
||||
assert!(hs[1].1 < Group::Properties.compact_height());
|
||||
for avail in [0.0, -50.0, 1.0, f32::NAN, f32::INFINITY, 1e9] {
|
||||
for (g, h) in l.heights_for(&ESSENTIALS, avail, 28.0) {
|
||||
assert!(h.is_finite() && h >= 0.0, "{g:?} at {avail}: {h}");
|
||||
@@ -48,7 +48,7 @@ fn short_columns_squeeze_groups_down_to_their_minimum_and_never_go_negative() {
|
||||
#[test]
|
||||
fn bad_stored_values_are_sanitised() {
|
||||
let mut l = DockLayout { order: vec![Group::Layers, Group::Layers, Group::Color], ..Default::default() };
|
||||
assert_eq!(l.order(), vec![Group::Layers, Group::Color, Group::Properties, Group::Navigator, Group::History]);
|
||||
assert_eq!(l.order(), vec![Group::Layers, Group::Color, Group::Properties, Group::Character, Group::Navigator, Group::History]);
|
||||
for bad in [f32::NAN, -10.0, f32::INFINITY, 1e12] {
|
||||
l.heights.insert(Group::Color, bad);
|
||||
let h = l.height(Group::Color);
|
||||
@@ -56,7 +56,7 @@ fn bad_stored_values_are_sanitised() {
|
||||
}
|
||||
// Unknown fields, wrong types and old UI state all load.
|
||||
let back: DockLayout = serde_json::from_value(json!({"order": ["layers"], "bogus": 1})).unwrap();
|
||||
assert_eq!(back.order(), vec![Group::Layers, Group::Color, Group::Properties, Group::Navigator, Group::History]);
|
||||
assert_eq!(back.order(), vec![Group::Layers, Group::Color, Group::Properties, Group::Character, Group::Navigator, Group::History]);
|
||||
let mut ui = serde_json::to_value(crate::state::UiState::default()).unwrap();
|
||||
ui.as_object_mut().unwrap().remove("dock");
|
||||
let ui: crate::state::UiState = serde_json::from_value(ui).unwrap();
|
||||
@@ -150,11 +150,13 @@ fn dragging_the_splitter_resizes_and_survives_a_ui_state_round_trip() {
|
||||
let props = rect_of(&h, Group::Properties);
|
||||
let layers = rect_of(&h, Group::Layers);
|
||||
let split = Pos2::new(props.center().x, props.bottom() + GAP / 2.0);
|
||||
drag(&mut h, split, split - vec2(0.0, 90.0));
|
||||
let color = rect_of(&h, Group::Color);
|
||||
drag(&mut h, split, split - vec2(0.0, 60.0));
|
||||
let props2 = rect_of(&h, Group::Properties);
|
||||
let layers2 = rect_of(&h, Group::Layers);
|
||||
assert!((props2.height() - (props.height() - 90.0)).abs() < 2.0, "{props:?} -> {props2:?}");
|
||||
assert!((layers2.top() - (layers.top() - 90.0)).abs() < 2.0, "{layers:?} -> {layers2:?}");
|
||||
assert!((props2.height() - (props.height() - 60.0)).abs() < 2.0, "{props:?} -> {props2:?}");
|
||||
assert!((layers2.top() - (layers.top() - 60.0)).abs() < 2.0, "{layers:?} -> {layers2:?}");
|
||||
assert_eq!(rect_of(&h, Group::Color), color, "the group above keeps its place");
|
||||
assert_eq!(layers2.bottom(), layers.bottom());
|
||||
let stored = h.state().ui.dock.heights.get(&Group::Properties).copied().unwrap();
|
||||
// Dragging past the minimum stops at it.
|
||||
@@ -348,3 +350,219 @@ fn clicking_around_the_ui_keeps_the_panels_put() {
|
||||
h.state_mut().ui.dialogs.clear();
|
||||
}
|
||||
}
|
||||
|
||||
fn is_pro(theme: ThemeKind) -> bool {
|
||||
matches!(theme, ThemeKind::Pro | ThemeKind::ProMedium)
|
||||
}
|
||||
|
||||
/// Full app at `size` (1× scale) on a document with `n` layers named "Row 00", "Row 01", …
|
||||
fn app_harness_rows(size: egui::Vec2, theme: ThemeKind, n: usize) -> Harness<'static, PhotocraftApp> {
|
||||
let mut h = Harness::builder().with_size(size).with_max_steps(64).build_eframe(move |cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, theme);
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.ui.theme = theme;
|
||||
app.run("file.new", json!({"width": 200, "height": 150})).unwrap();
|
||||
for i in 0..n {
|
||||
app.run("layer.new.layer", json!({"name": format!("Row {i:02}")})).unwrap();
|
||||
}
|
||||
app.sync_views();
|
||||
app
|
||||
});
|
||||
h.run_steps(8);
|
||||
h
|
||||
}
|
||||
|
||||
/// Layer rows fully inside the Layers rows viewport.
|
||||
fn rows_in_view(h: &Harness<'static, PhotocraftApp>, n: usize) -> usize {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let layers = rect_of(h, Group::Layers);
|
||||
(0..n)
|
||||
.filter(|i| {
|
||||
h.query_all_by_label(&format!("Row {i:02}"))
|
||||
.map(|q| q.rect())
|
||||
.any(|r| r.left() >= layers.left() - 1.0 && r.top() >= layers.top() + 26.0 && r.bottom() <= layers.bottom() - 36.0)
|
||||
})
|
||||
.count()
|
||||
}
|
||||
|
||||
/// #147: the default workspace gives Layers the column's spare height: at least ten rows on a
|
||||
/// 900 pt window, and a usable list on a 720 pt one.
|
||||
#[test]
|
||||
fn default_layout_shows_ten_layer_rows_at_900pt() {
|
||||
for (size, want) in [(vec2(1440.0, 900.0), 10), (vec2(1280.0, 720.0), 4)] {
|
||||
let h = app_harness_rows(size, ThemeKind::ProMedium, 40);
|
||||
let rows = rows_in_view(&h, 40);
|
||||
let groups = last_rects(&h.ctx);
|
||||
assert!(rows >= want, "{size:?}: {rows} rows visible, want ≥ {want}: {groups:?}");
|
||||
// Color and Properties stay open, just not at the expense of Layers.
|
||||
for g in [Group::Color, Group::Properties] {
|
||||
assert!(rect_of(&h, g).height() >= g.compact_height() - 0.5, "{g:?} at {size:?}: {groups:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_give_way_to_layers_but_user_sizes_stay() {
|
||||
let l = DockLayout::default();
|
||||
// A tall column: everyone at their defaults, Layers takes the rest.
|
||||
let hs = l.heights_for(&ESSENTIALS, 1200.0, 28.0);
|
||||
assert_eq!(hs[0].1, Group::Color.default_height());
|
||||
assert_eq!(hs[1].1, Group::Properties.default_height());
|
||||
// A short one: default-sized groups shrink toward their compact heights so Layers keeps
|
||||
// its preferred height, the one nearest Layers first.
|
||||
let hs = l.heights_for(&ESSENTIALS, 820.0, 28.0);
|
||||
assert!(hs[2].1 >= Group::Layers.preferred_fill() - 0.5, "{hs:?}");
|
||||
assert!(hs[1].1 < Group::Properties.default_height() && hs[1].1 >= Group::Properties.compact_height(), "{hs:?}");
|
||||
// Heights the user dragged to (saved in prefs) are kept as they are.
|
||||
let mut mine = DockLayout::default();
|
||||
mine.heights.insert(Group::Properties, 340.0);
|
||||
mine.heights.insert(Group::Color, 200.0);
|
||||
let hs = mine.heights_for(&ESSENTIALS, 800.0, 28.0);
|
||||
assert_eq!((hs[0].1, hs[1].1), (200.0, 340.0), "{hs:?}");
|
||||
}
|
||||
|
||||
/// #150: Window › Character opens a Character | Paragraph group next to Properties (which
|
||||
/// stays), and toggles closed again; old saved layouts place it above Layers.
|
||||
#[test]
|
||||
fn window_character_opens_its_own_group_and_keeps_properties() {
|
||||
for theme in [ThemeKind::ProMedium, ThemeKind::Studio] {
|
||||
let (app, _, _) = app_with_layers();
|
||||
let mut h = harness(app, vec2(1440.0, 900.0), theme);
|
||||
let ctx = h.ctx.clone();
|
||||
crate::menus::invoke(h.state_mut(), &ctx, "window.panel.character", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
let app = h.state();
|
||||
assert!(app.ui.panels.character && app.ui.panels.properties && app.ui.dock_tabs.character == 0, "{theme:?}");
|
||||
let ch = rect_of(&h, Group::Character);
|
||||
assert!(ch.height() > 60.0, "{theme:?}: Character expanded: {ch:?}");
|
||||
if is_pro(theme) {
|
||||
let props = rect_of(&h, Group::Properties);
|
||||
assert!(props.height() > 60.0 && props.bottom() <= ch.top(), "{theme:?}: Properties visible above Character");
|
||||
}
|
||||
assert!(rect_of(&h, Group::Layers).top() > ch.bottom(), "{theme:?}: Layers stays the filler at the bottom");
|
||||
assert_eq!(crate::view_cmds::checked(h.state(), "window.panel.character"), Some(true));
|
||||
// Paragraph is the group's second tab; choosing it again on its tab closes the group.
|
||||
crate::menus::invoke(h.state_mut(), &ctx, "window.panel.paragraph", json!({})).unwrap();
|
||||
assert!(h.state().ui.panels.character && h.state().ui.dock_tabs.character == 1);
|
||||
crate::menus::invoke(h.state_mut(), &ctx, "window.panel.paragraph", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert!(!h.state().ui.panels.character && h.state().ui.panels.properties, "{theme:?}");
|
||||
assert!(!last_rects(&h.ctx).iter().any(|(g, _)| *g == Group::Character));
|
||||
// Type › Panels › Character Panel always shows it; Reset Workspace closes it.
|
||||
crate::menus::invoke(h.state_mut(), &ctx, "type.panels.character", json!({})).unwrap();
|
||||
crate::menus::invoke(h.state_mut(), &ctx, "type.panels.character", json!({})).unwrap();
|
||||
assert!(h.state().ui.panels.character);
|
||||
crate::menus::invoke(h.state_mut(), &ctx, "window.workspace.resetWorkspace", json!({})).unwrap();
|
||||
assert!(!h.state().ui.panels.character && h.state().ui.panels.properties);
|
||||
}
|
||||
// A layout saved before the group existed keeps Layers last.
|
||||
let old = DockLayout { order: vec![Group::Color, Group::Properties, Group::Navigator, Group::History, Group::Layers], ..Default::default() };
|
||||
assert_eq!(old.order(), Group::ALL.to_vec());
|
||||
let moved = DockLayout { order: vec![Group::Layers, Group::Color], ..Default::default() };
|
||||
assert_eq!(moved.order(), vec![Group::Layers, Group::Color, Group::Properties, Group::Character, Group::Navigator, Group::History]);
|
||||
}
|
||||
|
||||
type StripProbe = Option<(Vec<(usize, Rect)>, Rect, Option<Rect>, Rect)>;
|
||||
|
||||
/// #151: at narrow widths and 2× scale, in every theme, tabs elide or overflow into a chevron
|
||||
/// and never run under the panel menu button.
|
||||
#[test]
|
||||
fn tab_strips_never_overlap_the_menu_button() {
|
||||
let overlap = |a: Rect, b: Rect| {
|
||||
let i = a.intersect(b);
|
||||
i.width() > 0.5 && i.height() > 0.5
|
||||
};
|
||||
for theme in ThemeKind::ALL {
|
||||
for scale in [1.0, 2.0] {
|
||||
for width in [180.0, 250.0, 290.0, 420.0] {
|
||||
for g in Group::ALL {
|
||||
let tabs = g.tabs(is_pro(theme));
|
||||
for sel in 0..tabs.len() {
|
||||
let mut h = Harness::builder().with_size(vec2(width, 200.0)).with_pixels_per_point(scale).build_ui_state(
|
||||
move |ui, out: &mut StripProbe| {
|
||||
if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
|
||||
return;
|
||||
}
|
||||
let mut s = sel;
|
||||
let r = crate::widgets::card_ex(ui, "t", tabs, &mut s, false, |ui, _| {
|
||||
ui.label("body");
|
||||
});
|
||||
*out = Some((r.tabs, r.menu.rect, r.chevron, ui.max_rect()));
|
||||
},
|
||||
None,
|
||||
);
|
||||
PhotocraftApp::setup_context(&h.ctx, theme);
|
||||
h.run_steps(3);
|
||||
let (shown, menu, chevron, card) = h.state().clone().unwrap();
|
||||
let what = format!("{theme:?} {scale}x {width}pt {g:?} selected {sel}");
|
||||
assert!(shown.iter().any(|(i, _)| *i == sel), "{what}: the selected tab is on the strip: {shown:?}");
|
||||
for (i, r) in &shown {
|
||||
assert!(!overlap(*r, menu), "{what}: tab {i} {r:?} runs under the menu {menu:?}");
|
||||
assert!(r.left() >= card.left() - 0.5 && r.right() <= card.right() + 0.5, "{what}: tab {i} outside the card");
|
||||
}
|
||||
for w in shown.windows(2) {
|
||||
assert!(!overlap(w[0].1, w[1].1), "{what}: tabs overlap");
|
||||
}
|
||||
if let Some(c) = chevron {
|
||||
assert!(!overlap(c, menu) && shown.iter().all(|(_, r)| !overlap(*r, c)), "{what}: chevron overlaps");
|
||||
} else {
|
||||
assert_eq!(shown.len(), tabs.len(), "{what}: a tab went missing without a chevron");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #151 in the real dock: every strip at 2× scale fits beside its menu button, and a tab in the
|
||||
/// chevron menu can still be chosen.
|
||||
#[test]
|
||||
fn dock_strips_fit_and_the_chevron_menu_switches_tabs() {
|
||||
for theme in ThemeKind::ALL {
|
||||
let (mut app, _, _) = app_with_layers();
|
||||
app.ui.panels.history = true;
|
||||
app.ui.panels.navigator = true;
|
||||
app.ui.panels.character = true;
|
||||
let mut h = Harness::builder().with_size(vec2(900.0, 1000.0)).with_pixels_per_point(2.0).build_ui_state(
|
||||
|ui, app: &mut PhotocraftApp| {
|
||||
if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
|
||||
return;
|
||||
}
|
||||
crate::panels::right_dock(app, ui);
|
||||
egui::CentralPanel::default().show(ui, |_| {});
|
||||
},
|
||||
app,
|
||||
);
|
||||
PhotocraftApp::setup_context(&h.ctx, theme);
|
||||
h.state_mut().ui.theme = theme;
|
||||
h.run_steps(4);
|
||||
let strips = last_strips(&h.ctx);
|
||||
assert!(strips.len() >= 5, "{theme:?}: {strips:?}");
|
||||
for s in &strips {
|
||||
for (i, r) in &s.tabs {
|
||||
assert!(r.intersect(s.menu).width() <= 0.5, "{theme:?} {:?}: tab {i} under the menu", s.group);
|
||||
}
|
||||
}
|
||||
}
|
||||
// A strip too narrow for its tabs: pick a hidden tab from the chevron menu.
|
||||
let mut h = Harness::builder().with_size(vec2(150.0, 200.0)).build_ui_state(
|
||||
|ui, sel: &mut usize| {
|
||||
if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
|
||||
return;
|
||||
}
|
||||
let _ = crate::widgets::card_ex(ui, "color", &["Color", "Swatches", "Gradients", "Patterns"], sel, false, |ui, _| {
|
||||
ui.label("body");
|
||||
});
|
||||
},
|
||||
0usize,
|
||||
);
|
||||
PhotocraftApp::setup_context(&h.ctx, ThemeKind::ProMedium);
|
||||
h.run_steps(3);
|
||||
use egui_kittest::kittest::Queryable;
|
||||
h.get_by_label("More panels").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Patterns").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(*h.state(), 3, "Patterns chosen from the chevron menu");
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//! Reveal the active layer in the Layers panel (#152), like Photoshop: whenever the active
|
||||
//! layer changes, by whatever means (a click on the canvas with Auto-Select, Select › layer
|
||||
//! commands, ⌥[ / ⌥], undo, automation), its closed parent groups open (`layer.setExpanded`)
|
||||
//! and the panel scrolls just enough to show its row, unless the row is already in view.
|
||||
//!
|
||||
//! Only a change of the active layer reveals: closing the group that holds it, or scrolling
|
||||
//! away, is left alone until the next change.
|
||||
|
||||
use egui::{Rect, pos2};
|
||||
use photocraft_doc::{LayerContent, LayerId};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
|
||||
/// Last active layer the panel saw: (document index, layer).
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
struct Seen(usize, Option<LayerId>);
|
||||
|
||||
fn seen_id() -> egui::Id {
|
||||
egui::Id::new("layers-reveal-seen")
|
||||
}
|
||||
|
||||
/// Called once per Layers panel frame, before its rows are listed. When the active layer
|
||||
/// changed since the last frame, opens its closed parent groups and returns it: the row to
|
||||
/// scroll into view (see [`scroll_to_row`]). The first frame of a document only records it.
|
||||
pub fn track(app: &mut PhotocraftApp, ctx: &egui::Context) -> Option<LayerId> {
|
||||
let index = app.session.active_index()?;
|
||||
let active = app.session.active()?.active_layer;
|
||||
let now = Seen(index, active);
|
||||
let before = ctx.data(|d| d.get_temp::<Seen>(seen_id()));
|
||||
ctx.data_mut(|d| d.insert_temp(seen_id(), now));
|
||||
match before {
|
||||
Some(Seen(i, prev)) if i == index && prev != active => {}
|
||||
_ => return None,
|
||||
}
|
||||
let id = active?;
|
||||
for group in closed_ancestors(app, id) {
|
||||
// A view change (no history step); a failure just leaves the group closed.
|
||||
let _ = app.session.execute("layer.setExpanded", json!({"layer": group.0, "expanded": true}));
|
||||
}
|
||||
Some(id)
|
||||
}
|
||||
|
||||
/// The groups containing `id` that are closed, outermost first.
|
||||
fn closed_ancestors(app: &PhotocraftApp, id: LayerId) -> Vec<LayerId> {
|
||||
let Some(doc) = app.session.active().map(|s| &s.doc) else { return Vec::new() };
|
||||
let Some(path) = doc.path_of(id) else { return Vec::new() };
|
||||
(1..path.len()).filter_map(|n| doc.layer_at(path.get(..n)?)).filter(|l| matches!(&l.content, LayerContent::Group(g) if !g.expanded)).map(|l| l.id).collect()
|
||||
}
|
||||
|
||||
/// After drawing the row that started at `top` in the Layers scroll area, scroll it into view
|
||||
/// if any of it is hidden (no animation: Photoshop jumps).
|
||||
pub fn scroll_to_row(ui: &egui::Ui, top: f32) {
|
||||
let bottom = ui.min_rect().bottom().max(top);
|
||||
let row = Rect::from_min_max(pos2(ui.min_rect().left(), top), pos2(ui.min_rect().right(), bottom));
|
||||
let view = ui.clip_rect();
|
||||
if row.top() < view.top() - 0.5 || row.bottom() > view.bottom() + 0.5 {
|
||||
ui.scroll_to_rect_animation(row, None, egui::style::ScrollAnimation::none());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "layer_reveal_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,132 @@
|
||||
//! #152: a new active layer opens its parent groups and is scrolled into view.
|
||||
|
||||
use egui::{Rect, vec2};
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
use photocraft_doc::LayerId;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
use crate::dock::{Group, last_rects};
|
||||
use crate::theme::ThemeKind;
|
||||
|
||||
/// 30 layers above a closed group holding `inner` (near the bottom of the panel).
|
||||
fn tall_doc() -> (PhotocraftApp, LayerId, LayerId, LayerId, LayerId) {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 150})).unwrap();
|
||||
app.run("layer.new.layer", json!({"name": "Deep inside"})).unwrap();
|
||||
let active = |app: &PhotocraftApp| app.session.active().unwrap().active_layer.unwrap();
|
||||
let inner = active(&app);
|
||||
app.run("layer.groupLayers", json!({})).unwrap();
|
||||
let group = app.session.active().unwrap().doc.walk().into_iter().find(|(_, _, l)| l.is_group()).map(|(_, _, l)| l.id).unwrap();
|
||||
app.run("layer.setExpanded", json!({"layer": group.0, "expanded": false})).unwrap();
|
||||
app.run("layer.select", json!({"layer": group.0})).unwrap();
|
||||
let mut stack = Vec::new();
|
||||
for i in 0..30 {
|
||||
app.run("layer.new.layer", json!({"name": format!("Stack {i:02}")})).unwrap();
|
||||
stack.push(active(&app));
|
||||
}
|
||||
app.sync_views();
|
||||
(app, inner, group, stack[29], stack[28])
|
||||
}
|
||||
|
||||
fn harness(app: PhotocraftApp, theme: ThemeKind) -> Harness<'static, PhotocraftApp> {
|
||||
let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut PhotocraftApp| {
|
||||
if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
|
||||
return;
|
||||
}
|
||||
crate::panels::right_dock(app, ui);
|
||||
egui::CentralPanel::default().show(ui, |_| {});
|
||||
},
|
||||
app,
|
||||
);
|
||||
PhotocraftApp::setup_context(&h.ctx, theme);
|
||||
h.state_mut().ui.theme = theme;
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
/// The Layers panel row of `name`, if drawn.
|
||||
fn row(h: &Harness<'static, PhotocraftApp>, name: &str) -> Option<Rect> {
|
||||
let layers = last_rects(&h.ctx).into_iter().find(|(g, _)| *g == Group::Layers)?.1;
|
||||
h.query_all_by_label(name).map(|n| n.rect()).find(|r| layers.contains(r.center()) || (r.left() >= layers.left() && r.right() <= layers.right()))
|
||||
}
|
||||
|
||||
/// Fully inside the rows viewport (below the strip and the blend/lock rows, above the footer).
|
||||
fn in_view(h: &Harness<'static, PhotocraftApp>, r: Rect) -> bool {
|
||||
let layers = last_rects(&h.ctx).into_iter().find(|(g, _)| *g == Group::Layers).unwrap().1;
|
||||
r.top() >= layers.top() + 26.0 && r.bottom() <= layers.bottom() - 36.0
|
||||
}
|
||||
|
||||
fn expanded(app: &PhotocraftApp, group: LayerId) -> bool {
|
||||
matches!(&app.session.active().unwrap().doc.layer(group).unwrap().content, photocraft_doc::LayerContent::Group(g) if g.expanded)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_active_layer_opens_its_groups_and_scrolls_into_view() {
|
||||
for theme in [ThemeKind::ProMedium, ThemeKind::Studio] {
|
||||
let (app, inner, group, top, below_top) = tall_doc();
|
||||
let mut h = harness(app, theme);
|
||||
let top_row = row(&h, "Stack 29").expect("top row drawn");
|
||||
assert!(in_view(&h, top_row), "{theme:?}: the panel starts at the top");
|
||||
assert!(row(&h, "Deep inside").is_none(), "{theme:?}: hidden in its closed group");
|
||||
|
||||
// Select › layer, Auto-Select, automation: all end in a new active layer.
|
||||
h.state_mut().run("layer.select", json!({"layer": inner.0})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert!(expanded(h.state(), group), "{theme:?}: the parent group opened");
|
||||
let r = row(&h, "Deep inside").expect("row listed once its group is open");
|
||||
assert!(in_view(&h, r), "{theme:?}: scrolled into view: {r:?}");
|
||||
assert!(!in_view(&h, row(&h, "Stack 29").unwrap()), "{theme:?}: the top scrolled away");
|
||||
|
||||
// Back to the top layer: scrolls back up.
|
||||
h.state_mut().run("layer.select", json!({"layer": top.0})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert!(in_view(&h, row(&h, "Stack 29").unwrap()), "{theme:?}: scrolled back to the top");
|
||||
|
||||
// A layer already in view doesn't move the list.
|
||||
let before = row(&h, "Stack 29").unwrap();
|
||||
h.state_mut().run("layer.select", json!({"layer": below_top.0})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert_eq!(row(&h, "Stack 29").unwrap(), before, "{theme:?}: no scroll for a visible row");
|
||||
|
||||
// Closing the group by hand is respected until the active layer changes again.
|
||||
h.state_mut().run("layer.select", json!({"layer": inner.0})).unwrap();
|
||||
h.run_steps(3);
|
||||
h.state_mut().run("layer.setExpanded", json!({"layer": group.0, "expanded": false})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert!(!expanded(h.state(), group), "{theme:?}: stays closed");
|
||||
// Undo and other commands that change the active layer reveal it too: undoing a new
|
||||
// layer deep in the list makes the top layer active again.
|
||||
h.state_mut().run("layer.select", json!({"layer": top.0})).unwrap();
|
||||
h.run_steps(3);
|
||||
h.state_mut().run("layer.select", json!({"layer": inner.0})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert!(expanded(h.state(), group));
|
||||
h.state_mut().run("layer.new.layer", json!({"name": "Scratch"})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert!(!in_view(&h, row(&h, "Stack 29").unwrap()));
|
||||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().session.active().unwrap().active_layer, Some(top), "{theme:?}: undo made the top layer active");
|
||||
assert!(in_view(&h, row(&h, "Stack 29").unwrap()), "{theme:?}: undo revealed it");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reveal_survives_odd_documents() {
|
||||
// No document, a document without layers in groups, a layer deleted between frames.
|
||||
let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
let mut h = harness(app, ThemeKind::ProMedium);
|
||||
h.run_steps(2);
|
||||
h.state_mut().run("file.new", json!({"width": 20, "height": 20})).unwrap();
|
||||
h.state_mut().sync_views();
|
||||
h.run_steps(3);
|
||||
h.state_mut().run("layer.new.layer", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
h.state_mut().run("layer.delete", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
h.state_mut().run("file.close", json!({})).ok();
|
||||
h.state_mut().sync_views();
|
||||
h.run_steps(3);
|
||||
}
|
||||
@@ -45,6 +45,7 @@ mod icon_data;
|
||||
pub mod icons;
|
||||
pub mod layer_menu_ui;
|
||||
pub mod layer_props_ui;
|
||||
mod layer_reveal;
|
||||
pub mod layer_row_ui;
|
||||
pub mod layer_style;
|
||||
pub mod layer_tree_ui;
|
||||
@@ -77,6 +78,7 @@ pub mod smart_ui;
|
||||
pub mod snap_ui;
|
||||
pub mod state;
|
||||
pub mod stylus;
|
||||
mod tab_strip;
|
||||
pub mod theme;
|
||||
mod timeline_ui;
|
||||
pub mod tone;
|
||||
|
||||
@@ -849,6 +849,7 @@ pub fn apply_workspace(app: &mut PhotocraftApp) {
|
||||
p.layers = layers;
|
||||
p.history = history;
|
||||
p.properties = props;
|
||||
p.character = false;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -774,6 +774,7 @@ fn dock_panels(app: &mut PhotocraftApp, ui: &mut egui::Ui, p: &crate::state::Pan
|
||||
let shown: Vec<Group> = [
|
||||
(Group::Color, p.color),
|
||||
(Group::Properties, t.pro && p.properties),
|
||||
(Group::Character, p.character),
|
||||
(Group::Navigator, p.navigator),
|
||||
(Group::History, p.history),
|
||||
(Group::Layers, p.layers),
|
||||
@@ -808,6 +809,7 @@ fn dock_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, group: crate::dock::Gro
|
||||
(Group::Color, _) => color_picker(app, ui),
|
||||
(Group::Properties, 0) => properties_body(app, ui),
|
||||
(Group::Properties, _) => adjustments_grid(app, ui),
|
||||
(Group::Character, tab) => crate::type_tool::character_panel(app, ui, tab == 1),
|
||||
(Group::Navigator, 0) => navigator(app, ui),
|
||||
(Group::Navigator, 1) => crate::tone::histogram_panel(app, ui),
|
||||
(Group::Navigator, _) => info_panel(app, ui),
|
||||
@@ -1053,6 +1055,8 @@ fn blend_options(groups: bool) -> Vec<(BlendMode, &'static str)> {
|
||||
}
|
||||
|
||||
fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
// A new active layer opens its parent groups and is scrolled into view (#152).
|
||||
let reveal = crate::layer_reveal::track(app, ui.ctx());
|
||||
let Some(st) = app.session.active() else {
|
||||
empty(ui, "No document");
|
||||
return;
|
||||
@@ -1182,7 +1186,11 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
let row = RowSel { selected: selection.contains(&l.id), primary: active == Some(l.id), multi: selection.len() > 1 };
|
||||
let top = ui.cursor().top();
|
||||
layer_row(app, &ctx, ui, &doc, l, depth, row, &mut actions);
|
||||
if reveal == Some(l.id) {
|
||||
crate::layer_reveal::scroll_to_row(ui, top);
|
||||
}
|
||||
if !l.effects.items.is_empty() && fx_collapsed.iter().all(|id| *id != l.id) {
|
||||
effect_rows(app, ui, l, depth);
|
||||
}
|
||||
|
||||
@@ -111,10 +111,11 @@ fn put_focus(h: &mut Harness<'_, PhotocraftApp>, place: Place) {
|
||||
}
|
||||
Place::OpacityField => {
|
||||
// The Layers panel's Opacity field: the DragValue in the right dock reading 100.
|
||||
let layers = crate::dock::last_rects(&h.ctx).into_iter().find(|(g, _)| *g == crate::dock::Group::Layers).expect("Layers drawn").1;
|
||||
let at = h
|
||||
.query_all_by_role(Role::SpinButton)
|
||||
.map(|n| n.rect())
|
||||
.filter(|r| r.center().x > 1100.0 && r.center().y > 500.0)
|
||||
.filter(|r| layers.contains(r.center()))
|
||||
.min_by(|a, b| a.center().y.total_cmp(&b.center().y))
|
||||
.expect("Layers opacity field")
|
||||
.center();
|
||||
|
||||
@@ -227,6 +227,9 @@ pub struct Panels {
|
||||
/// Window › Brush Settings (F5): floating, like Photoshop's.
|
||||
#[serde(default)]
|
||||
pub brush_settings: bool,
|
||||
/// Window › Character / Paragraph: the Character | Paragraph dock group (#150).
|
||||
#[serde(default)]
|
||||
pub character: bool,
|
||||
}
|
||||
|
||||
impl Default for Panels {
|
||||
@@ -241,6 +244,7 @@ impl Default for Panels {
|
||||
options_bar: true,
|
||||
status_bar: true,
|
||||
brush_settings: false,
|
||||
character: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -510,6 +514,8 @@ pub struct DockTabs {
|
||||
pub navigator: usize,
|
||||
/// History | Actions.
|
||||
pub history: usize,
|
||||
/// Character | Paragraph.
|
||||
pub character: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
//! Dock tab strips that fit any width (#151), like Photoshop's: when the tabs don't fit beside
|
||||
//! the panel menu button they shrink, eliding their labels with '…', and once they are at their
|
||||
//! minimum width the ones that still don't fit move into a » chevron menu at the end of the
|
||||
//! strip. The selected tab always stays on the strip, and no tab ever runs under the menu button.
|
||||
|
||||
use egui::{CornerRadius, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
|
||||
use crate::theme::{self, Tokens};
|
||||
|
||||
/// Which tabs a strip shows, at what widths, and which overflow into the chevron menu.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct TabFit {
|
||||
/// `(tab index, width)` for the tabs on the strip, in tab order.
|
||||
pub shown: Vec<(usize, f32)>,
|
||||
/// Tabs in the chevron menu, in tab order (empty: no chevron).
|
||||
pub overflow: Vec<usize>,
|
||||
}
|
||||
|
||||
/// Fit tabs with `natural` widths into `avail` points. Tabs shrink (never below `min_w`, or
|
||||
/// their natural width if smaller) before any overflow; overflowing tabs leave `chevron_w`
|
||||
/// for the chevron. `selected` is kept on the strip whatever happens.
|
||||
pub fn fit(natural: &[f32], selected: usize, avail: f32, min_w: f32, chevron_w: f32) -> TabFit {
|
||||
let clean = |w: f32| if w.is_finite() { w.max(0.0) } else { 0.0 };
|
||||
let avail = clean(avail);
|
||||
let min_w = clean(min_w);
|
||||
let natural: Vec<f32> = natural.iter().map(|w| clean(*w)).collect();
|
||||
let floor = |w: f32| w.min(min_w);
|
||||
let all: Vec<usize> = (0..natural.len()).collect();
|
||||
if natural.iter().sum::<f32>() <= avail {
|
||||
return TabFit { shown: all.iter().map(|&i| (i, natural.get(i).copied().unwrap_or(0.0))).collect(), overflow: Vec::new() };
|
||||
}
|
||||
if natural.iter().map(|w| floor(*w)).sum::<f32>() <= avail {
|
||||
return TabFit { shown: squeeze(&natural, &all, avail, min_w), overflow: Vec::new() };
|
||||
}
|
||||
// Overflow: the selected tab first, then the others in order, while they fit at their minimum.
|
||||
let budget = (avail - clean(chevron_w)).max(0.0);
|
||||
let sel = selected.min(natural.len().saturating_sub(1));
|
||||
let mut keep: Vec<usize> = Vec::new();
|
||||
let mut used = 0.0;
|
||||
for i in std::iter::once(sel).chain(all.iter().copied().filter(|i| *i != sel)) {
|
||||
let w = natural.get(i).map_or(0.0, |w| floor(*w));
|
||||
if used + w <= budget || (i == sel && !natural.is_empty()) {
|
||||
keep.push(i);
|
||||
used += w;
|
||||
}
|
||||
}
|
||||
keep.sort_unstable();
|
||||
let mut shown = squeeze(&natural, &keep, budget, min_w);
|
||||
// A strip too narrow even for the selected tab at its minimum: it takes what there is.
|
||||
if used > budget
|
||||
&& let Some(s) = shown.iter_mut().find(|(i, _)| *i == sel)
|
||||
{
|
||||
s.1 = budget;
|
||||
}
|
||||
let overflow = all.into_iter().filter(|i| !keep.contains(i)).collect();
|
||||
TabFit { shown, overflow }
|
||||
}
|
||||
|
||||
/// Widths for the `idx` tabs filling `total`: the widest shrink first (a common cap), none
|
||||
/// below `min(natural, min_w)`.
|
||||
fn squeeze(natural: &[f32], idx: &[usize], total: f32, min_w: f32) -> Vec<(usize, f32)> {
|
||||
let ws: Vec<f32> = idx.iter().map(|&i| natural.get(i).copied().unwrap_or(0.0)).collect();
|
||||
let sum_at = |cap: f32| ws.iter().map(|w| w.min(cap.max(w.min(min_w)))).sum::<f32>();
|
||||
let (mut lo, mut hi) = (min_w, ws.iter().copied().fold(min_w, f32::max));
|
||||
if sum_at(hi) > total {
|
||||
for _ in 0..32 {
|
||||
let mid = (lo + hi) / 2.0;
|
||||
if sum_at(mid) <= total {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lo = hi;
|
||||
}
|
||||
idx.iter().zip(ws).map(|(&i, w)| (i, w.min(lo.max(w.min(min_w))))).collect()
|
||||
}
|
||||
|
||||
/// A label laid out on one row no wider than `max_w`, cut with '…' when it doesn't fit.
|
||||
pub fn elided(ui: &Ui, text: &str, font: egui::FontId, color: egui::Color32, max_w: f32) -> std::sync::Arc<egui::Galley> {
|
||||
let mut job = egui::text::LayoutJob::simple_singleline(text.to_owned(), font, color);
|
||||
job.wrap = egui::text::TextWrapping { max_width: max_w.max(1.0), max_rows: 1, break_anywhere: true, overflow_character: Some('…') };
|
||||
ui.painter().layout_job(job)
|
||||
}
|
||||
|
||||
/// What a strip reported this frame.
|
||||
pub struct StripOut {
|
||||
pub double_clicked: bool,
|
||||
/// Rects of the tabs on the strip, `(tab index, rect)`.
|
||||
pub tabs: Vec<(usize, Rect)>,
|
||||
/// The » overflow button, when some tabs didn't fit.
|
||||
pub chevron: Option<Rect>,
|
||||
}
|
||||
|
||||
/// Width of the » overflow button.
|
||||
const CHEVRON_W: f32 = 18.0;
|
||||
|
||||
/// Draw the tabs of a strip in `area` (the strip minus the menu button). `paint_tab` draws one
|
||||
/// tab: (ui, rect, index, label galley, response, active).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn tabs_in(
|
||||
ui: &mut Ui,
|
||||
id: egui::Id,
|
||||
area: Rect,
|
||||
tabs: &[&str],
|
||||
selected: &mut usize,
|
||||
font: egui::FontId,
|
||||
pad: f32,
|
||||
min_w: f32,
|
||||
active: impl Fn(usize) -> bool,
|
||||
mut paint_tab: impl FnMut(&Ui, Rect, usize, std::sync::Arc<egui::Galley>, &Response, bool),
|
||||
) -> StripOut {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let natural: Vec<f32> = tabs.iter().map(|n| ui.painter().layout_no_wrap((*n).to_owned(), font.clone(), t.text).size().x + pad).collect();
|
||||
let f = fit(&natural, *selected, area.width(), min_w, CHEVRON_W);
|
||||
let mut x = area.left();
|
||||
let mut out = StripOut { double_clicked: false, tabs: Vec::with_capacity(f.shown.len()), chevron: None };
|
||||
for &(i, w) in &f.shown {
|
||||
let Some(name) = tabs.get(i) else { continue };
|
||||
let r = Rect::from_min_size(pos2(x, area.top()), vec2(w, area.height()));
|
||||
let resp = ui.interact(r, id.with(("tab", i)), Sense::click());
|
||||
// The padding gives way (down to a third) before the label is cut.
|
||||
let galley = elided(ui, name, font.clone(), t.text, (w - pad / 3.0).max(1.0));
|
||||
let cut = galley.size().x + pad + 0.5 < natural.get(i).copied().unwrap_or(0.0) && galley.size().x + pad / 3.0 >= w - 0.5;
|
||||
paint_tab(ui, r, i, galley, &resp, active(i));
|
||||
let resp = if cut { resp.on_hover_text(*name) } else { resp };
|
||||
out.double_clicked |= resp.double_clicked();
|
||||
if resp.clicked() {
|
||||
*selected = i;
|
||||
}
|
||||
out.tabs.push((i, r));
|
||||
x = r.right();
|
||||
}
|
||||
if !f.overflow.is_empty() {
|
||||
let r = Rect::from_min_size(pos2(x, area.top()), vec2(CHEVRON_W, area.height()));
|
||||
let resp = ui.interact(r, id.with("tab-overflow"), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r.shrink2(vec2(1.0, 3.0)), t.radius_sm, t.hover.gamma_multiply(0.6));
|
||||
}
|
||||
crate::icons::paint(ui, r, "chevrons-right", 12.0, if resp.hovered() { t.text } else { t.text_dim });
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "More panels"));
|
||||
let resp = resp.on_hover_text("More panels");
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
ui.set_min_width(140.0);
|
||||
for &i in &f.overflow {
|
||||
if let Some(name) = tabs.get(i)
|
||||
&& ui.button(*name).clicked()
|
||||
{
|
||||
*selected = i;
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
out.chevron = Some(r);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Photoshop-grammar strip (Pro themes): flat tabs on the dark strip; `menu_left` is where the
|
||||
/// panel menu button starts.
|
||||
pub fn pro_tabs(ui: &mut Ui, id: egui::Id, strip: Rect, menu_left: f32, tabs: &[&str], selected: &mut usize, collapsed: bool) -> StripOut {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let area = Rect::from_min_max(strip.min, pos2(menu_left.max(strip.left()), strip.bottom()));
|
||||
let sel = *selected;
|
||||
tabs_in(
|
||||
ui,
|
||||
id,
|
||||
area,
|
||||
tabs,
|
||||
selected,
|
||||
egui::FontId::proportional(11.5),
|
||||
22.0,
|
||||
40.0,
|
||||
|i| i == sel && !collapsed,
|
||||
|ui, r, i, galley, resp, active| {
|
||||
if active {
|
||||
ui.painter().rect_filled(r, CornerRadius { nw: if i == 0 { 3 } else { 0 }, ne: 0, sw: 0, se: 0 }, t.card);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.4));
|
||||
}
|
||||
let color = if active {
|
||||
t.text
|
||||
} else if resp.hovered() {
|
||||
t.text_dim
|
||||
} else {
|
||||
t.text_faint
|
||||
};
|
||||
ui.painter().galley_with_override_text_color(r.center() - galley.size() / 2.0, galley, color);
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Studio strip: pill tabs in `area` (the row minus the menu button).
|
||||
pub fn pill_tabs(ui: &mut Ui, id: egui::Id, area: Rect, tabs: &[&str], selected: &mut usize) -> StripOut {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let sel = *selected;
|
||||
tabs_in(
|
||||
ui,
|
||||
id,
|
||||
area,
|
||||
tabs,
|
||||
selected,
|
||||
theme::medium(12.5),
|
||||
20.0,
|
||||
44.0,
|
||||
|i| i == sel,
|
||||
|ui, r, _, galley, resp, active| {
|
||||
// 2 pt between pills, as the old horizontal layout had.
|
||||
let r = Rect::from_min_max(r.min, pos2((r.right() - 2.0).max(r.left()), r.bottom()));
|
||||
if active {
|
||||
crate::widgets::surface(ui, r, t.hover, true);
|
||||
if !t.bevel {
|
||||
ui.painter().rect_stroke(r, t.radius_sm, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
|
||||
}
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, t.radius_sm, t.hover.gamma_multiply(0.6));
|
||||
}
|
||||
let color = if active { t.text } else { t.text_dim };
|
||||
ui.painter().galley_with_override_text_color(r.center() - galley.size() / 2.0, galley, color);
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn total(f: &TabFit) -> f32 {
|
||||
f.shown.iter().map(|(_, w)| w).sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tabs_that_fit_keep_their_widths() {
|
||||
let f = fit(&[60.0, 70.0, 80.0], 0, 400.0, 40.0, 18.0);
|
||||
assert_eq!(f.shown, vec![(0, 60.0), (1, 70.0), (2, 80.0)]);
|
||||
assert!(f.overflow.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrow_strips_shrink_the_widest_tabs_first() {
|
||||
let f = fit(&[50.0, 70.0, 120.0], 0, 200.0, 40.0, 18.0);
|
||||
assert!(f.overflow.is_empty());
|
||||
assert!((total(&f) - 200.0).abs() < 0.1, "{f:?}");
|
||||
assert_eq!(f.shown[0], (0, 50.0), "a tab narrower than the cap keeps its width");
|
||||
assert_eq!(f.shown[1], (1, 70.0));
|
||||
assert!((f.shown[2].1 - 80.0).abs() < 0.1, "only the widest shrank: {f:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflow_keeps_the_selected_tab_and_leaves_room_for_the_chevron() {
|
||||
let natural = [80.0, 90.0, 85.0, 95.0];
|
||||
for sel in 0..4 {
|
||||
let f = fit(&natural, sel, 120.0, 40.0, 18.0);
|
||||
assert!(f.shown.iter().any(|(i, _)| *i == sel), "{sel}: {f:?}");
|
||||
assert!(!f.overflow.is_empty() && !f.overflow.contains(&sel));
|
||||
assert!(total(&f) <= 120.0 - 18.0 + 1e-3, "{f:?}");
|
||||
assert_eq!(f.shown.len() + f.overflow.len(), 4);
|
||||
assert!(f.shown.windows(2).all(|w| w[0].0 < w[1].0), "tab order kept");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_inputs_never_panic_or_go_negative() {
|
||||
for avail in [0.0, -10.0, 5.0, f32::NAN, f32::INFINITY] {
|
||||
for natural in [&[][..], &[f32::NAN, 50.0][..], &[1e9, -5.0, 30.0][..]] {
|
||||
for sel in [0, 1, 99] {
|
||||
let f = fit(natural, sel, avail, 40.0, 18.0);
|
||||
assert!(f.shown.iter().all(|(_, w)| w.is_finite() && *w >= 0.0), "{avail} {natural:?} {f:?}");
|
||||
assert_eq!(f.shown.len() + f.overflow.len(), natural.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+149
-128
@@ -803,6 +803,22 @@ fn align_glyph(p: &egui::Painter, r: egui::Rect, a: photocraft_doc::text::TextAl
|
||||
|
||||
/// Properties panel sections for a type layer: Character and Paragraph (Photoshop CC layout).
|
||||
pub fn type_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
type_sections(app, ui, true, true);
|
||||
}
|
||||
|
||||
/// Window › Character / Paragraph (#150): the same controls as Properties, in their own dock
|
||||
/// group. Without a type layer (or a type edit) there is nothing to show.
|
||||
pub fn character_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui, paragraph: bool) {
|
||||
if styles_at(app).is_none() {
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
ui.add_space(8.0);
|
||||
ui.label(egui::RichText::new("Select a type layer to edit its character and paragraph settings.").color(t.text_dim));
|
||||
return;
|
||||
}
|
||||
type_sections(app, ui, !paragraph, paragraph);
|
||||
}
|
||||
|
||||
fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, paragraph: bool) {
|
||||
let Some((c, para)) = styles_at(app) else { return };
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
let section = |ui: &mut egui::Ui, title: &str| {
|
||||
@@ -810,141 +826,146 @@ pub fn type_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
ui.label(egui::RichText::new(title).font(crate::theme::semibold(12.0)).color(t.text));
|
||||
ui.add_space(2.0);
|
||||
};
|
||||
section(ui, "Character");
|
||||
let mut fam = c.font_family.clone();
|
||||
ui.horizontal(|ui| {
|
||||
if font_picker(ui, &mut fam) {
|
||||
app.ui.tool_options.type_font = fam.clone();
|
||||
let st = styles(&fam);
|
||||
let style = if st.contains(&c.font_style) { c.font_style.clone() } else { st.first().cloned().unwrap_or_else(|| "Regular".into()) };
|
||||
apply(app, ui.ctx(), json!({"font": fam, "fontStyle": style}));
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
let mut style = if c.font_style.is_empty() { "Regular".to_string() } else { c.font_style.clone() };
|
||||
let opts: Vec<(String, String)> = styles(&fam).into_iter().map(|s| (s.clone(), s)).collect();
|
||||
let opts_ref: Vec<(String, &str)> = opts.iter().map(|(a, b)| (a.clone(), b.as_str())).collect();
|
||||
if crate::widgets::dropdown(ui, "props-type-style", &mut style, &opts_ref, 170.0) {
|
||||
apply(app, ui.ctx(), json!({"fontStyle": style}));
|
||||
}
|
||||
});
|
||||
let w = ((ui.available_width() - 70.0) / 2.0).clamp(50.0, 90.0);
|
||||
ui.horizontal(|ui| {
|
||||
let k = shown_scale(app);
|
||||
if let Some(v) = num_field(ui, "tT", "Font size", c.size_pt * k, 0.1..=1296.0, "pt", w) {
|
||||
app.ui.tool_options.type_size = v;
|
||||
apply(app, ui.ctx(), json!({"size": v / k}));
|
||||
}
|
||||
let lead = c.leading_pt.unwrap_or(c.size_pt * para.auto_leading.max(0.01)) * k;
|
||||
if let Some(v) = num_field(ui, "A↕", "Leading (set to the font size × auto-leading when Auto)", lead, 0.1..=5000.0, "pt", w) {
|
||||
apply(app, ui.ctx(), json!({"leading": v / k}));
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
let mut k = match c.kerning {
|
||||
photocraft_doc::text::Kerning::Metrics => "metrics",
|
||||
photocraft_doc::text::Kerning::Optical => "optical",
|
||||
photocraft_doc::text::Kerning::Off => "off",
|
||||
}
|
||||
.to_string();
|
||||
icon_label(ui, "text-cursor", "Kerning");
|
||||
if crate::widgets::dropdown(
|
||||
ui,
|
||||
"props-kern",
|
||||
&mut k,
|
||||
&[("metrics".to_string(), "Metrics"), ("optical".to_string(), "Optical"), ("off".to_string(), "0")],
|
||||
w,
|
||||
) {
|
||||
apply(app, ui.ctx(), json!({"kerning": k}));
|
||||
}
|
||||
if let Some(v) = num_field(ui, "VA", "Tracking (1/1000 em)", c.tracking, -1000.0..=10000.0, "", w) {
|
||||
apply(app, ui.ctx(), json!({"tracking": v}));
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(v) = num_field(ui, "↕T", "Vertical scale", c.vertical_scale * 100.0, 0.0..=1000.0, "%", w) {
|
||||
apply(app, ui.ctx(), json!({"verticalScale": v}));
|
||||
}
|
||||
if let Some(v) = num_field(ui, "↔T", "Horizontal scale", c.horizontal_scale * 100.0, 0.0..=1000.0, "%", w) {
|
||||
apply(app, ui.ctx(), json!({"horizontalScale": v}));
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(v) = num_field(ui, "Aª", "Baseline shift", c.baseline_shift_pt, -1296.0..=1296.0, "pt", w) {
|
||||
apply(app, ui.ctx(), json!({"baselineShift": v}));
|
||||
}
|
||||
// Colour chip.
|
||||
ui.add_space(8.0);
|
||||
ui.label(egui::RichText::new("Color:").color(t.text_dim).size(12.0));
|
||||
let rgb = c.color.to_rgb();
|
||||
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
let (rect, resp) = ui.allocate_exact_size(egui::vec2(40.0, 18.0), egui::Sense::click());
|
||||
ui.painter().rect_filled(rect, 2.0, Color32::from_rgb(q(rgb[0]), q(rgb[1]), q(rgb[2])));
|
||||
ui.painter().rect_stroke(rect, 2.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Outside);
|
||||
egui::Popup::from_toggle_button_response(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
|
||||
let mut col = Color32::from_rgb(q(rgb[0]), q(rgb[1]), q(rgb[2]));
|
||||
if egui::color_picker::color_picker_color32(ui, &mut col, egui::color_picker::Alpha::Opaque) {
|
||||
apply(app, ui.ctx(), json!({"color": format!("#{:02x}{:02x}{:02x}", col.r(), col.g(), col.b())}));
|
||||
if character {
|
||||
section(ui, "Character");
|
||||
let mut fam = c.font_family.clone();
|
||||
ui.horizontal(|ui| {
|
||||
if font_picker(ui, &mut fam) {
|
||||
app.ui.tool_options.type_font = fam.clone();
|
||||
let st = styles(&fam);
|
||||
let style = if st.contains(&c.font_style) { c.font_style.clone() } else { st.first().cloned().unwrap_or_else(|| "Regular".into()) };
|
||||
apply(app, ui.ctx(), json!({"font": fam, "fontStyle": style}));
|
||||
}
|
||||
});
|
||||
});
|
||||
// Faux styles row: T (bold) T (italic) TT Tᴛ T̲ T̶
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 2.0;
|
||||
let caps = c.caps;
|
||||
let toggles: [(&str, &str, bool, serde_json::Value); 6] = [
|
||||
("T", "Faux Bold", c.faux_bold, json!({"fauxBold": !c.faux_bold})),
|
||||
("T", "Faux Italic", c.faux_italic, json!({"fauxItalic": !c.faux_italic})),
|
||||
(
|
||||
"TT",
|
||||
"All Caps",
|
||||
caps == photocraft_doc::text::Caps::AllCaps,
|
||||
json!({"caps": if caps == photocraft_doc::text::Caps::AllCaps { "normal" } else { "allCaps" }}),
|
||||
),
|
||||
(
|
||||
"Tᴛ",
|
||||
"Small Caps",
|
||||
caps == photocraft_doc::text::Caps::SmallCaps,
|
||||
json!({"caps": if caps == photocraft_doc::text::Caps::SmallCaps { "normal" } else { "smallCaps" }}),
|
||||
),
|
||||
("T", "Underline", c.underline, json!({"underline": !c.underline})),
|
||||
("T", "Strikethrough", c.strikethrough, json!({"strikethrough": !c.strikethrough})),
|
||||
];
|
||||
for (i, (glyph, tip, on, props)) in toggles.into_iter().enumerate() {
|
||||
let (r, resp) = ui.allocate_exact_size(egui::vec2(28.0, 24.0), egui::Sense::click());
|
||||
let bg = if on {
|
||||
t.accent_soft
|
||||
} else if resp.hovered() {
|
||||
t.hover
|
||||
} else {
|
||||
Color32::TRANSPARENT
|
||||
};
|
||||
ui.painter().rect_filled(r, 3.0, bg);
|
||||
let font = if i == 0 { crate::theme::semibold(13.0) } else { egui::FontId::proportional(13.0) };
|
||||
let col = if on { t.text } else { t.text_dim };
|
||||
let g = ui.painter().layout_no_wrap(glyph.to_string(), font, col);
|
||||
let pos = r.center() - g.size() / 2.0;
|
||||
let gr = egui::Rect::from_min_size(pos, g.size());
|
||||
if i == 1 {
|
||||
// Faux italic: a slanted T drawn as strokes (no italic face is bundled).
|
||||
let (top, bot, cx) = (gr.top() + 3.0, gr.bottom() - 3.0, gr.center().x);
|
||||
let slant = (bot - top) * 0.25;
|
||||
ui.painter().line_segment([egui::pos2(cx - 4.0 + slant / 2.0, top), egui::pos2(cx + 4.0 + slant / 2.0, top)], Stroke::new(1.3, col));
|
||||
ui.painter().line_segment([egui::pos2(cx + slant / 2.0, top), egui::pos2(cx - slant / 2.0, bot)], Stroke::new(1.3, col));
|
||||
} else {
|
||||
ui.painter().galley(pos, g, col);
|
||||
ui.horizontal(|ui| {
|
||||
let mut style = if c.font_style.is_empty() { "Regular".to_string() } else { c.font_style.clone() };
|
||||
let opts: Vec<(String, String)> = styles(&fam).into_iter().map(|s| (s.clone(), s)).collect();
|
||||
let opts_ref: Vec<(String, &str)> = opts.iter().map(|(a, b)| (a.clone(), b.as_str())).collect();
|
||||
if crate::widgets::dropdown(ui, "props-type-style", &mut style, &opts_ref, 170.0) {
|
||||
apply(app, ui.ctx(), json!({"fontStyle": style}));
|
||||
}
|
||||
if i == 4 {
|
||||
ui.painter().line_segment([egui::pos2(gr.left(), gr.bottom() - 2.0), egui::pos2(gr.right(), gr.bottom() - 2.0)], Stroke::new(1.0, col));
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
let k = shown_scale(app);
|
||||
if let Some(v) = num_field(ui, "tT", "Font size", c.size_pt * k, 0.1..=1296.0, "pt", w) {
|
||||
app.ui.tool_options.type_size = v;
|
||||
apply(app, ui.ctx(), json!({"size": v / k}));
|
||||
}
|
||||
if i == 5 {
|
||||
ui.painter().line_segment([egui::pos2(gr.left() - 1.0, gr.center().y), egui::pos2(gr.right() + 1.0, gr.center().y)], Stroke::new(1.0, col));
|
||||
let lead = c.leading_pt.unwrap_or(c.size_pt * para.auto_leading.max(0.01)) * k;
|
||||
if let Some(v) = num_field(ui, "A↕", "Leading (set to the font size × auto-leading when Auto)", lead, 0.1..=5000.0, "pt", w) {
|
||||
apply(app, ui.ctx(), json!({"leading": v / k}));
|
||||
}
|
||||
if resp.on_hover_text(tip).clicked() {
|
||||
apply(app, ui.ctx(), props);
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
let mut k = match c.kerning {
|
||||
photocraft_doc::text::Kerning::Metrics => "metrics",
|
||||
photocraft_doc::text::Kerning::Optical => "optical",
|
||||
photocraft_doc::text::Kerning::Off => "off",
|
||||
}
|
||||
}
|
||||
});
|
||||
.to_string();
|
||||
icon_label(ui, "text-cursor", "Kerning");
|
||||
if crate::widgets::dropdown(
|
||||
ui,
|
||||
"props-kern",
|
||||
&mut k,
|
||||
&[("metrics".to_string(), "Metrics"), ("optical".to_string(), "Optical"), ("off".to_string(), "0")],
|
||||
w,
|
||||
) {
|
||||
apply(app, ui.ctx(), json!({"kerning": k}));
|
||||
}
|
||||
if let Some(v) = num_field(ui, "VA", "Tracking (1/1000 em)", c.tracking, -1000.0..=10000.0, "", w) {
|
||||
apply(app, ui.ctx(), json!({"tracking": v}));
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(v) = num_field(ui, "↕T", "Vertical scale", c.vertical_scale * 100.0, 0.0..=1000.0, "%", w) {
|
||||
apply(app, ui.ctx(), json!({"verticalScale": v}));
|
||||
}
|
||||
if let Some(v) = num_field(ui, "↔T", "Horizontal scale", c.horizontal_scale * 100.0, 0.0..=1000.0, "%", w) {
|
||||
apply(app, ui.ctx(), json!({"horizontalScale": v}));
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(v) = num_field(ui, "Aª", "Baseline shift", c.baseline_shift_pt, -1296.0..=1296.0, "pt", w) {
|
||||
apply(app, ui.ctx(), json!({"baselineShift": v}));
|
||||
}
|
||||
// Colour chip.
|
||||
ui.add_space(8.0);
|
||||
ui.label(egui::RichText::new("Color:").color(t.text_dim).size(12.0));
|
||||
let rgb = c.color.to_rgb();
|
||||
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
let (rect, resp) = ui.allocate_exact_size(egui::vec2(40.0, 18.0), egui::Sense::click());
|
||||
ui.painter().rect_filled(rect, 2.0, Color32::from_rgb(q(rgb[0]), q(rgb[1]), q(rgb[2])));
|
||||
ui.painter().rect_stroke(rect, 2.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Outside);
|
||||
egui::Popup::from_toggle_button_response(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
|
||||
let mut col = Color32::from_rgb(q(rgb[0]), q(rgb[1]), q(rgb[2]));
|
||||
if egui::color_picker::color_picker_color32(ui, &mut col, egui::color_picker::Alpha::Opaque) {
|
||||
apply(app, ui.ctx(), json!({"color": format!("#{:02x}{:02x}{:02x}", col.r(), col.g(), col.b())}));
|
||||
}
|
||||
});
|
||||
});
|
||||
// Faux styles row: T (bold) T (italic) TT Tᴛ T̲ T̶
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 2.0;
|
||||
let caps = c.caps;
|
||||
let toggles: [(&str, &str, bool, serde_json::Value); 6] = [
|
||||
("T", "Faux Bold", c.faux_bold, json!({"fauxBold": !c.faux_bold})),
|
||||
("T", "Faux Italic", c.faux_italic, json!({"fauxItalic": !c.faux_italic})),
|
||||
(
|
||||
"TT",
|
||||
"All Caps",
|
||||
caps == photocraft_doc::text::Caps::AllCaps,
|
||||
json!({"caps": if caps == photocraft_doc::text::Caps::AllCaps { "normal" } else { "allCaps" }}),
|
||||
),
|
||||
(
|
||||
"Tᴛ",
|
||||
"Small Caps",
|
||||
caps == photocraft_doc::text::Caps::SmallCaps,
|
||||
json!({"caps": if caps == photocraft_doc::text::Caps::SmallCaps { "normal" } else { "smallCaps" }}),
|
||||
),
|
||||
("T", "Underline", c.underline, json!({"underline": !c.underline})),
|
||||
("T", "Strikethrough", c.strikethrough, json!({"strikethrough": !c.strikethrough})),
|
||||
];
|
||||
for (i, (glyph, tip, on, props)) in toggles.into_iter().enumerate() {
|
||||
let (r, resp) = ui.allocate_exact_size(egui::vec2(28.0, 24.0), egui::Sense::click());
|
||||
let bg = if on {
|
||||
t.accent_soft
|
||||
} else if resp.hovered() {
|
||||
t.hover
|
||||
} else {
|
||||
Color32::TRANSPARENT
|
||||
};
|
||||
ui.painter().rect_filled(r, 3.0, bg);
|
||||
let font = if i == 0 { crate::theme::semibold(13.0) } else { egui::FontId::proportional(13.0) };
|
||||
let col = if on { t.text } else { t.text_dim };
|
||||
let g = ui.painter().layout_no_wrap(glyph.to_string(), font, col);
|
||||
let pos = r.center() - g.size() / 2.0;
|
||||
let gr = egui::Rect::from_min_size(pos, g.size());
|
||||
if i == 1 {
|
||||
// Faux italic: a slanted T drawn as strokes (no italic face is bundled).
|
||||
let (top, bot, cx) = (gr.top() + 3.0, gr.bottom() - 3.0, gr.center().x);
|
||||
let slant = (bot - top) * 0.25;
|
||||
ui.painter().line_segment([egui::pos2(cx - 4.0 + slant / 2.0, top), egui::pos2(cx + 4.0 + slant / 2.0, top)], Stroke::new(1.3, col));
|
||||
ui.painter().line_segment([egui::pos2(cx + slant / 2.0, top), egui::pos2(cx - slant / 2.0, bot)], Stroke::new(1.3, col));
|
||||
} else {
|
||||
ui.painter().galley(pos, g, col);
|
||||
}
|
||||
if i == 4 {
|
||||
ui.painter().line_segment([egui::pos2(gr.left(), gr.bottom() - 2.0), egui::pos2(gr.right(), gr.bottom() - 2.0)], Stroke::new(1.0, col));
|
||||
}
|
||||
if i == 5 {
|
||||
ui.painter().line_segment([egui::pos2(gr.left() - 1.0, gr.center().y), egui::pos2(gr.right() + 1.0, gr.center().y)], Stroke::new(1.0, col));
|
||||
}
|
||||
if resp.on_hover_text(tip).clicked() {
|
||||
apply(app, ui.ctx(), props);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if !paragraph {
|
||||
return;
|
||||
}
|
||||
section(ui, "Paragraph");
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 2.0;
|
||||
|
||||
@@ -209,9 +209,10 @@ fn panel_tab(app: &PhotocraftApp, id: &str) -> Option<(&'static str, usize)> {
|
||||
"window.panel.paths" => ("layers", 2),
|
||||
"window.panel.layers" => ("layers", 0),
|
||||
"window.panel.adjustments" => ("properties", 1),
|
||||
"window.panel.properties" | "window.panel.character" | "window.panel.paragraph" | "type.panels.character" | "type.panels.paragraph" => {
|
||||
("properties", 0)
|
||||
}
|
||||
"window.panel.properties" => ("properties", 0),
|
||||
// Their own Character | Paragraph group, so Properties stays open (#150).
|
||||
"window.panel.character" | "type.panels.character" => ("character", 0),
|
||||
"window.panel.paragraph" | "type.panels.paragraph" => ("character", 1),
|
||||
"window.panel.swatches" => ("color", usize::from(pro)),
|
||||
"window.panel.color" => ("color", usize::from(!pro)),
|
||||
_ => return None,
|
||||
@@ -225,6 +226,7 @@ fn panel_state<'a>(app: &'a mut PhotocraftApp, panel: &str) -> (&'a mut bool, &'
|
||||
"history" => (&mut p.history, &mut t.history),
|
||||
"layers" => (&mut p.layers, &mut t.layers),
|
||||
"color" => (&mut p.color, &mut t.color),
|
||||
"character" => (&mut p.character, &mut t.character),
|
||||
_ => (&mut p.properties, &mut t.properties),
|
||||
}
|
||||
}
|
||||
@@ -356,6 +358,7 @@ pub fn checked(app: &PhotocraftApp, id: &str) -> Option<bool> {
|
||||
"history" => (p.history, t.history),
|
||||
"layers" => (p.layers, t.layers),
|
||||
"color" => (p.color, t.color),
|
||||
"character" => (p.character, t.character),
|
||||
_ => (p.properties, t.properties),
|
||||
};
|
||||
return Some(vis && cur == tab);
|
||||
|
||||
@@ -33,6 +33,11 @@ pub struct CardResponse {
|
||||
pub menu: Response,
|
||||
/// A tab was double-clicked (Photoshop collapses the group).
|
||||
pub tab_double_clicked: bool,
|
||||
/// Rects of the tabs on the strip, `(tab index, rect)`; tabs that don't fit are in the
|
||||
/// chevron menu instead (#151).
|
||||
pub tabs: Vec<(usize, Rect)>,
|
||||
/// The » overflow button, when some tabs didn't fit.
|
||||
pub chevron: Option<Rect>,
|
||||
}
|
||||
|
||||
/// [`card`] that can be collapsed to its tab strip and reports strip and menu interactions.
|
||||
@@ -52,29 +57,18 @@ pub fn card_ex(ui: &mut Ui, id: &str, tabs: &[&str], selected: &mut usize, colla
|
||||
// Registered before the tabs so they keep their clicks; drags fall through to it.
|
||||
let strip_rect = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), 22.0));
|
||||
let strip = ui.interact(strip_rect, ui.id().with((id, "strip")), Sense::click_and_drag());
|
||||
let mut double = false;
|
||||
let menu = ui
|
||||
.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 2.0;
|
||||
for (i, name) in tabs.iter().enumerate() {
|
||||
let r = pill_tab(ui, name, *selected == i);
|
||||
double |= r.double_clicked();
|
||||
if r.clicked() {
|
||||
*selected = i;
|
||||
}
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 0.0;
|
||||
crate::icons::button(ui, "ellipsis", 22.0, false, &format!("{} options", tabs.get(*selected).copied().unwrap_or(id)))
|
||||
})
|
||||
.inner
|
||||
})
|
||||
.inner;
|
||||
// Pill tabs left of the menu button; they elide or overflow into a chevron (#151).
|
||||
let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::hover());
|
||||
let tip = format!("{} options", tabs.get(*selected).copied().unwrap_or(id));
|
||||
let menu_rect = Rect::from_min_max(pos2(row.right() - 22.0, row.top() + 1.0), pos2(row.right(), row.bottom() - 1.0));
|
||||
let menu = crate::icons::button(&mut ui.new_child(egui::UiBuilder::new().max_rect(menu_rect)), "ellipsis", 22.0, false, &tip);
|
||||
let area = Rect::from_min_max(row.min, pos2((menu_rect.left() - 4.0).max(row.left()), row.bottom()));
|
||||
let tabs_out = crate::tab_strip::pill_tabs(ui, ui.id().with((id, "tabs")), area, tabs, selected);
|
||||
if !collapsed {
|
||||
ui.add_space(6.0);
|
||||
body(ui, *selected);
|
||||
}
|
||||
CardResponse { strip, menu, tab_double_clicked: double }
|
||||
CardResponse { strip, menu, tab_double_clicked: tabs_out.double_clicked, tabs: tabs_out.tabs, chevron: tabs_out.chevron }
|
||||
})
|
||||
.inner;
|
||||
ui.add_space(6.0);
|
||||
@@ -91,34 +85,9 @@ fn pro_panel(ui: &mut Ui, id: &str, tabs: &[&str], selected: &mut usize, collaps
|
||||
let strip_resp = ui.interact(strip, ui.id().with((id, "strip")), Sense::click_and_drag());
|
||||
let rounding = if collapsed { CornerRadius::same(3) } else { CornerRadius { nw: 3, ne: 3, sw: 0, se: 0 } };
|
||||
ui.painter().rect_filled(strip, rounding, t.tab_strip);
|
||||
let mut x = strip.left();
|
||||
let mut double = false;
|
||||
for (i, name) in tabs.iter().enumerate() {
|
||||
let galley = ui.painter().layout_no_wrap((*name).to_owned(), egui::FontId::proportional(11.5), t.text);
|
||||
let r = Rect::from_min_size(pos2(x, strip.top()), vec2(galley.size().x + 22.0, strip.height()));
|
||||
let resp = ui.interact(r, ui.id().with((id, "tab", i)), Sense::click());
|
||||
let active = *selected == i && !collapsed;
|
||||
if active {
|
||||
ui.painter().rect_filled(r, CornerRadius { nw: if i == 0 { 3 } else { 0 }, ne: 0, sw: 0, se: 0 }, t.card);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.4));
|
||||
}
|
||||
let color = if active {
|
||||
t.text
|
||||
} else if resp.hovered() {
|
||||
t.text_dim
|
||||
} else {
|
||||
t.text_faint
|
||||
};
|
||||
ui.painter().galley_with_override_text_color(r.center() - galley.size() / 2.0, galley, color);
|
||||
double |= resp.double_clicked();
|
||||
if resp.clicked() {
|
||||
*selected = i;
|
||||
}
|
||||
x = r.right();
|
||||
}
|
||||
// Panel menu (hamburger).
|
||||
// Panel menu (hamburger); the tabs stay left of it, eliding or overflowing (#151).
|
||||
let menu = Rect::from_center_size(pos2(strip.right() - 14.0, strip.center().y), vec2(20.0, 18.0));
|
||||
let tabs_out = crate::tab_strip::pro_tabs(ui, ui.id().with((id, "tabs")), strip, menu.left(), tabs, selected, collapsed);
|
||||
let mresp = ui.interact(menu, ui.id().with((id, "menu")), Sense::click());
|
||||
let c = if mresp.hovered() { t.text } else { t.text_faint };
|
||||
for k in 0..3 {
|
||||
@@ -137,7 +106,7 @@ fn pro_panel(ui: &mut Ui, id: &str, tabs: &[&str], selected: &mut usize, collaps
|
||||
});
|
||||
}
|
||||
ui.add_space(2.0);
|
||||
CardResponse { strip: strip_resp, menu: mresp, tab_double_clicked: double }
|
||||
CardResponse { strip: strip_resp, menu: mresp, tab_double_clicked: tabs_out.double_clicked, tabs: tabs_out.tabs, chevron: tabs_out.chevron }
|
||||
}
|
||||
|
||||
pub fn pill_tab(ui: &mut Ui, label: &str, selected: bool) -> Response {
|
||||
|
||||
Reference in new issue
Block a user