Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
rustbasic opened this issue Oct 20, 2024 · 5 comments · May be fixed by #5307
Open
Labels
bug Something is broken egui

Comments

@rustbasic
Copy link
Contributor

rustbasic commented Oct 20, 2024

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
Copy link
Collaborator

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?

Bildschirmaufnahme.2024-10-20.um.17.06.02.mov

@rustbasic
Copy link
Contributor Author

rustbasic commented Oct 20, 2024

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
Copy link
Collaborator

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

  • they type eg
  • the first result is eggs so scroll_to_rect is called with the position of egg, the scroll animation starts
  • they type ui
  • the first result is now egui, so scroll_to_rect is now called with the position of 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
Copy link
Contributor Author

rustbasic commented Oct 22, 2024

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
Copy link
Contributor Author

rustbasic commented Oct 26, 2024

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment