From 1ed5e22c9f8340dc09f01ff2552b094faa103593 Mon Sep 17 00:00:00 2001 From: Timur Kelman Date: Sat, 4 Jan 2025 16:51:39 +0100 Subject: [PATCH 01/21] Create tooltips.lua --- gui/tooltips.lua | 281 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 281 insertions(+) create mode 100644 gui/tooltips.lua diff --git a/gui/tooltips.lua b/gui/tooltips.lua new file mode 100644 index 0000000000..b3b5212d88 --- /dev/null +++ b/gui/tooltips.lua @@ -0,0 +1,281 @@ +-- Show tooltips on units and/or mouse + +local RELOAD = false -- set to true when actively working on this script + +local gui = require('gui') +local widgets = require('gui.widgets') +local ResizingPanel = require('gui.widgets.containers.resizing_panel') + +-------------------------------------------------------------------------------- + +local follow_units = true; +local follow_mouse = true; +local function change_follow_units(new, old) + follow_units = new +end +local function change_follow_mouse(new, old) + follow_mouse = new +end + +local shortenings = { + ["Store item in stockpile"] = "Store item", +} + +-------------------------------------------------------------------------------- + +local TITLE = "Tooltips" + +if RELOAD then TooltipControlWindow = nil end +TooltipControlWindow = defclass(TooltipControlWindow, widgets.Window) +TooltipControlWindow.ATTRS { + frame_title=TITLE, + frame_inset=0, + resizable=false, + frame = { + w = 25, + h = 4, + -- just under the minimap: + r = 2, + t = 18, + }, +} + +function TooltipControlWindow:init() + self:addviews{ + widgets.ToggleHotkeyLabel{ + view_id = 'btn_follow_units', + frame={t=0, h=1}, + label="Follow units", + key='CUSTOM_ALT_U', + on_change=change_follow_units, + }, + widgets.ToggleHotkeyLabel{ + view_id = 'btn_follow_mouse', + frame={t=1, h=1}, + label="Follow mouse", + key='CUSTOM_ALT_M', + on_change=change_follow_mouse, + }, + } +end + +local function GetUnitJob(unit) + local job = unit.job + if job and job.current_job then + return dfhack.job.getName(job.current_job) + end + return nil +end + +local function GetUnitNameAndJob(unit) + local sb = {} + sb[#sb+1] = dfhack.units.getReadableName(unit) + local jobName = GetUnitJob(unit) + if jobName then + sb[#sb+1] = ": " + sb[#sb+1] = jobName + end + return table.concat(sb) +end + +local function GetTooltipText(x,y,z) + local txt = {} + local units = dfhack.units.getUnitsInBox(x,y,z,x,y,z) or {} -- todo: maybe (optionally) use filter parameter here? + + for _,unit in pairs(units) do + txt[#txt+1] = GetUnitNameAndJob(unit) + txt[#txt+1] = NEWLINE + end + + return txt +end + +-------------------------------------------------------------------------------- +-- MouseTooltip is an almost copy&paste of the DimensionsTooltip +-- +if RELOAD then MouseTooltip = nil end +MouseTooltip = defclass(MouseTooltip, ResizingPanel) + +MouseTooltip.ATTRS{ + frame_style=gui.FRAME_THIN, + frame_background=gui.CLEAR_PEN, + no_force_pause_badge=true, + auto_width=true, + display_offset={x=3, y=3}, +} + +function MouseTooltip:init() + ensure_key(self, 'frame').w = 17 + self.frame.h = 4 + + self.label = widgets.Label{ + frame={t=0}, + auto_width=true, + } + + self:addviews{ + widgets.Panel{ + -- set minimum size for tooltip frame so the DFHack frame badge fits + frame={t=0, l=0, w=7, h=2}, + }, + self.label, + } +end + +function MouseTooltip:render(dc) + if not follow_mouse then return end + + local x, y = dfhack.screen.getMousePos() + if not x then return end + + local pos = dfhack.gui.getMousePos() + local text = GetTooltipText(pos2xyz(pos)) + if #text == 0 then return end + self.label:setText(text) + + local sw, sh = dfhack.screen.getWindowSize() + local frame_width = math.max(9, self.label:getTextWidth() + 2) + self.frame.l = math.min(x + self.display_offset.x, sw - frame_width) + self.frame.t = math.min(y + self.display_offset.y, sh - self.frame.h) + self:updateLayout() + MouseTooltip.super.render(self, dc) +end + +-------------------------------------------------------------------------------- + +if RELOAD then TooltipsVizualizer = nil end +TooltipsVizualizer = defclass(TooltipsVizualizer, gui.ZScreen) +TooltipsVizualizer.ATTRS{ + focus_path='TooltipsVizualizer', + pass_movement_keys=true, +} + +function TooltipsVizualizer:init() + local controls = TooltipControlWindow{view_id = 'controls'} + local tooltip = MouseTooltip{view_id = 'tooltip'} + self:addviews{controls, tooltip} +end + +-- map coordinates -> interface layer coordinates +function GetScreenCoordinates(map_coord) + if not map_coord then return end + + -- -> map viewport offset + local vp = df.global.world.viewport + local vp_Coord = vp.window_x -- is actually coord + local map_offset_by_vp = { + x = map_coord.x - vp_Coord.x, + y = map_coord.y - vp_Coord.y, + z = map_coord.z - vp_Coord.z, + } + -- -> pixel offset + local gps = df.global.gps + local map_tile_pixels = gps.viewport_zoom_factor // 4; + local screen_coord_px = { + x = map_tile_pixels * map_offset_by_vp.x, + y = map_tile_pixels * map_offset_by_vp.y, + } + -- -> interface layer coordinates + local screen_coord_text = { + x = math.ceil( screen_coord_px.x / gps.tile_pixel_x ), + y = math.ceil( screen_coord_px.y / gps.tile_pixel_y ), + } + + return screen_coord_text +end + +function TooltipsVizualizer:onRenderFrame(dc, rect) + TooltipsVizualizer.super.onRenderFrame(self, dc, rect) + + if not follow_units then return end + + if not dfhack.screen.inGraphicsMode() and not gui.blink_visible(500) then + return + end + + local vp = df.global.world.viewport + local topleft = vp.window_x + local width = vp.max_x + local height = vp.max_y + local bottomright = {x = topleft.x + width, y = topleft.y + height, z = topleft.z} + + local units = dfhack.units.getUnitsInBox(topleft.x,topleft.y,topleft.z,bottomright.x,bottomright.y,bottomright.z) or {} + if #units == 0 then return end + + local oneTileOffset = GetScreenCoordinates({x = topleft.x + 1, y = topleft.y + 1, z = topleft.z + 0}) + local pen = COLOR_WHITE + + local used_tiles = {} + for i = #units, 1, -1 do + local unit = units[i] + local txt = GetUnitJob(unit) + if not txt then goto continue end + + txt = shortenings[txt] or txt + + local pos = xyz2pos(dfhack.units.getPosition(unit)) + if not pos then goto continue end + + local scrPos = GetScreenCoordinates(pos) + local y = scrPos.y - 1 -- subtract 1 to move the text over the heads + local x = scrPos.x + oneTileOffset.x - 1 -- subtract 1 to move the text inside the map tile + + -- to resolve overlaps, we'll mark every coordinate we write anything in, + -- and then check if the new tooltip will overwrite any used coordinate. + -- if it will, try the next row, to a maximum offset of 4. + local row + local dy = 0 + -- todo: search for the "best" offset instead, f.e. max `usedAt` value, with `-1` the best + local usedAt = -1 + for yOffset = 0, 4 do + dy = yOffset + + row = used_tiles[y + dy] + if not row then + row = {} + used_tiles[y + dy] = row + end + + usedAt = -1 + for j = 0, #txt - 1 do + if row[x + j] then + usedAt = j + break + end + end + + if usedAt == -1 then break end + end -- for dy + + -- in case there isn't enough space, cut the text off + if usedAt > 0 then + txt = txt:sub(0, usedAt - 1) .. '_' + end + + dc:seek(x, y + dy):pen(pen):string(txt) + + -- mark coordinates as used + for j = 0, #txt - 1 do + row[x + j] = true + end + + ::continue:: + end +end + +function TooltipsVizualizer:onDismiss() + view = nil +end + +---------------------------------------------------------------- + +if not dfhack.isMapLoaded() then + qerror('gui/tooltips requires a map to be loaded') +end + +if RELOAD and view then + view:dismiss() + -- view is nil now +end + +view = view and view:raise() or TooltipsVizualizer{}:show() From 7abc35586e913e8748679b25abf063cefd72337f Mon Sep 17 00:00:00 2001 From: Timur Kelman Date: Sat, 4 Jan 2025 17:36:39 +0100 Subject: [PATCH 02/21] Create tooltips.rst --- docs/gui/tooltips.rst | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 docs/gui/tooltips.rst diff --git a/docs/gui/tooltips.rst b/docs/gui/tooltips.rst new file mode 100644 index 0000000000..290196560b --- /dev/null +++ b/docs/gui/tooltips.rst @@ -0,0 +1,15 @@ +gui/tooltips +============ + +.. dfhack-tool:: + :summary: Show tooltips with useful info. + :tags: fort inspection + +This script shows "tooltips" following units and/or mouse with job names. + +Usage +----- + +:: + + gui/tooltips From 66dce0d4f116b5e3ca560ea927592dd765f19a44 Mon Sep 17 00:00:00 2001 From: Timur Kelman Date: Mon, 6 Jan 2025 11:16:22 +0100 Subject: [PATCH 03/21] Update docs/gui/tooltips.rst Co-authored-by: Myk --- docs/gui/tooltips.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/gui/tooltips.rst b/docs/gui/tooltips.rst index 290196560b..673fc1087b 100644 --- a/docs/gui/tooltips.rst +++ b/docs/gui/tooltips.rst @@ -2,7 +2,7 @@ gui/tooltips ============ .. dfhack-tool:: - :summary: Show tooltips with useful info. + :summary: Show name and job tooltips near units on map. :tags: fort inspection This script shows "tooltips" following units and/or mouse with job names. From c27777a6f7aa360087be33cca4f932bb560b4181 Mon Sep 17 00:00:00 2001 From: Timur Kelman Date: Mon, 6 Jan 2025 11:17:00 +0100 Subject: [PATCH 04/21] Update gui/tooltips.lua Co-authored-by: Myk --- gui/tooltips.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/tooltips.lua b/gui/tooltips.lua index b3b5212d88..5ebf557831 100644 --- a/gui/tooltips.lua +++ b/gui/tooltips.lua @@ -82,7 +82,7 @@ local function GetTooltipText(x,y,z) local txt = {} local units = dfhack.units.getUnitsInBox(x,y,z,x,y,z) or {} -- todo: maybe (optionally) use filter parameter here? - for _,unit in pairs(units) do + for _,unit in ipairs(units) do txt[#txt+1] = GetUnitNameAndJob(unit) txt[#txt+1] = NEWLINE end From 0ca5a9ab825346800905bdb566c24d61b648ceac Mon Sep 17 00:00:00 2001 From: Timur Kelman Date: Tue, 7 Jan 2025 23:27:48 +0100 Subject: [PATCH 05/21] enter emoticons --- gui/tooltips.lua | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/gui/tooltips.lua b/gui/tooltips.lua index 5ebf557831..b54dc30481 100644 --- a/gui/tooltips.lua +++ b/gui/tooltips.lua @@ -59,6 +59,16 @@ function TooltipControlWindow:init() } end +local function GetUnitHappiness(unit) + -- keep in mind, this will look differently with game's font + local mapToEmoticon = {[0] = "=C", ":C", ":(", ":]", ":)", ":D", "=D" } + -- same as in ASCII mode, but for then middle (3), which is GREY instead of WHITE + local mapToColor = {[0] = COLOR_RED, COLOR_LIGHTRED, COLOR_YELLOW, COLOR_GREY, COLOR_GREEN, COLOR_LIGHTGREEN, COLOR_LIGHTCYAN} + local stressCat = dfhack.units.getStressCategory(unit) + if stressCat > 6 then stressCat = 6 end + return mapToEmoticon[stressCat], mapToColor[stressCat] +end + local function GetUnitJob(unit) local job = unit.job if job and job.current_job then @@ -208,14 +218,17 @@ function TooltipsVizualizer:onRenderFrame(dc, rect) local used_tiles = {} for i = #units, 1, -1 do local unit = units[i] - local txt = GetUnitJob(unit) - if not txt then goto continue end - txt = shortenings[txt] or txt + local happiness, happyPen = GetUnitHappiness(unit) + local job = GetUnitJob(unit) + job = shortenings[job] or job + if not job and not happiness then goto continue end local pos = xyz2pos(dfhack.units.getPosition(unit)) if not pos then goto continue end + local txt = table.concat({happiness, job}, " ") + local scrPos = GetScreenCoordinates(pos) local y = scrPos.y - 1 -- subtract 1 to move the text over the heads local x = scrPos.x + oneTileOffset.x - 1 -- subtract 1 to move the text inside the map tile @@ -249,10 +262,15 @@ function TooltipsVizualizer:onRenderFrame(dc, rect) -- in case there isn't enough space, cut the text off if usedAt > 0 then - txt = txt:sub(0, usedAt - 1) .. '_' + local s = happiness and #happiness + 1 or 0 + job = job:sub(0, usedAt - s - 1) .. '_' + txt = txt:sub(0, usedAt - 1) .. '_' -- for marking end - dc:seek(x, y + dy):pen(pen):string(txt) + dc:seek(x, y + dy) + :pen(happyPen):string(happiness or "") + :string((happiness and job) and " " or "") + :pen(pen):string(job or "") -- mark coordinates as used for j = 0, #txt - 1 do From 0f6aebae507ca9c2f17f6fd04756f9a489f8fc19 Mon Sep 17 00:00:00 2001 From: Timur Kelman Date: Tue, 7 Jan 2025 23:39:43 +0100 Subject: [PATCH 06/21] fix GetScreenCoordinates for ASCII mode Also, simplify GetUnitJob --- gui/tooltips.lua | 39 ++++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/gui/tooltips.lua b/gui/tooltips.lua index b54dc30481..c4fc628173 100644 --- a/gui/tooltips.lua +++ b/gui/tooltips.lua @@ -70,11 +70,8 @@ local function GetUnitHappiness(unit) end local function GetUnitJob(unit) - local job = unit.job - if job and job.current_job then - return dfhack.job.getName(job.current_job) - end - return nil + local job = unit.job.current_job + return job and dfhack.job.getName(job) end local function GetUnitNameAndJob(unit) @@ -169,7 +166,6 @@ end -- map coordinates -> interface layer coordinates function GetScreenCoordinates(map_coord) if not map_coord then return end - -- -> map viewport offset local vp = df.global.world.viewport local vp_Coord = vp.window_x -- is actually coord @@ -178,20 +174,25 @@ function GetScreenCoordinates(map_coord) y = map_coord.y - vp_Coord.y, z = map_coord.z - vp_Coord.z, } - -- -> pixel offset - local gps = df.global.gps - local map_tile_pixels = gps.viewport_zoom_factor // 4; - local screen_coord_px = { - x = map_tile_pixels * map_offset_by_vp.x, - y = map_tile_pixels * map_offset_by_vp.y, - } - -- -> interface layer coordinates - local screen_coord_text = { - x = math.ceil( screen_coord_px.x / gps.tile_pixel_x ), - y = math.ceil( screen_coord_px.y / gps.tile_pixel_y ), - } - return screen_coord_text + if not dfhack.screen.inGraphicsMode() then + return map_offset_by_vp + else + -- -> pixel offset + local gps = df.global.gps + local map_tile_pixels = gps.viewport_zoom_factor // 4; + local screen_coord_px = { + x = map_tile_pixels * map_offset_by_vp.x, + y = map_tile_pixels * map_offset_by_vp.y, + } + -- -> interface layer coordinates + local screen_coord_text = { + x = math.ceil( screen_coord_px.x / gps.tile_pixel_x ), + y = math.ceil( screen_coord_px.y / gps.tile_pixel_y ), + } + + return screen_coord_text + end end function TooltipsVizualizer:onRenderFrame(dc, rect) From be5bccd8a7f5951413782860a7f94adf609f524c Mon Sep 17 00:00:00 2001 From: Timur Kelman Date: Tue, 7 Jan 2025 23:51:44 +0100 Subject: [PATCH 07/21] tooltips.rst: add IMPORTANT NOTE as well as some clarifications --- docs/gui/tooltips.rst | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/gui/tooltips.rst b/docs/gui/tooltips.rst index 673fc1087b..b01d076a26 100644 --- a/docs/gui/tooltips.rst +++ b/docs/gui/tooltips.rst @@ -5,7 +5,15 @@ gui/tooltips :summary: Show name and job tooltips near units on map. :tags: fort inspection -This script shows "tooltips" following units and/or mouse with job names. +**IMPORTANT NOTE**: the tooltips will show over any vanilla UI elements! + + +This script shows "tooltips" in two optional modes: + +* following the mouse, when a unit is underneath the cursor; +* following units on the map. + +Information shown includes happiness indicator, name, and current job. Usage ----- From 9484f8eee7a4b42da7c608d9db930385e5cac157 Mon Sep 17 00:00:00 2001 From: Timur Kelman Date: Sun, 12 Jan 2025 11:22:50 +0100 Subject: [PATCH 08/21] vieport.window_x -> .coord --- gui/tooltips.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gui/tooltips.lua b/gui/tooltips.lua index c4fc628173..9db8dc72a2 100644 --- a/gui/tooltips.lua +++ b/gui/tooltips.lua @@ -168,7 +168,7 @@ function GetScreenCoordinates(map_coord) if not map_coord then return end -- -> map viewport offset local vp = df.global.world.viewport - local vp_Coord = vp.window_x -- is actually coord + local vp_Coord = vp.corner local map_offset_by_vp = { x = map_coord.x - vp_Coord.x, y = map_coord.y - vp_Coord.y, @@ -205,7 +205,7 @@ function TooltipsVizualizer:onRenderFrame(dc, rect) end local vp = df.global.world.viewport - local topleft = vp.window_x + local topleft = vp.corner local width = vp.max_x local height = vp.max_y local bottomright = {x = topleft.x + width, y = topleft.y + height, z = topleft.z} From 33dbe85f4e8d63c0dddf584b7c7fa11ef4d74e85 Mon Sep 17 00:00:00 2001 From: Timur Kelman Date: Sun, 12 Jan 2025 11:28:49 +0100 Subject: [PATCH 09/21] use `getUnitsInBox(pos1, pos2)` overload --- gui/tooltips.lua | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/gui/tooltips.lua b/gui/tooltips.lua index 9db8dc72a2..671b8faf3b 100644 --- a/gui/tooltips.lua +++ b/gui/tooltips.lua @@ -85,9 +85,9 @@ local function GetUnitNameAndJob(unit) return table.concat(sb) end -local function GetTooltipText(x,y,z) +local function GetTooltipText(pos) local txt = {} - local units = dfhack.units.getUnitsInBox(x,y,z,x,y,z) or {} -- todo: maybe (optionally) use filter parameter here? + local units = dfhack.units.getUnitsInBox(pos, pos) or {} -- todo: maybe (optionally) use filter parameter here? for _,unit in ipairs(units) do txt[#txt+1] = GetUnitNameAndJob(unit) @@ -136,7 +136,7 @@ function MouseTooltip:render(dc) if not x then return end local pos = dfhack.gui.getMousePos() - local text = GetTooltipText(pos2xyz(pos)) + local text = GetTooltipText(pos) if #text == 0 then return end self.label:setText(text) @@ -210,7 +210,7 @@ function TooltipsVizualizer:onRenderFrame(dc, rect) local height = vp.max_y local bottomright = {x = topleft.x + width, y = topleft.y + height, z = topleft.z} - local units = dfhack.units.getUnitsInBox(topleft.x,topleft.y,topleft.z,bottomright.x,bottomright.y,bottomright.z) or {} + local units = dfhack.units.getUnitsInBox(topleft, bottomright) or {} if #units == 0 then return end local oneTileOffset = GetScreenCoordinates({x = topleft.x + 1, y = topleft.y + 1, z = topleft.z + 0}) From 8d345a743557216e7c5493e000f0b9d7a670d3d8 Mon Sep 17 00:00:00 2001 From: Timur Kelman Date: Sun, 12 Jan 2025 13:03:05 +0100 Subject: [PATCH 10/21] make this an overlay --- gui/tooltips.lua | 75 ++++++++++++++++++++++++++++++++++-------------- 1 file changed, 54 insertions(+), 21 deletions(-) diff --git a/gui/tooltips.lua b/gui/tooltips.lua index 671b8faf3b..d9015afff0 100644 --- a/gui/tooltips.lua +++ b/gui/tooltips.lua @@ -1,20 +1,25 @@ -- Show tooltips on units and/or mouse +--@ module = true + local RELOAD = false -- set to true when actively working on this script local gui = require('gui') local widgets = require('gui.widgets') +local overlay = require('plugins.overlay') local ResizingPanel = require('gui.widgets.containers.resizing_panel') -------------------------------------------------------------------------------- -local follow_units = true; -local follow_mouse = true; +config = config or { + follow_units = true, + follow_mouse = true, +} local function change_follow_units(new, old) - follow_units = new + config.follow_units = new end local function change_follow_mouse(new, old) - follow_mouse = new + config.follow_mouse = new end local shortenings = { @@ -25,6 +30,22 @@ local shortenings = { local TITLE = "Tooltips" +if RELOAD then TooltipControlScreen = nil end +TooltipControlScreen = defclass(TooltipControlScreen, gui.ZScreen) +TooltipControlScreen.ATTRS { + focus_path = "TooltipControlScreen", + pass_movement_keys = true, +} + +function TooltipControlScreen:init() + local controls = TooltipControlWindow{view_id = 'controls'} + self:addviews{controls} +end + +function TooltipControlScreen:onDismiss() + view = nil +end + if RELOAD then TooltipControlWindow = nil end TooltipControlWindow = defclass(TooltipControlWindow, widgets.Window) TooltipControlWindow.ATTRS { @@ -130,7 +151,7 @@ function MouseTooltip:init() end function MouseTooltip:render(dc) - if not follow_mouse then return end + if not config.follow_mouse then return end local x, y = dfhack.screen.getMousePos() if not x then return end @@ -149,22 +170,25 @@ function MouseTooltip:render(dc) end -------------------------------------------------------------------------------- - -if RELOAD then TooltipsVizualizer = nil end -TooltipsVizualizer = defclass(TooltipsVizualizer, gui.ZScreen) -TooltipsVizualizer.ATTRS{ - focus_path='TooltipsVizualizer', - pass_movement_keys=true, +if RELOAD then TooltipsOverlay = nil end +TooltipsOverlay = defclass(TooltipsOverlay, overlay.OverlayWidget) +TooltipsOverlay.ATTRS{ + desc='Adds tooltips with some info to units.', + default_pos={x=1,y=1}, + default_enabled=true, + fullscreen=true, -- not player-repositionable + viewscreens={ + 'dwarfmode/Default', + }, } -function TooltipsVizualizer:init() - local controls = TooltipControlWindow{view_id = 'controls'} +function TooltipsOverlay:init() local tooltip = MouseTooltip{view_id = 'tooltip'} - self:addviews{controls, tooltip} + self:addviews{tooltip} end -- map coordinates -> interface layer coordinates -function GetScreenCoordinates(map_coord) +local function GetScreenCoordinates(map_coord) if not map_coord then return end -- -> map viewport offset local vp = df.global.world.viewport @@ -195,10 +219,10 @@ function GetScreenCoordinates(map_coord) end end -function TooltipsVizualizer:onRenderFrame(dc, rect) - TooltipsVizualizer.super.onRenderFrame(self, dc, rect) +function TooltipsOverlay:render(dc) + TooltipsOverlay.super.render(self, dc) - if not follow_units then return end + if not config.follow_units then return end if not dfhack.screen.inGraphicsMode() and not gui.blink_visible(500) then return @@ -282,12 +306,21 @@ function TooltipsVizualizer:onRenderFrame(dc, rect) end end -function TooltipsVizualizer:onDismiss() - view = nil +function TooltipsOverlay:preUpdateLayout(parent_rect) + self.frame.w = parent_rect.width + self.frame.h = parent_rect.height end ---------------------------------------------------------------- +OVERLAY_WIDGETS = { + tooltips=TooltipsOverlay, +} + +if dfhack_flags.module then + return +end + if not dfhack.isMapLoaded() then qerror('gui/tooltips requires a map to be loaded') end @@ -297,4 +330,4 @@ if RELOAD and view then -- view is nil now end -view = view and view:raise() or TooltipsVizualizer{}:show() +view = view and view:raise() or TooltipControlScreen{}:show() From 66d8d3915ce83d2a69f71f5d5ab91b8cd8627588 Mon Sep 17 00:00:00 2001 From: Timur Kelman Date: Sat, 18 Jan 2025 23:44:18 +0100 Subject: [PATCH 11/21] make possible to show specific stress/happiness levels; add config UI --- gui/tooltips.lua | 165 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 138 insertions(+), 27 deletions(-) diff --git a/gui/tooltips.lua b/gui/tooltips.lua index d9015afff0..cf1b861f82 100644 --- a/gui/tooltips.lua +++ b/gui/tooltips.lua @@ -11,19 +11,79 @@ local ResizingPanel = require('gui.widgets.containers.resizing_panel') -------------------------------------------------------------------------------- -config = config or { - follow_units = true, - follow_mouse = true, -} -local function change_follow_units(new, old) - config.follow_units = new +-- pens are the same as gui/control-panel.lua +local textures = require('gui.textures') +local function get_icon_pens() + local enabled_pen_left = dfhack.pen.parse{fg=COLOR_CYAN, + tile=curry(textures.tp_control_panel, 1), ch=string.byte('[')} + local enabled_pen_center = dfhack.pen.parse{fg=COLOR_LIGHTGREEN, + tile=curry(textures.tp_control_panel, 2) or nil, ch=251} -- check + local enabled_pen_right = dfhack.pen.parse{fg=COLOR_CYAN, + tile=curry(textures.tp_control_panel, 3) or nil, ch=string.byte(']')} + local disabled_pen_left = dfhack.pen.parse{fg=COLOR_CYAN, + tile=curry(textures.tp_control_panel, 4) or nil, ch=string.byte('[')} + local disabled_pen_center = dfhack.pen.parse{fg=COLOR_RED, + tile=curry(textures.tp_control_panel, 5) or nil, ch=string.byte('x')} + local disabled_pen_right = dfhack.pen.parse{fg=COLOR_CYAN, + tile=curry(textures.tp_control_panel, 6) or nil, ch=string.byte(']')} + local button_pen_left = dfhack.pen.parse{fg=COLOR_CYAN, + tile=curry(textures.tp_control_panel, 7) or nil, ch=string.byte('[')} + local button_pen_right = dfhack.pen.parse{fg=COLOR_CYAN, + tile=curry(textures.tp_control_panel, 8) or nil, ch=string.byte(']')} + local help_pen_center = dfhack.pen.parse{ + tile=curry(textures.tp_control_panel, 9) or nil, ch=string.byte('?')} + local configure_pen_center = dfhack.pen.parse{ + tile=curry(textures.tp_control_panel, 10) or nil, ch=15} -- gear/masterwork symbol + return enabled_pen_left, enabled_pen_center, enabled_pen_right, + disabled_pen_left, disabled_pen_center, disabled_pen_right, + button_pen_left, button_pen_right, + help_pen_center, configure_pen_center end -local function change_follow_mouse(new, old) - config.follow_mouse = new +local ENABLED_PEN_LEFT, ENABLED_PEN_CENTER, ENABLED_PEN_RIGHT, + DISABLED_PEN_LEFT, DISABLED_PEN_CENTER, DISABLED_PEN_RIGHT, + BUTTON_PEN_LEFT, BUTTON_PEN_RIGHT, + HELP_PEN_CENTER, CONFIGURE_PEN_CENTER = get_icon_pens() + +if RELOAD then ToggleLabel = nil end +ToggleLabel = defclass(ToggleLabel, widgets.CycleHotkeyLabel) +ToggleLabel.ATTRS{ + options={{value=true}, + {value=false}}, +} +function ToggleLabel:init() + ToggleLabel.super.init(self) + + local text = self.text + -- the very last token is the On/Off text -- we'll repurpose it as an indicator + text[#text] = { tile = function() return self:getOptionValue() and ENABLED_PEN_LEFT or DISABLED_PEN_LEFT end } + text[#text + 1] = { tile = function() return self:getOptionValue() and ENABLED_PEN_CENTER or DISABLED_PEN_CENTER end } + text[#text + 1] = { tile = function() return self:getOptionValue() and ENABLED_PEN_RIGHT or DISABLED_PEN_RIGHT end } + self:setText(text) end -local shortenings = { - ["Store item in stockpile"] = "Store item", +--- + +if RELOAD then config = nil end +config = config or { + follow_units = true, + follow_mouse = false, + show_happiness = true, + happiness_levels = { + -- keep in mind, the text will look differently with game's font + -- colors are same as in ASCII mode, but for then middle (3), which is GREY instead of WHITE + [0] = + {text = "=C", pen = COLOR_RED, visible = true, name = "Miserable"}, + {text = ":C", pen = COLOR_LIGHTRED, visible = true, name = "Unhappy"}, + {text = ":(", pen = COLOR_YELLOW, visible = false, name = "Displeased"}, + {text = ":]", pen = COLOR_GREY, visible = false, name = "Content"}, + {text = ":)", pen = COLOR_GREEN, visible = false, name = "Pleased"}, + {text = ":D", pen = COLOR_LIGHTGREEN, visible = true, name = "Happy"}, + {text = "=D", pen = COLOR_LIGHTCYAN, visible = true, name = "Ecstatic"}, + }, + show_unit_jobs = true, + job_shortenings = { + ["Store item in stockpile"] = "Store item", + } } -------------------------------------------------------------------------------- @@ -53,44 +113,93 @@ TooltipControlWindow.ATTRS { frame_inset=0, resizable=false, frame = { - w = 25, - h = 4, + w = 27, + h = 2 -- border + + 4 -- main options + + 7 -- happiness + , -- just under the minimap: r = 2, t = 18, }, } +-- right pad string `s` to `n` symbols with spaces +local function rpad(s, n) + local formatStr = "%-" .. n .. "s" -- `"%-10s"` + return string.format(formatStr, s) +end + function TooltipControlWindow:init() + local w = self.frame.w - 2 - 3 -- 2 is border, 3 is active indicator width + local keyW = 7 -- Length of "Alt+u: " + self:addviews{ - widgets.ToggleHotkeyLabel{ + ToggleLabel{ view_id = 'btn_follow_units', frame={t=0, h=1}, - label="Follow units", + label=rpad("Unit banners", w - keyW), key='CUSTOM_ALT_U', - on_change=change_follow_units, + initial_option=config.follow_units, + on_change=function(new) config.follow_units = new end, }, - widgets.ToggleHotkeyLabel{ + ToggleLabel{ view_id = 'btn_follow_mouse', frame={t=1, h=1}, - label="Follow mouse", + label=rpad("Mouse tooltip", w - keyW), key='CUSTOM_ALT_M', - on_change=change_follow_mouse, + initial_option=config.follow_mouse, + on_change=function(new) config.follow_mouse = new end, + }, + ToggleLabel{ + frame={t=2, h=1}, + label=rpad("Show jobs", w), + initial_option=config.show_unit_jobs, + on_change=function(new) config.show_unit_jobs = new end, + }, + ToggleLabel{ + frame={t=3, h=1}, + label=rpad("Show stress levels", w), + initial_option=config.show_happiness, + on_change=function(new) config.show_happiness = new end, }, } + + local happinessLabels = {} + + -- align the emoticons + local maxNameLength = 1 + for _, v in pairs(config.happiness_levels) do + local l = #v.name + if l > maxNameLength then + maxNameLength = l + end + end + + local indent = 3 + for lvl, cfg in pairs(config.happiness_levels) do + happinessLabels[#happinessLabels + 1] = ToggleLabel{ + frame={t=4+lvl, h=1, l=indent}, + initial_option=cfg.visible, + text_pen = cfg.pen, + label = rpad(rpad(cfg.name, maxNameLength) .. " " .. cfg.text, w - indent), + on_change = function(new) cfg.visible = new end + } + end + self:addviews(happinessLabels) end local function GetUnitHappiness(unit) - -- keep in mind, this will look differently with game's font - local mapToEmoticon = {[0] = "=C", ":C", ":(", ":]", ":)", ":D", "=D" } - -- same as in ASCII mode, but for then middle (3), which is GREY instead of WHITE - local mapToColor = {[0] = COLOR_RED, COLOR_LIGHTRED, COLOR_YELLOW, COLOR_GREY, COLOR_GREEN, COLOR_LIGHTGREEN, COLOR_LIGHTCYAN} + if not config.show_happiness then return end local stressCat = dfhack.units.getStressCategory(unit) if stressCat > 6 then stressCat = 6 end - return mapToEmoticon[stressCat], mapToColor[stressCat] + local happiness_level_cfg = config.happiness_levels[stressCat] + if not happiness_level_cfg.visible then return end + return happiness_level_cfg.text, happiness_level_cfg.pen end local function GetUnitJob(unit) + if not config.show_unit_jobs then return end local job = unit.job.current_job return job and dfhack.job.getName(job) end @@ -189,7 +298,6 @@ end -- map coordinates -> interface layer coordinates local function GetScreenCoordinates(map_coord) - if not map_coord then return end -- -> map viewport offset local vp = df.global.world.viewport local vp_Coord = vp.corner @@ -234,12 +342,13 @@ function TooltipsOverlay:render(dc) local height = vp.max_y local bottomright = {x = topleft.x + width, y = topleft.y + height, z = topleft.z} - local units = dfhack.units.getUnitsInBox(topleft, bottomright) or {} - if #units == 0 then return end + local units = dfhack.units.getUnitsInBox(topleft, bottomright) + if not units or #units == 0 then return end local oneTileOffset = GetScreenCoordinates({x = topleft.x + 1, y = topleft.y + 1, z = topleft.z + 0}) local pen = COLOR_WHITE + local shortenings = config.job_shortenings local used_tiles = {} for i = #units, 1, -1 do local unit = units[i] @@ -252,7 +361,9 @@ function TooltipsOverlay:render(dc) local pos = xyz2pos(dfhack.units.getPosition(unit)) if not pos then goto continue end - local txt = table.concat({happiness, job}, " ") + local txt = (happiness and job and happiness .. " " .. job) + or happiness + or job local scrPos = GetScreenCoordinates(pos) local y = scrPos.y - 1 -- subtract 1 to move the text over the heads From e97201c1b1ea2bcb2637ad01ee3705afc2c4e831 Mon Sep 17 00:00:00 2001 From: Timur Kelman Date: Sat, 18 Jan 2025 23:46:28 +0100 Subject: [PATCH 12/21] trim trailing whitespaces --- gui/tooltips.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/tooltips.lua b/gui/tooltips.lua index cf1b861f82..fbc52fdb14 100644 --- a/gui/tooltips.lua +++ b/gui/tooltips.lua @@ -175,7 +175,7 @@ function TooltipControlWindow:init() maxNameLength = l end end - + local indent = 3 for lvl, cfg in pairs(config.happiness_levels) do happinessLabels[#happinessLabels + 1] = ToggleLabel{ From 2e1cf878b24b012e49468595e539853c02584a6e Mon Sep 17 00:00:00 2001 From: Timur Kelman Date: Sat, 18 Jan 2025 23:57:16 +0100 Subject: [PATCH 13/21] fix an ASCII mode exception --- gui/tooltips.lua | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/gui/tooltips.lua b/gui/tooltips.lua index fbc52fdb14..31eee1510f 100644 --- a/gui/tooltips.lua +++ b/gui/tooltips.lua @@ -216,6 +216,8 @@ local function GetUnitNameAndJob(unit) end local function GetTooltipText(pos) + if not pos then return end + local txt = {} local units = dfhack.units.getUnitsInBox(pos, pos) or {} -- todo: maybe (optionally) use filter parameter here? @@ -267,7 +269,7 @@ function MouseTooltip:render(dc) local pos = dfhack.gui.getMousePos() local text = GetTooltipText(pos) - if #text == 0 then return end + if not text or #text == 0 then return end self.label:setText(text) local sw, sh = dfhack.screen.getWindowSize() From 7e53def023a67c14673bd6a89a3fa397f59310f0 Mon Sep 17 00:00:00 2001 From: Timur Kelman Date: Sat, 1 Feb 2025 11:16:27 +0100 Subject: [PATCH 14/21] render mouse tooltips over unit banners Also, don't import ResizingPanel separately --- gui/tooltips.lua | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/gui/tooltips.lua b/gui/tooltips.lua index 31eee1510f..746bf881d9 100644 --- a/gui/tooltips.lua +++ b/gui/tooltips.lua @@ -7,7 +7,6 @@ local RELOAD = false -- set to true when actively working on this script local gui = require('gui') local widgets = require('gui.widgets') local overlay = require('plugins.overlay') -local ResizingPanel = require('gui.widgets.containers.resizing_panel') -------------------------------------------------------------------------------- @@ -233,7 +232,7 @@ end -- MouseTooltip is an almost copy&paste of the DimensionsTooltip -- if RELOAD then MouseTooltip = nil end -MouseTooltip = defclass(MouseTooltip, ResizingPanel) +MouseTooltip = defclass(MouseTooltip, widgets.ResizingPanel) MouseTooltip.ATTRS{ frame_style=gui.FRAME_THIN, @@ -330,8 +329,11 @@ local function GetScreenCoordinates(map_coord) end function TooltipsOverlay:render(dc) + self:render_unit_banners(dc) TooltipsOverlay.super.render(self, dc) +end +function TooltipsOverlay:render_unit_banners(dc) if not config.follow_units then return end if not dfhack.screen.inGraphicsMode() and not gui.blink_visible(500) then From ec27b390e550f1ac89b0060318115e9aa52d554a Mon Sep 17 00:00:00 2001 From: Timur Kelman Date: Sat, 1 Feb 2025 12:22:59 +0100 Subject: [PATCH 15/21] use list instead of labels in config UI --- gui/tooltips.lua | 114 +++++++++++++++++++++++------------------------ 1 file changed, 55 insertions(+), 59 deletions(-) diff --git a/gui/tooltips.lua b/gui/tooltips.lua index 746bf881d9..e08b97e608 100644 --- a/gui/tooltips.lua +++ b/gui/tooltips.lua @@ -66,6 +66,10 @@ if RELOAD then config = nil end config = config or { follow_units = true, follow_mouse = false, + show_unit_jobs = true, + job_shortenings = { + ["Store item in stockpile"] = "Store item", + }, show_happiness = true, happiness_levels = { -- keep in mind, the text will look differently with game's font @@ -79,10 +83,6 @@ config = config or { {text = ":D", pen = COLOR_LIGHTGREEN, visible = true, name = "Happy"}, {text = "=D", pen = COLOR_LIGHTCYAN, visible = true, name = "Ecstatic"}, }, - show_unit_jobs = true, - job_shortenings = { - ["Store item in stockpile"] = "Store item", - } } -------------------------------------------------------------------------------- @@ -123,69 +123,65 @@ TooltipControlWindow.ATTRS { }, } --- right pad string `s` to `n` symbols with spaces -local function rpad(s, n) - local formatStr = "%-" .. n .. "s" -- `"%-10s"` - return string.format(formatStr, s) +local function make_enabled_text(indent, text, cfg, key) + local function get_enabled_button_token(enabled_tile, disabled_tile, cfg, key) + return { + tile=function() return cfg[key] and enabled_tile or disabled_tile end, + } + end + + local tokens = { + string.format("%" .. indent .. "s", ''), + get_enabled_button_token(ENABLED_PEN_LEFT, DISABLED_PEN_LEFT, cfg, key), + get_enabled_button_token(ENABLED_PEN_CENTER, DISABLED_PEN_CENTER, cfg, key), + get_enabled_button_token(ENABLED_PEN_RIGHT, DISABLED_PEN_RIGHT, cfg, key), + ' ', + } + if type(text) == 'string' then + tokens[#tokens+1] = text + else -- must be a table + -- append it + for _, v in ipairs(text) do + tokens[#tokens+1] = v + end + end + + return tokens +end + +local function make_choice(indent, text, cfg, key) + return { + text=make_enabled_text(indent, text, cfg, key), + data={cfg=cfg, key=key}, + } end function TooltipControlWindow:init() - local w = self.frame.w - 2 - 3 -- 2 is border, 3 is active indicator width - local keyW = 7 -- Length of "Alt+u: " + local choices = {} + table.insert(choices, make_choice(0, "unit banners", config, "follow_units")) + table.insert(choices, make_choice(0, "mouse tooltips", config, "follow_mouse")) + table.insert(choices, make_choice(0, "include jobs", config, "show_unit_jobs")) + table.insert(choices, make_choice(0, "include stress levels", config, "show_happiness")) + for i = 0, #config.happiness_levels do + local cfg = config.happiness_levels[i] + table.insert(choices, make_choice(3, {{text=cfg.text, pen=cfg.pen}, ' ', cfg.name}, cfg, "visible")) + end self:addviews{ - ToggleLabel{ - view_id = 'btn_follow_units', - frame={t=0, h=1}, - label=rpad("Unit banners", w - keyW), - key='CUSTOM_ALT_U', - initial_option=config.follow_units, - on_change=function(new) config.follow_units = new end, - }, - ToggleLabel{ - view_id = 'btn_follow_mouse', - frame={t=1, h=1}, - label=rpad("Mouse tooltip", w - keyW), - key='CUSTOM_ALT_M', - initial_option=config.follow_mouse, - on_change=function(new) config.follow_mouse = new end, - }, - ToggleLabel{ - frame={t=2, h=1}, - label=rpad("Show jobs", w), - initial_option=config.show_unit_jobs, - on_change=function(new) config.show_unit_jobs = new end, - }, - ToggleLabel{ - frame={t=3, h=1}, - label=rpad("Show stress levels", w), - initial_option=config.show_happiness, - on_change=function(new) config.show_happiness = new end, + widgets.List{ + frame={t=0}, + view_id='list', + on_submit=self:callback('on_submit'), + row_height=1, + choices = choices, }, } +end - local happinessLabels = {} - - -- align the emoticons - local maxNameLength = 1 - for _, v in pairs(config.happiness_levels) do - local l = #v.name - if l > maxNameLength then - maxNameLength = l - end - end - - local indent = 3 - for lvl, cfg in pairs(config.happiness_levels) do - happinessLabels[#happinessLabels + 1] = ToggleLabel{ - frame={t=4+lvl, h=1, l=indent}, - initial_option=cfg.visible, - text_pen = cfg.pen, - label = rpad(rpad(cfg.name, maxNameLength) .. " " .. cfg.text, w - indent), - on_change = function(new) cfg.visible = new end - } - end - self:addviews(happinessLabels) +function TooltipControlWindow:on_submit(index, choice) + local cfg = choice.data.cfg + local key = choice.data.key + cfg[key] = not cfg[key] end local function GetUnitHappiness(unit) From 5ec1e544798889416e139ce365f99b2d92e81e7b Mon Sep 17 00:00:00 2001 From: Timur Kelman Date: Sat, 1 Feb 2025 21:31:44 +0100 Subject: [PATCH 16/21] persist config (globally) --- gui/tooltips.lua | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/gui/tooltips.lua b/gui/tooltips.lua index e08b97e608..abd9acf8d6 100644 --- a/gui/tooltips.lua +++ b/gui/tooltips.lua @@ -5,6 +5,7 @@ local RELOAD = false -- set to true when actively working on this script local gui = require('gui') +local utils = require('utils') local widgets = require('gui.widgets') local overlay = require('plugins.overlay') @@ -85,6 +86,41 @@ config = config or { }, } +-------------------------------------------------------------------------------- +-- config persistence +local CONFIG_FILE_PATH = 'dfhack-config/tooltips.json' + +local function load_config() + local json = require('json') + + local f = json.open(CONFIG_FILE_PATH) + if f.exists then + -- remove unknown or out of date entries from the loaded config + -- shallow search should be enough + for k in pairs(f.data) do + if config[k] == nil then + f.data[k] = nil + end + end + + -- convert string keys into numbers - workaround json (encoder) limitations + ensure_key(f.data, "happiness_levels") + local t = f.data.happiness_levels + for k, v in pairs(t) do + t[tonumber(k)] = v + t[k] = nil + end + + utils.assign(config, f.data) + end + + f.data = config -- link the config info with the file + f:write() -- possibly update the stored config + return f +end + +local config_file = load_config() + -------------------------------------------------------------------------------- local TITLE = "Tooltips" @@ -182,6 +218,8 @@ function TooltipControlWindow:on_submit(index, choice) local cfg = choice.data.cfg local key = choice.data.key cfg[key] = not cfg[key] + + config_file:write() end local function GetUnitHappiness(unit) From 7abd0f97439203a17d49d8101f446ea5a72cd92d Mon Sep 17 00:00:00 2001 From: Timur Kelman Date: Fri, 21 Feb 2025 21:08:03 +0100 Subject: [PATCH 17/21] Delete docs/gui/tooltips.rst --- docs/gui/tooltips.rst | 23 ----------------------- 1 file changed, 23 deletions(-) delete mode 100644 docs/gui/tooltips.rst diff --git a/docs/gui/tooltips.rst b/docs/gui/tooltips.rst deleted file mode 100644 index b01d076a26..0000000000 --- a/docs/gui/tooltips.rst +++ /dev/null @@ -1,23 +0,0 @@ -gui/tooltips -============ - -.. dfhack-tool:: - :summary: Show name and job tooltips near units on map. - :tags: fort inspection - -**IMPORTANT NOTE**: the tooltips will show over any vanilla UI elements! - - -This script shows "tooltips" in two optional modes: - -* following the mouse, when a unit is underneath the cursor; -* following units on the map. - -Information shown includes happiness indicator, name, and current job. - -Usage ------ - -:: - - gui/tooltips From bb4972ff0c75e66e057c7471c8bfddf480977dba Mon Sep 17 00:00:00 2001 From: Timur Kelman Date: Fri, 21 Feb 2025 21:08:15 +0100 Subject: [PATCH 18/21] Delete gui/tooltips.lua --- gui/tooltips.lua | 482 ----------------------------------------------- 1 file changed, 482 deletions(-) delete mode 100644 gui/tooltips.lua diff --git a/gui/tooltips.lua b/gui/tooltips.lua deleted file mode 100644 index abd9acf8d6..0000000000 --- a/gui/tooltips.lua +++ /dev/null @@ -1,482 +0,0 @@ --- Show tooltips on units and/or mouse - ---@ module = true - -local RELOAD = false -- set to true when actively working on this script - -local gui = require('gui') -local utils = require('utils') -local widgets = require('gui.widgets') -local overlay = require('plugins.overlay') - --------------------------------------------------------------------------------- - --- pens are the same as gui/control-panel.lua -local textures = require('gui.textures') -local function get_icon_pens() - local enabled_pen_left = dfhack.pen.parse{fg=COLOR_CYAN, - tile=curry(textures.tp_control_panel, 1), ch=string.byte('[')} - local enabled_pen_center = dfhack.pen.parse{fg=COLOR_LIGHTGREEN, - tile=curry(textures.tp_control_panel, 2) or nil, ch=251} -- check - local enabled_pen_right = dfhack.pen.parse{fg=COLOR_CYAN, - tile=curry(textures.tp_control_panel, 3) or nil, ch=string.byte(']')} - local disabled_pen_left = dfhack.pen.parse{fg=COLOR_CYAN, - tile=curry(textures.tp_control_panel, 4) or nil, ch=string.byte('[')} - local disabled_pen_center = dfhack.pen.parse{fg=COLOR_RED, - tile=curry(textures.tp_control_panel, 5) or nil, ch=string.byte('x')} - local disabled_pen_right = dfhack.pen.parse{fg=COLOR_CYAN, - tile=curry(textures.tp_control_panel, 6) or nil, ch=string.byte(']')} - local button_pen_left = dfhack.pen.parse{fg=COLOR_CYAN, - tile=curry(textures.tp_control_panel, 7) or nil, ch=string.byte('[')} - local button_pen_right = dfhack.pen.parse{fg=COLOR_CYAN, - tile=curry(textures.tp_control_panel, 8) or nil, ch=string.byte(']')} - local help_pen_center = dfhack.pen.parse{ - tile=curry(textures.tp_control_panel, 9) or nil, ch=string.byte('?')} - local configure_pen_center = dfhack.pen.parse{ - tile=curry(textures.tp_control_panel, 10) or nil, ch=15} -- gear/masterwork symbol - return enabled_pen_left, enabled_pen_center, enabled_pen_right, - disabled_pen_left, disabled_pen_center, disabled_pen_right, - button_pen_left, button_pen_right, - help_pen_center, configure_pen_center -end -local ENABLED_PEN_LEFT, ENABLED_PEN_CENTER, ENABLED_PEN_RIGHT, - DISABLED_PEN_LEFT, DISABLED_PEN_CENTER, DISABLED_PEN_RIGHT, - BUTTON_PEN_LEFT, BUTTON_PEN_RIGHT, - HELP_PEN_CENTER, CONFIGURE_PEN_CENTER = get_icon_pens() - -if RELOAD then ToggleLabel = nil end -ToggleLabel = defclass(ToggleLabel, widgets.CycleHotkeyLabel) -ToggleLabel.ATTRS{ - options={{value=true}, - {value=false}}, -} -function ToggleLabel:init() - ToggleLabel.super.init(self) - - local text = self.text - -- the very last token is the On/Off text -- we'll repurpose it as an indicator - text[#text] = { tile = function() return self:getOptionValue() and ENABLED_PEN_LEFT or DISABLED_PEN_LEFT end } - text[#text + 1] = { tile = function() return self:getOptionValue() and ENABLED_PEN_CENTER or DISABLED_PEN_CENTER end } - text[#text + 1] = { tile = function() return self:getOptionValue() and ENABLED_PEN_RIGHT or DISABLED_PEN_RIGHT end } - self:setText(text) -end - ---- - -if RELOAD then config = nil end -config = config or { - follow_units = true, - follow_mouse = false, - show_unit_jobs = true, - job_shortenings = { - ["Store item in stockpile"] = "Store item", - }, - show_happiness = true, - happiness_levels = { - -- keep in mind, the text will look differently with game's font - -- colors are same as in ASCII mode, but for then middle (3), which is GREY instead of WHITE - [0] = - {text = "=C", pen = COLOR_RED, visible = true, name = "Miserable"}, - {text = ":C", pen = COLOR_LIGHTRED, visible = true, name = "Unhappy"}, - {text = ":(", pen = COLOR_YELLOW, visible = false, name = "Displeased"}, - {text = ":]", pen = COLOR_GREY, visible = false, name = "Content"}, - {text = ":)", pen = COLOR_GREEN, visible = false, name = "Pleased"}, - {text = ":D", pen = COLOR_LIGHTGREEN, visible = true, name = "Happy"}, - {text = "=D", pen = COLOR_LIGHTCYAN, visible = true, name = "Ecstatic"}, - }, -} - --------------------------------------------------------------------------------- --- config persistence -local CONFIG_FILE_PATH = 'dfhack-config/tooltips.json' - -local function load_config() - local json = require('json') - - local f = json.open(CONFIG_FILE_PATH) - if f.exists then - -- remove unknown or out of date entries from the loaded config - -- shallow search should be enough - for k in pairs(f.data) do - if config[k] == nil then - f.data[k] = nil - end - end - - -- convert string keys into numbers - workaround json (encoder) limitations - ensure_key(f.data, "happiness_levels") - local t = f.data.happiness_levels - for k, v in pairs(t) do - t[tonumber(k)] = v - t[k] = nil - end - - utils.assign(config, f.data) - end - - f.data = config -- link the config info with the file - f:write() -- possibly update the stored config - return f -end - -local config_file = load_config() - --------------------------------------------------------------------------------- - -local TITLE = "Tooltips" - -if RELOAD then TooltipControlScreen = nil end -TooltipControlScreen = defclass(TooltipControlScreen, gui.ZScreen) -TooltipControlScreen.ATTRS { - focus_path = "TooltipControlScreen", - pass_movement_keys = true, -} - -function TooltipControlScreen:init() - local controls = TooltipControlWindow{view_id = 'controls'} - self:addviews{controls} -end - -function TooltipControlScreen:onDismiss() - view = nil -end - -if RELOAD then TooltipControlWindow = nil end -TooltipControlWindow = defclass(TooltipControlWindow, widgets.Window) -TooltipControlWindow.ATTRS { - frame_title=TITLE, - frame_inset=0, - resizable=false, - frame = { - w = 27, - h = 2 -- border - + 4 -- main options - + 7 -- happiness - , - -- just under the minimap: - r = 2, - t = 18, - }, -} - -local function make_enabled_text(indent, text, cfg, key) - local function get_enabled_button_token(enabled_tile, disabled_tile, cfg, key) - return { - tile=function() return cfg[key] and enabled_tile or disabled_tile end, - } - end - - local tokens = { - string.format("%" .. indent .. "s", ''), - get_enabled_button_token(ENABLED_PEN_LEFT, DISABLED_PEN_LEFT, cfg, key), - get_enabled_button_token(ENABLED_PEN_CENTER, DISABLED_PEN_CENTER, cfg, key), - get_enabled_button_token(ENABLED_PEN_RIGHT, DISABLED_PEN_RIGHT, cfg, key), - ' ', - } - if type(text) == 'string' then - tokens[#tokens+1] = text - else -- must be a table - -- append it - for _, v in ipairs(text) do - tokens[#tokens+1] = v - end - end - - return tokens -end - -local function make_choice(indent, text, cfg, key) - return { - text=make_enabled_text(indent, text, cfg, key), - data={cfg=cfg, key=key}, - } -end - -function TooltipControlWindow:init() - local choices = {} - table.insert(choices, make_choice(0, "unit banners", config, "follow_units")) - table.insert(choices, make_choice(0, "mouse tooltips", config, "follow_mouse")) - table.insert(choices, make_choice(0, "include jobs", config, "show_unit_jobs")) - table.insert(choices, make_choice(0, "include stress levels", config, "show_happiness")) - for i = 0, #config.happiness_levels do - local cfg = config.happiness_levels[i] - table.insert(choices, make_choice(3, {{text=cfg.text, pen=cfg.pen}, ' ', cfg.name}, cfg, "visible")) - end - - self:addviews{ - widgets.List{ - frame={t=0}, - view_id='list', - on_submit=self:callback('on_submit'), - row_height=1, - choices = choices, - }, - } -end - -function TooltipControlWindow:on_submit(index, choice) - local cfg = choice.data.cfg - local key = choice.data.key - cfg[key] = not cfg[key] - - config_file:write() -end - -local function GetUnitHappiness(unit) - if not config.show_happiness then return end - local stressCat = dfhack.units.getStressCategory(unit) - if stressCat > 6 then stressCat = 6 end - local happiness_level_cfg = config.happiness_levels[stressCat] - if not happiness_level_cfg.visible then return end - return happiness_level_cfg.text, happiness_level_cfg.pen -end - -local function GetUnitJob(unit) - if not config.show_unit_jobs then return end - local job = unit.job.current_job - return job and dfhack.job.getName(job) -end - -local function GetUnitNameAndJob(unit) - local sb = {} - sb[#sb+1] = dfhack.units.getReadableName(unit) - local jobName = GetUnitJob(unit) - if jobName then - sb[#sb+1] = ": " - sb[#sb+1] = jobName - end - return table.concat(sb) -end - -local function GetTooltipText(pos) - if not pos then return end - - local txt = {} - local units = dfhack.units.getUnitsInBox(pos, pos) or {} -- todo: maybe (optionally) use filter parameter here? - - for _,unit in ipairs(units) do - txt[#txt+1] = GetUnitNameAndJob(unit) - txt[#txt+1] = NEWLINE - end - - return txt -end - --------------------------------------------------------------------------------- --- MouseTooltip is an almost copy&paste of the DimensionsTooltip --- -if RELOAD then MouseTooltip = nil end -MouseTooltip = defclass(MouseTooltip, widgets.ResizingPanel) - -MouseTooltip.ATTRS{ - frame_style=gui.FRAME_THIN, - frame_background=gui.CLEAR_PEN, - no_force_pause_badge=true, - auto_width=true, - display_offset={x=3, y=3}, -} - -function MouseTooltip:init() - ensure_key(self, 'frame').w = 17 - self.frame.h = 4 - - self.label = widgets.Label{ - frame={t=0}, - auto_width=true, - } - - self:addviews{ - widgets.Panel{ - -- set minimum size for tooltip frame so the DFHack frame badge fits - frame={t=0, l=0, w=7, h=2}, - }, - self.label, - } -end - -function MouseTooltip:render(dc) - if not config.follow_mouse then return end - - local x, y = dfhack.screen.getMousePos() - if not x then return end - - local pos = dfhack.gui.getMousePos() - local text = GetTooltipText(pos) - if not text or #text == 0 then return end - self.label:setText(text) - - local sw, sh = dfhack.screen.getWindowSize() - local frame_width = math.max(9, self.label:getTextWidth() + 2) - self.frame.l = math.min(x + self.display_offset.x, sw - frame_width) - self.frame.t = math.min(y + self.display_offset.y, sh - self.frame.h) - self:updateLayout() - MouseTooltip.super.render(self, dc) -end - --------------------------------------------------------------------------------- -if RELOAD then TooltipsOverlay = nil end -TooltipsOverlay = defclass(TooltipsOverlay, overlay.OverlayWidget) -TooltipsOverlay.ATTRS{ - desc='Adds tooltips with some info to units.', - default_pos={x=1,y=1}, - default_enabled=true, - fullscreen=true, -- not player-repositionable - viewscreens={ - 'dwarfmode/Default', - }, -} - -function TooltipsOverlay:init() - local tooltip = MouseTooltip{view_id = 'tooltip'} - self:addviews{tooltip} -end - --- map coordinates -> interface layer coordinates -local function GetScreenCoordinates(map_coord) - -- -> map viewport offset - local vp = df.global.world.viewport - local vp_Coord = vp.corner - local map_offset_by_vp = { - x = map_coord.x - vp_Coord.x, - y = map_coord.y - vp_Coord.y, - z = map_coord.z - vp_Coord.z, - } - - if not dfhack.screen.inGraphicsMode() then - return map_offset_by_vp - else - -- -> pixel offset - local gps = df.global.gps - local map_tile_pixels = gps.viewport_zoom_factor // 4; - local screen_coord_px = { - x = map_tile_pixels * map_offset_by_vp.x, - y = map_tile_pixels * map_offset_by_vp.y, - } - -- -> interface layer coordinates - local screen_coord_text = { - x = math.ceil( screen_coord_px.x / gps.tile_pixel_x ), - y = math.ceil( screen_coord_px.y / gps.tile_pixel_y ), - } - - return screen_coord_text - end -end - -function TooltipsOverlay:render(dc) - self:render_unit_banners(dc) - TooltipsOverlay.super.render(self, dc) -end - -function TooltipsOverlay:render_unit_banners(dc) - if not config.follow_units then return end - - if not dfhack.screen.inGraphicsMode() and not gui.blink_visible(500) then - return - end - - local vp = df.global.world.viewport - local topleft = vp.corner - local width = vp.max_x - local height = vp.max_y - local bottomright = {x = topleft.x + width, y = topleft.y + height, z = topleft.z} - - local units = dfhack.units.getUnitsInBox(topleft, bottomright) - if not units or #units == 0 then return end - - local oneTileOffset = GetScreenCoordinates({x = topleft.x + 1, y = topleft.y + 1, z = topleft.z + 0}) - local pen = COLOR_WHITE - - local shortenings = config.job_shortenings - local used_tiles = {} - for i = #units, 1, -1 do - local unit = units[i] - - local happiness, happyPen = GetUnitHappiness(unit) - local job = GetUnitJob(unit) - job = shortenings[job] or job - if not job and not happiness then goto continue end - - local pos = xyz2pos(dfhack.units.getPosition(unit)) - if not pos then goto continue end - - local txt = (happiness and job and happiness .. " " .. job) - or happiness - or job - - local scrPos = GetScreenCoordinates(pos) - local y = scrPos.y - 1 -- subtract 1 to move the text over the heads - local x = scrPos.x + oneTileOffset.x - 1 -- subtract 1 to move the text inside the map tile - - -- to resolve overlaps, we'll mark every coordinate we write anything in, - -- and then check if the new tooltip will overwrite any used coordinate. - -- if it will, try the next row, to a maximum offset of 4. - local row - local dy = 0 - -- todo: search for the "best" offset instead, f.e. max `usedAt` value, with `-1` the best - local usedAt = -1 - for yOffset = 0, 4 do - dy = yOffset - - row = used_tiles[y + dy] - if not row then - row = {} - used_tiles[y + dy] = row - end - - usedAt = -1 - for j = 0, #txt - 1 do - if row[x + j] then - usedAt = j - break - end - end - - if usedAt == -1 then break end - end -- for dy - - -- in case there isn't enough space, cut the text off - if usedAt > 0 then - local s = happiness and #happiness + 1 or 0 - job = job:sub(0, usedAt - s - 1) .. '_' - txt = txt:sub(0, usedAt - 1) .. '_' -- for marking - end - - dc:seek(x, y + dy) - :pen(happyPen):string(happiness or "") - :string((happiness and job) and " " or "") - :pen(pen):string(job or "") - - -- mark coordinates as used - for j = 0, #txt - 1 do - row[x + j] = true - end - - ::continue:: - end -end - -function TooltipsOverlay:preUpdateLayout(parent_rect) - self.frame.w = parent_rect.width - self.frame.h = parent_rect.height -end - ----------------------------------------------------------------- - -OVERLAY_WIDGETS = { - tooltips=TooltipsOverlay, -} - -if dfhack_flags.module then - return -end - -if not dfhack.isMapLoaded() then - qerror('gui/tooltips requires a map to be loaded') -end - -if RELOAD and view then - view:dismiss() - -- view is nil now -end - -view = view and view:raise() or TooltipControlScreen{}:show() From 561eeab61f0ecfba1bcf094db361d99a2154dda9 Mon Sep 17 00:00:00 2001 From: Timur Kelman Date: Fri, 21 Feb 2025 21:09:43 +0100 Subject: [PATCH 19/21] implement gui/spectate.lua --- gui/spectate.lua | 308 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 305 insertions(+), 3 deletions(-) diff --git a/gui/spectate.lua b/gui/spectate.lua index 4cea75855d..04191acd55 100644 --- a/gui/spectate.lua +++ b/gui/spectate.lua @@ -2,17 +2,319 @@ local gui = require('gui') local spectate = require('plugins.spectate') local widgets = require('gui.widgets') +-------------------------------------------------------------------------------- +--- ToggleLabel + +-- pens are the same as gui/control-panel.lua +local textures = require('gui.textures') +local function get_icon_pens() + local enabled_pen_left = dfhack.pen.parse{fg=COLOR_CYAN, + tile=curry(textures.tp_control_panel, 1), ch=string.byte('[')} + local enabled_pen_center = dfhack.pen.parse{fg=COLOR_LIGHTGREEN, + tile=curry(textures.tp_control_panel, 2) or nil, ch=251} -- check + local enabled_pen_right = dfhack.pen.parse{fg=COLOR_CYAN, + tile=curry(textures.tp_control_panel, 3) or nil, ch=string.byte(']')} + local disabled_pen_left = dfhack.pen.parse{fg=COLOR_CYAN, + tile=curry(textures.tp_control_panel, 4) or nil, ch=string.byte('[')} + local disabled_pen_center = dfhack.pen.parse{fg=COLOR_RED, + tile=curry(textures.tp_control_panel, 5) or nil, ch=string.byte('x')} + local disabled_pen_right = dfhack.pen.parse{fg=COLOR_CYAN, + tile=curry(textures.tp_control_panel, 6) or nil, ch=string.byte(']')} + local button_pen_left = dfhack.pen.parse{fg=COLOR_CYAN, + tile=curry(textures.tp_control_panel, 7) or nil, ch=string.byte('[')} + local button_pen_right = dfhack.pen.parse{fg=COLOR_CYAN, + tile=curry(textures.tp_control_panel, 8) or nil, ch=string.byte(']')} + local help_pen_center = dfhack.pen.parse{ + tile=curry(textures.tp_control_panel, 9) or nil, ch=string.byte('?')} + local configure_pen_center = dfhack.pen.parse{ + tile=curry(textures.tp_control_panel, 10) or nil, ch=15} -- gear/masterwork symbol + return enabled_pen_left, enabled_pen_center, enabled_pen_right, + disabled_pen_left, disabled_pen_center, disabled_pen_right, + button_pen_left, button_pen_right, + help_pen_center, configure_pen_center +end +local ENABLED_PEN_LEFT, ENABLED_PEN_CENTER, ENABLED_PEN_RIGHT, + DISABLED_PEN_LEFT, DISABLED_PEN_CENTER, DISABLED_PEN_RIGHT, + BUTTON_PEN_LEFT, BUTTON_PEN_RIGHT, + HELP_PEN_CENTER, CONFIGURE_PEN_CENTER = get_icon_pens() + +ToggleLabel = defclass(ToggleLabel, widgets.CycleHotkeyLabel) +ToggleLabel.ATTRS{ + options={{value=true}, + {value=false}}, +} +function ToggleLabel:init() + ToggleLabel.super.init(self) + + local text = self.text + -- the very last token is the On/Off text -- we'll repurpose it as an indicator + text[#text] = { tile = function() return self:getOptionValue() and ENABLED_PEN_LEFT or DISABLED_PEN_LEFT end } + text[#text + 1] = { tile = function() return self:getOptionValue() and ENABLED_PEN_CENTER or DISABLED_PEN_CENTER end } + text[#text + 1] = { tile = function() return self:getOptionValue() and ENABLED_PEN_RIGHT or DISABLED_PEN_RIGHT end } + self:setText(text) +end + +-------------------------------------------------------------------------------- +--- Spectate config window Spectate = defclass(Spectate, widgets.Window) Spectate.ATTRS { frame_title='Spectate', - frame={w=50, h=45}, + frame={w=35, h=30}, resizable=true, - resize_min={w=50, h=20}, + resize_min={w=35, h=30}, } +local function append(t, args) + for i = 1, #args do + t[#t + 1] = args[i] + end + return t +end + +local function create_toggle_button(cfg, cfg_key, left, top, hotkey, label) + return ToggleLabel{ + frame={t=top,l=left}, + initial_option = cfg[cfg_key], + on_change = function(new, old) cfg[cfg_key] = new; spectate.save_state() end, + key = hotkey, + label = label, + } +end + +local function create_numeric_edit_field(cfg, cfg_key, left, top, hotkey, label) + local editOnSubmit + local ef = widgets.EditField{ + frame={t=top,l=left}, + label_text = label, + text = tostring(cfg[cfg_key]), + modal = true, + key = hotkey, + on_char = function(new_char,text) return '0' <= new_char and new_char <= '9' end, + on_submit = function(text) editOnSubmit(text) end, + } + editOnSubmit = function(text) + if text == '' then + ef:setText(tostring(cfg[cfg_key])) + else + cfg[cfg_key] = tonumber(text) + spectate.save_state() + end + end + + return ef +end + +local function create_toggle_buttons(cfgFollow, keyFollow, cfgHover, keyHover, colFollow, colHover, top) + local tlFollow = create_toggle_button(cfgFollow, keyFollow, colFollow + 2, top) + local tlHover = create_toggle_button(cfgHover, keyHover, colHover + 1, top) + + return tlFollow, tlHover +end + +local function create_row(label, hotkey, suffix, colFollow, colHover, top) + local config = spectate.config + + suffix = suffix or '' + if suffix ~= '' then suffix = '-'..suffix end + + local keyFollow = 'tooltip-follow'..suffix + local keyHover = 'tooltip-hover'..suffix + + local tlFollow, tlHover = create_toggle_buttons(config, keyFollow, config, keyHover, colFollow, colHover, top) + local views = { + widgets.HotkeyLabel{ + frame={t=top,l=0,w=1}, + key = 'CUSTOM_' .. hotkey, + key_sep = '', + on_activate = function() tlFollow:cycle() end, + }, + widgets.HotkeyLabel{ + frame={t=top,l=1,w=1}, + key = 'CUSTOM_SHIFT_' .. hotkey, + key_sep = '', + on_activate = function() tlHover:cycle() end, + }, + widgets.Label{ + frame={t=top,l=2}, + text = ': ' .. label, + }, + tlFollow, + tlHover, + } + + return views +end + +local function make_choice(text, tlFollow, tlHover) + return { + text=text, + data={tlFollow=tlFollow, tlHover=tlHover}, + } +end + +local function pairsByKeys(t, f) + local a = {} + for n in pairs(t) do table.insert(a, n) end + table.sort(a, f) + local i = 0 -- iterator variable + local iter = function () -- iterator function + i = i + 1 + if a[i] == nil then return nil + else return a[i], t[a[i]] + end + end + return iter +end + +local function rpad(s, i) + return string.format("%-"..i.."s", s) +end + +local overlay = require('plugins.overlay') +local OVERLAY_NAME = 'spectate.tooltip' +local function isOverlayEnabled() + return overlay.get_state().config[OVERLAY_NAME].enabled +end + +local function enable_overlay(enabled) + local tokens = {'overlay'} + table.insert(tokens, enabled and 'enable' or 'disable') + table.insert(tokens, OVERLAY_NAME) + dfhack.run_command(tokens) +end + +function Spectate:updateOverlayDisabledGag(widget) + local w = widget or self.subviews.overlayIsDisabledGag + + if isOverlayEnabled() then + if w.frame.t < 500 then + w.frame.t = w.frame.t + 500 + end + else + if w.frame.t > 500 then + w.frame.t = w.frame.t - 500 + end + end + + if not widget then + self:updateLayout() + end +end + function Spectate:init() - self:addviews{ + local config = spectate.config + + local views = {} + local t = 0 + + local len = 20 + append(views, {create_toggle_button(config, 'auto-disengage', 0, t, 'CUSTOM_ALT_D', rpad("Auto disengage", len))}); t = t + 1 + append(views, {create_toggle_button(config, 'auto-unpause', 0, t, 'CUSTOM_ALT_U', rpad("Auto unpause", len))}); t = t + 1 + append(views, {create_toggle_button(config, 'cinematic-action', 0, t, 'CUSTOM_ALT_C', rpad("Cinematic action", len))}); t = t + 1 + append(views, {create_numeric_edit_field(config, 'follow-seconds', 0, t, 'CUSTOM_ALT_F', "Follow (s): ")}); t = t + 1 + append(views, {create_toggle_button(config, 'include-animals', 0, t, 'CUSTOM_ALT_A', rpad("Include animals", len))}); t = t + 1 + append(views, {create_toggle_button(config, 'include-hostiles', 0, t, 'CUSTOM_ALT_H', rpad("Include hostiles", len))}); t = t + 1 + append(views, {create_toggle_button(config, 'include-visitors', 0, t, 'CUSTOM_ALT_V', rpad("Include visitors", len))}); t = t + 1 + append(views, {create_toggle_button(config, 'include-wildlife', 0, t, 'CUSTOM_ALT_W', rpad("Include wildlife", len))}); t = t + 1 + append(views, {create_toggle_button(config, 'prefer-conflict', 0, t, 'CUSTOM_ALT_B', rpad("Prefer conflict", len))}); t = t + 1 + append(views, {create_toggle_button(config, 'prefer-new-arrivals', 0, t, 'CUSTOM_ALT_N', rpad("Prefer new arrivals", len))}); t = t + 1 + + t = t + 1 -- add a blank line + local colFollow, colHover = 15, 25 + -- tooltips headers + append(views, { + widgets.Label{ + frame={t=t,l=0}, + text="Tooltips:" + }, + }) + -- t = t + 1 + -- overlay is prerequisite for any other tooltip option + local overlayIsDisabledGag = nil + local lblOverlayOnChange = function(new, old) + enable_overlay(new) + self:updateOverlayDisabledGag() + end + append(views, { + ToggleLabel{ + frame={t=t,l=12}, + initial_option = isOverlayEnabled(), + on_change = lblOverlayOnChange, + key = 'CUSTOM_ALT_O', + label = "Overlay ", + } + }) + t = t + 1 + overlayIsDisabledGag = widgets.Panel{ + view_id='overlayIsDisabledGag', + frame={t=t,l=0,r=0,b=1}, -- b=1 because the very last row is where the HelpButton is placed + frame_background=gui.CLEAR_PEN, + subviews = { + widgets.WrappedLabel{ + frame={t=0,l=0,r=0,b=0}, + frame_background=gui.CLEAR_PEN, + text_to_wrap="Overlay has to be enabled for tooltips.", + } + }, } + + append(views, { + widgets.Label{ + frame={t=t,l=colFollow}, + text="Follow" + }, + widgets.Label{ + frame={t=t,l=colHover}, + text="Hover" + }, + }) + t = t + 1 + + -- enable/disable + append(views, create_row("Enable", 'E', '', colFollow, colHover, t)); t = t + 1 + append(views, create_row("Job", 'J', 'job', colFollow, colHover, t)); t = t + 1 + append(views, create_row("Name", 'N', 'name', colFollow, colHover, t)); t = t + 1 + append(views, create_row("Stress", 'S', 'stress', colFollow, colHover, t)); t = t + 1 + + -- next are individual stress levels + -- a list on the left to select one, individual buttons in two columns to be able to click on them + local choices = {} + local levels = config['tooltip-stress-levels'] + local stressFollow = config['tooltip-follow-stress-levels'] + local stressHover = config['tooltip-hover-stress-levels'] + local tList = t + for l, cfg in pairsByKeys(levels) do + local tlFollow, tlHover = create_toggle_buttons(stressFollow, l, stressHover, l, colFollow, colHover, t) + append(views, { tlFollow, tlHover }) + + table.insert(choices, make_choice({{text=cfg.text, pen=cfg.pen}, ' ', cfg.name}, tlFollow, tlHover)) + + t = t + 1 + end + append(views,{ + widgets.List{ + frame={t=tList,l=2}, + view_id='list_levels', + on_submit=function(index, choice) choice.data.tlFollow:cycle() end, + on_submit2=function(index, choice) choice.data.tlHover:cycle() end, + row_height=1, + choices = choices, + }, + }) + + append(views, {create_numeric_edit_field(config, 'tooltip-follow-blink-milliseconds', 0, t, 'CUSTOM_B', "Blink duration (ms): ")}); t = t + 1 + + append(views, { + widgets.HelpButton{ + frame={b=0,r=0}, + command = 'spectate', + } + }) + + append(views, {overlayIsDisabledGag}) -- must be the very last thing + self:updateOverlayDisabledGag(overlayIsDisabledGag) + + self:addviews(views) end SpectateScreen = defclass(SpectateScreen, gui.ZScreen) From 3c3fa0575d3b9c0876431b349643ea232c95bd04 Mon Sep 17 00:00:00 2001 From: Timur Kelman Date: Fri, 21 Feb 2025 21:24:42 +0100 Subject: [PATCH 20/21] adjust starting position --- gui/spectate.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/spectate.lua b/gui/spectate.lua index 04191acd55..dabfe5b42e 100644 --- a/gui/spectate.lua +++ b/gui/spectate.lua @@ -59,7 +59,7 @@ end Spectate = defclass(Spectate, widgets.Window) Spectate.ATTRS { frame_title='Spectate', - frame={w=35, h=30}, + frame={l=3, t=5, w=35, h=30}, resizable=true, resize_min={w=35, h=30}, } From 46689c23bb9b338a9e785c481221630c8694e265 Mon Sep 17 00:00:00 2001 From: Myk Taylor Date: Sat, 22 Feb 2025 02:44:46 -0800 Subject: [PATCH 21/21] refactor UI and use upstreamed functionality --- docs/gui/spectate.rst | 3 + gui/spectate.lua | 354 ++++++++++++++++-------------------------- 2 files changed, 140 insertions(+), 217 deletions(-) diff --git a/docs/gui/spectate.rst b/docs/gui/spectate.rst index e6034cbf80..802d4d3dac 100644 --- a/docs/gui/spectate.rst +++ b/docs/gui/spectate.rst @@ -8,6 +8,9 @@ gui/spectate This is an in-game configuration interface for `spectate`, which automatically sets the camera to follow interesting units. +You can configure the overlay tooltip settings as well as the follow mode +settings. + Usage ----- diff --git a/gui/spectate.lua b/gui/spectate.lua index dabfe5b42e..3c708be7a1 100644 --- a/gui/spectate.lua +++ b/gui/spectate.lua @@ -1,12 +1,16 @@ local gui = require('gui') +local overlay = require('plugins.overlay') local spectate = require('plugins.spectate') +local textures = require('gui.textures') +local utils = require('utils') local widgets = require('gui.widgets') +local OVERLAY_NAME = 'spectate.tooltip' + -------------------------------------------------------------------------------- --- ToggleLabel -- pens are the same as gui/control-panel.lua -local textures = require('gui.textures') local function get_icon_pens() local enabled_pen_left = dfhack.pen.parse{fg=COLOR_CYAN, tile=curry(textures.tp_control_panel, 1), ch=string.byte('[')} @@ -20,32 +24,15 @@ local function get_icon_pens() tile=curry(textures.tp_control_panel, 5) or nil, ch=string.byte('x')} local disabled_pen_right = dfhack.pen.parse{fg=COLOR_CYAN, tile=curry(textures.tp_control_panel, 6) or nil, ch=string.byte(']')} - local button_pen_left = dfhack.pen.parse{fg=COLOR_CYAN, - tile=curry(textures.tp_control_panel, 7) or nil, ch=string.byte('[')} - local button_pen_right = dfhack.pen.parse{fg=COLOR_CYAN, - tile=curry(textures.tp_control_panel, 8) or nil, ch=string.byte(']')} - local help_pen_center = dfhack.pen.parse{ - tile=curry(textures.tp_control_panel, 9) or nil, ch=string.byte('?')} - local configure_pen_center = dfhack.pen.parse{ - tile=curry(textures.tp_control_panel, 10) or nil, ch=15} -- gear/masterwork symbol return enabled_pen_left, enabled_pen_center, enabled_pen_right, - disabled_pen_left, disabled_pen_center, disabled_pen_right, - button_pen_left, button_pen_right, - help_pen_center, configure_pen_center + disabled_pen_left, disabled_pen_center, disabled_pen_right end local ENABLED_PEN_LEFT, ENABLED_PEN_CENTER, ENABLED_PEN_RIGHT, - DISABLED_PEN_LEFT, DISABLED_PEN_CENTER, DISABLED_PEN_RIGHT, - BUTTON_PEN_LEFT, BUTTON_PEN_RIGHT, - HELP_PEN_CENTER, CONFIGURE_PEN_CENTER = get_icon_pens() + DISABLED_PEN_LEFT, DISABLED_PEN_CENTER, DISABLED_PEN_RIGHT = get_icon_pens() -ToggleLabel = defclass(ToggleLabel, widgets.CycleHotkeyLabel) -ToggleLabel.ATTRS{ - options={{value=true}, - {value=false}}, -} -function ToggleLabel:init() - ToggleLabel.super.init(self) +ToggleLabel = defclass(ToggleLabel, widgets.ToggleHotkeyLabel) +function ToggleLabel:init() local text = self.text -- the very last token is the On/Off text -- we'll repurpose it as an indicator text[#text] = { tile = function() return self:getOptionValue() and ENABLED_PEN_LEFT or DISABLED_PEN_LEFT end } @@ -59,90 +46,79 @@ end Spectate = defclass(Spectate, widgets.Window) Spectate.ATTRS { frame_title='Spectate', - frame={l=3, t=5, w=35, h=30}, - resizable=true, - resize_min={w=35, h=30}, + frame={l=5, t=5, w=36, h=39}, } -local function append(t, args) - for i = 1, #args do - t[#t + 1] = args[i] - end - return t -end - -local function create_toggle_button(cfg, cfg_key, left, top, hotkey, label) +local function create_toggle_button(frame, cfg_elem, hotkey, label, cfg_elem_key) return ToggleLabel{ - frame={t=top,l=left}, - initial_option = cfg[cfg_key], - on_change = function(new, old) cfg[cfg_key] = new; spectate.save_state() end, - key = hotkey, - label = label, + frame=frame, + initial_option=spectate.get_config_elem(cfg_elem, cfg_elem_key), + on_change=function(val) dfhack.run_command('spectate', 'set', cfg_elem, tostring(val)) end, + key=hotkey, + label=label, } end -local function create_numeric_edit_field(cfg, cfg_key, left, top, hotkey, label) +local function create_numeric_edit_field(frame, cfg_elem, hotkey, label) local editOnSubmit local ef = widgets.EditField{ - frame={t=top,l=left}, + frame=frame, label_text = label, - text = tostring(cfg[cfg_key]), + text = tostring(spectate.get_config_elem(cfg_elem)), modal = true, key = hotkey, - on_char = function(new_char,text) return '0' <= new_char and new_char <= '9' end, + on_char = function(ch) return ch:match('%d') end, on_submit = function(text) editOnSubmit(text) end, } editOnSubmit = function(text) if text == '' then - ef:setText(tostring(cfg[cfg_key])) + ef:setText(tostring(spectate.get_config_elem(cfg_elem))) else - cfg[cfg_key] = tonumber(text) - spectate.save_state() + dfhack.run_command('spectate', 'set', cfg_elem, text) end end return ef end -local function create_toggle_buttons(cfgFollow, keyFollow, cfgHover, keyHover, colFollow, colHover, top) - local tlFollow = create_toggle_button(cfgFollow, keyFollow, colFollow + 2, top) - local tlHover = create_toggle_button(cfgHover, keyHover, colHover + 1, top) - +local function create_row_toggle_buttons(keyFollow, keyHover, colFollow, colHover, cfg_elem_key) + local tlFollow = create_toggle_button({l=colFollow+2}, keyFollow, nil, nil, cfg_elem_key) + local tlHover = create_toggle_button({l=colHover+1}, keyHover, nil, nil, cfg_elem_key) return tlFollow, tlHover end -local function create_row(label, hotkey, suffix, colFollow, colHover, top) - local config = spectate.config - +local function create_row(frame, label, hotkey, suffix, colFollow, colHover) suffix = suffix or '' if suffix ~= '' then suffix = '-'..suffix end local keyFollow = 'tooltip-follow'..suffix local keyHover = 'tooltip-hover'..suffix - local tlFollow, tlHover = create_toggle_buttons(config, keyFollow, config, keyHover, colFollow, colHover, top) - local views = { - widgets.HotkeyLabel{ - frame={t=top,l=0,w=1}, - key = 'CUSTOM_' .. hotkey, - key_sep = '', - on_activate = function() tlFollow:cycle() end, - }, - widgets.HotkeyLabel{ - frame={t=top,l=1,w=1}, - key = 'CUSTOM_SHIFT_' .. hotkey, - key_sep = '', - on_activate = function() tlHover:cycle() end, + local tlFollow, tlHover = create_row_toggle_buttons(keyFollow, keyHover, colFollow, colHover) + + return widgets.Panel{ + frame=utils.assign({h=1}, frame), + subviews={ + widgets.HotkeyLabel{ + frame={l=0,w=1}, + key='CUSTOM_' .. hotkey, + key_sep='', + on_activate=function() tlFollow:cycle() end, + }, + widgets.HotkeyLabel{ + frame={l=1,w=1}, + key='CUSTOM_SHIFT_' .. hotkey, + key_sep='', + on_activate=function() tlHover:cycle() end, + }, + widgets.Label{ + frame={l=2}, + text = ': ' .. label, + }, + tlFollow, + tlHover, }, - widgets.Label{ - frame={t=top,l=2}, - text = ': ' .. label, - }, - tlFollow, - tlHover, } - - return views end local function make_choice(text, tlFollow, tlHover) @@ -152,169 +128,113 @@ local function make_choice(text, tlFollow, tlHover) } end -local function pairsByKeys(t, f) - local a = {} - for n in pairs(t) do table.insert(a, n) end - table.sort(a, f) - local i = 0 -- iterator variable - local iter = function () -- iterator function - i = i + 1 - if a[i] == nil then return nil - else return a[i], t[a[i]] - end - end - return iter -end +-- individual stress levels +-- a list on the left to select one, individual buttons in two columns to be able to click on them +local function create_stress_list(frame, colFollow, colHover) + local levelsKey = 'tooltip-stress-levels' + local stressFollowKey = 'tooltip-follow-stress-levels' + local stressHoverKey = 'tooltip-hover-stress-levels' + + local choices, subviews = {}, {} + for idx=0,6 do + local cfgElemKey = tostring(idx) + local tlFollow, tlHover = create_row_toggle_buttons(stressFollowKey, stressHoverKey, colFollow, colHover, cfgElemKey) + table.insert(subviews, widgets.Panel{ + frame={t=idx, h=1}, + subviews={ + tlFollow, + tlHover, + } + }) -local function rpad(s, i) - return string.format("%-"..i.."s", s) -end + local elem = spectate.get_config_elem(levelsKey, cfgElemKey) + table.insert(choices, make_choice({{text=elem.text, pen=elem.pen}, ' ', elem.name}, tlFollow, tlHover)) + end -local overlay = require('plugins.overlay') -local OVERLAY_NAME = 'spectate.tooltip' -local function isOverlayEnabled() - return overlay.get_state().config[OVERLAY_NAME].enabled -end + table.insert(subviews, widgets.List{ + frame={l=2}, + on_submit=function(_, choice) choice.data.tlFollow:cycle() end, + on_submit2=function(_, choice) choice.data.tlHover:cycle() end, + choices=choices, + }) -local function enable_overlay(enabled) - local tokens = {'overlay'} - table.insert(tokens, enabled and 'enable' or 'disable') - table.insert(tokens, OVERLAY_NAME) - dfhack.run_command(tokens) + return widgets.Panel{ + frame=frame, + subviews=subviews, + } end -function Spectate:updateOverlayDisabledGag(widget) - local w = widget or self.subviews.overlayIsDisabledGag - - if isOverlayEnabled() then - if w.frame.t < 500 then - w.frame.t = w.frame.t + 500 - end - else - if w.frame.t > 500 then - w.frame.t = w.frame.t - 500 - end - end - - if not widget then - self:updateLayout() - end +local function rpad(s, i) + return string.format("%-"..i.."s", s) end function Spectate:init() - local config = spectate.config - - local views = {} - local t = 0 - - local len = 20 - append(views, {create_toggle_button(config, 'auto-disengage', 0, t, 'CUSTOM_ALT_D', rpad("Auto disengage", len))}); t = t + 1 - append(views, {create_toggle_button(config, 'auto-unpause', 0, t, 'CUSTOM_ALT_U', rpad("Auto unpause", len))}); t = t + 1 - append(views, {create_toggle_button(config, 'cinematic-action', 0, t, 'CUSTOM_ALT_C', rpad("Cinematic action", len))}); t = t + 1 - append(views, {create_numeric_edit_field(config, 'follow-seconds', 0, t, 'CUSTOM_ALT_F', "Follow (s): ")}); t = t + 1 - append(views, {create_toggle_button(config, 'include-animals', 0, t, 'CUSTOM_ALT_A', rpad("Include animals", len))}); t = t + 1 - append(views, {create_toggle_button(config, 'include-hostiles', 0, t, 'CUSTOM_ALT_H', rpad("Include hostiles", len))}); t = t + 1 - append(views, {create_toggle_button(config, 'include-visitors', 0, t, 'CUSTOM_ALT_V', rpad("Include visitors", len))}); t = t + 1 - append(views, {create_toggle_button(config, 'include-wildlife', 0, t, 'CUSTOM_ALT_W', rpad("Include wildlife", len))}); t = t + 1 - append(views, {create_toggle_button(config, 'prefer-conflict', 0, t, 'CUSTOM_ALT_B', rpad("Prefer conflict", len))}); t = t + 1 - append(views, {create_toggle_button(config, 'prefer-new-arrivals', 0, t, 'CUSTOM_ALT_N', rpad("Prefer new arrivals", len))}); t = t + 1 - - t = t + 1 -- add a blank line + local lWidth = 21 local colFollow, colHover = 15, 25 - -- tooltips headers - append(views, { + + self:addviews{ widgets.Label{ - frame={t=t,l=0}, - text="Tooltips:" + frame={t=0, l=0}, + text='See help for option details:', + }, + widgets.HelpButton{ + frame={t=0, r=0}, + command = 'spectate', }, - }) - -- t = t + 1 - -- overlay is prerequisite for any other tooltip option - local overlayIsDisabledGag = nil - local lblOverlayOnChange = function(new, old) - enable_overlay(new) - self:updateOverlayDisabledGag() - end - append(views, { ToggleLabel{ - frame={t=t,l=12}, - initial_option = isOverlayEnabled(), - on_change = lblOverlayOnChange, - key = 'CUSTOM_ALT_O', - label = "Overlay ", - } - }) - t = t + 1 - overlayIsDisabledGag = widgets.Panel{ - view_id='overlayIsDisabledGag', - frame={t=t,l=0,r=0,b=1}, -- b=1 because the very last row is where the HelpButton is placed - frame_background=gui.CLEAR_PEN, - subviews = { - widgets.WrappedLabel{ - frame={t=0,l=0,r=0,b=0}, - frame_background=gui.CLEAR_PEN, - text_to_wrap="Overlay has to be enabled for tooltips.", - } + frame={t=2}, + view_id='spectate_mode', + initial_option=spectate.isEnabled(), + on_change=function(val) dfhack.run_command(val and 'enable' or 'disable', 'spectate') end, + key='CUSTOM_ALT_E', + label='Spectate mode ', + }, + create_numeric_edit_field({t=4}, 'follow-seconds', 'CUSTOM_ALT_F', 'Switch target (sec): '), + create_toggle_button({t=6}, 'auto-unpause', 'CUSTOM_ALT_U', rpad('Auto unpause', lWidth)), + create_toggle_button({t=7}, 'cinematic-action', 'CUSTOM_ALT_C', rpad('Cinematic action', lWidth)), + create_toggle_button({t=8}, 'include-animals', 'CUSTOM_ALT_A', rpad('Include animals', lWidth)), + create_toggle_button({t=9}, 'include-hostiles', 'CUSTOM_ALT_H', rpad('Include hostiles', lWidth)), + create_toggle_button({t=10}, 'include-visitors', 'CUSTOM_ALT_V', rpad('Include visitors', lWidth)), + create_toggle_button({t=11}, 'include-wildlife', 'CUSTOM_ALT_W', rpad('Include wildlife', lWidth)), + create_toggle_button({t=12}, 'prefer-conflict', 'CUSTOM_ALT_B', rpad('Prefer conflict', lWidth)), + create_toggle_button({t=13}, 'prefer-new-arrivals', 'CUSTOM_ALT_N', rpad('Prefer new arrivals', lWidth)), + widgets.Divider{ + frame={t=15, h=1}, + frame_style=gui.FRAME_THIN, + frame_style_l=false, + frame_style_r=false, }, - } - - append(views, { widgets.Label{ - frame={t=t,l=colFollow}, - text="Follow" + frame={t=17, l=0}, + text="Tooltips:" + }, + ToggleLabel{ + frame={t=17, l=12}, + initial_option=overlay.isOverlayEnabled(OVERLAY_NAME), + on_change=function(val) dfhack.run_command('overlay', val and 'enable' or 'disable', OVERLAY_NAME) end, + key='CUSTOM_ALT_O', + label="Overlay ", }, widgets.Label{ - frame={t=t,l=colHover}, - text="Hover" + frame={t=19, l=colFollow}, + text='Follow', }, - }) - t = t + 1 - - -- enable/disable - append(views, create_row("Enable", 'E', '', colFollow, colHover, t)); t = t + 1 - append(views, create_row("Job", 'J', 'job', colFollow, colHover, t)); t = t + 1 - append(views, create_row("Name", 'N', 'name', colFollow, colHover, t)); t = t + 1 - append(views, create_row("Stress", 'S', 'stress', colFollow, colHover, t)); t = t + 1 - - -- next are individual stress levels - -- a list on the left to select one, individual buttons in two columns to be able to click on them - local choices = {} - local levels = config['tooltip-stress-levels'] - local stressFollow = config['tooltip-follow-stress-levels'] - local stressHover = config['tooltip-hover-stress-levels'] - local tList = t - for l, cfg in pairsByKeys(levels) do - local tlFollow, tlHover = create_toggle_buttons(stressFollow, l, stressHover, l, colFollow, colHover, t) - append(views, { tlFollow, tlHover }) - - table.insert(choices, make_choice({{text=cfg.text, pen=cfg.pen}, ' ', cfg.name}, tlFollow, tlHover)) - - t = t + 1 - end - append(views,{ - widgets.List{ - frame={t=tList,l=2}, - view_id='list_levels', - on_submit=function(index, choice) choice.data.tlFollow:cycle() end, - on_submit2=function(index, choice) choice.data.tlHover:cycle() end, - row_height=1, - choices = choices, + widgets.Label{ + frame={t=19, l=colHover}, + text='Hover', }, - }) - - append(views, {create_numeric_edit_field(config, 'tooltip-follow-blink-milliseconds', 0, t, 'CUSTOM_B', "Blink duration (ms): ")}); t = t + 1 - - append(views, { - widgets.HelpButton{ - frame={b=0,r=0}, - command = 'spectate', - } - }) - - append(views, {overlayIsDisabledGag}) -- must be the very last thing - self:updateOverlayDisabledGag(overlayIsDisabledGag) + create_row({t=21}, 'Enabled', 'E', '', colFollow, colHover), + create_numeric_edit_field({t=23}, 'tooltip-follow-blink-milliseconds', 'CUSTOM_B', 'Blink period (ms): '), + create_row({t=25}, 'Job', 'J', 'job', colFollow, colHover), + create_row({t=26}, 'Name', 'N', 'name', colFollow, colHover), + create_row({t=27}, 'Stress', 'S', 'stress', colFollow, colHover), + create_stress_list({t=28}, colFollow, colHover), + } +end - self:addviews(views) +function Spectate:render(dc) + self.subviews.spectate_mode:setOption(spectate.isEnabled()) + Spectate.super.render(self, dc) end SpectateScreen = defclass(SpectateScreen, gui.ZScreen)