bug: when frame_style is nil, Panel_end_drag will never be called to end a Panel resize drag after Panel_begin_drag
https://github.com/DFHack/dfhack/blob/e3165854e80411eec584c74949c73000f2aa0dcb/library/lua/gui/widgets/containers/panel.lua#L497C1-L498C1
when ---@field resizable boolean is set to true, Panel_begin_drag will be reached but Panel_end_drag will never be reached.
function Panel:onRenderFrame(dc, rect)
Panel.super.onRenderFrame(self, dc, rect)
-- undocumented behaviour causing drag to be stuck without Panel_end_drag ever called
-- early exits
if not self.frame_style then return end
local inactive = self.parent_view and self.parent_view.hasFocus
and not self.parent_view:hasFocus()
local pause_forced = not self.no_force_pause_badge and safe_index(self.parent_view, 'force_pause')
gui.paint_frame(dc, rect, self.frame_style, self.frame_title, inactive,
pause_forced, self.resizable)
if self.kbd_get_pos then
local pos = self.kbd_get_pos()
local pen = to_pen{fg=COLOR_GREEN, bg=COLOR_BLACK}
dc:seek(pos.x, pos.y):pen(pen):char(string.char(0xDB))
end
if self.drag_offset and not self.kbd_get_pos
and df.global.enabler.mouse_lbut_down == 0 then
-- desired callflow should reach here to properly finish a Panel drag
Panel_end_drag(self, nil, true)
end
end
side note, when studying journal.lua learning how to use a resizable panel, I noticed there was collapsed and a typo-ed colllapsed which is always nil
since not colllapsed is always true, unintended performance lost when collapsing table_of_contents_panel.
https://github.com/DFHack/scripts/blob/8a2c8e7b13d703da672532d408e4250dd11a86c9/gui/journal.lua#L75
shifter.Shifter{
view_id='shifter',
frame={l=0, w=1, t=1, b=2},
-- the intended name is collapsed here
collapsed=not toc_visible,
on_changed = function (collapsed)
self.subviews.table_of_contents_panel.visible = not collapsed
self.subviews.table_of_contents_divider.visible = not collapsed
-- if statement always true because it's spelled "colllapsed"
if not colllapsed then
self:reloadTableOfContents()
end
self:ensurePanelsRelSize()
self:updateLayout()
end,
},
PR created: https://github.com/DFHack/scripts/pull/1469
does this have operational consequences? can you demonstrate a situation where this produces undesired behavior?
Demo
The very first left mouse, on the divider
triggered the Panel_begin_drag event in Panel::onRender.
I did not hold my left mouse down further, the Panel has soft-locked, consumes all mouse and keyboard input.
The effective way out of the soft-lock is is to right mouse click, but resets the intended resize drag and resets it to original through Panel::OnInput.
https://github.com/DFHack/dfhack/blob/e3165854e80411eec584c74949c73000f2aa0dcb/library/lua/gui/widgets/containers/panel.lua#L298-L305
Demo Video
https://github.com/user-attachments/assets/fa5c5ab7-20af-48ba-a000-de7f5097e718
Demo Script
This is the script used in the video to demonstrate the bug live, give it a try.
--@module = true
local gui = require('gui')
local widgets = require('gui.widgets')
local INVISIBLE_FRAME = {
frame_pen=gui.CLEAR_PEN,
signature_pen=false,
}
-- MAIN BUG TRIGGERED HERE
-----------------------------------------
BugDemoPanel = defclass(BugDemoPanel, widgets.Panel)
BugDemoPanel.ATTRS {
--frame_style = INVISIBLE_FRAME
-- ^ This is the key part that triggers the bug.
-- if style is nil, it will be nil and panel.lua::Panel_end_drag will never be called
-- https://github.com/DFHack/dfhack/blob/e3165854e80411eec584c74949c73000f2aa0dcb/library/lua/gui/widgets/containers/panel.lua#L497C1-L498C1
-- even if user intends to finish drag
}
-----------------------------------------
function BugDemoPanel:onInput(keys)
local key_info = {}
for key, value in pairs(keys) do
table.insert(key_info, string.format("%s=%s", tostring(key), tostring(value)))
end
table.sort(key_info)
return BugDemoPanel.super.onInput(self, keys)
end
BugDemoWindow = defclass(BugDemoWindow, widgets.Window)
BugDemoWindow.ATTRS {
view_id = 'bugdemo_window',
frame_title = 'Frame Style Bug Demo',
resizable = true,
resize_min = {w = 54, h = 20},
frame_inset = {l = 0, r = 0, t = 0, b = 0}
}
BugDemoScreen = defclass(BugDemoScreen, gui.ZScreen)
BugDemoScreen.ATTRS {
view_id = 'bugdemo_screen',
focus_path = 'bugdemo',
pass_movement_keys = true,
defocusable = true
}
function BugDemoWindow:init()
self.resizing_panels = false
self:addviews{
BugDemoPanel {
view_id = 'category_panel',
frame = {l = 0, w = 22, t = 0, b = 1},
frame_inset = {l = 1, t = 0, b = 1, r = 1},
resizable = true,
resize_anchors = {l = false, t = false, r = true, b = true},
resize_min = {w = 24},
on_resize_begin = self:callback('onPanelResizeBegin'),
on_resize_end = self:callback('onPanelResizeEnd'),
subviews = {
widgets.Label {
frame = {t = 0, l = 0},
text = 'category_panel',
text_pen = COLOR_YELLOW
}
}
},
widgets.Divider {
view_id = 'divider1',
frame = {l = 30, t = 0, b = 2, w = 1},
interior_b = true,
frame_style_t = false
},
widgets.Panel {
view_id = 'item_panel',
frame = {t = 1, b = 3, l = 25, r = 0},
resize_min = {w = 30, h = 10},
frame_inset = {l = 1, r = 0},
subviews = {
widgets.Label {
view_id = 'item_header',
frame = {t = 0, l = 0, r = 0, h = 1},
text = 'item_panel',
text_pen = COLOR_YELLOW
}
}
},
widgets.HelpButton {
command = "gui/stockmonitor",
frame = {r = 0, t = 1}
},
widgets.Divider {
frame = {l = 0, r = 0, b = 2, h = 1},
frame_style_l = false,
frame_style_r = false,
interior_l = true
},
widgets.Panel {
frame = {l = 0, r = 0, b = 1, h = 1},
frame_inset = {l = 1, r = 1, t = 0, w = 100},
subviews = {
widgets.HotkeyLabel {
frame = {l = 0},
key = 'CUSTOM_CTRL_R',
label = 'Button A',
auto_width = true,
on_activate = function()
self:testResize()
end
},
widgets.HotkeyLabel {
frame = {l = 25},
key = 'CUSTOM_CTRL_O',
label = 'Button B',
auto_width = true,
on_activate = function()
end
}
}
}
}
end
function BugDemoWindow:testResize()
local category_panel = self.subviews.category_panel
if category_panel then
category_panel.frame.w = (category_panel.frame.w == 22) and 32 or 22
self:ensurePanelsRelSize()
self:updateLayout()
end
end
function BugDemoWindow:onPanelResizeBegin()
self.resizing_panels = true
end
function BugDemoWindow:onPanelResizeEnd()
self.resizing_panels = false
self:ensurePanelsRelSize()
self:updateLayout()
end
function BugDemoWindow:ensurePanelsRelSize()
local category_panel = self.subviews.category_panel
local item_panel = self.subviews.item_panel
local divider1 = self.subviews.divider1
if not category_panel or not item_panel or not divider1 then return end
local main_width = self.frame.w or 100
local min_item_width = 30
category_panel.frame.w =
math.min(math.max(category_panel.frame.w, 24),
main_width - min_item_width)
local cat_width = category_panel.frame.w
item_panel.frame.l = category_panel.visible and cat_width or 1
divider1.frame.l = item_panel.frame.l - 1
end
function BugDemoWindow:onRenderBody(dc)
if self.resizing_panels then
self:ensurePanelsRelSize()
self:updateLayout()
end
return BugDemoWindow.super.onRenderBody(self, dc)
end
function BugDemoWindow:preUpdateLayout()
self:ensurePanelsRelSize()
end
function BugDemoWindow:onInput(keys)
local key_info = {}
for key, value in pairs(keys) do
table.insert(key_info, string.format("%s=%s", tostring(key), tostring(value)))
end
table.sort(key_info)
return BugDemoWindow.super.onInput(self, keys)
end
function BugDemoScreen:init()
local optimal_size = self:calculateOptimalFrameSize()
self:addviews{BugDemoWindow {frame = optimal_size}}
end
function BugDemoScreen:calculateOptimalFrameSize()
local base_width = 100
local base_height = 35
local optimal_width = math.max(base_width, 90)
local optimal_height = math.max(base_height, 25)
return {w = optimal_width, h = optimal_height}
end
function BugDemoScreen:onDismiss()
bug_demo_screen = nil
end
bug_demo_screen = bug_demo_screen or nil
if dfhack_flags.module then
return
end
-- Main entry point
-- if not dfhack.isWorldLoaded() then
-- qerror("This script requires a loaded world")
-- end
-- Check for existing instance
if bug_demo_screen and not bug_demo_screen._native then
bug_demo_screen = nil
end
if bug_demo_screen then
local success, is_dismissed = pcall(function()
return bug_demo_screen:isDismissed()
end)
if not success or is_dismissed then
bug_demo_screen = nil
else
return
end
end
-- Create and show new instance
bug_demo_screen = BugDemoScreen{}:show()
... to fix the issue, the scripter has to ensure a frame_style that is non-nil.
-- MAIN BUG TRIGGERED HERE
-----------------------------------------
BugDemoPanel = defclass(BugDemoPanel, widgets.Panel)
BugDemoPanel.ATTRS {
--frame_style = INVISIBLE_FRAME
-- ^ This is the key part that triggers the bug.
-- if style is nil, it will be nil and panel.lua::Panel_end_drag will never be called
-- https://github.com/DFHack/dfhack/blob/e3165854e80411eec584c74949c73000f2aa0dcb/library/lua/gui/widgets/containers/panel.lua#L497C1-L498C1
-- even if user intends to finish drag
}
-----------------------------------------
The API docs did not mention frame_style must be set for resizable = true to work.
-- undocumented behaviour causing drag to be stuck without Panel_end_drag ever called
-- early exits
if not self.frame_style then return end