dfhack icon indicating copy to clipboard operation
dfhack copied to clipboard

bug: when frame_style is nil, Panel_end_drag will never be called to end a Panel resize drag after Panel_begin_drag

Open LouisGameDev opened this issue 7 months ago • 4 comments

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

LouisGameDev avatar Jun 26 '25 15:06 LouisGameDev

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

LouisGameDev avatar Jun 26 '25 20:06 LouisGameDev

does this have operational consequences? can you demonstrate a situation where this produces undesired behavior?

ab9rf avatar Jul 03 '25 12:07 ab9rf

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()

LouisGameDev avatar Jul 03 '25 20:07 LouisGameDev

... 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

LouisGameDev avatar Jul 03 '25 20:07 LouisGameDev