diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f7f109..94c29b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ All notable changes to TeXora. The newest version is first. +## 1.4.1 + +### Fixed + +- In the LaTeX studio, the slide and notes preview keeps the width you drag it to, instead of jumping back when you let go. +- The black line at the right edge of the notes page is gone, in the presenter screen and the LaTeX studio; a faint frame shows the page's edge instead. +- The presenter screen no longer leaves an empty grey strip beside the notes page: the Notes column fits the page, and the slides take the rest. +- In the LaTeX studio, a header wider than the preview no longer pushes the slide past the window's edge. + +### Improved + +- Double-click the edge between the source and the preview to split them half and half again. +- The LaTeX studio's preview grows with its width: on a wide panel the notes move beside the slide, so the slide can use the full height. +- The notes page in the studio gets a box of its own shape, so it fills it with no empty margin. +- In the presenter screen, widening the slide column makes the current slide bigger; in the large slide view, the notes page and the next slide share their column at the same width. + +### New + +- **Wrap text** in the LaTeX studio wraps long lines at the editor's width; line numbers still count real lines. + ## 1.4.0 ### Improved diff --git a/Cargo.lock b/Cargo.lock index 07cc37f..8d6df97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2863,7 +2863,7 @@ dependencies = [ [[package]] name = "texora" -version = "1.4.0" +version = "1.4.1" dependencies = [ "dirs", "display-info", diff --git a/Cargo.toml b/Cargo.toml index f1d33d6..1be8b8a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "texora" -version = "1.4.0" +version = "1.4.1" edition = "2021" description = "TeXora: a LaTeX-native presenter for Beamer and PDF slides" license = "PolyForm-Noncommercial-1.0.0" diff --git a/README.md b/README.md index 54d5892..f21343a 100644 --- a/README.md +++ b/README.md @@ -303,6 +303,7 @@ notes on the right, and compile messages along the bottom. | **Live** | Saves and rebuilds shortly after you stop typing. | | **Rebuild on save** | Rebuilds each time the file is saved, here or in your own editor. | | **Follow cursor** | Shows the slide for the line the cursor is on. | +| **Wrap text** | Wraps long lines at the editor's width instead of scrolling sideways. | | **Present** | Back to the presenter view. | - **Comment lines**: **Cmd/Ctrl+/** comments out the line the cursor is on, or every selected line, @@ -313,6 +314,8 @@ notes on the right, and compile messages along the bottom. - **Problems, Packages, Full log**: the panel along the bottom. Errors show their line number; click one to go there. The panel's buttons wrap long lines, copy the text, and hide or show the panel. Drag its top edge to resize it. +- Drag the edge between the source and the preview to make either wider; double-click it to split them + half and half again. - If a presentation does not compile when you open it, it opens in the studio with the problems listed. - Linking slides and source uses SyncTeX, which comes with TeX Live, MacTeX and MiKTeX. diff --git a/assets/guide.md b/assets/guide.md index 85d03a3..8b2ca4a 100644 --- a/assets/guide.md +++ b/assets/guide.md @@ -75,6 +75,7 @@ Open a `.tex` file, then press **Cmd/Ctrl+E**, the studio button in the toolbar, - Double-click a slide to find its source. Moving the cursor shows the matching slide (**Follow cursor**). - **Cmd/Ctrl+/** comments out the current or selected lines, or uncomments them if they all are. **Cmd/Ctrl+]** comments and **Cmd/Ctrl+[** uncomments. Change these in Settings. - The panel at the bottom holds problems, packages and the full log. Drag its top edge to resize it. +- Drag the edge between the source and the preview to make either wider. Double-click it to split them half and half again. - **Check packages**, also in the Tools menu, lists anything your file needs that is missing, and how to get it. **Settings > Required LaTeX packages** shows the same list. - You can also keep editing in your own LaTeX editor: each time you save, the slides rebuild. diff --git a/src/settings.rs b/src/settings.rs index 74f6804..08ed4b7 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -230,6 +230,8 @@ pub struct Layout { pub large_row: Option, pub split_col: Option, pub split_row: Option, + /// The LaTeX studio's slide and notes preview, as a share of the width beside the editor. + pub studio_col: Option, } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -249,6 +251,8 @@ pub struct Settings { /// (and the slides rebuild). Off: they go into the .note.md file beside the presentation. pub notes_in_tex: bool, pub editor_size: f32, + /// The LaTeX studio's editor wraps long lines at its width instead of scrolling sideways. + pub editor_wrap: bool, /// Look for a newer version when TeXora starts. pub check_updates: bool, /// A newer version the speaker chose to skip: its update notice is not shown again. @@ -272,6 +276,7 @@ impl Default for Settings { notes_size: 26.0, notes_in_tex: true, editor_size: 14.0, + editor_wrap: false, check_updates: true, skipped_update: String::new(), last_template: String::new(), diff --git a/src/ui_present.rs b/src/ui_present.rs index 95be4d9..a562bac 100644 --- a/src/ui_present.rs +++ b/src/ui_present.rs @@ -12,6 +12,27 @@ pub fn auto_col(width: f32, height: f32, aspect: f32, gap: f32) -> f32 { ((height * aspect + gap) / width.max(1.0)).clamp(0.45, 0.85) } +/// Share of the presenter's width for the Notes column when no divider has been moved. A PDF notes +/// page (`notes_aspect`) gets just the width its full height needs, so its card has no empty strip, +/// as long as the slide column keeps `slides_w`; whatever is left goes to the slides, which grow into +/// it. Written notes keep what is left once the slide column fits. +pub fn presenter_auto_col(inner_w: f32, notes_h: f32, notes_aspect: Option, slides_w: f32, gap: f32) -> f32 { + let inner_w = inner_w.max(1.0); + let notes_w = match notes_aspect { + Some(a) => (notes_h * a + 8.0).min(inner_w - slides_w - gap), + None => inner_w - slides_w - gap, + }; + ((notes_w + gap / 2.0) / inner_w).clamp(0.4, 0.8) +} + +/// Share of the slide column's height for the current slide when no divider has been moved: at least +/// half, and more when the column is wide enough for the current slide to fill its width, so widening +/// the column makes the current slide bigger. The next slide keeps at least 30%. +pub fn current_row(col_w: f32, col_h: f32, aspect: f32, chrome: f32, gap: f32) -> f32 { + let fit_h = (col_w - 28.0).max(1.0) / aspect.max(0.1) + chrome; + ((fit_h + gap / 2.0) / col_h.max(1.0)).clamp(0.5, 0.7) +} + /// What the pointer did to a divider this frame. pub struct Handle { pub delta: Vec2, @@ -572,9 +593,11 @@ impl App { if large { auto_col(inner_w, body_bottom - body_top - nav_h, aspect, gap + tools_w) } else { - // The notes take what is left once the right column fits the current and next slide. + // The slide column needs the width of the current and next slide stacked; the notes + // page gets what its height needs and the slides take the rest. let slide_w = ((body_bottom - body_top - 10.0 * k - gap) / 2.0 - 14.0 * k - 34.0) * aspect + 28.0; - (1.0 - (slide_w + gap) / inner_w.max(1.0)).clamp(0.45, 0.75) + let notes_h = body_bottom - nav_h - body_top - 14.0 * k - 30.0; + presenter_auto_col(inner_w, notes_h, self.notes_page_aspect(), slide_w, gap) } }); let div_x = full.left() + pad + inner_w * split; @@ -585,7 +608,16 @@ impl App { // Right column: notes (large slide) or the current slide on top, the next slide below. The // next slide's card fits its picture unless that divider has been moved. let next_fit = (col.width() - 28.0) / aspect + label + 34.0; - let auto_row = if large { (1.0 - (next_fit + gap) / col.height().max(1.0)).clamp(0.3, 0.7) } else { 0.5 }; + // With the large slide, a PDF notes page and the next slide share the column at the same width. + let auto_row = if large { + match self.notes_page_aspect() { + Some(na) => { + let notes_fit = (col.width() - 8.0) / na + label + 10.0; + (notes_fit / (notes_fit + next_fit)).clamp(0.3, 0.7) + } + None => (1.0 - (next_fit + gap) / col.height().max(1.0)).clamp(0.3, 0.7), + } + } else { current_row(col.width(), col.height(), aspect, label + 34.0, gap) }; let row_share = (if large { self.st.layout.large_row } else { self.st.layout.presenter_row }).unwrap_or(auto_row); let mid = col.top() + col.height() * row_share; let top_box = Rect::from_min_max(col.min, pos2(col.right(), mid - gap / 2.0)); @@ -909,35 +941,52 @@ impl App { } } + /// What the notes area shows for the current slide: the PDF notes page or written notes. + fn notes_mode(&self) -> NotesView { + let frame = self.frame(); + let has_pdf = self.deck.slides.get(self.cur).and_then(|x| x.notes).is_some(); + match self.notes_view { + NotesView::Auto if self.notes.contains_key(&frame) || self.note_pending.contains_key(&frame) => NotesView::Text, + NotesView::Auto if has_pdf => NotesView::Pdf, + NotesView::Auto => NotesView::Text, + NotesView::Pdf if !has_pdf => NotesView::Text, + m => m, + } + } + + /// Width over height of the PDF notes page the notes area is showing now, or None when it shows + /// written notes (or the note editor), which can take any shape. + pub fn notes_page_aspect(&self) -> Option { + if self.note_draft.is_some() || self.notes_mode() != NotesView::Pdf { + return None; + } + let (p, reg) = self.deck.slides.get(self.cur)?.notes?; + let (pw, ph) = self.doc.as_ref().map(|d| (d.pages[p].width, d.pages[p].height)).unwrap_or((4.0, 3.0)); + Some((if reg == Region::Full { pw / ph } else { pw / 2.0 / ph }).max(0.1)) + } + pub fn notes_panel(&mut self, ui: &mut Ui, rect: Rect, s: f32) { let frame = self.frame(); let sl = self.deck.slides.get(self.cur).cloned(); - let has_pdf = sl.as_ref().and_then(|x| x.notes).is_some(); let text = self.note_for(frame); if self.note_draft.is_some() { self.note_editor(ui, rect, s); return; } - let mode = match self.notes_view { - NotesView::Auto if self.notes.contains_key(&frame) || self.note_pending.contains_key(&frame) => NotesView::Text, - NotesView::Auto if has_pdf => NotesView::Pdf, - NotesView::Auto => NotesView::Text, - NotesView::Pdf if !has_pdf => NotesView::Text, - m => m, - }; + let mode = self.notes_mode(); match mode { NotesView::Pdf => { let (p, reg) = sl.and_then(|x| x.notes).unwrap(); - let (pw, ph) = self.doc.as_ref().map(|d| (d.pages[p].width, d.pages[p].height)).unwrap_or((4.0, 3.0)); - let a = if reg == Region::Full { pw / ph } else { pw / 2.0 / ph }; + let a = self.notes_page_aspect().unwrap_or(4.0 / 3.0); + // Centred across the box, so a box wider than the page has no empty strip on one side. let r = fit(rect, a, true); - let r = r.translate(vec2(rect.left() - r.left(), 0.0)); // The notes page has its own zoom, for reading it from further away. let uv = uv_rect(self.notes_zoom); self.paint_page(ui, r, p, reg, uv); let resp = ui.interact(r, sid("notes-page"), Sense::click_and_drag()); pan_zoom(ui, &resp, r, &mut self.notes_zoom, &mut self.zoom_changed); - ui.painter().vline(r.right(), r.y_range(), Stroke::new(1.5, Color32::BLACK)); + // A faint frame shows where the notes page ends, the same as around the slide. + ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, LINE), StrokeKind::Outside); } _ => { let inner = rect.shrink(14.0 * s.max(0.6)); @@ -1283,7 +1332,12 @@ impl App { self.nav_row(ui, Rect::from_min_max(pos2(left.left(), left.bottom()), pos2(left.right(), body.bottom())), s); let next_h = (right.height() * 0.32).min((right.width() - 28.0) / aspect) + label + 34.0; - let row = self.st.layout.split_row.unwrap_or(1.0 - (next_h + 14.0) / right.height().max(1.0)); + // A PDF notes page gets the height that lets it fill the column's width, when there is room. + let auto_row = match self.notes_page_aspect() { + Some(na) => (((right.width() - 8.0) / na + label + 6.0) / right.height().max(1.0)).min(1.0 - (next_h + 14.0) / right.height().max(1.0)).max(0.4), + None => 1.0 - (next_h + 14.0) / right.height().max(1.0), + }; + let row = self.st.layout.split_row.unwrap_or(auto_row.clamp(0.3, 0.8)); let cut = right.top() + right.height() * row; let notes = Rect::from_min_max(right.min, pos2(right.right(), cut)); let next = Rect::from_min_max(pos2(right.left(), cut + 14.0), right.max); @@ -1669,6 +1723,37 @@ pub fn engine_combo(ui: &mut Ui, e: &mut Engine) { mod tests { use super::*; + /// The case from the report: a 4:3 notes page in a wide window left an empty strip beside it. + #[test] + fn notes_column_fits_its_page_and_the_slides_take_the_rest() { + let (inner_w, notes_h, slides_w, gap) = (1300.0, 560.0, 330.0, 14.0); + let share = presenter_auto_col(inner_w, notes_h, Some(4.0 / 3.0), slides_w, gap); + let notes_w = share * inner_w - gap / 2.0; + assert!((notes_w - (notes_h * 4.0 / 3.0 + 8.0)).abs() < 1.0, "notes column {notes_w} fits the page"); + assert!(inner_w * (1.0 - share) - gap / 2.0 > slides_w, "slides get the rest"); + // Narrow window: the slide column keeps its width and the notes page shrinks to fit. + let share = presenter_auto_col(900.0, notes_h, Some(4.0 / 3.0), slides_w, gap); + assert!(900.0 * (1.0 - share) >= slides_w + gap / 2.0 - 1.0 || share <= 0.4 + 1e-6); + // Written notes: as before, what is left once the slides fit. + let share = presenter_auto_col(inner_w, notes_h, None, slides_w, gap); + assert!((inner_w * (1.0 - share) - gap / 2.0 - slides_w).abs() < 1.0); + } + + #[test] + fn a_wider_slide_column_gives_a_bigger_current_slide() { + let (h, a, chrome, gap) = (700.0, 4.0 / 3.0, 48.0, 14.0); + let mut last = 0.0; + for w in [250.0, 350.0, 450.0, 550.0, 650.0, 800.0] { + let row = current_row(w, h, a, chrome, gap); + assert!((0.5..=0.7).contains(&row)); + // The current slide's picture: as wide as fits in its share of the height. + let cur = fit(Rect::from_min_size(Pos2::ZERO, vec2(w - 28.0, h * row - gap / 2.0 - chrome)), a, false); + assert!(cur.width() + 0.5 >= last, "current slide shrank at column width {w}"); + last = cur.width(); + } + assert!(current_row(650.0, h, a, chrome, gap) > 0.5, "a wide column gives the current slide more than half"); + } + /// Long notes wrap at the panel's width and never push it wider. #[test] fn automatic_fit_gives_a_slide_the_full_height() { diff --git a/src/ui_studio.rs b/src/ui_studio.rs index 6be23ac..7f0f24b 100644 --- a/src/ui_studio.rs +++ b/src/ui_studio.rs @@ -110,6 +110,9 @@ impl App { ui.checkbox(&mut self.st.live_edit, "Live").on_hover_text("Save and rebuild shortly after you stop typing"); ui.checkbox(&mut self.st.auto_compile, "Rebuild on save"); ui.checkbox(&mut self.follow_cursor, "Follow cursor").on_hover_text("Show the slide for the line you are on"); + if ui.checkbox(&mut self.st.editor_wrap, "Wrap text").on_hover_text("Wrap long lines at the editor's width instead of scrolling sideways").changed() { + self.save_layout(); + } ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if labelled_button(ui, Icon::Presenter, "Present", false).clicked() { self.view = View::Presenter; @@ -122,8 +125,29 @@ impl App { let log = Panel::bottom("studio-log").resizable(self.log_open).frame(Frame::new().fill(SURFACE).inner_margin(Margin::symmetric(10, 4))); let log = if self.log_open { log.default_size(170.0).size_range(90.0..=max) } else { log.exact_size(38.0) }; log.show(ui, |ui| self.log_panel(ui)); - Panel::right("studio-preview").resizable(true).default_size(ui.available_width() * 0.5).size_range(240.0..=(ui.available_width() - 240.0).max(260.0)).show(ui, |ui| self.preview_panel(ui)); + // The preview's width is kept by TeXora, not by the panel itself: the panel's own resizing put + // it back where it was as soon as the mouse button was let go. + let avail = ui.available_width(); + let max_w = (avail - 240.0).max(260.0); + let w = (self.st.layout.studio_col.unwrap_or(0.5) * avail).clamp(240.0, max_w); + let edge = Panel::right("studio-preview").resizable(false).exact_size(w).show(ui, |ui| self.preview_panel(ui)).response.rect; CentralPanel::default().frame(Frame::new().fill(BG).inner_margin(Margin::symmetric(8, 4))).show(ui, |ui| self.editor_panel(ui)); + // Drag the edge between the editor and the preview; double-click it for half and half again. + let grip = Rect::from_min_max(pos2(edge.left() - 4.0, edge.top()), pos2(edge.left() + 4.0, edge.bottom())); + let resp = ui.interact(grip, sid("studio-divider"), Sense::click_and_drag()).on_hover_cursor(CursorIcon::ResizeHorizontal); + if resp.hovered() || resp.dragged() { + ui.painter().vline(edge.left(), edge.y_range(), Stroke::new(2.0, LEAF)); + } + if resp.dragged() { + let to = (w - resp.drag_delta().x).clamp(240.0, max_w); + self.st.layout.studio_col = Some(to / avail.max(1.0)); + } + if resp.double_clicked() { + self.st.layout.studio_col = None; + } + if resp.drag_stopped() || resp.double_clicked() { + self.save_layout(); + } } fn log_panel(&mut self, ui: &mut Ui) { @@ -268,13 +292,16 @@ impl App { TextEdit::store_state(ui.ctx(), id, st); } let errors: Vec = self.problems.iter().filter(|p| p.is_error).filter_map(|p| p.line).collect(); - ScrollArea::both().id_salt("editor-scroll").auto_shrink([false, false]).show(ui, |ui| { + let wrap = self.st.editor_wrap; + let area = if wrap { ScrollArea::vertical() } else { ScrollArea::both() }; + // Each mode keeps its own scroll position, so switching does not leave the view off to the side. + area.id_salt(("editor-scroll", wrap)).auto_shrink([false, false]).show(ui, |ui| { ui.horizontal_top(|ui| { ui.spacing_mut().item_spacing.x = 0.0; let (g, _) = ui.allocate_exact_size(vec2(gutter, row_h * lines as f32 + 8.0), Sense::hover()); - let mut layouter = |ui: &Ui, buf: &dyn TextBuffer, _w: f32| { + let mut layouter = |ui: &Ui, buf: &dyn TextBuffer, w: f32| { let mut job = highlight(buf.as_str(), size); - job.wrap.max_width = f32::INFINITY; + job.wrap.max_width = if wrap { w.max(size * 4.0) } else { f32::INFINITY }; ui.fonts_mut(|f| f.layout_job(job)) }; let out = TextEdit::multiline(&mut src.text) @@ -286,21 +313,28 @@ impl App { .margin(Margin::ZERO) .layouter(&mut layouter) .show(ui); - // Numbers follow the rows actually drawn, so they stay level with the text. + // Numbers follow the rows actually drawn, so they stay level with the text. A wrapped + // line takes several rows but gets one number, on its first row. let clip = ui.clip_rect(); - for (n, row) in out.galley.rows.iter().enumerate() { + let mut line_y = Vec::with_capacity(lines); + let mut starts_line = true; + for row in out.galley.rows.iter() { let y = out.galley_pos.y + row.pos.y; - if y + row_h < clip.top() || y > clip.bottom() { - continue; + if starts_line { + line_y.push(y); + let n = line_y.len(); + if y + row_h >= clip.top() && y <= clip.bottom() { + let c = if errors.contains(&n) { + BAD + } else if n == src.cursor_line { + INK + } else { + Color32::from_gray(165) + }; + ui.painter().text(pos2(g.right() - 10.0, y), Align2::RIGHT_TOP, n.to_string(), FontId::monospace(size - 1.0), c); + } } - let c = if errors.contains(&(n + 1)) { - BAD - } else if n + 1 == src.cursor_line { - INK - } else { - Color32::from_gray(165) - }; - ui.painter().text(pos2(g.right() - 10.0, y), Align2::RIGHT_TOP, (n + 1).to_string(), FontId::monospace(size - 1.0), c); + starts_line = row.ends_with_newline; } if out.response.changed() { src.dirty = true; @@ -315,7 +349,7 @@ impl App { } if let Some(l) = goto { out.response.request_focus(); - let y = g.top() + (l.saturating_sub(1)) as f32 * row_h; + let y = line_y.get(l.saturating_sub(1)).copied().unwrap_or(g.top() + (l.saturating_sub(1)) as f32 * row_h); ui.scroll_to_rect(Rect::from_min_size(pos2(g.left(), y), vec2(10.0, row_h)), Some(Align::Center)); } }); @@ -331,8 +365,11 @@ impl App { } fn preview_panel(&mut self, ui: &mut Ui) { + // The panel's own edges, taken before anything is drawn: a header wider than the panel would + // otherwise stretch the space and push the slide past the window's edge. + let panel = ui.max_rect(); ui.add_space(4.0); - ui.horizontal(|ui| { + ui.horizontal_wrapped(|ui| { let c = ui.ctx().clone(); if ui.button("<").clicked() { self.run(Action::Prev, &c); @@ -348,14 +385,14 @@ impl App { ui.label(RichText::new("Double-click for source. Wheel to zoom.").color(MUTED).size(12.5)); } }); - // The slide on top and its notes below, in two boxes of the same size. - let area = ui.available_rect_before_wrap().shrink(4.0); - let caption = 22.0; - let half = Rect::from_min_max(area.min, pos2(area.right(), area.top() + (area.height() - caption) / 2.0)); - let r = fit(half, self.deck.aspect.max(0.1), true); + // The slide on top, as wide as the panel allows at its own shape, and its notes below in whatever + // height is left. The notes keep at least a quarter of the height, so a wide panel cannot push them out. + let top = ui.cursor().top(); + let area = Rect::from_min_max(pos2(panel.left(), top), pos2(panel.right(), panel.bottom().max(top + 200.0))).shrink(4.0); + let (r, notes) = preview_layout(area, self.deck.aspect.max(0.1), self.notes_page_aspect()); let idx = self.cur; let Some(sl) = self.deck.slides.get(idx).cloned() else { return }; - let notes = r.translate(vec2(0.0, r.height() + caption)); + let caption = CAPTION; ui.painter().text(pos2(notes.left(), notes.top() - caption / 2.0), Align2::LEFT_CENTER, "Notes", FontId::proportional(13.0), MUTED); ui.painter().rect_filled(notes, 0.0, Color32::from_rgb(228, 228, 228)); ui.painter().rect_stroke(notes, 0.0, Stroke::new(1.0, LINE), StrokeKind::Inside); @@ -392,6 +429,58 @@ impl App { } } +/// Height of the "Notes" caption between the slide and its notes. +const CAPTION: f32 = 22.0; + +/// Where the studio preview draws the slide inside `area`: the full width of the panel at the slide's +/// own shape, unless that would leave the notes below less than a quarter of the height (and at least +/// 110 points); then the slide is shrunk, still at its own shape, and centred. +pub fn slide_box(area: Rect, aspect: f32) -> Rect { + let notes_min = (area.height() * 0.25).max(110.0); + let max_h = (area.height() - CAPTION - notes_min).max(60.0); + fit(Rect::from_min_size(area.min, vec2(area.width(), max_h)), aspect, true) +} + +/// The slide and notes boxes of the studio preview. Notes go below the slide, or beside it when the +/// panel is wide enough that this makes the slide clearly bigger; either way the slide keeps its shape +/// and stays inside `area`. With a PDF notes page (`notes_aspect`, its width over height) the notes box +/// takes that page's shape, as large as the space left allows, so the page fills it with no empty +/// margin; written notes take all of the space left. +pub fn preview_layout(area: Rect, aspect: f32, notes_aspect: Option) -> (Rect, Rect) { + let below = slide_box(area, aspect); + let gap = 12.0; + let notes_w = (area.width() * 0.28).max(220.0); + let side = fit(Rect::from_min_size(area.min, vec2((area.width() - notes_w - gap).max(60.0), area.height())), aspect, true); + let (slide, space) = if side.width() > below.width() * 1.08 { + let space = Rect::from_min_max(pos2(side.right() + gap, area.top() + CAPTION), pos2(area.right(), side.bottom().max(area.top() + CAPTION + 120.0))); + let space = match notes_aspect { + Some(_) => Rect::from_min_max(space.min, pos2(space.right(), area.bottom().max(space.top() + 120.0))), + None => space, + }; + (side, space) + } else { + // A notes page under the slide shares the height with it: as wide as the slide when both fit, + // otherwise the slide gives up to a quarter of its width so the page stays readable. + let below = match notes_aspect { + Some(na) => { + let shared = (area.height() - CAPTION) / (1.0 / aspect + 1.0 / na.max(0.1)); + let w = shared.max(below.width() * 0.75).min(below.width()); + Rect::from_min_size(pos2(area.center().x - w / 2.0, area.top()), vec2(w, w / aspect)) + } + None => below, + }; + (below, Rect::from_min_max(pos2(below.left(), below.bottom() + CAPTION), pos2(below.right(), area.bottom().max(below.bottom() + CAPTION + 40.0)))) + }; + let notes = match notes_aspect { + Some(a) => { + let r = fit(space, a.max(0.1), true); + r.translate(vec2(space.left() - r.left(), 0.0)) + } + None => space, + }; + (slide, notes) +} + /// What the comment shortcuts do to the lines. #[derive(Clone, Copy, Debug, PartialEq)] pub enum CommentMode { @@ -518,6 +607,82 @@ mod tests { assert_eq!(j.text, src); } + #[test] + fn preview_grows_with_the_panel_and_keeps_the_slide_shape() { + let aspect = 4.0 / 3.0; + let mut last = 0.0; + for w in [300.0, 500.0, 700.0, 900.0] { + let area = Rect::from_min_size(pos2(10.0, 20.0), vec2(w, 900.0)); + let r = slide_box(area, aspect); + assert!((r.width() / r.height() - aspect).abs() < 0.01, "shape kept at width {w}"); + assert!(area.contains_rect(r), "slide not cropped at width {w}"); + assert!(r.width() > last, "slide grows when the panel widens to {w}"); + assert!(area.bottom() - r.bottom() - CAPTION >= 900.0 * 0.25 - 0.5, "notes keep a quarter"); + last = r.width(); + } + // Narrow panel: the slide uses all of the width. + let r = slide_box(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 900.0)), aspect); + assert!((r.width() - 400.0).abs() < 0.5); + // Very wide panel: limited by height, centred, still at its shape. + let area = Rect::from_min_size(Pos2::ZERO, vec2(2000.0, 600.0)); + let r = slide_box(area, 16.0 / 9.0); + assert!((r.center().x - area.center().x).abs() < 0.5); + assert!((r.width() / r.height() - 16.0 / 9.0).abs() < 0.01); + } + + #[test] + fn wide_preview_puts_notes_beside_a_bigger_slide() { + let aspect = 4.0 / 3.0; + // Tall, narrow panel: notes below, slide as wide as the panel. + let tall = Rect::from_min_size(Pos2::ZERO, vec2(400.0, 900.0)); + let (s, n) = preview_layout(tall, aspect, None); + assert!(n.top() > s.bottom() && (s.width() - 400.0).abs() < 0.5); + // Wide, short panel: notes beside, and the slide is bigger than it would be with notes below. + let wide = Rect::from_min_size(Pos2::ZERO, vec2(1100.0, 560.0)); + let (s, n) = preview_layout(wide, aspect, None); + assert!(n.left() > s.right(), "notes beside"); + assert!(s.width() > slide_box(wide, aspect).width()); + for r in [s, n] { + assert!(wide.contains_rect(r)); + } + assert!((s.width() / s.height() - aspect).abs() < 0.01); + // Widening the panel never makes the slide smaller. + let mut last = 0.0; + for w in (300..1600).step_by(50) { + let (s, _) = preview_layout(Rect::from_min_size(Pos2::ZERO, vec2(w as f32, 560.0)), aspect, None); + assert!(s.width() + 0.5 >= last, "slide shrank at width {w}"); + last = s.width(); + } + } + + #[test] + fn a_notes_page_gets_a_box_of_its_own_shape() { + // The case from the report: a 16:9 slide and notes page in a tall panel, where the space left + // under the slide is wider than the page needs. + let wide_slide = 16.0 / 9.0; + for (w, h) in [(560.0, 600.0), (560.0, 900.0), (400.0, 900.0), (1100.0, 560.0), (300.0, 1200.0)] { + let area = Rect::from_min_size(pos2(5.0, 40.0), vec2(w, h)); + let (s, n) = preview_layout(area, wide_slide, Some(wide_slide)); + assert!((n.width() / n.height() - wide_slide).abs() < 0.01, "notes box keeps the page shape at {w}x{h}"); + assert!(area.contains_rect(n) && area.contains_rect(s), "both inside at {w}x{h}"); + assert!(!n.intersects(s.shrink(0.5)), "no overlap at {w}x{h}"); + assert!(n.width() > 60.0); + // Notes below start at the slide's left edge and are never wider than it. + if n.top() > s.bottom() { + assert!((n.left() - s.left()).abs() < 0.5 && n.width() <= s.width() + 0.5); + } + } + // Short of height: the slide gives a little width so the notes page is not tiny. + let area = Rect::from_min_size(Pos2::ZERO, vec2(600.0, 560.0)); + let (s, n) = preview_layout(area, 4.0 / 3.0, Some(4.0 / 3.0)); + assert!(n.width() > s.width() * 0.6, "notes {} vs slide {}", n.width(), s.width()); + assert!(s.width() >= slide_box(area, 4.0 / 3.0).width() * 0.75 - 0.5); + // A 4:3 notes page under a 16:9 slide in a roomy panel: as wide as the slide. + let area = Rect::from_min_size(Pos2::ZERO, vec2(500.0, 1200.0)); + let (s, n) = preview_layout(area, wide_slide, Some(4.0 / 3.0)); + assert!((n.width() - s.width()).abs() < 0.5); + } + #[test] fn line_and_char_positions_agree() { let t = "aƩ\nbb\ncc";