emilk / egui

egui: an easy-to-use immediate mode GUI in Rust that runs on both web and native
https://www.egui.rs/
Apache License 2.0
22.55k stars 1.62k forks source link

When moving with the animation function of `ScrollArea`, it does not move to the exact location. #5289

Open rustbasic opened 1 month ago

rustbasic commented 1 month ago

When using the animation feature of the ScrollArea, it does not move to the correct location. There is a part that is interfering incorrectly during the animation process.

lucasmerlin commented 1 month ago

Can you explain further or show a example of how to reproduce the bug? In your PR you just made it so you can't make any changes to the scroll target while a animation is running, I don't think that can be a solution.

But I did notice this behavior if you repeatedly click the scroll by button, it will stutter and accelerate / decelerate, while I would expect a relatively constant and smooth animation, is this what you mean?

https://github.com/user-attachments/assets/d37a6545-1a9d-4ba0-951c-c252c929ee88

rustbasic commented 1 month ago

In order to reproduce the bug or show an example, I need to create a sample test program for a few days, but I don't have time right now.

To put it simply, when I search for a character in TextEdit and scroll to that position using scroll_to_delta(), the scroll does not reach the correct position. It's better to prevent the next scroll than to have the scroll not go exactly where you want it to.

In my opinion, it's best to disable the animation feature if we need to keep calling ui.scroll_to_cursor. Or maybe there's a way to further refine the movement of scroll_to_delta() and scroll_to_cursor.

Rather than making another scroll while scrolling, it would be better to increase the scroll speed so that the scroll ends sooner.

lucasmerlin commented 1 month ago

If you call scroll_with_delta repeatedly I think it makes sense that it might not reach any predictable location.

But it shouldn't be a problem to call scroll_to_cursor or scroll_to_rect repeatedly, have you tried using that? Once the scroll animation finishes it should always be at the expected position (of whatever target was passed in the last call), even if called repeatedly with different values.

It's better to prevent the next scroll than to have the scroll not go exactly where you want it to.

As a user I would always want the last scroll to succeed. If there is e.g. a search bar that scrolls to results as I am typing, it may not ignore the last scroll_to request. Imagine in a text that contains eggs and egui someone searches for egui

if the second call is ignored because a animation is still in progress the user would now be scrolled to eggs even though they searched for egui

PS I wonder what text would contain both eggs and egui 😄

rustbasic commented 1 month ago

Dear lucasmerlin & Dear emilk

Here is a more detailed explanation.

If you separate ScrollArea::vertical() and ScrollArea::horizontal(), scrolling will not work. This seems to be because you cannot select the ID of a specific scroll area to scroll, but this is not an easy problem to solve, so let's skip it for now.

If you separate ScrollArea::vertical() and ScrollArea::horizontal(), scrolling will not work, so you need to handle scrolling directly. However, if you do not do it like #5109, the scrolling you handled directly will not be applied. Also, if you do not do it like #5290, you will not be able to move to the correct location. ( By the way, #5290 is now included in #5109. )

