diff --git a/changelog.txt b/changelog.txt index 5e1761acb..2cee1f410 100644 --- a/changelog.txt +++ b/changelog.txt @@ -30,6 +30,7 @@ Template for new versions: - `embark-anyone`: allows you to embark as any civilization, including dead and non-dwarven ones - `idle-crafting`: allow dwarves to independently satisfy their need to craft objects - `gui/family-affairs`: (reinstated) inspect or meddle with pregnancies, marriages, or lover relationships +- `notes`: Manage map-specific notes ## New Features - `caravan`: DFHack dialogs for trade screens (both ``Bring goods to depot`` and the ``Trade`` barter screen) can now filter by item origins (foreign vs. fort-made) and can filter bins by whether they have a mix of ethically acceptable and unacceptable items in them diff --git a/docs/notes.rst b/docs/notes.rst new file mode 100644 index 000000000..faed2c8fb --- /dev/null +++ b/docs/notes.rst @@ -0,0 +1,42 @@ +notes +===== + +.. dfhack-tool:: + :summary: Manage map-specific notes. + :tags: fort interface map + +The `notes` tool enables players to annotate specific tiles +on the Dwarf Fortress game map with customizable notes. + +Each note is displayed as a green pin on the map and includes a one-line title and a detailed comment. + +It can be used to e.g.: + - marking plans for future constructions + - explaining mechanisms or traps + - noting historical events + +Usage +----- + +:: + + notes add + +Add new note in the current position of the keyboard cursor. + +Creating a Note +--------------- +1. Use the keyboard cursor to select the desired map tile where you want to place a note. +2. Execute ``notes add`` via the DFHack console. +3. In the pop-up dialog, fill in the note's title and detailed comment. +4. Press :kbd:`Alt` + :kbd:`S` to create the note. + +Editing or Deleting a Note +-------------------------- +- Click on the green pin representing the note directly on the map. +- A dialog will appear, offering options to edit the title or comment, or to delete the note entirely. + +Managing Notes Visibility +------------------------- +- Access the `gui/control-panel` / ``UI Overlays`` tab. +- Toggle the ``notes.map-notes`` overlay to show or hide the notes on the map. diff --git a/gui/journal.lua b/gui/journal.lua index cfb6f234b..d14468129 100644 --- a/gui/journal.lua +++ b/gui/journal.lua @@ -123,6 +123,7 @@ function JournalWindow:init() on_text_change=self:callback('onTextChange'), on_cursor_change=self:callback('onCursorChange'), }, + widgets.HelpButton{command="gui/journal", frame={r=0,t=1}}, widgets.Panel{ frame={l=0,r=0,b=1,h=1}, frame_inset={l=1,r=1,t=0, w=100}, diff --git a/internal/journal/table_of_contents.lua b/internal/journal/table_of_contents.lua index 67e12209a..c1e2096df 100644 --- a/internal/journal/table_of_contents.lua +++ b/internal/journal/table_of_contents.lua @@ -37,12 +37,12 @@ function TableOfContents:init() local function can_prev() local toc = self.subviews.table_of_contents - return #toc:getChoices() > 0 and toc:getSelected() > 1 + return #toc:getChoices() > 0 end local function can_next() local toc = self.subviews.table_of_contents local num_choices = #toc:getChoices() - return num_choices > 0 and toc:getSelected() < num_choices + return num_choices > 0 end self:addviews{ diff --git a/internal/journal/text_editor.lua b/internal/journal/text_editor.lua index 8bdd0aeb7..cf15776d4 100644 --- a/internal/journal/text_editor.lua +++ b/internal/journal/text_editor.lua @@ -94,6 +94,7 @@ TextEditor.ATTRS{ select_pen = COLOR_CYAN, on_text_change = DEFAULT_NIL, on_cursor_change = DEFAULT_NIL, + one_line_mode = false, debug = false } @@ -104,27 +105,28 @@ function TextEditor:init() TextEditorView{ view_id='text_area', frame={l=0,r=3,t=0}, - text = self.init_text, + text=self.init_text, - text_pen = self.text_pen, - ignore_keys = self.ignore_keys, - select_pen = self.select_pen, - debug = self.debug, + text_pen=self.text_pen, + ignore_keys=self.ignore_keys, + select_pen=self.select_pen, + debug=self.debug, + one_line_mode=self.one_line_mode, - on_text_change = function (val) + 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') + 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}} + on_scroll=self:callback('onScrollbar'), + visible=not self.one_line_mode + } } self:setFocus(true) end @@ -169,14 +171,14 @@ function TextEditor:setCursor(cursor_offset) end function TextEditor:getPreferredFocusState() - return true + return self.parent_view.focus end function TextEditor:postUpdateLayout() self:updateScrollbar(self.render_start_line_y) if self.subviews.text_area.cursor == nil then - local cursor = self.init_cursor or #self.text + 1 + local cursor = self.init_cursor or #self.init_text + 1 self.subviews.text_area:setCursor(cursor) self:scrollToCursor(cursor) end @@ -234,6 +236,10 @@ function TextEditor:onInput(keys) return self.subviews.scrollbar:onInput(keys) end + if keys._MOUSE_L and self:getMousePos() then + self:setFocus(true) + end + return TextEditor.super.onInput(self, keys) end @@ -248,6 +254,7 @@ TextEditorView.ATTRS{ on_cursor_change = DEFAULT_NIL, enable_cursor_blink = true, debug = false, + one_line_mode = false, history_size = 10, } @@ -270,6 +277,8 @@ function TextEditorView:init() bold=true }) + self.text = self:normalizeText(self.text) + self.wrapped_text = wrapped_text.WrappedText{ text=self.text, wrap_width=256 @@ -278,6 +287,14 @@ function TextEditorView:init() self.history = TextEditorHistory{history_size=self.history_size} end +function TextEditorView:normalizeText(text) + if self.one_line_mode then + return text:gsub("\r?\n", "") + end + + return text +end + function TextEditorView:setRenderStartLineY(render_start_line_y) self.render_start_line_y = render_start_line_y end @@ -407,7 +424,7 @@ end function TextEditorView:setText(text) local changed = self.text ~= text - self.text = text + self.text = self:normalizeText(text) self:recomputeLines() @@ -629,6 +646,8 @@ function TextEditorView:onInput(keys) self:paste() self.history:store(HISTORY_ENTRY.OTHER, self.text, self.cursor) return true + else + return TextEditor.super.onInput(self, keys) end end @@ -777,12 +796,14 @@ 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) + if not self.one_line_mode then + self.history:store( + HISTORY_ENTRY.WHITESPACE_BLOCK, + self.text, + self.cursor + ) + self:insert(NEWLINE) + end return true diff --git a/notes.lua b/notes.lua new file mode 100644 index 000000000..b025f0185 --- /dev/null +++ b/notes.lua @@ -0,0 +1,341 @@ +--@ module = true + +local gui = require('gui') +local widgets = require('gui.widgets') +local textures = require('gui.textures') +local overlay = require('plugins.overlay') +local guidm = require('gui.dwarfmode') +local text_editor = reqscript('internal/journal/text_editor') + +local green_pin = dfhack.textures.loadTileset( + 'hack/data/art/note_green_pin_map.png', + 32, + 32, + true +) + +NotesOverlay = defclass(NotesOverlay, overlay.OverlayWidget) +NotesOverlay.ATTRS{ + desc='Render map notes.', + viewscreens='dwarfmode', + default_enabled=true, + overlay_onupdate_max_freq_seconds=30, +} + +local waypoints = df.global.plotinfo.waypoints +local map_points = df.global.plotinfo.waypoints.points + +function NotesOverlay:init() + self.visible_notes = {} + self.note_manager = nil + self.last_click_pos = {} + self:reloadVisibleNotes() +end + +function NotesOverlay:overlay_onupdate() + self:reloadVisibleNotes() +end + +function NotesOverlay:overlay_trigger(args) + return self:showNoteManager() +end + +function NotesOverlay:onInput(keys) + if keys._MOUSE_L then + local top_most_screen = dfhack.gui.getDFViewscreen(true) + if dfhack.gui.matchFocusString('dwarfmode/Default', top_most_screen) then + local pos = dfhack.gui.getMousePos() + if pos == nil then + return false + end + + local note = self:clickedNote(pos) + if note ~= nil then + self:showNoteManager(note) + end + end + end +end + +function NotesOverlay:clickedNote(click_pos) + local pos_curr_note = same_xyz(self.last_click_pos, click_pos) + and self.note_manager + and self.note_manager.note + or nil + + self.last_click_pos = click_pos + + local last_note_on_pos = nil + local first_note_on_pos = nil + for _, note in ipairs(self.visible_notes) do + if same_xyz(note.point.pos, click_pos) then + if (last_note_on_pos and pos_curr_note + and last_note_on_pos.point.id == pos_curr_note.point.id + ) then + return note + end + + first_note_on_pos = first_note_on_pos or note + last_note_on_pos = note + end + end + + return first_note_on_pos +end + +function NotesOverlay:showNoteManager(note) + if self.note_manager ~= nil then + self.note_manager:dismiss() + end + + self.note_manager = NoteManager{ + note=note, + on_update=function() self:reloadVisibleNotes() end + } + + return self.note_manager:show() +end + +function NotesOverlay:viewportChanged() + return self.viewport_pos.x ~= df.global.window_x or + self.viewport_pos.y ~= df.global.window_y or + self.viewport_pos.z ~= df.global.window_z +end + +function NotesOverlay:onRenderFrame(dc) + if not df.global.pause_state and not dfhack.screen.inGraphicsMode() then + return + end + + if self:viewportChanged() then + self:reloadVisibleNotes() + end + + dc:map(true) + + local texpos = dfhack.textures.getTexposByHandle(green_pin[1]) + dc:pen({fg=COLOR_BLACK, bg=COLOR_LIGHTCYAN, tile=texpos}) + + for _, note in pairs(self.visible_notes) do + dc + :seek(note.screen_pos.x, note.screen_pos.y) + :char('N') + end + + dc:map(false) +end + +function NotesOverlay:reloadVisibleNotes() + self.visible_notes = {} + + local viewport = guidm.Viewport.get() + self.viewport_pos = { + x=df.global.window_x, + y=df.global.window_y, + z=df.global.window_z + } + + for _, map_point in ipairs(map_points) do + if (viewport:isVisible(map_point.pos) + and map_point.name ~= nil and #map_point.name > 0) + then + local screen_pos = viewport:tileToScreen(map_point.pos) + table.insert(self.visible_notes, { + point=map_point, + screen_pos=screen_pos + }) + end + end +end + +NoteManager = defclass(NoteManager, gui.ZScreen) +NoteManager.ATTRS{ + focus_path='notes/note-manager', + note=DEFAULT_NIL, + on_update=DEFAULT_NIL, +} + +function NoteManager:init() + local edit_mode = self.note ~= nil + + self:addviews{ + widgets.Window{ + frame={w=35,h=20}, + frame_inset={t=1}, + resizable=true, + subviews={ + widgets.HotkeyLabel { + key='CUSTOM_ALT_N', + label='Name', + frame={l=0,t=0}, + auto_width=true, + on_activate=function() self.subviews.name:setFocus(true) end, + }, + text_editor.TextEditor{ + view_id='name', + frame={t=1,h=3}, + frame_style=gui.FRAME_INTERIOR, + init_text=self.note and self.note.point.name or '', + init_cursor=1, + one_line_mode=true + }, + widgets.HotkeyLabel { + key='CUSTOM_ALT_C', + label='Comment', + frame={l=0,t=5}, + auto_width=true, + on_activate=function() self.subviews.comment:setFocus(true) end, + }, + text_editor.TextEditor{ + view_id='comment', + frame={t=6,b=3}, + frame_style=gui.FRAME_INTERIOR, + init_text=self.note and self.note.point.comment or '', + init_cursor=1 + }, + widgets.Panel{ + view_id='buttons', + frame={b=0,h=1}, + frame_inset={l=1,r=1}, + subviews={ + widgets.HotkeyLabel{ + view_id='Save', + frame={l=0,t=0,h=1}, + auto_width=true, + label='Save', + key='CUSTOM_ALT_S', + visible=edit_mode, + on_activate=function() self:saveNote() end, + enabled=function() return #self.subviews.name:getText() > 0 end, + }, + widgets.HotkeyLabel{ + view_id='Create', + frame={l=0,t=0,h=1}, + auto_width=true, + label='Create', + key='CUSTOM_ALT_S', + visible=not edit_mode, + on_activate=function() self:createNote() end, + enabled=function() return #self.subviews.name:getText() > 0 end, + }, + widgets.HotkeyLabel{ + view_id='delete', + frame={r=0,t=0,h=1}, + auto_width=true, + label='Delete', + key='CUSTOM_ALT_D', + visible=edit_mode, + on_activate=function() self:deleteNote() end, + } or nil, + } + } + }, + }, + } +end + +function NoteManager:createNote() + local cursor_pos = guidm.getCursorPos() + if cursor_pos == nil then + dfhack.printerr('Enable keyboard cursor to add a note.') + return + end + + local name = self.subviews.name:getText() + local comment = self.subviews.comment:getText() + + if #name == 0 then + dfhack.printerr('Note need at least a name') + return + end + + map_points:insert("#", { + new=true, + + id = waypoints.next_point_id, + tile=88, + fg_color=7, + bg_color=0, + name=name, + comment=comment, + pos=cursor_pos + }) + waypoints.next_point_id = waypoints.next_point_id + 1 + + if self.on_update then + self.on_update() + end + + self:dismiss() +end + +function NoteManager:saveNote() + if self.note == nil then + return + end + + local name = self.subviews.name:getText() + local comment = self.subviews.comment:getText() + + if #name == 0 then + dfhack.printerr('Note need at least a name') + return + end + + self.note.point.name = name + self.note.point.comment = comment + + if self.on_update then + self.on_update() + end + + self:dismiss() +end + +function NoteManager:deleteNote() + if self.note == nil then + return + end + + for ind, map_point in pairs(map_points) do + if map_point.id == self.note.point.id then + map_points:erase(ind) + break + end + end + + if self.on_update then + self.on_update() + end + + self:dismiss() +end + +function NoteManager:onDismiss() + self.note = nil +end + +-- register widgets +OVERLAY_WIDGETS = { + map_notes=NotesOverlay +} + +local function main(args) + if #args == 0 then + return + end + + if args[1] == 'add' then + local cursor_pos = guidm.getCursorPos() + if cursor_pos == nil then + dfhack.printerr('Enable keyboard cursor to add a note.') + return + end + + return dfhack.internal.runCommand('overlay trigger notes.map_notes') + end +end + +if not dfhack_flags.module then + main({...}) +end diff --git a/test/gui/journal.lua b/test/gui/journal.lua index 29fcc1d69..d845aef20 100644 --- a/test/gui/journal.lua +++ b/test/gui/journal.lua @@ -77,7 +77,7 @@ local function arrange_empty_journal(options) gui_journal.main({ save_prefix='test:', save_on_change=options.save_on_change or false, - save_layout=options.allow_layout_restore or false + save_layout=options.allow_layout_restore or false, }) local journal = gui_journal.view @@ -3068,3 +3068,6 @@ function test.show_tutorials_on_first_use() expect.str_find('Section 1\n', read_rendered_text(toc_panel)); journal:dismiss() end + +-- TODO: separate journal tests from TextEditor tests +-- add "one_line_mode" tests