From 7344dfe651fa9a72edebf01ae361be490d95a1f7 Mon Sep 17 00:00:00 2001 From: Peter Nelson Date: Wed, 14 May 2025 18:06:51 +0100 Subject: [PATCH] Change: Reflow Textfile window content incrementally. This avoids a stall when reflowing a long text file with some truetype fonts. --- src/textfile_gui.cpp | 239 +++++++++++++++++++++++++++++-------------- src/textfile_gui.h | 39 +++++-- src/window.cpp | 9 ++ 3 files changed, 204 insertions(+), 83 deletions(-) diff --git a/src/textfile_gui.cpp b/src/textfile_gui.cpp index 579b802a43..cffaf71240 100644 --- a/src/textfile_gui.cpp +++ b/src/textfile_gui.cpp @@ -105,40 +105,25 @@ void TextfileWindow::ConstructWindow() } /** - * Get the total height of the content displayed in this window, if wrapping is disabled. - * @return the height in pixels + * Reset the reflow process to start on the next UI tick. */ -uint TextfileWindow::ReflowContent() +void TextfileWindow::ReflowContent() { - uint height = 0; - if (!this->IsTextWrapped()) { - for (auto &line : this->lines) { - line.top = height; - height++; - line.bottom = height; - } - } else { - int max_width = this->GetWidget(WID_TF_BACKGROUND)->current_x - WidgetDimensions::scaled.frametext.Horizontal(); - for (auto &line : this->lines) { - line.top = height; - height += GetStringHeight(line.text, max_width, FS_MONO) / GetCharacterHeight(FS_MONO); - line.bottom = height; - } - } + /* Minimum number of lines that will be flowed. */ + if (this->num_lines == 0) this->num_lines = std::size(this->lines); - return height; -} + auto it = this->GetIteratorFromPosition(this->vscroll->GetPosition()); -uint TextfileWindow::GetContentHeight() -{ - if (this->lines.empty()) return 0; - return this->lines.back().bottom; + auto adapter = AlternatingView{this->lines, it}; + this->reflow_iter = adapter.begin(); + this->reflow_end = adapter.end(); } /* virtual */ void TextfileWindow::UpdateWidgetSize(WidgetID widget, Dimension &size, [[maybe_unused]] const Dimension &padding, [[maybe_unused]] Dimension &fill, [[maybe_unused]] Dimension &resize) { switch (widget) { case WID_TF_BACKGROUND: + resize.width = GetCharacterHeight(FS_MONO); // Width is not available here as the font may not be loaded yet. resize.height = GetCharacterHeight(FS_MONO); size.height = 4 * resize.height + WidgetDimensions::scaled.frametext.Vertical(); // At least 4 lines are visible. @@ -148,19 +133,13 @@ uint TextfileWindow::GetContentHeight() } /** Set scrollbars to the right lengths. */ -void TextfileWindow::SetupScrollbars(bool force_reflow) +void TextfileWindow::SetupScrollbars() { - if (this->IsTextWrapped()) { - /* Reflow is mandatory if text wrapping is on */ - uint height = this->ReflowContent(); - this->vscroll->SetCount(height); - this->hscroll->SetCount(0); - } else { - uint height = force_reflow ? this->ReflowContent() : this->GetContentHeight(); - this->vscroll->SetCount(height); - this->hscroll->SetCount(this->max_length); - } + this->vscroll->SetCount(this->num_lines); + this->hscroll->SetCount(this->IsTextWrapped() ? 0 : CeilDiv(this->max_width, this->resize.step_width)); + this->SetWidgetDirty(WID_TF_VSCROLLBAR); + this->SetWidgetDirty(WID_TF_HSCROLLBAR); this->SetWidgetDisabledState(WID_TF_HSCROLLBAR, this->IsTextWrapped()); } @@ -308,18 +287,17 @@ const TextfileWindow::Hyperlink *TextfileWindow::GetHyperlink(Point pt) const /* Which line was clicked. */ const int clicked_row = this->GetRowFromWidget(pt.y, WID_TF_BACKGROUND, WidgetDimensions::scaled.frametext.top, GetCharacterHeight(FS_MONO)) + this->GetScrollbar(WID_TF_VSCROLLBAR)->GetPosition(); - size_t line_index; - size_t subline; - if (this->IsTextWrapped()) { - auto it = std::ranges::find_if(this->lines, [clicked_row](const Line &l) { return l.top <= clicked_row && l.bottom > clicked_row; }); - if (it == this->lines.cend()) return nullptr; - line_index = it - this->lines.cbegin(); - subline = clicked_row - it->top; - Debug(misc, 4, "TextfileWindow check hyperlink: clicked_row={}, line_index={}, line.top={}, subline={}", clicked_row, line_index, it->top, subline); - } else { - line_index = clicked_row / GetCharacterHeight(FS_MONO); - subline = 0; - } + + int visible_line = 0; + auto it = std::ranges::find_if(this->lines, [&visible_line, clicked_row](const Line &l) { + visible_line += l.num_lines; + return (visible_line - l.num_lines) <= clicked_row && visible_line > clicked_row; + }); + if (it == this->lines.cend()) return nullptr; + + size_t line_index = it - this->lines.cbegin(); + size_t subline = clicked_row - (visible_line - it->num_lines); + Debug(misc, 4, "TextfileWindow check hyperlink: clicked_row={}, line_index={}, line.top={}, subline={}", clicked_row, line_index, visible_line - it->num_lines, subline); /* Find hyperlinks in this line. */ std::vector found_links; @@ -329,12 +307,12 @@ const TextfileWindow::Hyperlink *TextfileWindow::GetHyperlink(Point pt) const if (found_links.empty()) return nullptr; /* Build line layout to figure out character position that was clicked. */ - uint window_width = this->IsTextWrapped() ? this->GetWidget(WID_TF_BACKGROUND)->current_x - WidgetDimensions::scaled.frametext.Horizontal() : INT_MAX; - Layouter layout(this->lines[line_index].text, window_width, FS_MONO); + const Line &line = this->lines[line_index]; + Layouter layout(line.text, line.wrapped_width, FS_MONO); assert(subline < layout.size()); ptrdiff_t char_index = layout.GetCharAtPosition(pt.x - WidgetDimensions::scaled.frametext.left, subline); if (char_index < 0) return nullptr; - Debug(misc, 4, "TextfileWindow check hyperlink click: line={}, subline={}, char_index={}", line_index, subline, (int)char_index); + Debug(misc, 4, "TextfileWindow check hyperlink click: line={}, subline={}, char_index={}", line_index, subline, char_index); /* Found character index in line, check if any links are at that position. */ for (const Hyperlink *link : found_links) { @@ -570,6 +548,14 @@ void TextfileWindow::AfterLoadMarkdown() /* virtual */ void TextfileWindow::DrawWidget(const Rect &r, WidgetID widget) const { + if (widget == WID_TF_CAPTION && std::size(this->lines) > 0 && this->reflow_iter != this->reflow_end) { + /* Draw a progress bar in the caption. */ + Rect fr = r.Shrink(WidgetDimensions::scaled.captiontext).WithHeight(WidgetDimensions::scaled.vsep_normal, true); + size_t remaining = std::distance(this->reflow_iter, this->reflow_end); + fr = fr.WithWidth(static_cast(remaining * fr.Width() / std::size(this->lines)), _current_text_dir != TD_RTL); + GfxFillRect(fr, PC_WHITE, FILLRECT_CHECKER); + } + if (widget != WID_TF_BACKGROUND) return; Rect fr = r.Shrink(WidgetDimensions::scaled.frametext); @@ -582,18 +568,21 @@ void TextfileWindow::AfterLoadMarkdown() fr = fr.Translate(-fr.left, -fr.top); int line_height = GetCharacterHeight(FS_MONO); - if (!this->IsTextWrapped()) fr = ScrollRect(fr, *this->hscroll, 1); + if (!this->IsTextWrapped()) fr = ScrollRect(fr, *this->hscroll, this->resize.step_width); int pos = this->vscroll->GetPosition(); int cap = this->vscroll->GetCapacity(); - + int cur_line = 0; for (auto &line : this->lines) { - if (line.bottom < pos) continue; - if (line.top > pos + cap) break; + int top = cur_line; + cur_line += line.num_lines; + if (cur_line <= pos) continue; + if (top > pos + cap) break; - int y_offset = (line.top - pos) * line_height; - if (this->IsTextWrapped()) { - DrawStringMultiLineWithClipping(fr.left, fr.right, y_offset, y_offset + (line.bottom - line.top) * line_height, line.text, line.colour, SA_TOP | SA_LEFT, false, FS_MONO); + int y_offset = (top - pos) * line_height; + if (line.wrapped_width != 0) { + Rect tr = fr.WithWidth(line.wrapped_width, _current_text_dir == TD_RTL); + DrawStringMultiLineWithClipping(tr.left, tr.right, y_offset, y_offset + line.num_lines * line_height, line.text, line.colour, SA_TOP | SA_LEFT, false, FS_MONO); } else { DrawString(fr.left, fr.right, y_offset, line.text, line.colour, SA_TOP | SA_LEFT, false, FS_MONO); } @@ -605,14 +594,31 @@ void TextfileWindow::AfterLoadMarkdown() this->vscroll->SetCapacityFromWidget(this, WID_TF_BACKGROUND, WidgetDimensions::scaled.frametext.Vertical()); this->hscroll->SetCapacityFromWidget(this, WID_TF_BACKGROUND, WidgetDimensions::scaled.framerect.Horizontal()); - this->SetupScrollbars(false); + this->UpdateVisibleIterators(); + this->ReflowContent(); + this->SetupScrollbars(); +} + +/* virtual */ void TextfileWindow::OnInit() +{ + /* If font has changed we need to recalculate the maximum width. */ + this->num_lines = 0; + this->max_width = 0; + for (auto &line : this->lines) { + line.max_width = -1; + line.num_lines = 1; + line.wrapped_width = 0; + } + + this->ReflowContent(); } /* virtual */ void TextfileWindow::OnInvalidateData([[maybe_unused]] int data, [[maybe_unused]] bool gui_scope) { if (!gui_scope) return; - this->SetupScrollbars(true); + this->ReflowContent(); + this->SetupScrollbars(); } void TextfileWindow::OnDropdownSelect(WidgetID widget, int index) @@ -622,16 +628,107 @@ void TextfileWindow::OnDropdownSelect(WidgetID widget, int index) this->ScrollToLine(index); } +extern bool CanContinueRealtimeTick(); + +TextfileWindow::ReflowState TextfileWindow::ContinueReflow() +{ + if (this->reflow_iter == this->reflow_end) return ReflowState::None; + + int window_width = this->GetWidget(WID_TF_BACKGROUND)->current_x - WidgetDimensions::scaled.frametext.Horizontal(); + + bool wrapped = this->IsTextWrapped(); + bool dirty = false; + int pos = this->vscroll->GetPosition(); + + for (/* nothing */; this->reflow_iter != this->reflow_end; ++this->reflow_iter) { + auto it = this->reflow_iter.Base(); + Line &line = *it; + + int old_lines = line.num_lines; + if (wrapped) { + if (line.wrapped_width != window_width) { + line.num_lines = GetStringHeight(line.text, window_width, FS_MONO) / GetCharacterHeight(FS_MONO); + line.wrapped_width = window_width; + } + } else { + if (line.max_width == -1) { + line.max_width = GetStringBoundingBox(line.text, FS_MONO).width; + this->max_width = std::max(this->max_width, line.max_width); + } + line.num_lines = 1; + line.wrapped_width = 0; + } + + /* Adjust the total number of lines. */ + this->num_lines += (line.num_lines - old_lines); + + /* Maintain scroll position. */ + if (this->visible_first > it) pos += (line.num_lines - old_lines); + + /* Mark dirty if visible range is touched. */ + if (it >= this->visible_first && it <= this->visible_last) dirty = true; + + if (!CanContinueRealtimeTick()) break; + } + + if (this->vscroll->SetPosition(pos)) dirty = true; + + return dirty ? ReflowState::VisibleReflowed : ReflowState::Reflowed; +} + +void TextfileWindow::OnRealtimeTick(uint) +{ + auto r = this->ContinueReflow(); + if (r == ReflowState::None) return; + + this->SetupScrollbars(); + + if (r == ReflowState::VisibleReflowed) { + this->SetWidgetDirty(WID_TF_BACKGROUND); + this->UpdateVisibleIterators(); + } + + /* Caption is always dirty. */ + this->SetWidgetDirty(WID_TF_CAPTION); +} + +void TextfileWindow::UpdateVisibleIterators() +{ + int pos = this->vscroll->GetPosition(); + int cap = this->vscroll->GetCapacity(); + this->visible_first = this->GetIteratorFromPosition(pos); + + /* The last visible iterator ignores line wrapping so that it does not need to change when line heights change. */ + this->visible_last = std::ranges::next(this->visible_first, cap + 1, std::end(this->lines)); +} + +void TextfileWindow::OnScrollbarScroll(WidgetID widget) +{ + if (widget != WID_TF_VSCROLLBAR) return; + + this->UpdateVisibleIterators(); + this->ReflowContent(); +} + +std::vector::iterator TextfileWindow::GetIteratorFromPosition(int pos) +{ + for (auto it = std::begin(this->lines); it != std::end(this->lines); ++it) { + pos -= it->num_lines; + if (pos <= 0) return it; + } + return std::end(this->lines); +} + void TextfileWindow::ScrollToLine(size_t line) { Scrollbar *sb = this->GetScrollbar(WID_TF_VSCROLLBAR); - int newpos; - if (this->IsTextWrapped()) { - newpos = this->lines[line].top; - } else { - newpos = static_cast(line); + int newpos = 0; + for (auto it = std::begin(this->lines); it != std::end(this->lines) && line > 0; --line, ++it) { + newpos += it->num_lines; } sb->SetPosition(std::min(newpos, sb->GetCount() - sb->GetCapacity())); + this->UpdateVisibleIterators(); + this->ReflowContent(); this->SetDirty(); } @@ -814,25 +911,17 @@ void TextfileWindow::LoadText(std::string_view buf) /* Split the string on newlines. */ std::string_view p(text); - int row = 0; auto next = p.find_first_of('\n'); while (next != std::string_view::npos) { - this->lines.emplace_back(row, p.substr(0, next)); + this->lines.emplace_back(p.substr(0, next)); p.remove_prefix(next + 1); - row++; next = p.find_first_of('\n'); } - this->lines.emplace_back(row, p); - - /* Calculate maximum text line length. */ - uint max_length = 0; - for (auto &line : this->lines) { - max_length = std::max(max_length, GetStringBoundingBox(line.text, FS_MONO).width); - } - this->max_length = max_length; + this->lines.emplace_back(p); this->AfterLoadText(); + this->ReflowContent(); CheckForMissingGlyphs(true, this); diff --git a/src/textfile_gui.h b/src/textfile_gui.h index fb07f23843..e538efa128 100644 --- a/src/textfile_gui.h +++ b/src/textfile_gui.h @@ -10,6 +10,7 @@ #ifndef TEXTFILE_GUI_H #define TEXTFILE_GUI_H +#include "misc/alternating_iterator.hpp" #include "fileio_type.h" #include "strings_func.h" #include "textfile_type.h" @@ -28,8 +29,11 @@ struct TextfileWindow : public Window, MissingGlyphSearcher { bool OnTooltip([[maybe_unused]] Point pt, WidgetID widget, TooltipCloseCondition close_cond) override; void DrawWidget(const Rect &r, WidgetID widget) const override; void OnResize() override; + void OnInit() override; void OnInvalidateData(int data = 0, bool gui_scope = true) override; void OnDropdownSelect(WidgetID widget, int index) override; + void OnRealtimeTick(uint delta_ms) override; + void OnScrollbarScroll(WidgetID widget) override; void Reset() override; FontSize DefaultSize() override; @@ -46,12 +50,13 @@ protected: void ConstructWindow(); struct Line { - int top = 0; ///< Top scroll position in visual lines. - int bottom = 0; ///< Bottom scroll position in visual lines. - std::string text{}; ///< Contents of the line. + int num_lines = 1; ///< Number of visual lines for this line. + int wrapped_width = 0; + int max_width = -1; TextColour colour = TC_WHITE; ///< Colour to render text line in. + std::string text{}; ///< Contents of the line. - Line(int top, std::string_view text) : top(top), bottom(top + 1), text(text) {} + Line(std::string_view text) : text(text) {} Line() {} }; @@ -100,11 +105,29 @@ protected: private: uint search_iterator = 0; ///< Iterator for the font check search. - uint max_length = 0; ///< Maximum length of unwrapped text line. + int max_width = 0; ///< Maximum length of unwrapped text line. + size_t num_lines = 0; ///< Number of lines of text, taking account of wrapping. - uint ReflowContent(); - uint GetContentHeight(); - void SetupScrollbars(bool force_reflow); + using LineIterator = std::vector::iterator; + using ReflowIterator = AlternatingIterator; + + ReflowIterator reflow_iter; ///< Current iterator for reflow. + ReflowIterator reflow_end; ///< End iterator for reflow. + + LineIterator visible_first; ///< Iterator to first visible element. + LineIterator visible_last; ///< Iterator to last visible element. + + enum class ReflowState : uint8_t { + None, ///< Nothing has been reflowed. + Reflowed, ///< Content has been reflowed. + VisibleReflowed, ///< Visible content has been reflowed. + }; + + std::vector::iterator GetIteratorFromPosition(int pos); + void UpdateVisibleIterators(); + void ReflowContent(); + ReflowState ContinueReflow(); + void SetupScrollbars(); const Hyperlink *GetHyperlink(Point pt) const; void AfterLoadMarkdown(); diff --git a/src/window.cpp b/src/window.cpp index 094df6190e..7ae5624fe8 100644 --- a/src/window.cpp +++ b/src/window.cpp @@ -3042,11 +3042,20 @@ void InputLoop() HandleMouseEvents(); } +static std::chrono::time_point _realtime_tick_start; + +bool CanContinueRealtimeTick() +{ + auto now = std::chrono::steady_clock::now(); + return std::chrono::duration_cast(now - _realtime_tick_start).count() < (MILLISECONDS_PER_TICK * 3 / 4); +} + /** * Dispatch OnRealtimeTick event over all windows */ void CallWindowRealtimeTickEvent(uint delta_ms) { + _realtime_tick_start = std::chrono::steady_clock::now(); for (Window *w : Window::Iterate()) { w->OnRealtimeTick(delta_ms); }