I have created the following example program to test this. ( If select the searched word, you will see that it does not scroll, but if you apply #5109, it will scroll. )

#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release
#![allow(rustdoc::missing_crate_level_docs)] // it's an example

use eframe::egui::*;

fn main() -> eframe::Result {
    let options = eframe::NativeOptions {
        viewport: egui::ViewportBuilder::default().with_inner_size([1280.0, 720.0]),
        ..Default::default()
    };
    eframe::run_native(
        "Test Editor",
        options,
        Box::new(|cc| Ok(Box::new(MyApp::new(cc)))),
    )
}

#[derive(Default)]
pub struct MyApp {
    editor: TextEditData,
    continuous_mode: bool,
    frame_history: FrameHistory,
    show_settings: bool,
    show_inspection: bool,
    show_memory: bool,
}

#[derive(Default)]
pub struct TextEditData {
    text: String,
    current_line: usize,
    ccursor: epaint::text::cursor::CCursor,
    row_height_vec: Vec<f32>,
    line_char_indexes: Vec<usize>,
    line_byte_indexes: Vec<usize>,
    find_input: String,
    find_vec: Vec<FindStringValue>,
    move_cursor: Option<usize>,
}

#[allow(dead_code)]
#[derive(Clone, Default)]
pub struct FindStringValue {
    find_num: usize,
    global_index: usize,
    line_num: usize,
    line: String,
}

impl MyApp {
    pub fn new(_cc: &eframe::CreationContext<'_>) -> MyApp {
        let mut app: MyApp = Default::default();
        app.continuous_mode = true;

        app.editor.text = Self::fill_text();
        app.editor.current_line = 1;
        app.editor.find_input = "Here".to_string();
        count_textdata(&mut app.editor);

        app
    }

    pub fn fill_text() -> String {
        let mut text = String::new();
        let here_text = "Here it is.\n";
        let lorem_ipsum_text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.\nUt enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.\nDuis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.\nExcepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n";

        for i in 0..=2000 {
            if i % 100 == 0 {
                text.push_str(here_text);
            }
            text.push_str(lorem_ipsum_text);
        }

        text
    }
}

pub struct FrameHistory {
    frame_times: egui::util::History<f32>,
}

impl Default for FrameHistory {
    fn default() -> Self {
        let max_age: f32 = 1.0;
        let max_len = (max_age * 300.0).round() as usize;
        Self {
            frame_times: egui::util::History::new(0..max_len, max_age),
        }
    }
}

impl FrameHistory {
    // Called first
    pub fn on_new_frame(&mut self, now: f64, previous_frame_time: Option<f32>) {
        let previous_frame_time = previous_frame_time.unwrap_or_default();
        if let Some(latest) = self.frame_times.latest_mut() {
            *latest = previous_frame_time; // rewrite history now that we know
        }
        self.frame_times.add(now, previous_frame_time); // projected
    }

    pub fn fps(&self) -> f32 {
        self.frame_times.rate().unwrap_or_default()
    }
}

impl eframe::App for MyApp {
    fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
        ctx.set_theme(Theme::Dark);
        ctx.all_styles_mut(|style| {
            style.spacing.scroll.bar_width = 13.0;
        });

        self.frame_history
            .on_new_frame(ctx.input(|i| i.time), frame.info().cpu_usage);

        egui::SidePanel::left("side_panel_left").show(ctx, |ui| {
            ui.heading("Test Editor");
            ui.label("This is an editor for testing scrolling, etc.");

            ui.add_space(10.0);
            ui.checkbox(&mut self.continuous_mode, " Continuous Mode");
            let fps = format!("FPS : {}", FrameHistory::fps(&self.frame_history));
            ui.label(fps);

            ui.add_space(10.0);
            ui.checkbox(&mut self.show_settings, "🔧 Settings");
            ui.checkbox(&mut self.show_inspection, "🔍 Inspection");
            ui.checkbox(&mut self.show_memory, "📝 Memory");
        });

        egui::SidePanel::right("side_panel_right").show(ctx, |ui| {
            egui::ScrollArea::both().auto_shrink(false).show(ui, |ui| {
                find_string_ui(self, ui);
            });
        });

        egui::TopBottomPanel::bottom("bottom_panel")
            .resizable(true)
            .show(ctx, |ui| {
                egui::ScrollArea::vertical()
                    .auto_shrink(false)
                    .show(ui, |ui| {
                        let current_line = format!(
                            "Line: {}/{}",
                            self.editor.current_line,
                            self.editor.row_height_vec.len()
                        );
                        ui.label(current_line);
                    });
            });

        egui::CentralPanel::default().show(ctx, |ui| {
            let max_height = ui.available_height();

            egui::ScrollArea::vertical()
                .id_salt("editor_scroll_vertical")
                .auto_shrink(false)
                .max_height(max_height)
                .show(ui, |ui| {
                    ui.horizontal(|ui| {
                        display_line_counter(&mut self.editor, ui);

                        egui::ScrollArea::horizontal()
                            .id_salt("editor_scroll_horizontal")
                            .auto_shrink(false)
                            .max_height(max_height)
                            .show(ui, |ui| {
                                editor_ui(self, ui, max_height);
                            });
                    });
                });
        });

        egui::Window::new("🔧 Settings")
            .open(&mut self.show_settings)
            .vscroll(true)
            .show(ctx, |ui| {
                ctx.settings_ui(ui);
            });

        egui::Window::new("🔍 Inspection")
            .open(&mut self.show_inspection)
            .vscroll(true)
            .show(ctx, |ui| {
                ctx.inspection_ui(ui);
            });

        egui::Window::new("📝 Memory")
            .open(&mut self.show_memory)
            .resizable(false)
            .show(ctx, |ui| {
                ctx.memory_ui(ui);
            });

        if self.continuous_mode {
            ctx.request_repaint();
        }
    }
}

pub fn display_line_counter(editor: &mut TextEditData, ui: &mut egui::Ui) {
    let font_id = egui::FontId::monospace(11.0);

    ui.vertical(|ui| {
        ui.spacing_mut().item_spacing.y = 0.0; // spacing adjustment

        ui.label(
            // spacing adjustment
            egui::RichText::new("     ").font(FontId::monospace(2.0)),
        );

        for number in 1..=editor.row_height_vec.len() {
            let row_height = editor.row_height_vec[number - 1];
            let number_response = ui.add_sized(
                [50.0, row_height],
                egui::Label::new(
                    egui::RichText::new(format!("{:>5}", number))
                        .font(font_id.clone())
                        .color(Color32::LIGHT_GRAY),
                )
                .selectable(false),
            );

            if number == editor.current_line {
                let rect = number_response.rect;
                let radius = 0.2 * ui.spacing().interact_size.y;
                let rect_fill = Color32::TRANSPARENT;
                let bg_stroke = ui.style_mut().visuals.selection.stroke;

                ui.painter().rect(rect, radius, rect_fill, bg_stroke);
            }
        }
    });
}

pub fn editor_ui(app: &mut MyApp, ui: &mut egui::Ui, avail_height: f32) {
    let font_id = egui::FontId::monospace(13.0);
    let row_height = ui.fonts(|f| f.row_height(&font_id.clone()));

    let desired_width = ui.available_width();
    let desired_rows: usize = (avail_height / row_height) as usize;

    let mut layouter = |ui: &egui::Ui, string: &str, _wrap_width: f32| {
        let layout_job = text::LayoutJob::simple(
            string.to_string(),
            font_id.clone(),
            Color32::LIGHT_GRAY,
            f32::INFINITY,
        );
        ui.fonts(|f| f.layout_job(layout_job))
    };

    let text_edit = egui::TextEdit::multiline(&mut app.editor.text)
        .id_salt("editor")
        .cursor_at_end(false)
        .lock_focus(true)
        .font(font_id.clone()) // for cursor height
        .desired_width(desired_width)
        .desired_rows(desired_rows)
        .layouter(&mut layouter);

    let mut output = text_edit.show(ui);

    remember_row_height_vec(&mut app.editor, &mut output);
    if let Some(cursor_range) = output.cursor_range {
        app.editor.ccursor = cursor_range.primary.ccursor;
    }
    count_textdata(&mut app.editor);

    editor_sub(app, ui, &mut output);
}

pub fn editor_sub(
    app: &mut MyApp,
    ui: &mut egui::Ui,
    output: &mut egui::text_edit::TextEditOutput,
) {
    let input = ui.input(|i| i.clone());
    let row_height = app.editor.row_height_vec[0];

    let mut request_scroll = false;
    let mut index = app.editor.ccursor.index;

    if input.key_pressed(egui::Key::ArrowUp) && input.modifiers.is_none() {
        ui.scroll_with_delta(egui::vec2(0.0, row_height));
    }

    if (input.key_pressed(egui::Key::ArrowDown) && input.modifiers.is_none())
        || input.key_pressed(egui::Key::Enter)
    {
        ui.scroll_with_delta(egui::vec2(0.0, -row_height));
    }

    if app.editor.move_cursor != None {
        index = app.editor.move_cursor.unwrap_or(0);
        app.editor.move_cursor = None;

        request_scroll = true;

        if index == 0 {
            output.response.scroll_to_me(Some(egui::Align::Min));
        }

        let text_edit_id = output.response.id;
        if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), text_edit_id) {
            let ccursor = egui::text::CCursor::new(index);
            state
                .cursor
                .set_char_range(Some(egui::text::CCursorRange::two(ccursor, ccursor)));
            state.store(ui.ctx(), text_edit_id);
        }
    }

    if request_scroll {
        scroll_request_index(&mut app.editor, ui, index);
        output.response.request_focus();
    }
}

pub fn scroll_request_index(textedit: &mut TextEditData, ui: &mut egui::Ui, index: usize) {
    let row_height = textedit.row_height_vec[0];
    let request_line_num = get_line_by_char_index(textedit, index);
    let move_size: f32 =
        (request_line_num as f32 - textedit.current_line as f32) * row_height;

    ui.scroll_with_delta(egui::vec2(0.0, -move_size));
}

