Files
SurvivorCore/plugin/Nav.luau
T
Samuel LisonandClaude Opus 4.8 91c7a87f89 feat(plugin): SurvivorCore Studio — floating window, sidebar nav, search, Engine Config editor, roster overrides (#11, #21, #40)
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>
2026-07-07 12:07:48 +10:00

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