-- Nova UI 1.0.0
-- Original Drawing-based interface library for Nova's external Lua 5.4 VM.
-- Documentation: https://externals.gg/docs/ui-library

local NovaUI = {
    Version = "1.0.0",
    Windows = {},
    Themes = {},
    Flags = {},
    _drawings = {},
    _drawingOwners = {},
    _notifications = {},
    _running = true,
    _connection = nil,
    _modal = nil,
}

assert(type(Drawing) == "table" and type(Drawing.new) == "function", "Nova UI requires the Drawing API")
assert(type(Vector2) == "table" and type(Color3) == "table", "Nova UI requires Vector2 and Color3")

local RunService = game:GetService("RunService")
local UserInputService = game:GetService("UserInputService")
local HttpService = game:GetService("HttpService")

local environment = getgenv()
if type(environment.NovaUI) == "table" and type(environment.NovaUI.Destroy) == "function" then
    pcall(function() environment.NovaUI:Destroy() end)
end

local function clamp(value, minimum, maximum)
    value = tonumber(value) or minimum
    if value < minimum then return minimum end
    if value > maximum then return maximum end
    return value
end

local function round(value, places)
    local power = 10 ^ (places or 0)
    return math.floor(value * power + 0.5) / power
end

local function copy(source)
    local out = {}
    for key, value in pairs(source or {}) do
        out[key] = type(value) == "table" and copy(value) or value
    end
    return out
end

local function merge(base, override)
    local out = copy(base)
    for key, value in pairs(override or {}) do out[key] = value end
    return out
end

local function safe(callback, ...)
    if type(callback) ~= "function" then return true end
    local ok, result = pcall(callback, ...)
    if not ok then warn("[Nova UI] " .. tostring(result)) end
    return ok, result
end

local function color(r, g, b)
    return Color3.fromRGB(r, g, b)
end

local function colorMix(a, b, alpha)
    alpha = clamp(alpha, 0, 1)
    return Color3.new(
        a.R + (b.R - a.R) * alpha,
        a.G + (b.G - a.G) * alpha,
        a.B + (b.B - a.B) * alpha
    )
end

local function colorToHex(value)
    local r = math.floor(clamp(value.R, 0, 1) * 255 + 0.5)
    local g = math.floor(clamp(value.G, 0, 1) * 255 + 0.5)
    local b = math.floor(clamp(value.B, 0, 1) * 255 + 0.5)
    return string.format("#%02X%02X%02X", r, g, b)
end

local function normalizeConfig(value)
    return type(value) == "table" and value or { Title = tostring(value or "") }
end

local function containsText(haystack, needle)
    needle = tostring(needle or ""):lower()
    if needle == "" then return true end
    return tostring(haystack or ""):lower():find(needle, 1, true) ~= nil
end

local function sanitizeName(value)
    value = tostring(value or "default"):gsub("[^%w%-%_]", "_")
    return value ~= "" and value or "default"
end

NovaUI.Themes.Nova = {
    Accent = color(242, 97, 115),
    AccentSoft = color(78, 31, 39),
    Background = color(8, 8, 10),
    Surface = color(14, 14, 17),
    SurfaceRaised = color(19, 19, 23),
    Control = color(27, 27, 32),
    ControlHover = color(34, 34, 40),
    Border = color(45, 45, 52),
    Text = color(241, 241, 244),
    TextSoft = color(188, 188, 198),
    Muted = color(128, 128, 142),
    Success = color(99, 212, 154),
    Warning = color(239, 184, 86),
    Danger = color(242, 97, 115),
}

NovaUI.Themes.Midnight = merge(NovaUI.Themes.Nova, {
    Accent = color(96, 165, 250), AccentSoft = color(28, 50, 83), Background = color(6, 9, 16),
    Surface = color(11, 16, 27), SurfaceRaised = color(16, 23, 38), Control = color(23, 32, 50),
})

NovaUI.Themes.Violet = merge(NovaUI.Themes.Nova, {
    Accent = color(167, 139, 250), AccentSoft = color(55, 41, 91), Background = color(9, 7, 14),
    Surface = color(16, 12, 23), SurfaceRaised = color(23, 18, 32), Control = color(31, 25, 43),
})

NovaUI.Themes.Ember = merge(NovaUI.Themes.Nova, {
    Accent = color(251, 146, 60), AccentSoft = color(91, 48, 22), Background = color(12, 8, 6),
    Surface = color(21, 13, 9), SurfaceRaised = color(29, 18, 12), Control = color(40, 25, 17),
})

NovaUI.Themes.Mono = merge(NovaUI.Themes.Nova, {
    Accent = color(226, 226, 232), AccentSoft = color(62, 62, 68), Background = color(7, 7, 8),
    Surface = color(14, 14, 15), SurfaceRaised = color(20, 20, 22), Control = color(28, 28, 31),
})

local runtime = {
    mouse = Vector2.new(),
    down = false,
    pressed = false,
    released = false,
    keys = {},
    previousKeys = {},
    pressedKeys = {},
    focus = nil,
    dragWindow = nil,
    resizeWindow = nil,
    slider = nil,
    scrollbar = nil,
}

local keyNames = {
    [1] = "Mouse1", [2] = "Mouse2", [4] = "Mouse3", [8] = "Backspace", [9] = "Tab",
    [13] = "Enter", [16] = "Shift", [17] = "Ctrl",
    [18] = "Alt", [27] = "Escape", [32] = "Space", [33] = "PageUp", [34] = "PageDown",
    [35] = "End", [36] = "Home", [37] = "Left", [38] = "Up", [39] = "Right",
    [40] = "Down", [45] = "Insert", [46] = "Delete", [91] = "LeftWindows",
    [160] = "LeftShift", [161] = "RightShift", [162] = "LeftCtrl", [163] = "RightCtrl",
    [164] = "LeftAlt", [165] = "RightAlt", [186] = ";", [187] = "=",
    [188] = ",", [189] = "-", [190] = ".", [191] = "/", [192] = "`",
    [219] = "[", [220] = "\\", [221] = "]", [222] = "'",
}

for code = 48, 57 do keyNames[code] = string.char(code) end
for code = 65, 90 do keyNames[code] = string.char(code) end
for code = 112, 123 do keyNames[code] = "F" .. tostring(code - 111) end

local nameToKey = {}
for code, name in pairs(keyNames) do nameToKey[name:lower()] = code end

local shifted = {
    [48] = ")", [49] = "!", [50] = "@", [51] = "#", [52] = "$", [53] = "%",
    [54] = "^", [55] = "&", [56] = "*", [57] = "(", [186] = ":", [187] = "+",
    [188] = "<", [189] = "_", [190] = ">", [191] = "?", [192] = "~",
    [219] = "{", [220] = "|", [221] = "}", [222] = '"',
}

local function keyLabel(value)
    if type(value) == "number" then return keyNames[value] or ("VK " .. tostring(value)) end
    return tostring(value or "None")
end

local function keyCode(value)
    if type(value) == "number" then return value end
    return nameToKey[tostring(value or ""):lower()]
end

local function keyToCharacter(code, shift)
    if code >= 65 and code <= 90 then
        local value = string.char(code)
        return shift and value or value:lower()
    end
    if code >= 48 and code <= 57 then return shift and shifted[code] or string.char(code) end
    if code == 32 then return " " end
    local plain = { [186] = ";", [187] = "=", [188] = ",", [189] = "-", [190] = ".", [191] = "/", [192] = "`", [219] = "[", [220] = "\\", [221] = "]", [222] = "'" }
    return shift and shifted[code] or plain[code]
end