pub fn remember_row_height_vec(
    textedit: &mut TextEditData,
    output: &mut egui::text_edit::TextEditOutput,
) {
    textedit.row_height_vec.clear();

    for i in 0..output.galley.rows.len() {
        let mut row_height = output.galley.rows[i].height();
        row_height = (row_height * 10.0).round() / 10.0;
        textedit.row_height_vec.push(row_height);
    }
}

pub fn count_textdata(textedit: &mut TextEditData) {
    let text_len = textedit.text.len();
    if text_len == 0 {
        // clear_textdata_sub(textedit);
    }

    let text = &textedit.text;
    let index = textedit.ccursor.index;

    let (line_char_indexes, line_byte_indexes) = create_line_indexes_from_text(text);

    let (current_line, _line_start_index) = get_line_and_start_index(&line_char_indexes, index);

    textedit.line_char_indexes = line_char_indexes;
    textedit.line_byte_indexes = line_byte_indexes;
    textedit.current_line = current_line;
}

pub fn create_line_indexes_from_text(text: &str) -> (Vec<usize>, Vec<usize>) {
    let mut line_char_indexes = vec![0];
    let mut line_byte_indexes = vec![0];

    let mut char_count = 0;
    let mut byte_count = 0;

    for c in text.chars() {
        char_count += 1;
        byte_count += c.len_utf8();

        if c == '\n' {
            line_char_indexes.push(char_count);
            line_byte_indexes.push(byte_count);
        }
    }
    line_char_indexes.push(char_count);
    line_byte_indexes.push(byte_count);

    (line_char_indexes, line_byte_indexes)
}

pub fn get_line_by_char_index(textedit: &mut TextEditData, index: usize) -> usize {
    let (line, _line_start_index) = get_line_and_start_index(&textedit.line_char_indexes, index);

    line
}

pub fn get_line_and_start_index(line_indexes: &[usize], index: usize) -> (usize, usize) {
    let mut line = 1;
    for (i, line_start) in line_indexes.iter().enumerate() {
        line = i;
        if *line_start > index {
            break;
        }
    }

    (line, line_indexes[line - 1])
}

pub fn find_string_ui(app: &mut MyApp, ui: &mut egui::Ui) {
    let font_id = egui::FontId::monospace(13.0);

    ui.add_space(10.0);
    ui.label("Find");
    let _find_line = ui.add(
        egui::TextEdit::singleline(&mut app.editor.find_input)
            .id_salt("Find_String")
            .desired_width(100.0)
            .font(font_id.clone())
            .cursor_at_end(true)
            .lock_focus(true),
    );

    find_string_in_text(
        &mut app.editor.find_vec,
        &mut app.editor.text,
        &app.editor.find_input,
        app.editor.line_char_indexes.clone(),
    );

    let max_height = ui.available_height();
    let selected = false;

    ScrollArea::both()
        .auto_shrink([true, true])
        .max_height(max_height)
        .show(ui, |ui| {
            for find in &app.editor.find_vec.clone() {
                let message = format!("{:>5}: {}", find.line_num, find.line);

                ui.horizontal(|ui| {
                    let response =
                        ui.selectable_label(selected, RichText::new(message).font(font_id.clone()));

                    if response.clicked() {
                        app.editor.move_cursor =
                            Some(find.global_index + app.editor.find_input.chars().count());
                    }
                });
            }
        });
}

pub fn find_string_in_text(
    find_vec: &mut Vec<FindStringValue>,
    text: &mut String,
    search_string: &str,
    line_char_indexes: Vec<usize>,
) {
    find_vec.clear();

    if search_string.trim().is_empty() {
        return;
    }

    let search_result = find_vec;
    let mut find_num = 0;

    let reader = std::io::BufReader::new(text.as_bytes());

    for (line_num, line) in std::io::BufRead::lines(reader).enumerate() {
        let line = match line {
            Ok(line) => line,
            Err(_err) => String::new(),
        };

        let mut start = 0;
        let line_start_char_index = line_char_indexes[line_num];

        while let Some(index) = line[start..].find(search_string) {
            let real_index = index + start;
            start = real_index + search_string.len();

            let column_char_index = line[0..real_index].chars().count();

            find_num += 1;
            search_result.push(FindStringValue {
                find_num,
                global_index: line_start_char_index + column_char_index,
                line_num: line_num + 1, // +1 to account for 1-based indexing
                line: line.clone(),
            });
        }
    }
}
rustbasic commented 1 month ago

Dear lucasmerlin & Dear emilk

If there is a new scroll during the scroll animation, it has been fixed in #5307 to move to that location. I think this is the best approach until we find a better way.