mirror of
https://github.com/TemujinCalidius/SurvivorCore.git
synced 2026-08-14 09:02:29 +00:00
New shell: Theme (shared visual system), Nav (rail + lazy router + badges), OverviewPage, SearchPage (global editable results), SpawnHelpers; init.server restructured around data-driven pages with last-page persistence and undo/redo-aware refresh. Widget: floating 660×580 (min 480×380), new ID SurvivorCoreStudio. ConfigAdmin/-Ui: deltas-only editor pages for all 11 engine Config sections incl. Theme colors (R,G,B + swatch) and fonts. ContentAdmin/-Ui: per-category pages over the merged roster; + Override entries (blank = inherit) tuning code-registered defs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
262 lines
7.2 KiB
Luau
262 lines
7.2 KiB
Luau
--!nonstrict
|
|
--[[
|
|
Nav — the plugin's sidebar rail + page router. Pages are data (id/label/section/mount);
|
|
Nav renders the grouped rail (uppercase section headers, count badges), lazy-mounts each
|
|
page into its own detail frame on first selection, and toggles visibility after that —
|
|
so per-page state (scroll position, footer status) survives switching.
|
|
|
|
refreshAll() refreshes the ACTIVE page immediately and dirty-flags the other mounted pages,
|
|
which re-render the next time they're shown — an edit on one page never rebuilds a form the
|
|
user might be mid-thought on elsewhere, but every page is fresh by the time it's seen.
|
|
]]
|
|
|
|
local Theme = require(script.Parent.Theme)
|
|
|
|
local Nav = {}
|
|
|
|
export type PageHandle = { refresh: () -> () }
|
|
|
|
export type Page = {
|
|
id: string,
|
|
label: string,
|
|
section: string, -- rail group header ("" = top-level, no header)
|
|
badge: (() -> number?)?,
|
|
hidden: boolean?, -- routable but not listed (e.g. search results)
|
|
mount: (frame: Frame) -> PageHandle,
|
|
}
|
|
|
|
export type Options = {
|
|
railWidth: number?,
|
|
initialId: string?,
|
|
onNavigate: ((id: string) -> ())?,
|
|
onSearch: ((query: string) -> ())?,
|
|
}
|
|
|
|
local ITEM_H = 24
|
|
local SEARCH_H = 26
|
|
|
|
function Nav.mount(root: Frame, pages: { Page }, opts: Options?)
|
|
local options: Options = opts or {}
|
|
local railWidth = options.railWidth or 156
|
|
|
|
local byId: { [string]: Page } = {}
|
|
for _, page in pages do
|
|
byId[page.id] = page
|
|
end
|
|
|
|
-- ── Layout: rail (left) + detail (right) ────────────────────────────────
|
|
local rail = Theme.make("Frame", {
|
|
Size = UDim2.new(0, railWidth, 1, 0),
|
|
BackgroundColor3 = Theme.COLOR.RAIL,
|
|
BorderSizePixel = 0,
|
|
}) :: Frame
|
|
rail.Parent = root
|
|
|
|
local detail = Theme.make("Frame", {
|
|
Position = UDim2.fromOffset(railWidth, 0),
|
|
Size = UDim2.new(1, -railWidth, 1, 0),
|
|
BackgroundTransparency = 1,
|
|
}) :: Frame
|
|
detail.Parent = root
|
|
|
|
-- Search box pinned to the rail top.
|
|
local searchBox = Theme.textBox({
|
|
Size = UDim2.new(1, -12, 0, SEARCH_H),
|
|
Position = UDim2.fromOffset(6, 6),
|
|
Text = "",
|
|
PlaceholderText = "Search content…",
|
|
TextSize = 12,
|
|
})
|
|
searchBox.Parent = rail
|
|
if options.onSearch then
|
|
searchBox:GetPropertyChangedSignal("Text"):Connect(function()
|
|
options.onSearch(searchBox.Text)
|
|
end)
|
|
end
|
|
|
|
local railScroll = Theme.make("ScrollingFrame", {
|
|
Position = UDim2.fromOffset(0, SEARCH_H + 12),
|
|
Size = UDim2.new(1, 0, 1, -(SEARCH_H + 12)),
|
|
BackgroundTransparency = 1,
|
|
BorderSizePixel = 0,
|
|
ScrollBarThickness = 4,
|
|
CanvasSize = UDim2.new(),
|
|
AutomaticCanvasSize = Enum.AutomaticSize.Y,
|
|
}, {
|
|
Theme.make("UIListLayout", { Padding = UDim.new(0, 2), SortOrder = Enum.SortOrder.LayoutOrder }),
|
|
Theme.pad(6, 6, 0, 10),
|
|
}) :: ScrollingFrame
|
|
railScroll.Parent = rail
|
|
|
|
-- ── Router state ─────────────────────────────────────────────────────────
|
|
local frames: { [string]: Frame } = {}
|
|
local handles: { [string]: PageHandle } = {}
|
|
local dirty: { [string]: boolean } = {}
|
|
local activeId: string? = nil
|
|
local items: { [string]: { button: TextButton, label: TextLabel, bar: Frame, badge: TextLabel? } } = {}
|
|
|
|
local function paintItem(id: string)
|
|
local item = items[id]
|
|
if not item then
|
|
return
|
|
end
|
|
local selected = id == activeId
|
|
item.button.BackgroundColor3 = if selected then Theme.COLOR.PANEL else Theme.COLOR.RAIL
|
|
item.button.BackgroundTransparency = if selected then 0 else 1
|
|
item.label.TextColor3 = if selected then Theme.COLOR.TEXT else Theme.COLOR.DIM
|
|
item.bar.Visible = selected
|
|
end
|
|
|
|
local function refreshBadges()
|
|
for id, item in items do
|
|
local page = byId[id]
|
|
if item.badge and page and page.badge then
|
|
local count = page.badge()
|
|
item.badge.Text = if count ~= nil then tostring(count) else ""
|
|
item.badge.Visible = count ~= nil
|
|
end
|
|
end
|
|
end
|
|
|
|
local api = {}
|
|
|
|
function api.selectPage(id: string)
|
|
local page = byId[id]
|
|
if not page then
|
|
return
|
|
end
|
|
if not frames[id] then
|
|
local frame = Theme.make("Frame", {
|
|
Size = UDim2.fromScale(1, 1),
|
|
BackgroundColor3 = Theme.COLOR.BG,
|
|
BorderSizePixel = 0,
|
|
Visible = false,
|
|
}) :: Frame
|
|
frame.Parent = detail
|
|
frames[id] = frame
|
|
handles[id] = page.mount(frame)
|
|
dirty[id] = nil -- mount() renders fresh
|
|
end
|
|
if activeId ~= id then
|
|
local previous = activeId and frames[activeId :: string]
|
|
if previous then
|
|
previous.Visible = false
|
|
end
|
|
local old = activeId
|
|
activeId = id
|
|
frames[id].Visible = true
|
|
if old then
|
|
paintItem(old)
|
|
end
|
|
paintItem(id)
|
|
end
|
|
if dirty[id] then
|
|
dirty[id] = nil
|
|
handles[id].refresh()
|
|
end
|
|
if options.onNavigate and not page.hidden then
|
|
options.onNavigate(id)
|
|
end
|
|
end
|
|
|
|
-- Refresh the active page now; mark every other mounted page to refresh on next show.
|
|
function api.refreshAll()
|
|
for id in handles do
|
|
if id == activeId then
|
|
handles[id].refresh()
|
|
else
|
|
dirty[id] = true
|
|
end
|
|
end
|
|
refreshBadges()
|
|
end
|
|
|
|
function api.getActiveId(): string?
|
|
return activeId
|
|
end
|
|
|
|
-- ── Rail rendering (grouped, ordered as given) ───────────────────────────
|
|
local order = 0
|
|
local seenSections: { [string]: boolean } = {}
|
|
for _, page in pages do
|
|
if page.hidden then
|
|
continue
|
|
end
|
|
if page.section ~= "" and not seenSections[page.section] then
|
|
seenSections[page.section] = true
|
|
order += 1
|
|
Theme.label({
|
|
Size = UDim2.new(1, 0, 0, 20),
|
|
Text = string.upper(page.section),
|
|
TextColor3 = Theme.COLOR.DIM,
|
|
TextSize = 10,
|
|
LayoutOrder = order,
|
|
}).Parent =
|
|
railScroll
|
|
end
|
|
order += 1
|
|
|
|
local button = Theme.make("TextButton", {
|
|
Size = UDim2.new(1, 0, 0, ITEM_H),
|
|
BackgroundColor3 = Theme.COLOR.RAIL,
|
|
BackgroundTransparency = 1,
|
|
AutoButtonColor = false,
|
|
Text = "",
|
|
BorderSizePixel = 0,
|
|
LayoutOrder = order,
|
|
}, { Theme.corner(4) }) :: TextButton
|
|
button.Parent = railScroll
|
|
local label = Theme.make("TextLabel", {
|
|
Size = UDim2.new(1, -34, 1, 0),
|
|
Position = UDim2.fromOffset(10, 0),
|
|
BackgroundTransparency = 1,
|
|
Text = page.label,
|
|
TextColor3 = Theme.COLOR.DIM,
|
|
TextXAlignment = Enum.TextXAlignment.Left,
|
|
TextTruncate = Enum.TextTruncate.AtEnd,
|
|
TextSize = 12,
|
|
Font = Theme.FONT,
|
|
}) :: TextLabel
|
|
label.Parent = button
|
|
|
|
local bar = Theme.make("Frame", {
|
|
Size = UDim2.new(0, 2, 1, -6),
|
|
Position = UDim2.fromOffset(0, 3),
|
|
BackgroundColor3 = Theme.COLOR.ACCENT,
|
|
BorderSizePixel = 0,
|
|
Visible = false,
|
|
}) :: Frame
|
|
bar.Parent = button
|
|
|
|
local badgeLabel: TextLabel? = nil
|
|
if page.badge then
|
|
badgeLabel = Theme.badge("")
|
|
local badge = badgeLabel :: TextLabel
|
|
badge.AnchorPoint = Vector2.new(1, 0.5)
|
|
badge.Position = UDim2.new(1, -4, 0.5, 0)
|
|
badge.Parent = button
|
|
end
|
|
|
|
items[page.id] = { button = button, label = label, bar = bar, badge = badgeLabel }
|
|
button.MouseEnter:Connect(function()
|
|
if page.id ~= activeId then
|
|
button.BackgroundTransparency = 0
|
|
button.BackgroundColor3 = Theme.COLOR.PANEL_HOVER
|
|
end
|
|
end)
|
|
button.MouseLeave:Connect(function()
|
|
paintItem(page.id)
|
|
end)
|
|
button.MouseButton1Click:Connect(function()
|
|
api.selectPage(page.id)
|
|
end)
|
|
end
|
|
|
|
refreshBadges()
|
|
api.selectPage(options.initialId or pages[1].id)
|
|
|
|
return api
|
|
end
|
|
|
|
return Nav
|