diff --git a/Cargo.lock b/Cargo.lock index 554e41ec10f..47503ef23e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4661,6 +4661,14 @@ dependencies = [ "memchr", ] +[[package]] +name = "wrapping-layout" +version = "0.1.0" +dependencies = [ + "eframe", + "tracing-subscriber", +] + [[package]] name = "x11-dl" version = "2.21.0" diff --git a/crates/egui/src/containers/mod.rs b/crates/egui/src/containers/mod.rs index 53e8e7e2ce0..4bb5bb4e90c 100644 --- a/crates/egui/src/containers/mod.rs +++ b/crates/egui/src/containers/mod.rs @@ -14,7 +14,7 @@ pub(crate) mod window; pub use { area::Area, - collapsing_header::{CollapsingHeader, CollapsingResponse}, + collapsing_header::{CollapsingHeader, CollapsingResponse, CollapsingState}, combo_box::*, frame::Frame, panel::{CentralPanel, SidePanel, TopBottomPanel}, diff --git a/crates/egui/src/containers/panel.rs b/crates/egui/src/containers/panel.rs index f5267e0f0c7..52d134eba29 100644 --- a/crates/egui/src/containers/panel.rs +++ b/crates/egui/src/containers/panel.rs @@ -98,6 +98,7 @@ pub struct SidePanel { show_separator_line: bool, default_width: f32, width_range: Rangef, + visuals: Option, } impl SidePanel { @@ -121,6 +122,7 @@ impl SidePanel { show_separator_line: true, default_width: 200.0, width_range: Rangef::new(96.0, f32::INFINITY), + visuals: None, } } @@ -190,6 +192,12 @@ impl SidePanel { self.frame = Some(frame); self } + + /// Optionally override visual style + pub fn visuals(mut self, visuals: style::Widgets) -> Self { + self.visuals = Some(visuals); + self + } } impl SidePanel { @@ -216,6 +224,7 @@ impl SidePanel { show_separator_line, default_width, width_range, + visuals, } = self; let available_rect = ui.available_rect_before_wrap(); @@ -296,15 +305,28 @@ impl SidePanel { PanelState { rect }.store(ui.ctx(), id); { - let stroke = if is_resizing { - ui.style().visuals.widgets.active.fg_stroke // highly visible - } else if resize_hover { - ui.style().visuals.widgets.hovered.fg_stroke // highly visible - } else if show_separator_line { - // TODO(emilk): distinguish resizable from non-resizable - ui.style().visuals.widgets.noninteractive.bg_stroke // dim + let stroke = if let Some(widgets) = visuals { + if is_resizing { + widgets.active.fg_stroke // highly visible + } else if resize_hover { + widgets.hovered.fg_stroke // highly visible + } else if show_separator_line { + // TODO(emilk): distinguish resizable from non-resizable + widgets.noninteractive.bg_stroke // dim + } else { + Stroke::NONE + } } else { - Stroke::NONE + if is_resizing { + ui.style().visuals.widgets.active.fg_stroke // highly visible + } else if resize_hover { + ui.style().visuals.widgets.hovered.fg_stroke // highly visible + } else if show_separator_line { + // TODO(emilk): distinguish resizable from non-resizable + ui.style().visuals.widgets.noninteractive.bg_stroke // dim + } else { + Stroke::NONE + } }; // TODO(emilk): draw line on top of all panels in this ui when https://github.com/emilk/egui/issues/1516 is done // In the meantime: nudge the line so its inside the panel, so it won't be covered by neighboring panel diff --git a/crates/egui/src/containers/scroll_area.rs b/crates/egui/src/containers/scroll_area.rs index 0c2cead7023..cba0545db46 100644 --- a/crates/egui/src/containers/scroll_area.rs +++ b/crates/egui/src/containers/scroll_area.rs @@ -119,6 +119,23 @@ pub struct ScrollArea { /// end position until user manually changes position. It will become true /// again once scroll handle makes contact with end. stick_to_end: [bool; 2], + + /// Override for scroll delta. Normally taken from frame_state + override_scroll_delta: Option, +} + +impl ScrollArea { + pub fn is_scrolling(ui: &Ui, id_source: Id) -> bool { + let id = ui.make_persistent_id(id_source); + let scroll_target = ui.ctx().frame_state(|state| { + state.scroll_target[0].is_some() || state.scroll_target[1].is_some() + }); + if let Some(state) = State::load(ui.ctx(), id) { + state.vel != Vec2::ZERO || scroll_target + } else { + false + } + } } impl ScrollArea { @@ -158,6 +175,7 @@ impl ScrollArea { scrolling_enabled: true, drag_to_scroll: true, stick_to_end: [false; 2], + override_scroll_delta: None, } } @@ -332,6 +350,11 @@ impl ScrollArea { self.stick_to_end[1] = stick; self } + + pub fn override_scroll_delta(mut self, delta: Vec2) -> Self { + self.override_scroll_delta = Some(delta); + self + } } struct Prepared { @@ -357,6 +380,7 @@ struct Prepared { scrolling_enabled: bool, stick_to_end: [bool; 2], + override_scroll_delta: Option, } impl ScrollArea { @@ -373,6 +397,7 @@ impl ScrollArea { scrolling_enabled, drag_to_scroll, stick_to_end, + override_scroll_delta, } = self; let ctx = ui.ctx().clone(); @@ -519,6 +544,7 @@ impl ScrollArea { viewport, scrolling_enabled, stick_to_end, + override_scroll_delta, } } @@ -629,6 +655,7 @@ impl Prepared { viewport: _, scrolling_enabled, stick_to_end, + override_scroll_delta, } = self; let content_size = content_ui.min_size(); @@ -702,7 +729,10 @@ impl Prepared { if scrolling_enabled && ui.rect_contains_pointer(outer_rect) { for d in 0..2 { if has_bar[d] { - let scroll_delta = ui.ctx().frame_state(|fs| fs.scroll_delta); + let scroll_delta = match override_scroll_delta { + Some(delta) => delta, + None => ui.ctx().frame_state(|fs| fs.scroll_delta), + }; let scrolling_up = state.offset[d] > 0.0 && scroll_delta[d] > 0.0; let scrolling_down = state.offset[d] < max_offset[d] && scroll_delta[d] < 0.0; diff --git a/crates/egui/src/layout.rs b/crates/egui/src/layout.rs index 1dd77c21580..352447962cd 100644 --- a/crates/egui/src/layout.rs +++ b/crates/egui/src/layout.rs @@ -704,35 +704,37 @@ impl Layout { item_spacing: Vec2, ) { egui_assert!(!cursor.any_nan()); + let mut newline = false; if self.main_wrap { if cursor.intersects(frame_rect.shrink(1.0)) { // make row/column larger if necessary *cursor = cursor.union(frame_rect); } else { - // this is a new row or column. We temporarily use NAN for what will be filled in later. + // this is a new row or column. the cursor moves to the edge of the frame + newline = true; match self.main_dir { Direction::LeftToRight => { *cursor = Rect::from_min_max( - pos2(f32::NAN, frame_rect.min.y), + pos2(widget_rect.max.x, frame_rect.min.y), pos2(INFINITY, frame_rect.max.y), ); } Direction::RightToLeft => { *cursor = Rect::from_min_max( pos2(-INFINITY, frame_rect.min.y), - pos2(f32::NAN, frame_rect.max.y), + pos2(widget_rect.min.x, frame_rect.max.y), ); } Direction::TopDown => { *cursor = Rect::from_min_max( - pos2(frame_rect.min.x, f32::NAN), + pos2(frame_rect.min.x, widget_rect.max.y), pos2(frame_rect.max.x, INFINITY), ); } Direction::BottomUp => { *cursor = Rect::from_min_max( pos2(frame_rect.min.x, -INFINITY), - pos2(frame_rect.max.x, f32::NAN), + pos2(frame_rect.max.x, widget_rect.min.y), ); } }; @@ -748,20 +750,23 @@ impl Layout { } } - match self.main_dir { - Direction::LeftToRight => { - cursor.min.x = widget_rect.max.x + item_spacing.x; - } - Direction::RightToLeft => { - cursor.max.x = widget_rect.min.x - item_spacing.x; - } - Direction::TopDown => { - cursor.min.y = widget_rect.max.y + item_spacing.y; - } - Direction::BottomUp => { - cursor.max.y = widget_rect.min.y - item_spacing.y; - } - }; + // apply item_spacing unless this is a newline + if !newline { + match self.main_dir { + Direction::LeftToRight => { + cursor.min.x = widget_rect.max.x + item_spacing.x; + } + Direction::RightToLeft => { + cursor.max.x = widget_rect.min.x - item_spacing.x; + } + Direction::TopDown => { + cursor.min.y = widget_rect.max.y + item_spacing.y; + } + Direction::BottomUp => { + cursor.max.y = widget_rect.min.y - item_spacing.y; + } + }; + } } /// Move to the next row in a wrapping layout. diff --git a/crates/egui/src/widgets/label.rs b/crates/egui/src/widgets/label.rs index 288fcb422e8..0a222f591c5 100644 --- a/crates/egui/src/widgets/label.rs +++ b/crates/egui/src/widgets/label.rs @@ -152,6 +152,16 @@ impl Label { !text_galley.galley.rows.is_empty(), "Galleys are never empty" ); + + // set the row height to ensure the cursor advancement is correct. when creating a child ui such as with + // ui.horizontal_wrapped, the initial cursor will be set to the height of the child ui. this can lead + // to the cursor not advancing to the second row but rather expanding the height of the cursor. + // + // note that we do not set the row height earlier in this function as we do want to allow populating + // `first_row_min_height` above. however it is crucial the placer knows the actual row height by + // setting the cursor height before ui.allocate_rect() gets called. + ui.set_row_height(text_galley.galley.rows[0].height()); + // collect a response from many rows: let rect = text_galley.galley.rows[0] .rect diff --git a/crates/epaint/src/text/text_layout.rs b/crates/epaint/src/text/text_layout.rs index e36ee1eaa3a..0e16ad9f6ab 100644 --- a/crates/epaint/src/text/text_layout.rs +++ b/crates/epaint/src/text/text_layout.rs @@ -147,6 +147,9 @@ fn layout_section( paragraph.empty_paragraph_height = line_height; // TODO(emilk): replace this hack with actually including `\n` in the glyphs? } + // TODO(bu5hm4nn): in a label widget, `leading_space` is used to adjust for existing text in a screen row, + // but the comment on `LayoutSection::leading_space` makes it clear it was originally intended for typographical + // indentation and not for screen layout paragraph.cursor_x += leading_space; let mut last_glyph_id = None; @@ -244,34 +247,20 @@ fn line_break(paragraph: &Paragraph, job: &LayoutJob, out_rows: &mut Vec, e let mut first_row_indentation = paragraph.glyphs[0].pos.x; let mut row_start_x = 0.0; let mut row_start_idx = 0; + let mut non_empty_rows = 0; for i in 0..paragraph.glyphs.len() { - if job.wrap.max_rows <= out_rows.len() { - *elided = true; + let potential_row_width = paragraph.glyphs[i].max_x() - row_start_x - first_row_indentation; + + if job.wrap.max_rows > 0 && non_empty_rows >= job.wrap.max_rows { break; } - let potential_row_width = paragraph.glyphs[i].max_x() - row_start_x; - - if job.wrap.max_width < potential_row_width { - // Row break: - - if first_row_indentation > 0.0 - && !row_break_candidates.has_good_candidate(job.wrap.break_anywhere) - { - // Allow the first row to be completely empty, because we know there will be more space on the next row: - // TODO(emilk): this records the height of this first row as zero, though that is probably fine since first_row_indentation usually comes with a first_row_min_height. - out_rows.push(Row { - section_index_at_start: paragraph.section_index_at_start, - glyphs: vec![], - visuals: Default::default(), - rect: rect_from_x_range(first_row_indentation..=first_row_indentation), - ends_with_newline: false, - }); - row_start_x += first_row_indentation; - first_row_indentation = 0.0; - } else if let Some(last_kept_index) = row_break_candidates.get(job.wrap.break_anywhere) - { + // (bu5hm4nn): we want to actually allow as much text as possible on the first line so + // we don't need a special case for the first row, but we need to subtract + // the first_row_indentation from the allowed max width + if potential_row_width > (job.wrap.max_width - first_row_indentation) { + if let Some(last_kept_index) = row_break_candidates.get(job.wrap.break_anywhere) { let glyphs: Vec = paragraph.glyphs[row_start_idx..=last_kept_index] .iter() .copied() @@ -297,6 +286,12 @@ fn line_break(paragraph: &Paragraph, job: &LayoutJob, out_rows: &mut Vec, e row_start_idx = last_kept_index + 1; row_start_x = paragraph.glyphs[row_start_idx].pos.x; row_break_candidates = Default::default(); + non_empty_rows += 1; + + // (bu5hm4nn) first row indentation gets consumed the first time it's used + if first_row_indentation > 0.0 { + first_row_indentation = 0.0 + } } else { // Found no place to break, so we have to overrun wrap_width. } @@ -925,6 +920,7 @@ impl RowBreakCandidates { .flatten() } + #[allow(dead_code)] fn has_good_candidate(&self, break_anywhere: bool) -> bool { if break_anywhere { self.any.is_some() @@ -1062,3 +1058,36 @@ mod tests { ); } } + +#[test] +fn test_line_break_first_row_not_empty() { + let mut fonts = FontsImpl::new(1.0, 1024, super::FontDefinitions::default()); + let mut layout_job = LayoutJob::single_section( + "SomeSuperLongTextThatDoesNotHaveAnyGoodBreakCandidatesButStillNeedsToBeBroken".into(), + super::TextFormat::default(), + ); + + // a small area + layout_job.wrap.max_width = 110.0; + + // give the first row a leading space, simulating that there already is + // text in this visual row + layout_job.sections.first_mut().unwrap().leading_space = 50.0; + + let galley = super::layout(&mut fonts, layout_job.into()); + assert_eq!( + galley + .rows + .iter() + .map(|row| row.glyphs.iter().map(|g| g.chr).collect::()) + .collect::>(), + vec![ + "SomeSup", + "erLongTextThat", + "DoesNotHaveAn", + "yGoodBreakCand", + "idatesButStillNe", + "edsToBeBroken" + ] + ); +} diff --git a/examples/wrapping-layout/Cargo.toml b/examples/wrapping-layout/Cargo.toml new file mode 100644 index 00000000000..072000d8989 --- /dev/null +++ b/examples/wrapping-layout/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "wrapping-layout" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +eframe = { path = "../../crates/eframe", features = [ + "__screenshot", # __screenshot is so we can dump a screenshot using EFRAME_SCREENSHOT_TO +] } +tracing-subscriber = "0.3" diff --git a/examples/wrapping-layout/src/main.rs b/examples/wrapping-layout/src/main.rs new file mode 100644 index 00000000000..ddeb45d48da --- /dev/null +++ b/examples/wrapping-layout/src/main.rs @@ -0,0 +1,54 @@ +use eframe::{ + egui::{self, WidgetText}, + emath::Align, + epaint::Stroke, +}; + +fn main() -> Result<(), eframe::Error> { + let options = eframe::NativeOptions { + initial_window_size: Some(egui::vec2(380.0, 440.0)), + ..Default::default() + }; + eframe::run_native( + "Horizontal Wrapped Layouts", + options, + Box::new(|cc| Box::new(MyEguiApp::new(cc))), + ) +} + +#[derive(Default)] +struct MyEguiApp {} + +impl MyEguiApp { + fn new(_cc: &eframe::CreationContext<'_>) -> Self { + Self::default() + } +} + +impl eframe::App for MyEguiApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + egui::CentralPanel::default().show(ctx, |ui| { + ui.horizontal_wrapped(|ui| { + ui.hyperlink_to("@npub1vdaeclr2mnntmyw...", "whocares"); + let text = " LotsOfTextPrecededByASpace5kgqfqqxwhkrkw60stn8aph4gm2h2053xvwvvlvjm3q9eqdpqxycrqvpqd3hhgar9wfujqarfvd4k2arncqzpgxqzz6sp5vfenc5l4uafsky0w069zs329edf608ggpjjveguwxfl3xlswg5vq9qyyssqj46d5x3gsnljffm79eqwszk4mk47lkxywdp8mxum7un3qm0ztwj9jf46cm4lw2un9hk4gttgtjdrk29h27xu4e3ume20sqsna8q7xwspqqkwq7"; + ui.label(text); + ui.style_mut().visuals.widgets.noninteractive.fg_stroke = Stroke::new( 1.0, eframe::epaint::Color32::RED ); + ui.label("More text followed by two newlines\n\n"); + ui.style_mut().visuals.widgets.noninteractive.fg_stroke = Stroke::new( 1.0, eframe::epaint::Color32::GREEN ); + ui.label("more text, no newline"); + ui.reset_style(); + }); + ui.separator(); + ui.horizontal_wrapped(|ui| { + ui.label("Hyperlink no newline:"); + let url = "https://i.nostrimg.com/c72f5e1a2e162fad2625e15651a654465c06016016f7743b496021cafa2a524e/file.jpeg"; + ui.hyperlink_to( url, url ); + ui.end_row(); + ui.label("Hyperlink break_anywhere=true"); + let mut job = WidgetText::from(url).into_text_job(ui.style(), egui::FontSelection::Default, Align::LEFT); + job.job.wrap.break_anywhere = true; + ui.hyperlink_to( job.job, url ); + }); + }); + } +}