mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 18:46:57 -06:00
Merge PR #695 from NikitkaRa: Outline: lay out only the slides in view
# Conflicts: # crates/ui-egui/src/lib.rs # crates/ui-egui/src/sorter.rs
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
//! Large presentations in the editor: views that list every slide lay out only what is in view.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Services, SlideApp};
|
||||
use deckcraft_engine::Session;
|
||||
|
||||
fn harness(n: usize) -> egui_kittest::Harness<'static, SlideApp> {
|
||||
let mut s = Session::with_new();
|
||||
for _ in 1..n {
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
}
|
||||
egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(s, Services::default()),
|
||||
)
|
||||
}
|
||||
|
||||
/// Outline view of 2000 slides lays out the slides in view (and the current one), not all 2000
|
||||
/// title editors every frame (40 ms a frame in a release build, growing with the text).
|
||||
#[test]
|
||||
fn the_outline_of_a_huge_deck_lays_out_only_what_is_in_view() {
|
||||
let mut h = harness(2000);
|
||||
h.state_mut().run("view.outline", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
let editors = h.query_all_by_role(Role::TextInput).count();
|
||||
assert!(editors > 5 && editors < 200, "{editors} text editors laid out");
|
||||
// The current slide is always laid out, wherever it is.
|
||||
h.state_mut().run("slide.go", json!({"index": 1500})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert!(h.query_all_by_role(Role::TextInput).count() < 200);
|
||||
assert_eq!(h.state().session.doc().unwrap().selection.slide, 1500);
|
||||
}
|
||||
@@ -1208,6 +1208,8 @@ mod eyedropper_tests;
|
||||
#[cfg(test)]
|
||||
mod animpane_tests;
|
||||
#[cfg(test)]
|
||||
mod huge_deck_tests;
|
||||
#[cfg(test)]
|
||||
mod ime_tests;
|
||||
#[cfg(test)]
|
||||
mod ui_soak_tests;
|
||||
|
||||
@@ -413,65 +413,24 @@ pub fn outline_pane(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let doc = st.doc.clone();
|
||||
let cur = st.selection.slide;
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||
// Only the slides in view (and the current one, whose editor may have the focus) are
|
||||
// laid out; the rest take their last measured height (estimated until first shown).
|
||||
// Laying out every slide's editors each frame made a 2000-slide outline crawl.
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show_viewport(ui, |ui, viewport| {
|
||||
let origin = ui.cursor().top();
|
||||
let margin = 300.0;
|
||||
for (i, s) in doc.slides.iter().enumerate() {
|
||||
let title_shape = s.shapes.iter().find(|x| x.ph_type().is_some_and(|k| k.is_title())).map(|x| x.id.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(format!("{}", i + 1)).color(t.text_dim));
|
||||
let (r, _) = ui.allocate_exact_size(vec2(14.0, 10.0), Sense::hover());
|
||||
ui.painter().rect_stroke(
|
||||
r,
|
||||
CornerRadius::same(1),
|
||||
Stroke::new(1.0, if i == cur { t.accent } else { t.text_dim }),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
let id = egui::Id::new(("ol-title", s.id.0));
|
||||
let cur_title = s.title();
|
||||
let mut v: String = ui.data_mut(|d| d.get_temp(id).unwrap_or_else(|| cur_title.clone()));
|
||||
let resp =
|
||||
ui.add(egui::TextEdit::singleline(&mut v).font(theme::bold(14.0)).frame(egui::Frame::NONE).desired_width(f32::INFINITY));
|
||||
if resp.gained_focus() {
|
||||
let _ = app.run("slide.go", json!({"index": i}));
|
||||
}
|
||||
if resp.lost_focus()
|
||||
&& v != cur_title
|
||||
&& let Some(tid) = title_shape
|
||||
{
|
||||
let _ = app.run("text.set", json!({"id": tid, "text": v}));
|
||||
}
|
||||
if !resp.has_focus() {
|
||||
ui.data_mut(|d| d.remove::<String>(id));
|
||||
} else {
|
||||
ui.data_mut(|d| d.insert_temp(id, v));
|
||||
}
|
||||
});
|
||||
for sh in s.shapes.iter().filter(|x| !x.ph_type().is_some_and(|k| k.is_title())) {
|
||||
let Some(tb) = &sh.text else { continue };
|
||||
if tb.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let id = egui::Id::new(("ol-body", s.id.0, sh.id.0));
|
||||
let cur_text: String =
|
||||
tb.paragraphs.iter().map(|p| format!("{}{}", "\t".repeat(p.level as usize), p.text())).collect::<Vec<_>>().join("\n");
|
||||
let mut v: String = ui.data_mut(|d| d.get_temp(id).unwrap_or_else(|| cur_text.clone()));
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(36.0);
|
||||
let resp =
|
||||
ui.add(egui::TextEdit::multiline(&mut v).frame(egui::Frame::NONE).desired_width(f32::INFINITY).desired_rows(1));
|
||||
if resp.gained_focus() {
|
||||
let _ = app.run("slide.go", json!({"index": i}));
|
||||
}
|
||||
if resp.lost_focus() && v != cur_text {
|
||||
let _ = app.run("text.set", json!({"id": sh.id.0, "text": v}));
|
||||
}
|
||||
if !resp.has_focus() {
|
||||
ui.data_mut(|d| d.remove::<String>(id));
|
||||
} else {
|
||||
ui.data_mut(|d| d.insert_temp(id, v.clone()));
|
||||
}
|
||||
});
|
||||
let hid = egui::Id::new(("ol-height", s.id.0));
|
||||
let known: Option<f32> = ui.data(|d| d.get_temp(hid));
|
||||
let h = known.unwrap_or_else(|| outline_estimate(s));
|
||||
let y = ui.cursor().top() - origin;
|
||||
if i == cur || (y + h >= viewport.min.y - margin && y <= viewport.max.y + margin) {
|
||||
outline_slide(app, ui, &t, i, s, cur);
|
||||
let real = ui.cursor().top() - origin - y;
|
||||
ui.data_mut(|d| d.insert_temp(hid, real));
|
||||
} else {
|
||||
ui.allocate_exact_size(vec2(ui.available_width(), (h - ui.spacing().item_spacing.y).max(0.0)), Sense::hover());
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -481,3 +440,69 @@ pub fn outline_pane(app: &mut SlideApp, ui: &mut Ui) {
|
||||
#[cfg(test)]
|
||||
#[path = "sorter_tests.rs"]
|
||||
mod ui_tests;
|
||||
|
||||
/// A slide's outline block height before it has been laid out: title row plus its text lines.
|
||||
fn outline_estimate(s: &deckcraft_model::Slide) -> f32 {
|
||||
let lines: usize = s
|
||||
.shapes
|
||||
.iter()
|
||||
.filter(|x| !x.ph_type().is_some_and(|k| k.is_title()))
|
||||
.filter_map(|x| x.text.as_ref())
|
||||
.filter(|tb| !tb.is_empty())
|
||||
.map(|tb| tb.paragraphs.len().max(1))
|
||||
.sum();
|
||||
30.0 + lines as f32 * 19.0
|
||||
}
|
||||
|
||||
/// One slide in the outline: number, title editor, then its other text.
|
||||
fn outline_slide(app: &mut SlideApp, ui: &mut Ui, t: &Tokens, i: usize, s: &deckcraft_model::Slide, cur: usize) {
|
||||
let title_shape = s.shapes.iter().find(|x| x.ph_type().is_some_and(|k| k.is_title())).map(|x| x.id.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(format!("{}", i + 1)).color(t.text_dim));
|
||||
let (r, _) = ui.allocate_exact_size(vec2(14.0, 10.0), Sense::hover());
|
||||
ui.painter().rect_stroke(r, CornerRadius::same(1), Stroke::new(1.0, if i == cur { t.accent } else { t.text_dim }), egui::StrokeKind::Inside);
|
||||
let id = egui::Id::new(("ol-title", s.id.0));
|
||||
let cur_title = s.title();
|
||||
let mut v: String = ui.data_mut(|d| d.get_temp(id).unwrap_or_else(|| cur_title.clone()));
|
||||
let resp = ui.add(egui::TextEdit::singleline(&mut v).font(theme::bold(14.0)).frame(egui::Frame::NONE).desired_width(f32::INFINITY));
|
||||
if resp.gained_focus() {
|
||||
let _ = app.run("slide.go", json!({"index": i}));
|
||||
}
|
||||
if resp.lost_focus()
|
||||
&& v != cur_title
|
||||
&& let Some(tid) = title_shape
|
||||
{
|
||||
let _ = app.run("text.set", json!({"id": tid, "text": v}));
|
||||
}
|
||||
if !resp.has_focus() {
|
||||
ui.data_mut(|d| d.remove::<String>(id));
|
||||
} else {
|
||||
ui.data_mut(|d| d.insert_temp(id, v));
|
||||
}
|
||||
});
|
||||
for sh in s.shapes.iter().filter(|x| !x.ph_type().is_some_and(|k| k.is_title())) {
|
||||
let Some(tb) = &sh.text else { continue };
|
||||
if tb.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let id = egui::Id::new(("ol-body", s.id.0, sh.id.0));
|
||||
let cur_text: String = tb.paragraphs.iter().map(|p| format!("{}{}", "\t".repeat(p.level as usize), p.text())).collect::<Vec<_>>().join("\n");
|
||||
let mut v: String = ui.data_mut(|d| d.get_temp(id).unwrap_or_else(|| cur_text.clone()));
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(36.0);
|
||||
let resp = ui.add(egui::TextEdit::multiline(&mut v).frame(egui::Frame::NONE).desired_width(f32::INFINITY).desired_rows(1));
|
||||
if resp.gained_focus() {
|
||||
let _ = app.run("slide.go", json!({"index": i}));
|
||||
}
|
||||
if resp.lost_focus() && v != cur_text {
|
||||
let _ = app.run("text.set", json!({"id": sh.id.0, "text": v}));
|
||||
}
|
||||
if !resp.has_focus() {
|
||||
ui.data_mut(|d| d.remove::<String>(id));
|
||||
} else {
|
||||
ui.data_mut(|d| d.insert_temp(id, v.clone()));
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user