diff --git a/docs/gui/journal.rst b/docs/gui/journal.rst index b8ef5b1c5..ba3619d2e 100644 --- a/docs/gui/journal.rst +++ b/docs/gui/journal.rst @@ -20,7 +20,7 @@ Supported Features ------------------ - Cursor Control: Navigate through text using arrow keys (left, right, up, down) for precise cursor placement. -- Fast Rewind: Use :kbd:`Shift` + :kbd:`Left` / :kbd:`Ctrl` + :kbd:`B` and :kbd:`Shift` + :kbd:`Right` / :kbd:`Ctrl` + :kbd:`F` to move the cursor one word back or forward. +- Fast Rewind: Use :kbd:`Ctrl` + :kbd:`Left` / :kbd:`Ctrl` + :kbd:`B` and :kbd:`Ctrl` + :kbd:`Right` / :kbd:`Ctrl` + :kbd:`F` to move the cursor one word back or forward. - Longest X Position Memory: The cursor remembers the longest x position when moving up or down, making vertical navigation more intuitive. - Mouse Control: Use the mouse to position the cursor within the text, providing an alternative to keyboard navigation. - New Lines: Easily insert new lines using the :kbd:`Enter` key, supporting multiline text input. @@ -35,6 +35,7 @@ Supported Features - Jump to Beginning/End: Quickly move the cursor to the beginning or end of the text using :kbd:`Shift` + :kbd:`Up` and :kbd:`Shift` + :kbd:`Down`. - Select Word/Line: Use double click to select current word, or triple click to select current line - Select All: Select entire text by :kbd:`Ctrl` + :kbd:`A` +- Undo/Redo: Undo/Redo changes by :kbd:`Ctrl` + :kbd:`Z` / :kbd:`Ctrl` + :kbd:`Y` - Clipboard Operations: Perform OS clipboard cut, copy, and paste operations on selected text, allowing you to paste the copied content into other applications. - Copy Text: Use :kbd:`Ctrl` + :kbd:`C` to copy selected text. - copy selected text, if available @@ -46,6 +47,8 @@ Supported Features - replace selected text, if available - If no text is selected, paste text in the cursor position - Scrolling behaviour for long text build-in +- Table of contents (:kbd:`Ctrl` + :kbd:`O`), with headers line prefixed by '#', e.g. '# Fort history', '## Year 1' +- Table of contents navigation: jump to previous/next section by :kbd:`Ctrl` + :kbd:`Up` / :kbd:`Ctrl` + :kbd:`Down` Usage ----- diff --git a/gui/journal.lua b/gui/journal.lua index 32c74dabb..9f43e16f7 100644 --- a/gui/journal.lua +++ b/gui/journal.lua @@ -6,6 +6,8 @@ local widgets = require 'gui.widgets' local utils = require 'utils' local json = require 'json' local text_editor = reqscript('internal/journal/text_editor') +local shifter = reqscript('internal/journal/shifter') +local table_of_contents = reqscript('internal/journal/table_of_contents') local RESIZE_MIN = {w=32, h=10} @@ -18,12 +20,98 @@ JournalWindow.ATTRS { frame_title='DF Journal', resizable=true, resize_min=RESIZE_MIN, - frame_inset=0 + frame_inset={l=0,r=0,t=0,b=0}, + init_text=DEFAULT_NIL, + init_cursor=1, + save_layout=true, + + on_text_change=DEFAULT_NIL, + on_cursor_change=DEFAULT_NIL, + on_layout_change=DEFAULT_NIL } function JournalWindow:init() - local config_frame = copyall(journal_config.data.frame or {}) - self.frame = self:sanitizeFrame(config_frame) + local frame, toc_visible, toc_width = self:loadConfig() + + self.frame = frame and self:sanitizeFrame(frame) or self.frame + + self:addviews({ + table_of_contents.TableOfContents{ + view_id='table_of_contents_panel', + frame={l=0, w=toc_width, t=0, b=1}, + visible=toc_visible, + frame_inset={l=1, t=0, b=1, r=1}, + + resize_min={w=20}, + resizable=true, + resize_anchors={l=false, t=false, b=true, r=true}, + + on_resize_begin=self:callback('onPanelResizeBegin'), + on_resize_end=self:callback('onPanelResizeEnd'), + + on_submit=self:callback('onTableOfContentsSubmit') + }, + shifter.Shifter{ + view_id='shifter', + frame={l=0, w=1, t=1, b=2}, + collapsed=not toc_visible, + on_changed = function (collapsed) + self.subviews.table_of_contents_panel.visible = not collapsed + self.subviews.table_of_contents_divider.visible = not collapsed + + if not colllapsed then + self.subviews.table_of_contents_panel:reload( + self.subviews.journal_editor:getText(), + self.subviews.journal_editor:getCursor() + ) + end + + self:ensurePanelsRelSize() + self:updateLayout() + end, + }, + widgets.Divider{ + frame={l=0,r=0,b=2,h=1}, + frame_style_l=false, + frame_style_r=false, + interior_l=true, + }, + widgets.Divider{ + view_id='table_of_contents_divider', + + frame={l=30,t=0,b=2,w=1}, + visible=toc_visible, + + interior_b=true, + frame_style_t=false, + }, + text_editor.TextEditor{ + view_id='journal_editor', + frame={t=1, b=3, l=25, r=0}, + resize_min={w=30, h=10}, + frame_inset={l=1,r=0}, + init_text=self.init_text, + init_cursor=self.init_cursor, + on_text_change=self:callback('onTextChange'), + on_cursor_change=self:callback('onCursorChange'), + }, + widgets.Panel{ + frame={l=0,r=0,b=1,h=1}, + frame_inset={l=1,r=1,t=0, w=100}, + subviews={ + widgets.HotkeyLabel{ + key='CUSTOM_CTRL_O', + label='Table of Contents', + on_activate=function() self.subviews.shifter:toggle() end + } + } + } + }) + + self.subviews.table_of_contents_panel:reload( + self.init_text, + self.subviews.journal_editor:getCursor() or self.init_cursor + ) end function JournalWindow:sanitizeFrame(frame) @@ -48,56 +136,158 @@ function JournalWindow:sanitizeFrame(frame) return frame end -function JournalWindow:postUpdateLayout() - self:saveConfig() -end - function JournalWindow:saveConfig() + if not self.save_layout then + return + end + + local toc = self.subviews.table_of_contents_panel + utils.assign(journal_config.data, { - frame = self.frame + frame = self.frame, + toc = { + width = toc.frame.w, + visible = toc.visible + } }) journal_config:write() end +function JournalWindow:loadConfig() + if not self.save_layout then + return nil, false, 25 + end + + local window_frame = copyall(journal_config.data.frame or {}) + window_frame.w = window_frame.w or 80 + window_frame.h = window_frame.h or 50 + + local table_of_contents = copyall(journal_config.data.toc or {}) + table_of_contents.width = table_of_contents.width or 20 + table_of_contents.visible = table_of_contents.visible or false + + return window_frame, table_of_contents.visible or false, table_of_contents.width or 25 +end + +function JournalWindow:onPanelResizeBegin() + self.resizing_panels = true +end + +function JournalWindow:onPanelResizeEnd() + self.resizing_panels = false + self:ensurePanelsRelSize() + + self:updateLayout() +end + +function JournalWindow:onRenderBody(painter) + if self.resizing_panels then + self:ensurePanelsRelSize() + self:updateLayout() + end + + return JournalWindow.super.onRenderBody(self, painter) +end + +function JournalWindow:ensurePanelsRelSize() + local toc_panel = self.subviews.table_of_contents_panel + local editor = self.subviews.journal_editor + local divider = self.subviews.table_of_contents_divider + + toc_panel.frame.w = math.min( + math.max(toc_panel.frame.w, toc_panel.resize_min.w), + self.frame.w - editor.resize_min.w + ) + editor.frame.l = toc_panel.visible and toc_panel.frame.w or 1 + divider.frame.l = editor.frame.l - 1 +end + +function JournalWindow:preUpdateLayout() + self:ensurePanelsRelSize() +end + +function JournalWindow:postUpdateLayout() + self:saveConfig() +end + +function JournalWindow:onCursorChange(cursor) + self.subviews.table_of_contents_panel:setCursor(cursor) + local section_index = self.subviews.table_of_contents_panel:currentSection() + self.subviews.table_of_contents_panel:setSelectedSection(section_index) + + if self.on_cursor_change ~= nil then + self.on_cursor_change(cursor) + end +end + +function JournalWindow:onTextChange(text) + self.subviews.table_of_contents_panel:reload( + text, + self.subviews.journal_editor:getCursor() + ) + + if self.on_text_change ~= nil then + self.on_text_change(text) + end +end + +function JournalWindow:onTableOfContentsSubmit(ind, section) + self.subviews.journal_editor:setCursor(section.line_cursor) + self.subviews.journal_editor:scrollToCursor(section.line_cursor) +end + JournalScreen = defclass(JournalScreen, gui.ZScreen) JournalScreen.ATTRS { focus_path='journal', - save_on_change=true + save_on_change=true, + save_layout=true, + save_prefix='' } -function JournalScreen:init(options) - local content = self:loadContextContent() +function JournalScreen:init() + local context = self:loadContext() self:addviews{ JournalWindow{ view_id='journal_window', - frame_title='DF Journal', frame={w=65, h=45}, + resize_min={w=50, h=20}, resizable=true, - resize_min={w=32, h=10}, - frame_inset=0, - subviews={ - text_editor.TextEditor{ - view_id='journal_editor', - frame={l=1, t=1, b=1, r=0}, - text=content, - on_change=function(text) self:saveContextContent(text) end - } - } - } + + save_layout=self.save_layout, + + init_text=context.text[1], + init_cursor=context.cursor[1], + + on_text_change=self:callback('saveContext'), + on_cursor_change=self:callback('saveContext') + }, } end -function JournalScreen:loadContextContent() - local site_data = dfhack.persistent.getSiteData(JOURNAL_PERSIST_KEY) or { - text = {''} - } - return site_data.text ~= nil and site_data.text[1] or '' +function JournalScreen:loadContext() + local site_data = dfhack.persistent.getSiteData( + self.save_prefix .. JOURNAL_PERSIST_KEY + ) or {} + site_data.text = site_data.text or {''} + site_data.cursor = site_data.cursor or {#site_data.text[1] + 1} + + return site_data end -function JournalScreen:saveContextContent(text) +function JournalScreen:onTextChange(text) + self:saveContext(text) +end + +function JournalScreen:saveContext() if self.save_on_change and dfhack.isWorldLoaded() then - dfhack.persistent.saveSiteData(JOURNAL_PERSIST_KEY, {text={text}}) + local text = self.subviews.journal_editor:getText() + local cursor = self.subviews.journal_editor:getCursor() + + dfhack.persistent.saveSiteData( + self.save_prefix .. JOURNAL_PERSIST_KEY, + {text={text}, cursor={cursor}} + ) end end @@ -105,12 +295,19 @@ function JournalScreen:onDismiss() view = nil end -function main() +function main(options) if not dfhack.isMapLoaded() or not dfhack.world.isFortressMode() then qerror('journal requires a fortress map to be loaded') end - view = view and view:raise() or JournalScreen{}:show() + local save_layout = options and options.save_layout + local save_on_change = options and options.save_on_change + + view = view and view:raise() or JournalScreen{ + save_prefix=options and options.save_prefix or '', + save_layout=save_layout == nil and true or save_layout, + save_on_change=save_on_change == nil and true or save_on_change, + }:show() end if not dfhack_flags.module then diff --git a/internal/journal/shifter.lua b/internal/journal/shifter.lua new file mode 100644 index 000000000..1c4aefea4 --- /dev/null +++ b/internal/journal/shifter.lua @@ -0,0 +1,55 @@ +-- >> / << toggle button +--@ module = true + +local widgets = require 'gui.widgets' + +local TO_THE_RIGHT = string.char(16) +local TO_THE_LEFT = string.char(17) + +function get_shifter_text(state) + local ch = state and TO_THE_RIGHT or TO_THE_LEFT + return { + ' ', NEWLINE, + ch, NEWLINE, + ch, NEWLINE, + ' ', NEWLINE, + } +end + +Shifter = defclass(Shifter, widgets.Widget) +Shifter.ATTRS { + frame={l=0, w=1, t=0, b=0}, + collapsed=false, + on_changed=DEFAULT_NIL, +} + +function Shifter:init() + self:addviews{ + widgets.Label{ + view_id='shifter_label', + frame={l=0, r=0, t=0, b=0}, + text=get_shifter_text(self.collapsed), + on_click=function () + self:toggle(not self.collapsed) + end + } + } +end + +function Shifter:toggle(state) + if state == nil then + self.collapsed = not self.collapsed + else + self.collapsed = state + end + + self.subviews.shifter_label:setText( + get_shifter_text(self.collapsed) + ) + + self:updateLayout() + + if self.on_changed then + self.on_changed(self.collapsed) + end +end diff --git a/internal/journal/table_of_contents.lua b/internal/journal/table_of_contents.lua new file mode 100644 index 000000000..8365564a6 --- /dev/null +++ b/internal/journal/table_of_contents.lua @@ -0,0 +1,123 @@ +--@ module = true + +local gui = require 'gui' +local widgets = require 'gui.widgets' + +local df_major_version = tonumber(dfhack.getCompiledDFVersion():match('%d+')) + +local INVISIBLE_FRAME = { + frame_pen=gui.CLEAR_PEN, + signature_pen=false, +} + +TableOfContents = defclass(TableOfContents, widgets.Panel) +TableOfContents.ATTRS { + frame_style=INVISIBLE_FRAME, + frame_background = gui.CLEAR_PEN, + on_submit=DEFAULT_NIL, + text_cursor=DEFAULT_NIL +} + +function TableOfContents:init() + self:addviews{ + widgets.List{ + frame={l=0, t=0, r=0, b=3}, + view_id='table_of_contents', + choices={}, + on_submit=self.on_submit + }, + } + + if df_major_version < 51 then + -- widgets below this line require DF 51 + -- TODO: remove this check once DF 51 is stable and DFHack is no longer + -- releasing new versions for DF 50 + return + end + + self:addviews{ + widgets.HotkeyLabel{ + frame={b=0}, + key='A_MOVE_N_DOWN', + label='Previous Section', + on_activate=self:callback('previousSection'), + }, + widgets.HotkeyLabel{ + frame={b=1}, + key='A_MOVE_S_DOWN', + label='Next Section', + on_activate=self:callback('nextSection'), + } + } +end + +function TableOfContents:previousSection() + local section_cursor, section = self:currentSection() + + if section.line_cursor == self.text_cursor then + self.subviews.table_of_contents:setSelected(section_cursor - 1) + end + + self.subviews.table_of_contents:submit() +end + +function TableOfContents:nextSection() + local curr_sel = self.subviews.table_of_contents:getSelected() + + local target_sel = self.text_cursor and + self:currentSection() + 1 or curr_sel + 1 + + if curr_sel ~= target_sel then + self.subviews.table_of_contents:setSelected(target_sel) + self.subviews.table_of_contents:submit() + end +end + +function TableOfContents:setSelectedSection(section_index) + local curr_sel = self.subviews.table_of_contents:getSelected() + + if curr_sel ~= section_index then + self.subviews.table_of_contents:setSelected(section_index) + end +end + +function TableOfContents:currentSection() + local section_ind = nil + + for ind, choice in ipairs(self.subviews.table_of_contents.choices) do + if choice.line_cursor > self.text_cursor then + break + end + section_ind = ind + end + + return section_ind, self.subviews.table_of_contents.choices[section_ind] +end + +function TableOfContents:setCursor(cursor) + self.text_cursor = cursor +end + +function TableOfContents:reload(text, cursor) + if not self.visible then + return + end + + local sections = {} + + local line_cursor = 1 + for line in text:gmatch("[^\n]*") do + local header, section = line:match("^(#+)%s(.+)") + if header ~= nil then + table.insert(sections, { + line_cursor=line_cursor, + text=string.rep(" ", #header - 1) .. section, + }) + end + + line_cursor = line_cursor + #line + 1 + end + + self.text_cursor = cursor + self.subviews.table_of_contents:setChoices(sections) +end diff --git a/internal/journal/text_editor.lua b/internal/journal/text_editor.lua index 1202b233d..8bdd0aeb7 100644 --- a/internal/journal/text_editor.lua +++ b/internal/journal/text_editor.lua @@ -6,69 +6,184 @@ local widgets = require 'gui.widgets' local wrapped_text = reqscript('internal/journal/wrapped_text') local CLIPBOARD_MODE = {LOCAL = 1, LINE = 2} +local HISTORY_ENTRY = { + TEXT_BLOCK = 1, + WHITESPACE_BLOCK = 2, + BACKSPACE = 2, + DELETE = 3, + OTHER = 4 +} + +TextEditorHistory = defclass(TextEditorHistory) + +TextEditorHistory.ATTRS{ + history_size = 25, +} + +function TextEditorHistory:init() + self.past = {} + self.future = {} +end + +function TextEditorHistory:store(history_entry_type, text, cursor) + local last_entry = self.past[#self.past] + + if not last_entry or history_entry_type == HISTORY_ENTRY.OTHER or + last_entry.entry_type ~= history_entry_type then + table.insert(self.past, { + entry_type=history_entry_type, + text=text, + cursor=cursor + }) + end + + self.future = {} + + if #self.past > self.history_size then + table.remove(self.past, 1) + end +end + +function TextEditorHistory:undo(curr_text, curr_cursor) + if #self.past == 0 then + return nil + end + + local history_entry = table.remove(self.past, #self.past) + + table.insert(self.future, { + entry_type=OTHER, + text=curr_text, + cursor=curr_cursor + }) + + if #self.future > self.history_size then + table.remove(self.future, 1) + end + + return history_entry +end + +function TextEditorHistory:redo(curr_text, curr_cursor) + if #self.future == 0 then + return true + end + + local history_entry = table.remove(self.future, #self.future) -TextEditor = defclass(TextEditor, widgets.Widget) + table.insert(self.past, { + entry_type=OTHER, + text=curr_text, + cursor=curr_cursor + }) + + if #self.past > self.history_size then + table.remove(self.past, 1) + end + + return history_entry +end + +TextEditor = defclass(TextEditor, widgets.Panel) TextEditor.ATTRS{ - text = '', + init_text = '', + init_cursor = DEFAULT_NIL, text_pen = COLOR_LIGHTCYAN, ignore_keys = {'STRING_A096'}, select_pen = COLOR_CYAN, - on_change = DEFAULT_NIL, + on_text_change = DEFAULT_NIL, + on_cursor_change = DEFAULT_NIL, debug = false } function TextEditor:init() self.render_start_line_y = 1 - self.scrollbar = widgets.Scrollbar{ - view_id='text_area_scrollbar', - frame={r=0,t=1}, - on_scroll=self:callback('onScrollbar') - } - self.editor = TextEditorView{ - view_id='text_area', - frame={l=0,r=3,t=0}, - text = self.text, - text_pen = self.text_pen, - ignore_keys = self.ignore_keys, - select_pen = self.select_pen, - debug = self.debug, - - on_change = function (val) - self:updateLayout() - if self.on_change then - self.on_change(val) - end - end, - - on_cursor_change = function () - local x, y = self.editor.wrapped_text:indexToCoords(self.editor.cursor) - if (y >= self.render_start_line_y + self.editor.frame_body.height) then - self:setRenderStartLineY(y - self.editor.frame_body.height + 1) - elseif (y < self.render_start_line_y) then - self:setRenderStartLineY(y) - end - end - } self:addviews{ - self.editor, - self.scrollbar, + TextEditorView{ + view_id='text_area', + frame={l=0,r=3,t=0}, + text = self.init_text, + + text_pen = self.text_pen, + ignore_keys = self.ignore_keys, + select_pen = self.select_pen, + debug = self.debug, + + on_text_change = function (val) + self:updateLayout() + if self.on_text_change then + self.on_text_change(val) + end + end, + on_cursor_change = self:callback('onCursorChange') + }, + widgets.Scrollbar{ + view_id='scrollbar', + frame={r=0,t=1}, + on_scroll=self:callback('onScrollbar') + }, widgets.HelpButton{command="gui/journal", frame={r=0,t=0}} } self:setFocus(true) end +function TextEditor:getText() + return self.subviews.text_area.text +end + +function TextEditor:getCursor() + return self.subviews.text_area.cursor +end + +function TextEditor:onCursorChange(cursor) + local x, y = self.subviews.text_area.wrapped_text:indexToCoords( + self.subviews.text_area.cursor + ) + + if y >= self.render_start_line_y + self.subviews.text_area.frame_body.height then + self:updateScrollbar( + y - self.subviews.text_area.frame_body.height + 1 + ) + elseif (y < self.render_start_line_y) then + self:updateScrollbar(y) + end + + if self.on_cursor_change then + self.on_cursor_change(cursor) + end +end + +function TextEditor:scrollToCursor(cursor_offset) + if self.subviews.scrollbar.visible then + local _, cursor_liny_y = self.subviews.text_area.wrapped_text:indexToCoords( + cursor_offset + ) + self:updateScrollbar(cursor_liny_y) + end +end + +function TextEditor:setCursor(cursor_offset) + return self.subviews.text_area:setCursor(cursor_offset) +end + function TextEditor:getPreferredFocusState() return true end function TextEditor:postUpdateLayout() - self:updateScrollbar() + self:updateScrollbar(self.render_start_line_y) + + if self.subviews.text_area.cursor == nil then + local cursor = self.init_cursor or #self.text + 1 + self.subviews.text_area:setCursor(cursor) + self:scrollToCursor(cursor) + end end function TextEditor:onScrollbar(scroll_spec) - local height = self.editor.frame_body.height + local height = self.subviews.text_area.frame_body.height local render_start_line = self.render_start_line_y if scroll_spec == 'down_large' then @@ -83,48 +198,45 @@ function TextEditor:onScrollbar(scroll_spec) render_start_line = tonumber(scroll_spec) end - self:setRenderStartLineY(math.min( - #self.editor.wrapped_text.lines - height + 1, - math.max(1, render_start_line) - )) - self:updateScrollbar() + self:updateScrollbar(render_start_line) end -function TextEditor:updateScrollbar() - local lines_count = #self.editor.wrapped_text.lines +function TextEditor:updateScrollbar(scrollbar_current_y) + local lines_count = #self.subviews.text_area.wrapped_text.lines - self.scrollbar:update( - self.render_start_line_y, + local render_start_line_y = (math.min( + #self.subviews.text_area.wrapped_text.lines - self.subviews.text_area.frame_body.height + 1, + math.max(1, scrollbar_current_y) + )) + + self.subviews.scrollbar:update( + render_start_line_y, self.frame_body.height, lines_count ) if (self.frame_body.height >= lines_count) then - self:setRenderStartLineY(1) + render_start_line_y = 1 end + + self.render_start_line_y = render_start_line_y + self.subviews.text_area:setRenderStartLineY(self.render_start_line_y) end function TextEditor:renderSubviews(dc) - self.editor.frame_body.y1 = self.frame_body.y1-(self.render_start_line_y - 1) + self.subviews.text_area.frame_body.y1 = self.frame_body.y1-(self.render_start_line_y - 1) TextEditor.super.renderSubviews(self, dc) end function TextEditor:onInput(keys) - if (self.scrollbar.is_dragging) then - return self.scrollbar:onInput(keys) + if (self.subviews.scrollbar.is_dragging) then + return self.subviews.scrollbar:onInput(keys) end return TextEditor.super.onInput(self, keys) end - -function TextEditor:setRenderStartLineY(render_start_line_y) - self.render_start_line_y = render_start_line_y - self.editor:setRenderStartLineY(render_start_line_y) -end - - TextEditorView = defclass(TextEditorView, widgets.Widget) TextEditorView.ATTRS{ @@ -132,10 +244,11 @@ TextEditorView.ATTRS{ text_pen = COLOR_LIGHTCYAN, ignore_keys = {'STRING_A096'}, pen_selection = COLOR_CYAN, - on_change = DEFAULT_NIL, + on_text_change = DEFAULT_NIL, on_cursor_change = DEFAULT_NIL, enable_cursor_blink = true, - debug = false + debug = false, + history_size = 10, } function TextEditorView:init() @@ -143,7 +256,8 @@ function TextEditorView:init() self.clipboard = nil self.clipboard_mode = CLIPBOARD_MODE.LOCAL self.render_start_line_y = 1 - self.cursor = #self.text + 1 + + self.cursor = nil self.main_pen = dfhack.pen.parse({ fg=self.text_pen, @@ -160,6 +274,8 @@ function TextEditorView:init() text=self.text, wrap_width=256 } + + self.history = TextEditorHistory{history_size=self.history_size} end function TextEditorView:setRenderStartLineY(render_start_line_y) @@ -196,7 +312,7 @@ function TextEditorView:setCursor(cursor_offset) self.last_cursor_x = nil if self.on_cursor_change then - self.on_cursor_change() + self.on_cursor_change(self.cursor) end end @@ -295,8 +411,8 @@ function TextEditorView:setText(text) self:recomputeLines() - if changed and self.on_change then - self.on_change(text) + if changed and self.on_text_change then + self.on_text_change(text) end end @@ -382,7 +498,7 @@ function TextEditorView:onRenderBody(dc) local cursor_char = self:charAtCursor() local x, y = self.wrapped_text:indexToCoords(self.cursor) local debug_msg = string.format( - 'x: %s y: %s ind: %s #line: %s char: %s', + 'x: %s y: %s ind: %s #line: %s char: %s hist-: %s hist+: %s', x, y, self.cursor, @@ -390,7 +506,9 @@ function TextEditorView:onRenderBody(dc) (cursor_char == NEWLINE and 'NEWLINE') or (cursor_char == ' ' and 'SPACE') or (cursor_char == '' and 'nil') or - cursor_char + cursor_char, + #self.history.past, + #self.history.future ) local sel_debug_msg = self.sel_end and string.format( 'sel_end: %s', @@ -494,18 +612,44 @@ function TextEditorView:onInput(keys) if self:onMouseInput(keys) then return true - elseif self:onCursorInput(keys) then + elseif self:onHistoryInput(keys) then return true elseif self:onTextManipulationInput(keys) then return true + elseif self:onCursorInput(keys) then + return true elseif keys.CUSTOM_CTRL_C then self:copy() return true elseif keys.CUSTOM_CTRL_X then self:cut() + self.history:store(HISTORY_ENTRY.OTHER, self.text, self.cursor) return true elseif keys.CUSTOM_CTRL_V then self:paste() + self.history:store(HISTORY_ENTRY.OTHER, self.text, self.cursor) + return true + end +end + +function TextEditorView:onHistoryInput(keys) + if keys.CUSTOM_CTRL_Z then + local history_entry = self.history:undo(self.text, self.cursor) + + if history_entry then + self:setText(history_entry.text) + self:setCursor(history_entry.cursor) + end + + return true + elseif keys.CUSTOM_CTRL_Y then + local history_entry = self.history:redo(self.text, self.cursor) + + if history_entry then + self:setText(history_entry.text) + self:setCursor(history_entry.cursor) + end + return true end end @@ -605,12 +749,12 @@ function TextEditorView:onCursorInput(keys) -- go to text end self:setCursor(#self.text + 1) return true - elseif keys.CUSTOM_CTRL_B or keys.KEYBOARD_CURSOR_LEFT_FAST then + elseif keys.CUSTOM_CTRL_B or keys.A_MOVE_W_DOWN then -- back one word local word_start = self:wordStartOffset() self:setCursor(word_start) return true - elseif keys.CUSTOM_CTRL_F or keys.KEYBOARD_CURSOR_RIGHT_FAST then + elseif keys.CUSTOM_CTRL_F or keys.A_MOVE_E_DOWN then -- forward one word local word_end = self:wordEndOffset() self:setCursor(word_end) @@ -633,12 +777,20 @@ end function TextEditorView:onTextManipulationInput(keys) if keys.SELECT then -- handle enter + self.history:store( + HISTORY_ENTRY.WHITESPACE_BLOCK, + self.text, + self.cursor + ) self:insert(NEWLINE) + return true elseif keys._STRING then if keys._STRING == 0 then -- handle backspace + self.history:store(HISTORY_ENTRY.BACKSPACE, self.text, self.cursor) + if (self:hasSelection()) then self:eraseSelection() else @@ -652,12 +804,19 @@ function TextEditorView:onTextManipulationInput(keys) ) self:eraseSelection() end + else + local cv = string.char(keys._STRING) + if (self:hasSelection()) then + self.history:store(HISTORY_ENTRY.OTHER, self.text, self.cursor) self:eraseSelection() + else + local entry_type = cv == ' ' and HISTORY_ENTRY.WHITESPACE_BLOCK + or HISTORY_ENTRY.TEXT_BLOCK + self.history:store(entry_type, self.text, self.cursor) end - local cv = string.char(keys._STRING) self:insert(cv) end @@ -668,6 +827,8 @@ function TextEditorView:onTextManipulationInput(keys) return true elseif keys.CUSTOM_CTRL_U then -- delete current line + self.history:store(HISTORY_ENTRY.OTHER, self.text, self.cursor) + if (self:hasSelection()) then -- delete all lines that has selection self:setSelection( @@ -682,18 +843,24 @@ function TextEditorView:onTextManipulationInput(keys) ) self:eraseSelection() end + return true elseif keys.CUSTOM_CTRL_K then -- delete from cursor to end of current line + self.history:store(HISTORY_ENTRY.OTHER, self.text, self.cursor) + local line_end = self:lineEndOffset(self.sel_end or self.cursor) - 1 self:setSelection( self.cursor, math.max(line_end, self.cursor) ) self:eraseSelection() + return true elseif keys.CUSTOM_CTRL_D then -- delete char, there is no support for `Delete` key + self.history:store(HISTORY_ENTRY.DELETE, self.text, self.cursor) + if (self:hasSelection()) then self:eraseSelection() else @@ -706,6 +873,8 @@ function TextEditorView:onTextManipulationInput(keys) return true elseif keys.CUSTOM_CTRL_W then -- delete one word backward + self.history:store(HISTORY_ENTRY.OTHER, self.text, self.cursor) + if not self:hasSelection() and self.cursor ~= 1 then self:setSelection( self:wordStartOffset(), @@ -713,6 +882,7 @@ function TextEditorView:onTextManipulationInput(keys) ) end self:eraseSelection() + return true end end diff --git a/test/gui/journal.lua b/test/gui/journal.lua index 265fd8305..7a2674a20 100644 --- a/test/gui/journal.lua +++ b/test/gui/journal.lua @@ -6,6 +6,8 @@ config = { mode = 'fortress' } +local df_major_version = tonumber(dfhack.getCompiledDFVersion():match('%d+')) + local function simulate_input_keys(...) local keys = {...} for _,key in ipairs(keys) do @@ -49,14 +51,13 @@ local function simulate_mouse_click(element, x, y) gui_journal.view:onRender() end -local function simulate_mouse_drag(text_area, x_from, y_from, x_to, y_to) - local g_x_from, g_y_from = text_area.frame_body:globalXY(x_from, y_from) - local g_x_to, g_y_to = text_area.frame_body:globalXY(x_to, y_to) +local function simulate_mouse_drag(element, x_from, y_from, x_to, y_to) + local g_x_from, g_y_from = element.frame_body:globalXY(x_from, y_from) + local g_x_to, g_y_to = element.frame_body:globalXY(x_to, y_to) df.global.gps.mouse_x = g_x_from df.global.gps.mouse_y = g_y_from - gui.simulateInput(dfhack.gui.getCurViewscreen(true), { _MOUSE_L=true, _MOUSE_L_DOWN=true, @@ -73,35 +74,45 @@ end local function arrange_empty_journal(options) options = options or {} - gui_journal.main() - local journal = gui_journal.view - journal.save_on_change = options.save_on_change or false + gui_journal.main({ + save_prefix='test:', + save_on_change=options.save_on_change or false, + save_layout=options.allow_layout_restore or false + }) + local journal = gui_journal.view local journal_window = journal.subviews.journal_window - if not options.allow_size_restore then - journal_window.frame.w = 50 - journal_window.frame.h = 50 + if not options.allow_layout_restore then + journal_window.frame= {w = 50, h = 50} end if options.w then - journal_window.frame.w = options.w + 7 + journal_window.frame.w = options.w + 8 end if options.h then - journal_window.frame.h = options.h + 4 + journal_window.frame.h = options.h + 6 end - journal:updateLayout() local text_area = journal_window.subviews.text_area text_area.enable_cursor_blink = false - text_area:setText('') + if not options.save_on_change then + text_area:setText('') + end + + if not options.allow_layout_restore then + local toc_panel = journal_window.subviews.table_of_contents_panel + toc_panel.visible = false + toc_panel.frame.w = 25 + end + journal:updateLayout() journal:onRender() - return journal, text_area + return journal, text_area, journal_window end local function read_rendered_text(text_area) @@ -165,7 +176,7 @@ function test.load() end function test.load_input_multiline_text() - local journal, text_area = arrange_empty_journal({w=80}) + local journal, text_area, journal_window = arrange_empty_journal({w=80}) local text = table.concat({ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', @@ -179,6 +190,51 @@ function test.load_input_multiline_text() journal:dismiss() end +function test.handle_numpad_numbers_as_text() + local journal, text_area, journal_window = arrange_empty_journal({w=80}) + + local text = table.concat({ + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + simulate_input_text(text) + + simulate_input_keys({ + STANDARDSCROLL_LEFT = true, + KEYBOARD_CURSOR_LEFT = true, + _STRING = 52, + STRING_A052 = true, + }) + + expect.eq(read_rendered_text(text_area), text .. '4_') + + simulate_input_keys({ + STRING_A054 = true, + STANDARDSCROLL_RIGHT = true, + KEYBOARD_CURSOR_RIGHT = true, + _STRING = 54, + }) + expect.eq(read_rendered_text(text_area), text .. '46_') + + simulate_input_keys({ + KEYBOARD_CURSOR_DOWN = true, + STRING_A050 = true, + _STRING = 50, + STANDARDSCROLL_DOWN = true, + }) + + expect.eq(read_rendered_text(text_area), text .. '462_') + + simulate_input_keys({ + KEYBOARD_CURSOR_UP = true, + STRING_A056 = true, + STANDARDSCROLL_UP = true, + _STRING = 56, + }) + + expect.eq(read_rendered_text(text_area), text .. '4628_') + journal:dismiss() +end + function test.wrap_text_to_available_width() local journal, text_area = arrange_empty_journal({w=55}) @@ -633,172 +689,6 @@ function test.keyboard_arrow_right_navigation() journal:dismiss() end -function test.fast_rewind_words_right() - local journal, text_area = arrange_empty_journal({w=55}) - - local text = table.concat({ - '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', - '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', - }, '\n') - - simulate_input_text(text) - text_area:setCursor(1) - journal:onRender() - - simulate_input_keys('KEYBOARD_CURSOR_RIGHT_FAST') - - expect.eq(read_rendered_text(text_area), table.concat({ - '60:_Lorem ipsum dolor sit amet, consectetur adipiscing ', - 'elit.', - '112: Sed consectetur, urna sit amet aliquet egestas, ', - 'ante nibh porttitor mi, vitae rutrum eros metus nec ', - 'libero.', - }, '\n')); - - simulate_input_keys('KEYBOARD_CURSOR_RIGHT_FAST') - - expect.eq(read_rendered_text(text_area), table.concat({ - '60: Lorem_ipsum dolor sit amet, consectetur adipiscing ', - 'elit.', - '112: Sed consectetur, urna sit amet aliquet egestas, ', - 'ante nibh porttitor mi, vitae rutrum eros metus nec ', - 'libero.', - }, '\n')); - - for i=1,6 do - simulate_input_keys('KEYBOARD_CURSOR_RIGHT_FAST') - end - - expect.eq(read_rendered_text(text_area), table.concat({ - '60: Lorem ipsum dolor sit amet, consectetur adipiscing_', - 'elit.', - '112: Sed consectetur, urna sit amet aliquet egestas, ', - 'ante nibh porttitor mi, vitae rutrum eros metus nec ', - 'libero.', - }, '\n')); - - simulate_input_keys('KEYBOARD_CURSOR_RIGHT_FAST') - - expect.eq(read_rendered_text(text_area), table.concat({ - '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', - 'elit._', - '112: Sed consectetur, urna sit amet aliquet egestas, ', - 'ante nibh porttitor mi, vitae rutrum eros metus nec ', - 'libero.', - }, '\n')); - - simulate_input_keys('KEYBOARD_CURSOR_RIGHT_FAST') - - expect.eq(read_rendered_text(text_area), table.concat({ - '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', - 'elit.', - '112:_Sed consectetur, urna sit amet aliquet egestas, ', - 'ante nibh porttitor mi, vitae rutrum eros metus nec ', - 'libero.', - }, '\n')); - - for i=1,17 do - simulate_input_keys('KEYBOARD_CURSOR_RIGHT_FAST') - end - - expect.eq(read_rendered_text(text_area), table.concat({ - '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', - 'elit.', - '112: Sed consectetur, urna sit amet aliquet egestas, ', - 'ante nibh porttitor mi, vitae rutrum eros metus nec ', - 'libero._', - }, '\n')); - - simulate_input_keys('KEYBOARD_CURSOR_RIGHT_FAST') - - expect.eq(read_rendered_text(text_area), table.concat({ - '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', - 'elit.', - '112: Sed consectetur, urna sit amet aliquet egestas, ', - 'ante nibh porttitor mi, vitae rutrum eros metus nec ', - 'libero._', - }, '\n')); - - journal:dismiss() -end - -function test.fast_rewind_words_left() - local journal, text_area = arrange_empty_journal({w=55}) - - local text = table.concat({ - '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', - '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', - }, '\n') - - simulate_input_text(text) - - simulate_input_keys('KEYBOARD_CURSOR_LEFT_FAST') - - expect.eq(read_rendered_text(text_area), table.concat({ - '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', - 'elit.', - '112: Sed consectetur, urna sit amet aliquet egestas, ', - 'ante nibh porttitor mi, vitae rutrum eros metus nec ', - '_ibero.', - }, '\n')); - - simulate_input_keys('KEYBOARD_CURSOR_LEFT_FAST') - - expect.eq(read_rendered_text(text_area), table.concat({ - '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', - 'elit.', - '112: Sed consectetur, urna sit amet aliquet egestas, ', - 'ante nibh porttitor mi, vitae rutrum eros metus _ec ', - 'libero.', - }, '\n')); - - for i=1,8 do - simulate_input_keys('KEYBOARD_CURSOR_LEFT_FAST') - end - - expect.eq(read_rendered_text(text_area), table.concat({ - '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', - 'elit.', - '112: Sed consectetur, urna sit amet aliquet egestas, ', - '_nte nibh porttitor mi, vitae rutrum eros metus nec ', - 'libero.', - }, '\n')); - - simulate_input_keys('KEYBOARD_CURSOR_LEFT_FAST') - - expect.eq(read_rendered_text(text_area), table.concat({ - '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', - 'elit.', - '112: Sed consectetur, urna sit amet aliquet _gestas, ', - 'ante nibh porttitor mi, vitae rutrum eros metus nec ', - 'libero.', - }, '\n')); - - for i=1,16 do - simulate_input_keys('KEYBOARD_CURSOR_LEFT_FAST') - end - - expect.eq(read_rendered_text(text_area), table.concat({ - '_0: Lorem ipsum dolor sit amet, consectetur adipiscing ', - 'elit.', - '112: Sed consectetur, urna sit amet aliquet egestas, ', - 'ante nibh porttitor mi, vitae rutrum eros metus nec ', - 'libero.', - }, '\n')); - - simulate_input_keys('KEYBOARD_CURSOR_LEFT_FAST') - - expect.eq(read_rendered_text(text_area), table.concat({ - '_0: Lorem ipsum dolor sit amet, consectetur adipiscing ', - 'elit.', - '112: Sed consectetur, urna sit amet aliquet egestas, ', - 'ante nibh porttitor mi, vitae rutrum eros metus nec ', - 'libero.', - }, '\n')); - - journal:dismiss() -end - function test.handle_backspace() local journal, text_area = arrange_empty_journal({w=55}) @@ -1404,41 +1294,6 @@ function test.arrows_reset_selection() journal:dismiss() end -function test.fast_rewind_reset_selection() - local journal, text_area = arrange_empty_journal({w=65}) - - local text = table.concat({ - '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', - '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', - }, '\n') - - simulate_input_text(text) - - simulate_input_keys('CUSTOM_CTRL_A') - - expect.eq(read_rendered_text(text_area), table.concat({ - '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', - '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', - 'porttitor mi, vitae rutrum eros metus nec libero.', - }, '\n')); - - expect.eq(read_selected_text(text_area), table.concat({ - '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', - '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', - 'porttitor mi, vitae rutrum eros metus nec libero.', - }, '\n')); - - simulate_input_keys('KEYBOARD_CURSOR_LEFT_FAST') - expect.eq(read_selected_text(text_area), '') - - simulate_input_keys('CUSTOM_CTRL_A') - - simulate_input_keys('KEYBOARD_CURSOR_RIGHT_FAST') - expect.eq(read_selected_text(text_area), '') - - journal:dismiss() -end - function test.click_reset_selection() local journal, text_area = arrange_empty_journal({w=65}) @@ -2350,18 +2205,22 @@ function test.cut_and_paste_selected_text() journal:dismiss() end -function test.restore_size_and_position() - local journal, _ = arrange_empty_journal() +function test.restore_layout() + local journal, _ = arrange_empty_journal({allow_layout_restore=true}) + journal.subviews.journal_window.frame = { l = 13, t = 13, w = 80, h = 23 } + journal.subviews.table_of_contents_panel.frame.w = 37 + journal:updateLayout() + journal:dismiss() - journal, _ = arrange_empty_journal({allow_size_restore=true}) + journal, _ = arrange_empty_journal({allow_layout_restore=true}) expect.eq(journal.subviews.journal_window.frame.l, 13) expect.eq(journal.subviews.journal_window.frame.t, 13) @@ -2371,9 +2230,43 @@ function test.restore_size_and_position() journal:dismiss() end +function test.restore_text_between_sessions() + local journal, text_area = arrange_empty_journal({w=80,save_on_change=true}) + + simulate_input_keys('CUSTOM_CTRL_A') + simulate_input_keys('CUSTOM_CTRL_D') + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas,', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n') + + simulate_input_text(text) + simulate_mouse_click(text_area, 10, 1) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed c_nsectetur, urna sit amet aliquet egestas,', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + journal:dismiss() + + journal, text_area = arrange_empty_journal({w=80, save_on_change=true}) + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed c_nsectetur, urna sit amet aliquet egestas,', + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, '\n')); + + journal:dismiss() +end + function test.scroll_long_text() local journal, text_area = arrange_empty_journal({w=100, h=10}) - local scrollbar = journal.subviews.text_area_scrollbar + local scrollbar = journal.subviews.scrollbar local text = table.concat({ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', @@ -2583,3 +2476,574 @@ function test.scroll_follows_cursor() journal:dismiss() end + +function test.generate_table_of_contents() + local journal, text_area = arrange_empty_journal({w=100, h=10}) + + local text = table.concat({ + '# Header 1', + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + 'Nulla ut lacus ut tortor semper consectetur.', + '# Header 2', + 'Ut eu orci non nibh hendrerit posuere.', + 'Sed euismod odio eu fringilla bibendum.', + '## Subheader 1', + 'Etiam dignissim diam nec aliquet facilisis.', + 'Integer tristique purus at tellus luctus, vel aliquet sapien sollicitudin.', + '## Subheader 2', + 'Fusce ornare est vitae urna feugiat, vel interdum quam vestibulum.', + '10: Vivamus id felis scelerisque, lobortis diam ut, mollis nisi.', + '### Subsubheader 1', + '# Header 3', + 'Donec quis lectus ac erat placerat eleifend.', + 'Aenean non orci id erat malesuada pharetra.', + 'Nunc in lectus et metus finibus venenatis.', + }, '\n') + + simulate_input_text(text) + + expect.eq(journal.subviews.table_of_contents_panel.visible, false) + + simulate_input_keys('CUSTOM_CTRL_O') + + expect.eq(journal.subviews.table_of_contents_panel.visible, true) + + local toc_items = journal.subviews.table_of_contents.choices + + expect.eq(#toc_items, 6) + + local expectChoiceToMatch = function (a, b) + expect.eq(a.line_cursor, b.line_cursor) + expect.eq(a.text, b.text) + end + + expectChoiceToMatch(toc_items[1], {line_cursor=1, text='Header 1'}) + expectChoiceToMatch(toc_items[2], {line_cursor=114, text='Header 2'}) + expectChoiceToMatch(toc_items[3], {line_cursor=204, text=' Subheader 1'}) + expectChoiceToMatch(toc_items[4], {line_cursor=338, text=' Subheader 2'}) + expectChoiceToMatch(toc_items[5], {line_cursor=485, text=' Subsubheader 1'}) + expectChoiceToMatch(toc_items[6], {line_cursor=504, text='Header 3'}) + + journal:dismiss() +end + +function test.jump_to_table_of_contents_sections() + local journal, text_area = arrange_empty_journal({ + w=100, + h=10, + allow_layout_restore=false + }) + + local text = table.concat({ + '# Header 1', + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + 'Nulla ut lacus ut tortor semper consectetur.', + '# Header 2', + 'Ut eu orci non nibh hendrerit posuere.', + 'Sed euismod odio eu fringilla bibendum.', + '## Subheader 1', + 'Etiam dignissim diam nec aliquet facilisis.', + 'Integer tristique purus at tellus luctus, vel aliquet sapien sollicitudin.', + '## Subheader 2', + 'Fusce ornare est vitae urna feugiat, vel interdum quam vestibulum.', + '10: Vivamus id felis scelerisque, lobortis diam ut, mollis nisi.', + '### Subsubheader 1', + '# Header 3', + 'Donec quis lectus ac erat placerat eleifend.', + 'Aenean non orci id erat malesuada pharetra.', + 'Nunc in lectus et metus finibus venenatis.', + }, '\n') + + simulate_input_text(text) + + simulate_input_keys('CUSTOM_CTRL_O') + + local toc = journal.subviews.table_of_contents + + toc:setSelected(1) + toc:submit() + + gui_journal.view:onRender() + + expect.eq(read_rendered_text(text_area), table.concat({ + '_ Header 1', + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + 'Nulla ut lacus ut tortor semper consectetur.', + '# Header 2', + 'Ut eu orci non nibh hendrerit posuere.', + 'Sed euismod odio eu fringilla bibendum.', + '## Subheader 1', + 'Etiam dignissim diam nec aliquet facilisis.', + 'Integer tristique purus at tellus luctus, vel aliquet sapien sollicitudin.', + '## Subheader 2', + }, '\n')) + + toc:setSelected(2) + toc:submit() + + gui_journal.view:onRender() + + expect.eq(read_rendered_text(text_area), table.concat({ + '_ Header 2', + 'Ut eu orci non nibh hendrerit posuere.', + 'Sed euismod odio eu fringilla bibendum.', + '## Subheader 1', + 'Etiam dignissim diam nec aliquet facilisis.', + 'Integer tristique purus at tellus luctus, vel aliquet sapien sollicitudin.', + '## Subheader 2', + 'Fusce ornare est vitae urna feugiat, vel interdum quam vestibulum.', + '10: Vivamus id felis scelerisque, lobortis diam ut, mollis nisi.', + '### Subsubheader 1', + }, '\n')) + + toc:setSelected(3) + toc:submit() + + gui_journal.view:onRender() + + expect.eq(read_rendered_text(text_area), table.concat({ + '_# Subheader 1', + 'Etiam dignissim diam nec aliquet facilisis.', + 'Integer tristique purus at tellus luctus, vel aliquet sapien sollicitudin.', + '## Subheader 2', + 'Fusce ornare est vitae urna feugiat, vel interdum quam vestibulum.', + '10: Vivamus id felis scelerisque, lobortis diam ut, mollis nisi.', + '### Subsubheader 1', + '# Header 3', + 'Donec quis lectus ac erat placerat eleifend.', + 'Aenean non orci id erat malesuada pharetra.', + }, '\n')) + + toc:setSelected(4) + toc:submit() + + gui_journal.view:onRender() + + expect.eq(read_rendered_text(text_area), table.concat({ + 'Etiam dignissim diam nec aliquet facilisis.', + 'Integer tristique purus at tellus luctus, vel aliquet sapien sollicitudin.', + '_# Subheader 2', + 'Fusce ornare est vitae urna feugiat, vel interdum quam vestibulum.', + '10: Vivamus id felis scelerisque, lobortis diam ut, mollis nisi.', + '### Subsubheader 1', + '# Header 3', + 'Donec quis lectus ac erat placerat eleifend.', + 'Aenean non orci id erat malesuada pharetra.', + 'Nunc in lectus et metus finibus venenatis.', + }, '\n')) + + toc:setSelected(5) + toc:submit() + + gui_journal.view:onRender() + + expect.eq(read_rendered_text(text_area), table.concat({ + 'Etiam dignissim diam nec aliquet facilisis.', + 'Integer tristique purus at tellus luctus, vel aliquet sapien sollicitudin.', + '## Subheader 2', + 'Fusce ornare est vitae urna feugiat, vel interdum quam vestibulum.', + '10: Vivamus id felis scelerisque, lobortis diam ut, mollis nisi.', + '_## Subsubheader 1', + '# Header 3', + 'Donec quis lectus ac erat placerat eleifend.', + 'Aenean non orci id erat malesuada pharetra.', + 'Nunc in lectus et metus finibus venenatis.', + }, '\n')) + + toc:setSelected(6) + toc:submit() + + gui_journal.view:onRender() + + expect.eq(read_rendered_text(text_area), table.concat({ + 'Etiam dignissim diam nec aliquet facilisis.', + 'Integer tristique purus at tellus luctus, vel aliquet sapien sollicitudin.', + '## Subheader 2', + 'Fusce ornare est vitae urna feugiat, vel interdum quam vestibulum.', + '10: Vivamus id felis scelerisque, lobortis diam ut, mollis nisi.', + '### Subsubheader 1', + '_ Header 3', + 'Donec quis lectus ac erat placerat eleifend.', + 'Aenean non orci id erat malesuada pharetra.', + 'Nunc in lectus et metus finibus venenatis.', + }, '\n')) + + journal:dismiss() +end + +function test.resize_table_of_contents_together() + local journal, text_area = arrange_empty_journal({ + w=100, + h=20, + allow_layout_restore=false + }) + + local text = table.concat({ + '# Header 1', + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + 'Nulla ut lacus ut tortor semper consectetur.', + }, '\n') + + simulate_input_text(text) + + expect.eq(text_area.frame_body.width, 101) + + simulate_input_keys('CUSTOM_CTRL_O') + + expect.eq(text_area.frame_body.width, 101 - 24) + + local toc_panel = journal.subviews.table_of_contents_panel + -- simulate mouse drag resize of toc panel + simulate_mouse_drag( + toc_panel, + toc_panel.frame_body.width + 1, + 1, + toc_panel.frame_body.width + 1 + 10, + 1 + ) + + expect.eq(text_area.frame_body.width, 101 - 24 - 10) + + journal:dismiss() +end + +function test.table_of_contents_selection_follows_cursor() + local journal, text_area = arrange_empty_journal({ + w=100, + h=50, + allow_layout_restore=false + }) + + local text = table.concat({ + '# Header 1', + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + 'Nulla ut lacus ut tortor semper consectetur.', + '# Header 2', + 'Ut eu orci non nibh hendrerit posuere.', + 'Sed euismod odio eu fringilla bibendum.', + '## Subheader 1', + 'Etiam dignissim diam nec aliquet facilisis.', + 'Integer tristique purus at tellus luctus, vel aliquet sapien sollicitudin.', + '## Subheader 2', + 'Fusce ornare est vitae urna feugiat, vel interdum quam vestibulum.', + '10: Vivamus id felis scelerisque, lobortis diam ut, mollis nisi.', + '### Subsubheader 1', + '# Header 3', + 'Donec quis lectus ac erat placerat eleifend.', + 'Aenean non orci id erat malesuada pharetra.', + 'Nunc in lectus et metus finibus venenatis.', + }, '\n') + + simulate_input_text(text) + + simulate_input_keys('CUSTOM_CTRL_O') + + local toc = journal.subviews.table_of_contents + + text_area:setCursor(1) + gui_journal.view:onRender() + + expect.eq(toc:getSelected(), 1) + + + text_area:setCursor(8) + gui_journal.view:onRender() + + expect.eq(toc:getSelected(), 1) + + + text_area:setCursor(140) + gui_journal.view:onRender() + + expect.eq(toc:getSelected(), 2) + + + text_area:setCursor(300) + gui_journal.view:onRender() + + expect.eq(toc:getSelected(), 3) + + + text_area:setCursor(646) + gui_journal.view:onRender() + + expect.eq(toc:getSelected(), 6) + + journal:dismiss() +end + +if df_major_version < 51 then + -- temporary ignore test features that base on newest API of the DF game + return +end + +function test.table_of_contents_keyboard_navigation() + local journal, text_area = arrange_empty_journal({ + w=100, + h=50, + allow_layout_restore=false + }) + + local text = table.concat({ + '# Header 1', + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + 'Nulla ut lacus ut tortor semper consectetur.', + '# Header 2', + 'Ut eu orci non nibh hendrerit posuere.', + 'Sed euismod odio eu fringilla bibendum.', + '## Subheader 1', + 'Etiam dignissim diam nec aliquet facilisis.', + 'Integer tristique purus at tellus luctus, vel aliquet sapien sollicitudin.', + '## Subheader 2', + 'Fusce ornare est vitae urna feugiat, vel interdum quam vestibulum.', + '10: Vivamus id felis scelerisque, lobortis diam ut, mollis nisi.', + '### Subsubheader 1', + '# Header 3', + 'Donec quis lectus ac erat placerat eleifend.', + 'Aenean non orci id erat malesuada pharetra.', + 'Nunc in lectus et metus finibus venenatis.', + }, '\n') + + simulate_input_text(text) + + simulate_input_keys('CUSTOM_CTRL_O') + + local toc = journal.subviews.table_of_contents + + text_area:setCursor(5) + gui_journal.view:onRender() + + simulate_input_keys('A_MOVE_N_DOWN') + + expect.eq(toc:getSelected(), 1) + + simulate_input_keys('A_MOVE_N_DOWN') + + expect.eq(toc:getSelected(), 6) + + simulate_input_keys('A_MOVE_N_DOWN') + simulate_input_keys('A_MOVE_N_DOWN') + + expect.eq(toc:getSelected(), 4) + + simulate_input_keys('A_MOVE_S_DOWN') + + expect.eq(toc:getSelected(), 5) + + simulate_input_keys('A_MOVE_S_DOWN') + simulate_input_keys('A_MOVE_S_DOWN') + simulate_input_keys('A_MOVE_S_DOWN') + + expect.eq(toc:getSelected(), 2) + + + text_area:setCursor(250) + gui_journal.view:onRender() + + simulate_input_keys('A_MOVE_N_DOWN') + + expect.eq(toc:getSelected(), 3) + + journal:dismiss() +end + +function test.fast_rewind_words_right() + local journal, text_area = arrange_empty_journal({w=55}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n') + + simulate_input_text(text) + text_area:setCursor(1) + journal:onRender() + + simulate_input_keys('A_MOVE_E_DOWN') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60:_Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + }, '\n')); + + simulate_input_keys('A_MOVE_E_DOWN') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem_ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + }, '\n')); + + for i=1,6 do + simulate_input_keys('A_MOVE_E_DOWN') + end + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing_', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + }, '\n')); + + simulate_input_keys('A_MOVE_E_DOWN') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit._', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + }, '\n')); + + simulate_input_keys('A_MOVE_E_DOWN') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112:_Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + }, '\n')); + + for i=1,17 do + simulate_input_keys('A_MOVE_E_DOWN') + end + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero._', + }, '\n')); + + simulate_input_keys('A_MOVE_E_DOWN') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero._', + }, '\n')); + + journal:dismiss() +end + +function test.fast_rewind_words_left() + local journal, text_area = arrange_empty_journal({w=55}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n') + + simulate_input_text(text) + + simulate_input_keys('A_MOVE_W_DOWN') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + '_ibero.', + }, '\n')); + + simulate_input_keys('A_MOVE_W_DOWN') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus _ec ', + 'libero.', + }, '\n')); + + for i=1,8 do + simulate_input_keys('A_MOVE_W_DOWN') + end + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + '_nte nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + }, '\n')); + + simulate_input_keys('A_MOVE_W_DOWN') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet _gestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + }, '\n')); + + for i=1,16 do + simulate_input_keys('A_MOVE_W_DOWN') + end + + expect.eq(read_rendered_text(text_area), table.concat({ + '_0: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + }, '\n')); + + simulate_input_keys('A_MOVE_W_DOWN') + + expect.eq(read_rendered_text(text_area), table.concat({ + '_0: Lorem ipsum dolor sit amet, consectetur adipiscing ', + 'elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ', + 'ante nibh porttitor mi, vitae rutrum eros metus nec ', + 'libero.', + }, '\n')); + + journal:dismiss() +end + +function test.fast_rewind_reset_selection() + local journal, text_area = arrange_empty_journal({w=65}) + + local text = table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n') + + simulate_input_text(text) + + simulate_input_keys('CUSTOM_CTRL_A') + + expect.eq(read_rendered_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n')); + + expect.eq(read_selected_text(text_area), table.concat({ + '60: Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '112: Sed consectetur, urna sit amet aliquet egestas, ante nibh ', + 'porttitor mi, vitae rutrum eros metus nec libero.', + }, '\n')); + + simulate_input_keys('A_MOVE_W_DOWN') + expect.eq(read_selected_text(text_area), '') + + simulate_input_keys('CUSTOM_CTRL_A') + + simulate_input_keys('A_MOVE_E_DOWN') + expect.eq(read_selected_text(text_area), '') + + journal:dismiss() +end