local function readInput()
    local snapshot = getinputstate()
    runtime.active = snapshot.Active == true
    local keys = {}
    for _, code in ipairs(snapshot.Keys or {}) do keys[code] = true end
    -- Nova's snapshot intentionally omits generic modifier keys; read them directly.
    for _, code in ipairs({ 16, 17, 18 }) do
        if iskeydown(code) then keys[code] = true end
    end
    runtime.previousKeys = runtime.keys
    runtime.keys = keys
    runtime.pressedKeys = {}
    for code in pairs(keys) do
        if not runtime.previousKeys[code] then runtime.pressedKeys[#runtime.pressedKeys + 1] = code end
    end
    runtime.mouse = snapshot.MousePosition or getmouseposition() or Vector2.new()
    local down = runtime.active and ismouse1pressed()
    runtime.pressed = down and not runtime.down
    runtime.released = not down and runtime.down
    runtime.down = down
end

local function pointInside(position, x, y, width, height)
    return position.X >= x and position.X <= x + width and position.Y >= y and position.Y <= y + height
end

local function set(object, key, value)
    if object then pcall(function() object[key] = value end) end
end

local function remove(object)
    if object then pcall(function() object:Remove() end) end
end

local function drawing(owner, kind, properties)
    local object = Drawing.new(kind)
    for key, value in pairs(properties or {}) do set(object, key, value) end
    set(object, "Visible", false)
    owner._drawings[#owner._drawings + 1] = object
    NovaUI._drawings[#NovaUI._drawings + 1] = object
    NovaUI._drawingOwners[object] = owner
    return object
end

local function layeredZ(object, z)
    local owner = NovaUI._drawingOwners[object]
    if owner and owner._overlay then return 10000 + (z or 1) end
    local window = owner and (owner.Window or (owner.Tabs and owner))
    return (z or 1) + (window and window._layer or 0)
end

local function show(object, visible)
    set(object, "Visible", visible == true)
end

local function square(owner, z)
    return drawing(owner, "Square", { Filled = true, Color = color(255, 255, 255), Transparency = 1, Rounding = 8, ZIndex = z or 1 })
end

local function text(owner, z, size)
    return drawing(owner, "Text", {
        Text = "", Color = color(255, 255, 255), Outline = false, Center = false,
        Font = Drawing.Fonts and (Drawing.Fonts.Plex or Drawing.Fonts.UI) or 0,
        TextSize = size or 13, ZIndex = z or 2,
    })
end

local function line(owner, z)
    return drawing(owner, "Line", { Color = color(255, 255, 255), Thickness = 1, Transparency = 1, ZIndex = z or 2 })
end

local function placeSquare(object, x, y, width, height, fill, rounding, z, transparency)
    set(object, "Position", Vector2.new(x, y))
    set(object, "Size", Vector2.new(math.max(0, width), math.max(0, height)))
    set(object, "Color", fill)
    set(object, "Rounding", rounding or 0)
    set(object, "Transparency", transparency == nil and 1 or transparency)
    set(object, "ZIndex", layeredZ(object, z or 1))
    show(object, width > 0 and height > 0)
end

local function placeText(object, x, y, value, fill, size, z, center)
    set(object, "Position", Vector2.new(x, y))
    set(object, "Text", tostring(value or ""))
    set(object, "Color", fill)
    set(object, "TextSize", size or 13)
    set(object, "Center", center == true)
    set(object, "ZIndex", layeredZ(object, z or 2))
    show(object, true)
end

local function placeLine(object, x1, y1, x2, y2, fill, thickness, z)
    set(object, "From", Vector2.new(x1, y1))
    set(object, "To", Vector2.new(x2, y2))
    set(object, "Color", fill)
    set(object, "Thickness", thickness or 1)
    set(object, "ZIndex", layeredZ(object, z or 2))
    show(object, true)
end

local function hideList(items)
    for _, object in ipairs(items or {}) do show(object, false) end
end

local function fireChanged(control, value)
    control.Window._configDirty = true
    control.Window._nextAutoSave = tick() + (control.Window.AutoSaveDelay or 1.5)
    safe(control.Callback, value)
    for _, callback in ipairs(control._listeners) do safe(callback, value) end
end

local controlMethods = {}
controlMethods.__index = controlMethods

function controlMethods:GetValue()
    return self.Value
end

function controlMethods:SetValue(value, silent)
    if self.Type == "slider" then value = round(clamp(value, self.Min, self.Max), self.Rounding) end
    if self.Type == "toggle" then value = value == true end
    if self.Type == "input" then value = tostring(value or ""):sub(1, self.MaxLength) end
    if self.Type == "color" and typeof(value) ~= "Color3" then
        if type(value) == "string" then
            local ok, parsed = pcall(Color3.fromHex, value)
            if ok then value = parsed else return self end
        else
            return self
        end
    end
    if self.Type == "dropdown" and self.Multi then
        local selected = {}
        if type(value) == "table" then
            for key, item in pairs(value) do
                if type(key) == "number" then selected[tostring(item)] = true elseif item then selected[tostring(key)] = true end
            end
        end
        value = selected
    end
    self.Value = value
    if self.Id then
        self.Window.Flags[self.Id] = value
        NovaUI.Flags[self.Id] = value
    end
    if not silent then fireChanged(self, value) end
    return self
end

function controlMethods:OnChanged(callback)
    assert(type(callback) == "function", "OnChanged expects a function")
    self._listeners[#self._listeners + 1] = callback
    return self
end

function controlMethods:SetVisible(visible)
    self.Visible = visible ~= false
    return self
end

function controlMethods:SetDisabled(disabled)
    self.Disabled = disabled == true
    return self
end

function controlMethods:SetDescription(description)
    self.Description = tostring(description or "")
    return self
end

function controlMethods:Destroy()
    self.Destroyed = true
    hideList(self._drawings)
    return self
end

local function createControl(section, kind, config)
    config = normalizeConfig(config)
    local control = setmetatable({
        Type = kind,
        Section = section,
        Tab = section.Tab,
        Window = section.Window,
        Id = config.Id or config.Flag,
        Title = config.Title or config.Name or kind,
        Description = config.Description or "",
        Callback = config.Callback,
        Visible = config.Visible ~= false,
        Disabled = config.Disabled == true,
        Value = config.Default,
        _listeners = {},
        _drawings = {},
        _config = config,
    }, controlMethods)
    section.Controls[#section.Controls + 1] = control
    if control.Id then section.Window.Controls[control.Id] = control end
    return control
end

local sectionMethods = {}
sectionMethods.__index = sectionMethods

local function addBasic(section, kind, config)
    local control = createControl(section, kind, config)
    control.Value = control._config.Default
    return control
end

function sectionMethods:AddLabel(config)
    local control = addBasic(self, "label", config)
    control.Value = control._config.Text or control.Title
    function control:SetText(value) self.Value = tostring(value or "") return self end
    return control
end

function sectionMethods:AddParagraph(config)
    local control = addBasic(self, "paragraph", config)
    control.Value = control._config.Content or control._config.Text or control.Description
    function control:SetText(value) self.Value = tostring(value or "") return self end
    return control
end

function sectionMethods:AddDivider(config)
    return addBasic(self, "divider", config)
end

function sectionMethods:AddButton(config)
    local control = addBasic(self, "button", config)
    function control:Press()
        if not self.Disabled then safe(self.Callback, self) end
        return self
    end
    return control
end

function sectionMethods:AddToggle(config)
    local control = addBasic(self, "toggle", config)
    control:SetValue(control._config.Default == true, true)
    return control
end

function sectionMethods:AddSlider(config)
    local control = addBasic(self, "slider", config)
    control.Min = tonumber(control._config.Min) or 0
    control.Max = tonumber(control._config.Max) or 100
    if control.Max < control.Min then control.Min, control.Max = control.Max, control.Min end
    control.Rounding = math.max(0, math.floor(tonumber(control._config.Rounding) or 0))
    control.Suffix = tostring(control._config.Suffix or "")
    control:SetValue(control._config.Default or control.Min, true)
    return control
end

function sectionMethods:AddDropdown(config)
    local control = addBasic(self, "dropdown", config)
    control.Values = copy(control._config.Values or control._config.Options or {})
    control.Multi = control._config.Multi == true
    control.MaxVisible = clamp(control._config.MaxVisible or 6, 2, 10)
    control.Open = false
    local fallback = control.Multi and {} or control.Values[1]
    control:SetValue(control._config.Default ~= nil and control._config.Default or fallback, true)
    function control:SetValues(values)
        self.Values = copy(values or {})
        return self
    end
    function control:Select(value)
        value = tostring(value)
        if self.Multi then
            local nextValue = copy(self.Value)
            nextValue[value] = not nextValue[value] or nil
            self:SetValue(nextValue)
        else
            self:SetValue(value)
            self.Open = false
        end
        return self
    end
    return control
end

function sectionMethods:AddMultiDropdown(config)
    config = normalizeConfig(config)
    config.Multi = true
    return self:AddDropdown(config)
end

function sectionMethods:AddInput(config)
    local control = addBasic(self, "input", config)
    control.Placeholder = tostring(control._config.Placeholder or "Enter a value")
    control.MaxLength = clamp(control._config.MaxLength or 256, 1, 4096)
    control.Numeric = control._config.Numeric == true
    control.Finished = control._config.Finished ~= false
    control:SetValue(control._config.Default or "", true)
    return control
end

function sectionMethods:AddKeybind(config)
    local control = addBasic(self, "keybind", config)
    control.Mode = tostring(control._config.Mode or "Toggle")
    control.Key = keyCode(control._config.Default or control._config.Key) or 0
    control.Value = control.Key
    control.Active = control.Mode == "Always"
    control.OnTriggered = control._config.OnTriggered
    function control:SetKey(value, silent)
        local code = keyCode(value) or tonumber(value) or 0
        self.Key, self.Value = code, code
        if self.Id then self.Window.Flags[self.Id], NovaUI.Flags[self.Id] = code, code end
        if not silent then fireChanged(self, code) end
        return self
    end
    function control:IsActive() return self.Mode == "Always" or self.Active == true end
    control:SetKey(control.Key, true)
    return control
end

function sectionMethods:AddColorPicker(config)
    local control = addBasic(self, "color", config)
    control:SetValue(control._config.Default or NovaUI.Themes.Nova.Accent, true)
    control.Open = false
    function control:SetColor(value, silent) return self:SetValue(value, silent) end
    return control
end

sectionMethods.Label = sectionMethods.AddLabel
sectionMethods.Paragraph = sectionMethods.AddParagraph
sectionMethods.Divider = sectionMethods.AddDivider
sectionMethods.Button = sectionMethods.AddButton
sectionMethods.Toggle = sectionMethods.AddToggle
sectionMethods.Slider = sectionMethods.AddSlider
sectionMethods.Dropdown = sectionMethods.AddDropdown
sectionMethods.MultiDropdown = sectionMethods.AddMultiDropdown
sectionMethods.Input = sectionMethods.AddInput
sectionMethods.Keybind = sectionMethods.AddKeybind
sectionMethods.ColorPicker = sectionMethods.AddColorPicker

local tabMethods = {}
tabMethods.__index = tabMethods

function tabMethods:AddSection(config)
    config = normalizeConfig(config)
    local section = setmetatable({
        Window = self.Window,
        Tab = self,
        Title = config.Title or config.Name or "Section",
        Description = config.Description or "",
        Column = config.Column or config.Side,
        Controls = {},
        _drawings = {},
        _ui = {},
    }, sectionMethods)
    self.Sections[#self.Sections + 1] = section
    return section
end

function tabMethods:_defaultSection()
    if not self.DefaultSection then self.DefaultSection = self:AddSection({ Title = self.Title }) end
    return self.DefaultSection
end

for _, method in ipairs({ "AddLabel", "AddParagraph", "AddDivider", "AddButton", "AddToggle", "AddSlider", "AddDropdown", "AddMultiDropdown", "AddInput", "AddKeybind", "AddColorPicker" }) do
    tabMethods[method] = function(self, config)
        local section = self:_defaultSection()
        return section[method](section, config)
    end
end

local function controlHeight(control)
    if control.Type == "label" then return 26 end
    if control.Type == "paragraph" then return 52 end
    if control.Type == "divider" then return 18 end
    if control.Type == "slider" then return 58 end
    if control.Type == "dropdown" then return 48 + (control.Open and math.min(#control.Values, control.MaxVisible) * 28 + 8 or 0) end
    if control.Type == "color" then return 48 + (control.Open and 96 or 0) end
    return 46
end

local function ensureControlDrawings(control)
    if control._ready then return end
    control._ready = true
    local ui = {}
    control._ui = ui
    ui.background = square(control, 8)
    ui.title = text(control, 10, 13)
    ui.description = text(control, 10, 11)
    ui.value = text(control, 11, 12)
    ui.accent = square(control, 11)
    ui.track = square(control, 10)
    ui.knob = square(control, 12)
    ui.line = line(control, 10)
    ui.options = {}
end

local function ensureSectionDrawings(section)
    if section._ready then return end
    section._ready = true
    section._ui.card = square(section, 5)
    section._ui.inner = square(section, 6)
    section._ui.title = text(section, 8, 12)
    section._ui.description = text(section, 8, 10)
end

local function ensureTabDrawings(tab)
    if tab._ready then return end
    tab._ready = true
    tab._drawings = tab._drawings or {}
    tab._ui = {
        background = square(tab, 7),
        marker = square(tab, 9),
        title = text(tab, 10, 12),
    }
end

local function asVector2(value, fallbackX, fallbackY)
    if typeof(value) == "Vector2" then return value end
    if type(value) == "table" then
        return Vector2.new(tonumber(value.X or value[1]) or fallbackX, tonumber(value.Y or value[2]) or fallbackY)
    end
    return Vector2.new(fallbackX, fallbackY)
end

local function resolveTheme(value)
    if type(value) == "string" then return copy(NovaUI.Themes[value] or NovaUI.Themes.Nova) end
    if type(value) == "table" then return merge(NovaUI.Themes.Nova, value) end
    return copy(NovaUI.Themes.Nova)
end

local function encodeValue(value)
    if typeof(value) == "Color3" then return { __novaType = "Color3", R = value.R, G = value.G, B = value.B } end
    if type(value) ~= "table" then return value end
    local result = {}
    for key, item in pairs(value) do result[key] = encodeValue(item) end
    return result
end

local function decodeValue(value)
    if type(value) ~= "table" then return value end
    if value.__novaType == "Color3" then return Color3.new(value.R or 0, value.G or 0, value.B or 0) end
    local result = {}
    for key, item in pairs(value) do result[key] = decodeValue(item) end
    return result
end

local windowMethods = {}
windowMethods.__index = windowMethods

function windowMethods:AddTab(config)
    config = normalizeConfig(config)
    local tab = setmetatable({
        Window = self,
        Title = config.Title or config.Name or ("Tab " .. tostring(#self.Tabs + 1)),
        Icon = config.Icon,
        Sections = {},
        Scroll = 0,
        ScrollMax = 0,
        _drawings = {},
        _ui = {},
    }, tabMethods)
    self.Tabs[#self.Tabs + 1] = tab
    if not self.SelectedTab then self.SelectedTab = tab end
    return tab
end

function windowMethods:SelectTab(tab)
    if type(tab) == "string" then
        for _, item in ipairs(self.Tabs) do if item.Title == tab then tab = item break end end
    elseif type(tab) == "number" then
        tab = self.Tabs[tab]
    end
    if type(tab) == "table" and tab.Window == self then self.SelectedTab = tab end
    return self
end

function windowMethods:SetTheme(theme)
    self.Theme = resolveTheme(theme)
    return self
end

function windowMethods:SetVisible(visible)
    self.Visible = visible ~= false
    return self
end

function windowMethods:Toggle()
    self.Visible = not self.Visible
    return self
end

function windowMethods:Minimize(minimized)
    if minimized == nil then minimized = not self.Minimized end
    self.Minimized = minimized == true
    return self
end

function windowMethods:SetPosition(position)
    self.Position = asVector2(position, self.Position.X, self.Position.Y)
    return self
end

function windowMethods:SetSize(size)
    size = asVector2(size, self.Size.X, self.Size.Y)
    self.Size = Vector2.new(clamp(size.X, self.MinSize.X, self.MaxSize.X), clamp(size.Y, self.MinSize.Y, self.MaxSize.Y))
    return self
end

function windowMethods:SetScale(scale)
    self.Scale = clamp(scale, 0.75, 1.5)
    return self
end

function windowMethods:GetValue(id)
    local control = self.Controls[id]
    return control and control:GetValue() or nil
end

function windowMethods:SetValue(id, value, silent)
    local control = self.Controls[id]
    if control then
        if control.Type == "keybind" then control:SetKey(value, silent) else control:SetValue(value, silent) end
    end
    return self
end

function windowMethods:Search(query)
    self.SearchQuery = tostring(query or "")
    return self
end

function windowMethods:ExportState(pretty)
    local state = { version = NovaUI.Version, flags = {} }
    for id, control in pairs(self.Controls) do state.flags[id] = encodeValue(control:GetValue()) end
    local ok, result = pcall(function() return HttpService:JSONEncode(state) end)
    if not ok then return nil, result end
    return result
end

function windowMethods:ImportState(value, silent)
    local state = value
    if type(value) == "string" then
        local ok, decoded = pcall(function() return HttpService:JSONDecode(value) end)
        if not ok then return false, decoded end
        state = decoded
    end
    if type(state) ~= "table" then return false, "Config must be a JSON string or table" end
    for id, valueToSet in pairs(state.flags or state) do
        local control = self.Controls[id]
        if control then
            local decoded = decodeValue(valueToSet)
            if control.Type == "keybind" then control:SetKey(decoded, silent) else control:SetValue(decoded, silent) end
        end
    end
    return true
end

function windowMethods:_configPath(name)
    return "nova-ui/" .. self.ConfigFolder .. "/" .. sanitizeName(name) .. ".json"
end

function windowMethods:SaveConfig(name)
    local body, encodeError = self:ExportState()
    if not body then return false, encodeError end
    local ok, result = pcall(function()
        if not isfolder("nova-ui") then makefolder("nova-ui") end
        local folder = "nova-ui/" .. self.ConfigFolder
        if not isfolder(folder) then makefolder(folder) end
        writefile(self:_configPath(name), body)
    end)
    if ok then self._configDirty = false end
    return ok, result
end

function windowMethods:LoadConfig(name, silent)
    local ok, result = pcall(function() return readfile(self:_configPath(name)) end)
    if not ok then return false, result end
    return self:ImportState(result, silent)
end

function windowMethods:DeleteConfig(name)
    local ok, result = pcall(function() delfile(self:_configPath(name)) end)
    return ok, result
end

function windowMethods:ListConfigs()
    local folder = "nova-ui/" .. self.ConfigFolder
    local ok, files = pcall(function() return listfiles(folder) end)
    if not ok then return {} end
    local names = {}
    for _, file in ipairs(files) do
        local name = tostring(file):match("([^/\\]+)%.json$")
        if name then names[#names + 1] = name end
    end
    table.sort(names)
    return names
end

function windowMethods:EnableAutoSave(name, seconds)
    self.AutoSaveName = sanitizeName(name or "autosave")
    self.AutoSaveDelay = clamp(seconds or 1.5, 0.25, 30)
    return self
end

function windowMethods:DisableAutoSave()
    self.AutoSaveName = nil
    return self
end

function windowMethods:Notify(config)
    config = normalizeConfig(config)
    config.Window = self
    return NovaUI:Notify(config)
end

function windowMethods:Dialog(config)
    config = normalizeConfig(config)
    config.Window = self
    return NovaUI:Dialog(config)
end

function windowMethods:Destroy()
    if self.Destroyed then return end
    self.Destroyed = true
    for _, object in ipairs(self._drawings) do remove(object) end
    for _, tab in ipairs(self.Tabs) do
        for _, object in ipairs(tab._drawings or {}) do remove(object) end
        for _, section in ipairs(tab.Sections) do
            for _, object in ipairs(section._drawings or {}) do remove(object) end
            for _, control in ipairs(section.Controls) do for _, object in ipairs(control._drawings or {}) do remove(object) end end
        end
    end
    for index, window in ipairs(NovaUI.Windows) do
        if window == self then table.remove(NovaUI.Windows, index) break end
    end
    safe(self.OnClose, self)
end

windowMethods.Tab = windowMethods.AddTab

local function ensureWindowDrawings(window)
    if window._ready then return end
    window._ready = true
    local ui = window._ui
    ui.shadow = square(window, 1)
    ui.border = square(window, 2)
    ui.background = square(window, 3)
    ui.header = square(window, 4)
    ui.sidebar = square(window, 4)
    ui.accent = square(window, 6)
    ui.title = text(window, 8, 15)
    ui.subtitle = text(window, 8, 10)
    ui.search = square(window, 7)
    ui.searchText = text(window, 9, 11)
    ui.searchAccent = square(window, 10)
    ui.minimize = text(window, 10, 17)
    ui.close = text(window, 10, 15)
    ui.resizeA = line(window, 10)
    ui.resizeB = line(window, 10)
    ui.scrollTrack = square(window, 7)
    ui.scrollThumb = square(window, 9)
    ui.empty = text(window, 8, 13)
end

function NovaUI:AddTheme(name, theme)
    assert(type(name) == "string" and name ~= "", "Theme name is required")
    NovaUI.Themes[name] = resolveTheme(theme)
    return NovaUI.Themes[name]
end

function NovaUI:CreateWindow(config)
    config = normalizeConfig(config)
    local index = #NovaUI.Windows + 1
    local position = asVector2(config.Position, 120 + (index - 1) * 28, 100 + (index - 1) * 24)
    local size = asVector2(config.Size, 720, 520)
    local window = setmetatable({
        Title = config.Title or config.Name or "Nova UI",
        Subtitle = config.Subtitle or config.Description or "Lua interface",
        Position = position,
        Size = size,
        MinSize = asVector2(config.MinSize, 520, 360),
        MaxSize = asVector2(config.MaxSize, 1400, 1000),
        Scale = clamp(config.Scale or 1, 0.75, 1.5),
        Theme = resolveTheme(config.Theme),
        Keybind = config.Keybind == false and 0 or (keyCode(config.Keybind or "Insert") or 45),
        ConfigFolder = sanitizeName(config.ConfigFolder or config.Title or "default"),
        Visible = config.Visible ~= false,
        Minimized = false,
        Resizable = config.Resizable ~= false,
        Tabs = {},
        Controls = {},
        Flags = {},
        SearchQuery = "",
        OnClose = config.OnClose,
        _drawings = {},
        _ui = {},
        _configDirty = false,
        _nextAutoSave = 0,
    }, windowMethods)
    window:SetSize(size)
    NovaUI.Windows[#NovaUI.Windows + 1] = window
    ensureWindowDrawings(window)
    return window
end

NovaUI.Window = NovaUI.CreateWindow

local function truncate(value, maximum)
    value = tostring(value or "")
    if #value <= maximum then return value end
    return value:sub(1, math.max(1, maximum - 3)) .. "..."
end

local function selectedText(control)
    if not control.Multi then return tostring(control.Value or "Select") end
    local values = {}
    for _, option in ipairs(control.Values) do if control.Value[tostring(option)] then values[#values + 1] = tostring(option) end end
    return #values > 0 and table.concat(values, ", ") or "Select"
end

local function controlMatches(window, section, control)
    local query = window.SearchQuery
    if query == "" then return true end
    if containsText(section.Title, query) or containsText(section.Description, query) then return true end
    return containsText(control.Title, query) or containsText(control.Description, query) or containsText(control.Type, query)
end

local function visibleControls(window, section)
    local result = {}
    for _, control in ipairs(section.Controls) do
        if not control.Destroyed and control.Visible and controlMatches(window, section, control) then result[#result + 1] = control end
    end
    return result
end

local function measuredSection(window, section)
    local controls = visibleControls(window, section)
    if #controls == 0 then return 0, controls end
    local height = section.Description ~= "" and 54 or 40
    for _, control in ipairs(controls) do height = height + controlHeight(control) + 8 end
    return height + 4, controls
end

local function ensureOption(control, index, withMarker)
    local option = control._ui.options[index]
    if not option then
        option = { background = square(control, 12), title = text(control, 14, 11) }
        if withMarker then option.marker = square(control, 15) end
        control._ui.options[index] = option
    elseif withMarker and not option.marker then
        option.marker = square(control, 15)
    end
    return option
end

local function hideUnusedOptions(control, firstUnused)
    for index = firstUnused, #control._ui.options do
        local option = control._ui.options[index]
        show(option.background, false)
        show(option.title, false)
        show(option.marker, false)
    end
end

local function renderControl(control, x, y, width, theme, interactive, viewTop, viewBottom)
    ensureControlDrawings(control)
    hideList(control._drawings)
    local height = controlHeight(control)
    if y < viewTop or y + height > viewBottom then return height end

    local ui = control._ui
    local mouse = runtime.mouse
    local hovered = pointInside(mouse, x, y, width, math.min(height, 48))
    local enabled = interactive and not control.Disabled
    local titleColor = control.Disabled and theme.Muted or theme.Text
    local surface = hovered and enabled and theme.ControlHover or theme.Control
    local maxChars = math.max(12, math.floor(width / 7))

    if control.Type == "label" then
        placeText(ui.title, x + 2, y + 5, truncate(control.Value or control.Title, maxChars), titleColor, 13, 10)
    elseif control.Type == "paragraph" then
        placeText(ui.title, x + 2, y + 3, truncate(control.Title, maxChars), titleColor, 13, 10)
        placeText(ui.description, x + 2, y + 25, truncate(control.Value, maxChars + 8), theme.TextSoft, 11, 10)
    elseif control.Type == "divider" then
        placeLine(ui.line, x, y + 9, x + width, y + 9, theme.Border, 1, 10)
        if control.Title ~= "divider" and control.Title ~= "" then
            placeSquare(ui.background, x + 8, y + 1, math.min(width - 16, #control.Title * 7 + 14), 17, theme.Surface, 0, 11)
            placeText(ui.title, x + 14, y + 2, control.Title, theme.Muted, 10, 12)
        end
    elseif control.Type == "button" then
        placeSquare(ui.background, x, y + 4, width, 36, surface, 7, 8)
        placeSquare(ui.accent, x, y + 4, 3, 36, theme.Accent, 3, 11)
        placeText(ui.title, x + width / 2, y + 14, truncate(control.Title, maxChars), titleColor, 12, 12, true)
        if enabled and runtime.pressed and hovered then control:Press() end
    elseif control.Type == "toggle" then
        placeSquare(ui.background, x, y + 2, width, 40, surface, 7, 8)
        placeText(ui.title, x + 12, y + (control.Description ~= "" and 8 or 13), truncate(control.Title, maxChars - 10), titleColor, 12, 11)
        if control.Description ~= "" then placeText(ui.description, x + 12, y + 24, truncate(control.Description, maxChars - 12), theme.Muted, 9, 11) end
        placeSquare(ui.track, x + width - 44, y + 12, 32, 18, control.Value and theme.AccentSoft or theme.Border, 9, 11)
        placeSquare(ui.knob, x + width - (control.Value and 29 or 41), y + 15, 12, 12, control.Value and theme.Accent or theme.TextSoft, 6, 12)
        if enabled and runtime.pressed and hovered then control:SetValue(not control.Value) end
    elseif control.Type == "slider" then
        placeSquare(ui.background, x, y + 2, width, 52, surface, 7, 8)
        placeText(ui.title, x + 12, y + 9, truncate(control.Title, maxChars - 10), titleColor, 12, 11)
        placeText(ui.value, x + width - 12, y + 9, tostring(control.Value) .. control.Suffix, theme.Accent, 11, 12)
        local trackX, trackY, trackW = x + 12, y + 37, width - 24
        local percent = (control.Value - control.Min) / math.max(0.00001, control.Max - control.Min)
        placeSquare(ui.track, trackX, trackY, trackW, 5, theme.Border, 3, 10)
        placeSquare(ui.accent, trackX, trackY, trackW * percent, 5, theme.Accent, 3, 11)
        placeSquare(ui.knob, trackX + trackW * percent - 4, trackY - 3, 9, 11, theme.Text, 5, 12)
        if enabled and runtime.pressed and pointInside(mouse, trackX, y + 28, trackW, 24) then runtime.slider = { Control = control, X = trackX, Width = trackW } end
        if enabled and runtime.slider and runtime.slider.Control == control and runtime.down then
            local nextValue = control.Min + clamp((mouse.X - trackX) / trackW, 0, 1) * (control.Max - control.Min)
            if nextValue ~= control.Value then control:SetValue(nextValue) end
        end
    elseif control.Type == "dropdown" then
        placeSquare(ui.background, x, y + 2, width, 40, surface, 7, 8)
        placeText(ui.title, x + 12, y + 7, truncate(control.Title, math.floor(maxChars * 0.45)), titleColor, 11, 11)
        placeText(ui.value, x + width - 28, y + 20, truncate(selectedText(control), math.floor(maxChars * 0.5)), theme.TextSoft, 10, 12)
        placeText(ui.description, x + width - 14, y + 12, control.Open and "-" or "+", theme.Accent, 13, 12, true)
        if enabled and runtime.pressed and hovered then control.Open = not control.Open end
        local optionY = y + 48
        local shown = math.min(#control.Values, control.MaxVisible)
        if control.Open then
            for index = 1, shown do
                local optionValue = tostring(control.Values[index])
                local option = ensureOption(control, index, false)
                local optionHover = pointInside(mouse, x + 6, optionY, width - 12, 25)
                local selected = control.Multi and control.Value[optionValue] or tostring(control.Value) == optionValue
                placeSquare(option.background, x + 6, optionY, width - 12, 25, optionHover and theme.ControlHover or theme.SurfaceRaised, 5, 12)
                if selected then placeSquare(ui.accent, x + 6, optionY, 3, 25, theme.Accent, 2, 14) end
                placeText(option.title, x + 16, optionY + 6, truncate(optionValue, maxChars - 5), selected and theme.Accent or theme.TextSoft, 10, 14)
                if enabled and runtime.pressed and optionHover then control:Select(optionValue) end
                optionY = optionY + 28
            end
        end
        hideUnusedOptions(control, shown + 1)
    elseif control.Type == "input" then
        local focused = runtime.focus and runtime.focus.Kind == "input" and runtime.focus.Control == control
        placeSquare(ui.background, x, y + 2, width, 40, focused and theme.SurfaceRaised or surface, 7, 8)
        placeText(ui.title, x + 12, y + 7, truncate(control.Title, math.floor(maxChars * 0.42)), titleColor, 11, 11)
        local value = control.Value ~= "" and control.Value or control.Placeholder
        placeText(ui.value, x + width - 12, y + 21, truncate(value, math.floor(maxChars * 0.55)), control.Value ~= "" and theme.TextSoft or theme.Muted, 10, 12)
        if focused then placeSquare(ui.accent, x + 8, y + 39, width - 16, 2, theme.Accent, 1, 12) end
        if enabled and runtime.pressed and hovered then runtime.focus = { Kind = "input", Control = control } end
    elseif control.Type == "keybind" then
        local focused = runtime.focus and runtime.focus.Kind == "keybind" and runtime.focus.Control == control
        placeSquare(ui.background, x, y + 2, width, 40, focused and theme.SurfaceRaised or surface, 7, 8)
        placeText(ui.title, x + 12, y + 13, truncate(control.Title, maxChars - 16), titleColor, 11, 11)
        placeSquare(ui.track, x + width - 88, y + 9, 76, 26, control:IsActive() and theme.AccentSoft or theme.Surface, 5, 11)
        placeText(ui.value, x + width - 50, y + 15, focused and "Press key" or keyLabel(control.Key), focused and theme.Accent or theme.TextSoft, 10, 12, true)
        if enabled and runtime.pressed and hovered then runtime.focus = { Kind = "keybind", Control = control } end
    elseif control.Type == "color" then
        placeSquare(ui.background, x, y + 2, width, 40, surface, 7, 8)
        placeText(ui.title, x + 12, y + 13, truncate(control.Title, maxChars - 16), titleColor, 11, 11)
        placeText(ui.value, x + width - 66, y + 14, colorToHex(control.Value), theme.TextSoft, 9, 12, true)
        placeSquare(ui.accent, x + width - 32, y + 11, 20, 20, control.Value, 5, 13)
        if enabled and runtime.pressed and hovered then control.Open = not control.Open end
        if control.Open then
            local channels = { { "R", control.Value.R }, { "G", control.Value.G }, { "B", control.Value.B } }
            for index, channel in ipairs(channels) do
                local option = ensureOption(control, index, true)
                local barX, barY, barW = x + 30, y + 48 + (index - 1) * 29, width - 42
                placeText(option.title, x + 8, barY + 4, channel[1], theme.Muted, 10, 14)
                placeSquare(option.background, barX, barY + 5, barW, 8, theme.Border, 4, 12)
                placeSquare(option.marker, barX + barW * channel[2] - 3, barY + 2, 7, 14, control.Value, 4, 15)
                if enabled and runtime.pressed and pointInside(mouse, barX, barY - 2, barW, 20) then
                    runtime.slider = { Control = control, X = barX, Width = barW, Channel = index }
                end
            end
            if enabled and runtime.slider and runtime.slider.Control == control and runtime.slider.Channel and runtime.down then
                local amount = clamp((mouse.X - runtime.slider.X) / runtime.slider.Width, 0, 1)
                local r, g, b = control.Value.R, control.Value.G, control.Value.B
                if runtime.slider.Channel == 1 then r = amount elseif runtime.slider.Channel == 2 then g = amount else b = amount end
                control:SetValue(Color3.new(r, g, b))
            end
        end
        hideUnusedOptions(control, control.Open and 4 or 1)
    end
    return height
end

local function hideSection(section)
    hideList(section._drawings)
    for _, control in ipairs(section.Controls) do hideList(control._drawings) end
end

local function hideWindow(window)
    hideList(window._drawings)
    for _, tab in ipairs(window.Tabs) do
        hideList(tab._drawings)
        for _, section in ipairs(tab.Sections) do hideSection(section) end
    end
end

local function renderSection(window, section, x, y, width, height, controls, viewTop, viewBottom, interactive)
    ensureSectionDrawings(section)
    hideSection(section)
    if height <= 0 or y >= viewBottom or y + height <= viewTop then return end
    local ui, theme = section._ui, window.Theme
    local clippedTop = math.max(y, viewTop)
    local clippedBottom = math.min(y + height, viewBottom)
    placeSquare(ui.card, x, clippedTop, width, clippedBottom - clippedTop, theme.Border, 9, 5)
    placeSquare(ui.inner, x + 1, clippedTop + 1, width - 2, math.max(0, clippedBottom - clippedTop - 2), theme.Surface, 8, 6)

    if y + 8 >= viewTop and y + 30 <= viewBottom then
        placeText(ui.title, x + 14, y + 11, truncate(section.Title, math.floor(width / 7) - 4), theme.Text, 12, 8)
        if section.Description ~= "" then
            placeText(ui.description, x + 14, y + 30, truncate(section.Description, math.floor(width / 6) - 4), theme.Muted, 9, 8)
        end
    end

    local controlY = y + (section.Description ~= "" and 54 or 40)
    for _, control in ipairs(controls) do
        local used = renderControl(control, x + 10, controlY, width - 20, theme, interactive, viewTop, viewBottom)
        controlY = controlY + used + 8
    end
end

local function insideWindow(window, point)
    local height = window.Minimized and 56 or window.Size.Y * window.Scale
    return pointInside(point, window.Position.X, window.Position.Y, window.Size.X * window.Scale, height)
end

local function renderWindow(window, interactive)
    if window.Destroyed or not window.Visible then hideWindow(window) return end
    ensureWindowDrawings(window)
    local ui, theme = window._ui, window.Theme
    local x, y = window.Position.X, window.Position.Y
    local width, height = window.Size.X * window.Scale, window.Size.Y * window.Scale
    local shownHeight = window.Minimized and 56 or height

    placeSquare(ui.shadow, x + 7, y + 9, width, shownHeight, color(0, 0, 0), 12, 1, 0.55)
    placeSquare(ui.border, x, y, width, shownHeight, theme.Border, 11, 2)
    placeSquare(ui.background, x + 1, y + 1, width - 2, shownHeight - 2, theme.Background, 10, 3)
    placeSquare(ui.header, x + 1, y + 1, width - 2, 55, theme.SurfaceRaised, 10, 4)
    placeSquare(ui.accent, x + 1, y + 54, width - 2, 2, theme.Accent, 0, 6)
    placeText(ui.title, x + 18, y + 11, truncate(window.Title, 28), theme.Text, 15, 8)
    placeText(ui.subtitle, x + 18, y + 32, truncate(window.Subtitle, 34), theme.Muted, 9, 8)
    placeText(ui.minimize, x + width - 49, y + 17, window.Minimized and "+" or "-", theme.TextSoft, 16, 10, true)
    placeText(ui.close, x + width - 23, y + 18, "x", theme.Muted, 13, 10, true)

    local minimizeHover = pointInside(runtime.mouse, x + width - 62, y + 8, 25, 38)
    local closeHover = pointInside(runtime.mouse, x + width - 36, y + 8, 26, 38)
    if minimizeHover then set(ui.minimize, "Color", theme.Accent) end
    if closeHover then set(ui.close, "Color", theme.Danger) end

    if interactive and runtime.pressed then
        if closeHover then window._pendingDestroy = true return end
        if minimizeHover then window:Minimize() return end
        if pointInside(runtime.mouse, x, y, width - 64, 55) then
            runtime.dragWindow = window
            runtime.dragOffset = Vector2.new(runtime.mouse.X - x, runtime.mouse.Y - y)
        end
    end

    if window.Minimized then
        for _, tab in ipairs(window.Tabs) do
            hideList(tab._drawings)
            for _, section in ipairs(tab.Sections) do hideSection(section) end
        end
        show(ui.sidebar, false)
        show(ui.search, false)
        show(ui.searchText, false)
        show(ui.searchAccent, false)
        show(ui.resizeA, false)
        show(ui.resizeB, false)
        show(ui.scrollTrack, false)
        show(ui.scrollThumb, false)
        show(ui.empty, false)
        return
    end

    local sidebarWidth = 150
    placeSquare(ui.sidebar, x + 1, y + 56, sidebarWidth, height - 57, theme.Surface, 0, 4)

    local searchX, searchY, searchWidth = x + width - 280, y + 13, 200
    local searchFocused = runtime.focus and runtime.focus.Kind == "search" and runtime.focus.Window == window
    placeSquare(ui.search, searchX, searchY, searchWidth, 30, searchFocused and theme.ControlHover or theme.Control, 7, 7)
    placeText(ui.searchText, searchX + 12, searchY + 8, truncate(window.SearchQuery ~= "" and window.SearchQuery or "Search controls  Ctrl+K", 27), window.SearchQuery ~= "" and theme.TextSoft or theme.Muted, 10, 9)
    if searchFocused then placeSquare(ui.searchAccent, searchX + 8, searchY + 28, searchWidth - 16, 2, theme.Accent, 1, 10)
    else show(ui.searchAccent, false) end
    if interactive and runtime.pressed and pointInside(runtime.mouse, searchX, searchY, searchWidth, 30) then
        runtime.focus = { Kind = "search", Window = window }
    end

    local tabY = y + 74
    for _, tab in ipairs(window.Tabs) do
        ensureTabDrawings(tab)
        local tabUi = tab._ui
        local active = tab == window.SelectedTab
        local hover = pointInside(runtime.mouse, x + 10, tabY, sidebarWidth - 20, 34)
        placeSquare(tabUi.background, x + 10, tabY, sidebarWidth - 20, 34, active and theme.Control or (hover and theme.SurfaceRaised or theme.Surface), 7, 7)
        placeSquare(tabUi.marker, x + 10, tabY + 7, active and 3 or 0, 20, theme.Accent, 2, 9)
        placeText(tabUi.title, x + 22, tabY + 10, truncate(tab.Title, 17), active and theme.Text or theme.TextSoft, 11, 10)
        if interactive and runtime.pressed and hover then window:SelectTab(tab) runtime.focus = nil end
        tabY = tabY + 40
    end

    local contentX = x + sidebarWidth + 15
    local contentY = y + 70
    local contentWidth = width - sidebarWidth - 30
    local contentHeight = height - 86
    local viewBottom = contentY + contentHeight
    local tab = window.SelectedTab
    local anyVisible = false

    for _, otherTab in ipairs(window.Tabs) do
        if otherTab ~= tab then for _, section in ipairs(otherTab.Sections) do hideSection(section) end end
    end

    if tab then
        local twoColumns = contentWidth >= 500
        local gap = 12
        local columnWidth = twoColumns and (contentWidth - gap - 8) / 2 or (contentWidth - 8)
        local left, right = {}, {}
        local leftHeight, rightHeight = 0, 0

        for _, section in ipairs(tab.Sections) do
            local sectionHeight, controls = measuredSection(window, section)
            if sectionHeight > 0 then
                anyVisible = true
                local side = tostring(section.Column or ""):lower()
                local useRight = twoColumns and (side == "right" or side == "2" or (side == "" and rightHeight < leftHeight))
                local item = { Section = section, Height = sectionHeight, Controls = controls }
                if useRight then right[#right + 1] = item rightHeight = rightHeight + sectionHeight + gap
                else left[#left + 1] = item leftHeight = leftHeight + sectionHeight + gap end
            else
                hideSection(section)
            end
        end

        local totalHeight = math.max(leftHeight, rightHeight)
        tab.ScrollMax = math.max(0, totalHeight - contentHeight)
        tab.Scroll = clamp(tab.Scroll, 0, tab.ScrollMax)
        local leftY, rightY = contentY - tab.Scroll, contentY - tab.Scroll
        for _, item in ipairs(left) do
            renderSection(window, item.Section, contentX, leftY, columnWidth, item.Height, item.Controls, contentY, viewBottom, interactive)
            leftY = leftY + item.Height + gap
        end
        for _, item in ipairs(right) do
            renderSection(window, item.Section, contentX + columnWidth + gap, rightY, columnWidth, item.Height, item.Controls, contentY, viewBottom, interactive)
            rightY = rightY + item.Height + gap
        end

        if tab.ScrollMax > 0 then
            local trackX = x + width - 10
            local thumbHeight = math.max(38, contentHeight * contentHeight / (contentHeight + tab.ScrollMax))
            local thumbTravel = contentHeight - thumbHeight
            local thumbY = contentY + (tab.Scroll / tab.ScrollMax) * thumbTravel
            placeSquare(ui.scrollTrack, trackX, contentY, 4, contentHeight, theme.Border, 2, 7)
            placeSquare(ui.scrollThumb, trackX - 1, thumbY, 6, thumbHeight, theme.Accent, 3, 9)
            if interactive and runtime.pressed and pointInside(runtime.mouse, trackX - 6, thumbY, 16, thumbHeight) then
                runtime.scrollbar = { Window = window, Tab = tab, StartY = runtime.mouse.Y, StartScroll = tab.Scroll, Travel = thumbTravel }
            end
        else
            show(ui.scrollTrack, false)
            show(ui.scrollThumb, false)
        end
    else
        show(ui.scrollTrack, false)
        show(ui.scrollThumb, false)
    end

    if not anyVisible then
        local message = window.SearchQuery ~= "" and "No controls match your search." or "Add a tab and section to get started."
        placeText(ui.empty, contentX + contentWidth / 2, contentY + 46, message, theme.Muted, 12, 8, true)
    else
        show(ui.empty, false)
    end

    if window.Resizable then
        placeLine(ui.resizeA, x + width - 17, y + height - 7, x + width - 7, y + height - 17, theme.Muted, 1, 10)
        placeLine(ui.resizeB, x + width - 11, y + height - 7, x + width - 7, y + height - 11, theme.Accent, 1, 10)
        if interactive and runtime.pressed and pointInside(runtime.mouse, x + width - 24, y + height - 24, 24, 24) then
            runtime.resizeWindow = window
            runtime.resizeStart = { Mouse = runtime.mouse, Size = window.Size }
        end
    else
        show(ui.resizeA, false)
        show(ui.resizeB, false)
    end
end

local notificationMethods = {}
notificationMethods.__index = notificationMethods

function notificationMethods:Dismiss()
    self.Closed = true
    return self
end

function notificationMethods:SetProgress(value)
    self.Progress = clamp(value, 0, 1)
    self.ManualProgress = true
    return self
end

function notificationMethods:SetText(value)
    self.Content = tostring(value or "")
    return self
end

local function ensureNotificationDrawings(item)
    if item._ready then return end
    item._ready = true
    item._drawings = item._drawings or {}
    item._ui = {
        shadow = square(item, 30), border = square(item, 31), background = square(item, 32),
        accent = square(item, 34), title = text(item, 35, 12), content = text(item, 35, 10),
        close = text(item, 36, 12), progressTrack = square(item, 34), progress = square(item, 35),
        action = square(item, 35), actionText = text(item, 36, 10),
    }
end

NovaUI.NotificationPosition = Vector2.new(24, 24)

function NovaUI:SetNotificationPosition(position)
    self.NotificationPosition = asVector2(position, 24, 24)
    return self
end

function NovaUI:Notify(config)
    config = normalizeConfig(config)
    local item = setmetatable({
        Title = config.Title or config.Name or "Notification",
        Content = config.Content or config.Description or config.Text or "",
        Type = tostring(config.Type or "Info"),
        Duration = clamp(config.Duration or 4, 0.5, 30),
        CreatedAt = tick(),
        Progress = config.Progress,
        ManualProgress = config.Progress ~= nil,
        Action = config.Action,
        Window = config.Window,
        _drawings = {},
        _ui = {},
        _overlay = true,
    }, notificationMethods)
    ensureNotificationDrawings(item)
    self._notifications[#self._notifications + 1] = item
    return item
end

local function notificationColor(item, theme)
    local kind = item.Type:lower()
    if kind == "success" then return theme.Success end
    if kind == "warning" then return theme.Warning end
    if kind == "error" or kind == "danger" then return theme.Danger end
    return theme.Accent
end

local function renderNotifications()
    local base = NovaUI.NotificationPosition
    local stackY = base.Y
    for index = #NovaUI._notifications, 1, -1 do
        local item = NovaUI._notifications[index]
        local elapsed = tick() - item.CreatedAt
        if item.Closed or elapsed >= item.Duration then
            for _, object in ipairs(item._drawings) do remove(object) end
            table.remove(NovaUI._notifications, index)
        end
    end
    for _, item in ipairs(NovaUI._notifications) do
        ensureNotificationDrawings(item)
        local ui = item._ui
        local theme = item.Window and item.Window.Theme or NovaUI.Themes.Nova
        local accent = notificationColor(item, theme)
        local x, y, width = base.X, stackY, 326
        local action = type(item.Action) == "table" and item.Action or nil
        local height = action and 96 or 78
        placeSquare(ui.shadow, x + 5, y + 6, width, height, color(0, 0, 0), 9, 30, 0.5)
        placeSquare(ui.border, x, y, width, height, theme.Border, 9, 31)
        placeSquare(ui.background, x + 1, y + 1, width - 2, height - 2, theme.SurfaceRaised, 8, 32)
        placeSquare(ui.accent, x, y, 4, height, accent, 4, 34)
        placeText(ui.title, x + 15, y + 12, truncate(item.Title, 36), theme.Text, 12, 35)
        placeText(ui.content, x + 15, y + 36, truncate(item.Content, action and 38 or 43), theme.TextSoft, 10, 35)
        placeText(ui.close, x + width - 16, y + 11, "x", theme.Muted, 11, 36, true)
        local progress = item.ManualProgress and clamp(item.Progress or 0, 0, 1) or clamp(1 - (tick() - item.CreatedAt) / item.Duration, 0, 1)
        placeSquare(ui.progressTrack, x + 12, y + height - 7, width - 24, 3, theme.Border, 2, 34)
        placeSquare(ui.progress, x + 12, y + height - 7, (width - 24) * progress, 3, accent, 2, 35)
        if action then
            local label = action.Title or action.Label or "Open"
            local actionHover = pointInside(runtime.mouse, x + 14, y + 57, 92, 25)
            placeSquare(ui.action, x + 14, y + 57, 92, 25, actionHover and theme.ControlHover or theme.Control, 5, 35)
            placeText(ui.actionText, x + 60, y + 64, label, accent, 10, 36, true)
            if runtime.pressed and actionHover and not NovaUI._modal then safe(action.Callback, item) end
        else
            show(ui.action, false) show(ui.actionText, false)
        end
        if runtime.pressed and pointInside(runtime.mouse, x + width - 31, y + 5, 28, 28) then item:Dismiss() end
        stackY = stackY + height + 10
    end
end

local modalMethods = {}
modalMethods.__index = modalMethods

function modalMethods:Close(value)
    if self.Closed then return self end
    self.Closed = true
    self.Value = value
    safe(self.Callback, value)
    for _, object in ipairs(self._drawings) do remove(object) end
    if NovaUI._modal == self then NovaUI._modal = nil end
    return self
end

local function ensureModalDrawings(modal)
    if modal._ready then return end
    modal._ready = true
    modal._ui = {
        overlay = square(modal, 50), border = square(modal, 51), panel = square(modal, 52),
        accent = square(modal, 53), title = text(modal, 54, 15), content = text(modal, 54, 11),
        close = text(modal, 55, 13), buttons = {},
    }
end

function NovaUI:Dialog(config)
    config = normalizeConfig(config)
    if self._modal then self._modal:Close(nil) end
    local buttons = config.Buttons or { { Title = "OK", Value = true } }
    local modal = setmetatable({
        Title = config.Title or "Dialog",
        Content = config.Content or config.Description or "",
        Buttons = buttons,
        Callback = config.Callback,
        Window = config.Window,
        _drawings = {},
        _ui = {},
        _overlay = true,
    }, modalMethods)
    ensureModalDrawings(modal)
    self._modal = modal
    return modal
end

function NovaUI:Confirm(config)
    config = normalizeConfig(config)
    config.Buttons = config.Buttons or {
        { Title = config.ConfirmText or "Confirm", Value = true },
        { Title = config.CancelText or "Cancel", Value = false },
    }
    return self:Dialog(config)
end

local function renderModal()
    local modal = NovaUI._modal
    if not modal or modal.Closed then return end
    local window = modal.Window
    if not window or window.Destroyed or not window.Visible then
        window = nil
        for index = #NovaUI.Windows, 1, -1 do
            if NovaUI.Windows[index].Visible and not NovaUI.Windows[index].Destroyed then window = NovaUI.Windows[index] break end
        end
    end
    if not window then modal:Close(nil) return end
    ensureModalDrawings(modal)
    local ui, theme = modal._ui, window.Theme
    local wx, wy = window.Position.X, window.Position.Y
    local ww, wh = window.Size.X * window.Scale, window.Minimized and 56 or window.Size.Y * window.Scale
    local panelW, panelH = math.min(430, ww - 40), 190
    local x, y = wx + (ww - panelW) / 2, wy + (wh - panelH) / 2
    placeSquare(ui.overlay, wx, wy, ww, wh, color(0, 0, 0), 10, 50, 0.72)
    placeSquare(ui.border, x, y, panelW, panelH, theme.Border, 10, 51)
    placeSquare(ui.panel, x + 1, y + 1, panelW - 2, panelH - 2, theme.SurfaceRaised, 9, 52)
    placeSquare(ui.accent, x, y, panelW, 3, theme.Accent, 2, 53)
    placeText(ui.title, x + 20, y + 21, truncate(modal.Title, 42), theme.Text, 15, 54)
    placeText(ui.content, x + 20, y + 59, truncate(modal.Content, 60), theme.TextSoft, 11, 54)
    placeText(ui.close, x + panelW - 20, y + 17, "x", theme.Muted, 13, 55, true)
    if runtime.pressed and pointInside(runtime.mouse, x + panelW - 37, y + 7, 32, 32) then modal:Close(nil) return end

    local count = math.max(1, math.min(#modal.Buttons, 3))
    local gap, buttonW = 10, (panelW - 40 - (count - 1) * 10) / count
    for index = 1, count do
        local config = modal.Buttons[index]
        if type(config) ~= "table" then config = { Title = tostring(config), Value = config } end
        local button = ui.buttons[index]
        if not button then
            button = { background = square(modal, 54), title = text(modal, 55, 11) }
            ui.buttons[index] = button
        end
        local bx, by = x + 20 + (index - 1) * (buttonW + gap), y + panelH - 54
        local hover = pointInside(runtime.mouse, bx, by, buttonW, 34)
        placeSquare(button.background, bx, by, buttonW, 34, index == 1 and theme.AccentSoft or (hover and theme.ControlHover or theme.Control), 7, 54)
        placeText(button.title, bx + buttonW / 2, by + 10, config.Title or config.Label or "OK", index == 1 and theme.Accent or theme.TextSoft, 11, 55, true)
        if runtime.pressed and hover then safe(config.Callback, config.Value, modal) modal:Close(config.Value) return end
    end
end

local function pressed(code)
    for _, value in ipairs(runtime.pressedKeys) do if value == code then return true end end
    return false
end

local function updateFocusedInput()
    if NovaUI._modal then
        if pressed(27) then NovaUI._modal:Close(nil) end
        return
    end

    if runtime.keys[17] and pressed(75) then
        local window = NovaUI.Windows[#NovaUI.Windows]
        if window and window.Visible and not window.Minimized then runtime.focus = { Kind = "search", Window = window } end
        return
    end

    local focus = runtime.focus
    if not focus then return end
    if pressed(27) then runtime.focus = nil return end

    if focus.Kind == "keybind" then
        for _, code in ipairs(runtime.pressedKeys) do
            if code > 6 and code ~= 16 and code ~= 17 and code ~= 18 then
                focus.Control:SetKey(code)
                runtime.focus = nil
                return
            end
        end
        return
    end

    local current
    if focus.Kind == "search" then current = focus.Window.SearchQuery
    elseif focus.Kind == "input" then current = focus.Control.Value
    else return end

    local changed = false
    if pressed(8) then current = current:sub(1, math.max(0, #current - 1)) changed = true end
    if pressed(13) then
        if focus.Kind == "input" then fireChanged(focus.Control, focus.Control.Value) end
        runtime.focus = nil
        return
    end

    if not runtime.keys[17] and not runtime.keys[18] then
        local shift = runtime.keys[16] == true
        for _, code in ipairs(runtime.pressedKeys) do
            local character = keyToCharacter(code, shift)
            if character then
                if focus.Kind ~= "input" or not focus.Control.Numeric or character:match("[%d%.%-]") then
                    current = current .. character
                    changed = true
                end
            end
        end
    end

    if changed then
        if focus.Kind == "search" then
            focus.Window.SearchQuery = current:sub(1, 80)
            if focus.Window.SelectedTab then focus.Window.SelectedTab.Scroll = 0 end
        else
            local control = focus.Control
            control:SetValue(current:sub(1, control.MaxLength), true)
            if not control.Finished then fireChanged(control, control.Value) end
        end
    end
end

local function updateWindowHotkeys()
    if runtime.focus or NovaUI._modal then return end
    for _, window in ipairs(NovaUI.Windows) do
        if window.Keybind > 0 and pressed(window.Keybind) then window:Toggle() end
    end
end

local function updateControlKeybinds()
    for _, window in ipairs(NovaUI.Windows) do
        for _, tab in ipairs(window.Tabs) do
            for _, section in ipairs(tab.Sections) do
                for _, control in ipairs(section.Controls) do
                    if control.Type == "keybind" and control.Key > 0 and not control.Destroyed then
                        local mode = control.Mode:lower()
                        local active = control.Active
                        if mode == "always" then
                            active = true
                        elseif mode == "hold" then
                            active = runtime.keys[control.Key] == true
                        elseif pressed(control.Key) and not (runtime.focus and runtime.focus.Control == control) then
                            active = not active
                        end
                        if active ~= control.Active then
                            control.Active = active
                            safe(control.OnTriggered, active, control)
                        elseif pressed(control.Key) then
                            safe(control.OnTriggered, active, control)
                        end
                    end
                end
            end
        end
    end
end

local function bringWindowToFront()
    if not runtime.pressed or NovaUI._modal then return end
    for index = #NovaUI.Windows, 1, -1 do
        local window = NovaUI.Windows[index]
        if window.Visible and insideWindow(window, runtime.mouse) then
            if index ~= #NovaUI.Windows then
                table.remove(NovaUI.Windows, index)
                NovaUI.Windows[#NovaUI.Windows + 1] = window
            end
            return
        end
    end
end

local function updatePointerActions()
    if runtime.dragWindow and runtime.down then
        runtime.dragWindow.Position = Vector2.new(runtime.mouse.X - runtime.dragOffset.X, runtime.mouse.Y - runtime.dragOffset.Y)
    end
    if runtime.resizeWindow and runtime.down then
        local window = runtime.resizeWindow
        local delta = runtime.mouse - runtime.resizeStart.Mouse
        window:SetSize(Vector2.new(
            runtime.resizeStart.Size.X + delta.X / window.Scale,
            runtime.resizeStart.Size.Y + delta.Y / window.Scale
        ))
    end
    if runtime.scrollbar and runtime.down then
        local info = runtime.scrollbar
        local ratio = info.Travel > 0 and (runtime.mouse.Y - info.StartY) / info.Travel or 0
        info.Tab.Scroll = clamp(info.StartScroll + ratio * info.Tab.ScrollMax, 0, info.Tab.ScrollMax)
    end
end

local function updateKeyboardScroll()
    if runtime.focus or NovaUI._modal then return end
    local window
    for index = #NovaUI.Windows, 1, -1 do
        if NovaUI.Windows[index].Visible and not NovaUI.Windows[index].Destroyed then window = NovaUI.Windows[index] break end
    end
    local tab = window and window.SelectedTab
    if not tab then return end
    if pressed(33) or pressed(38) then tab.Scroll = clamp(tab.Scroll - (pressed(33) and 240 or 42), 0, tab.ScrollMax) end
    if pressed(34) or pressed(40) then tab.Scroll = clamp(tab.Scroll + (pressed(34) and 240 or 42), 0, tab.ScrollMax) end
    if pressed(36) then tab.Scroll = 0 end
    if pressed(35) then tab.Scroll = tab.ScrollMax end
end

local function frame()
    if not NovaUI._running then return end
    readInput()
    updateFocusedInput()
    updateWindowHotkeys()
    updateControlKeybinds()
    updateKeyboardScroll()
    bringWindowToFront()
    updatePointerActions()

    local front
    for index, window in ipairs(NovaUI.Windows) do
        window._layer = index * 100
        if window.Visible and not window.Destroyed then front = window end
    end
    for _, window in ipairs(NovaUI.Windows) do renderWindow(window, window == front and not NovaUI._modal) end
    renderNotifications()
    renderModal()

    for index = #NovaUI.Windows, 1, -1 do
        local window = NovaUI.Windows[index]
        if window._pendingDestroy then
            window._pendingDestroy = false
            window:Destroy()
        elseif window.AutoSaveName and window._configDirty and tick() >= window._nextAutoSave then
            local ok = window:SaveConfig(window.AutoSaveName)
            if not ok then window._nextAutoSave = tick() + 5 end
        end
    end

    if runtime.released then
        runtime.dragWindow = nil
        runtime.resizeWindow = nil
        runtime.slider = nil
        runtime.scrollbar = nil
    end
end

function NovaUI:GetWindow(title)
    for _, window in ipairs(self.Windows) do if window.Title == title then return window end end
    return nil
end

function NovaUI:ToggleAll(visible)
    for _, window in ipairs(self.Windows) do window:SetVisible(visible == nil and not window.Visible or visible) end
    return self
end

function NovaUI:Destroy()
    if not self._running then return end
    self._running = false
    if self._connection and type(self._connection.Disconnect) == "function" then pcall(function() self._connection:Disconnect() end) end
    if self._modal then self._modal:Close(nil) end
    for index = #self._notifications, 1, -1 do self._notifications[index]:Dismiss() end
    for index = #self.Windows, 1, -1 do self.Windows[index]:Destroy() end
    for _, object in ipairs(self._drawings) do remove(object) end
    self.Windows = {}
    self._notifications = {}
    if environment.NovaUI == self then environment.NovaUI = nil end
end

NovaUI.Unload = NovaUI.Destroy

local renderSignal = RunService.RenderStepped or RunService.Heartbeat
NovaUI._connection = renderSignal:Connect(function()
    local ok, result = pcall(frame)
    if not ok and result ~= NovaUI._lastError then
        NovaUI._lastError = result
        warn("[Nova UI] render error: " .. tostring(result))
    end
end)

environment.NovaUI = NovaUI
return NovaUI
