From f8ce43784fa909b9c82abc795257c9d8ad7b6f7d Mon Sep 17 00:00:00 2001 From: Calic Date: Sun, 24 May 2026 01:16:14 +0200 Subject: [PATCH] Add immediate-mode overlay UI module Vertical layer-chip column on the left edge (Roof up top with divider, entity-slot dashed line between Lw and Ts), collapsible atlas+tile picker bottom-left, status chip bottom-right. Each frame redraws everything and dispatches mouse clicks via priority hit-tests. Tile thumbnails are placeholder gray rects in v0.1.0 because lib-core.maps does not yet expose per-tile UVs to Lua. --- src/ui.lua | 324 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 324 insertions(+) create mode 100644 src/ui.lua diff --git a/src/ui.lua b/src/ui.lua new file mode 100644 index 0000000..c93c281 --- /dev/null +++ b/src/ui.lua @@ -0,0 +1,324 @@ +-- src/ui.lua +-- Immediate-mode overlay UI: layer-chips, atlas+tile picker, status-chip. +-- Renders + hit-tests per frame. No retained widget state — read straight +-- from state.lua on every render and dispatch via actions.lua. + +local maps = require("lib-core.maps") +local state = require("src.state") +local actions = require("src.actions") + +local M = {} + +-- ===================================================================== +-- Constants +-- ===================================================================== +local CHIP_SIZE = 32 -- layer chip W/H in pixels +local CHIP_MARGIN = 4 -- gap between chips +local CHIP_ALPHA_ACTIVE = 0xFF +local CHIP_ALPHA_INACTIVE = 0x78 -- 120/255 +local CHIP_ALPHA_HOVER = 0xC8 -- 200/255 + +local PICKER_COLLAPSED_W = 80 +local PICKER_COLLAPSED_H = 80 +local PICKER_EXPANDED_W = 320 +local PICKER_EXPANDED_H = 240 +local PICKER_MARGIN = 8 +local TILE_THUMB_SIZE = 48 + +local STATUS_W = 80 +local STATUS_H = 24 +local STATUS_MARGIN = 8 + +-- Layer order (top of screen = top of z-stack). Roof above with divider. +local LAYER_CHIPS = { + { name="roof_toggle", label="Rf", is_roof=true }, + { name="canopy", label="Ca" }, + { name="upper_wall", label="Uw" }, + { name="wall", label="Wa" }, + { name="lower_wall", label="Lw" }, + -- entity-slot divider rendered between Lw and Ts + { name="topsurface", label="Ts" }, + { name="surface", label="Sf" }, + { name="subsurface", label="Sb" }, + { name="foundation", label="Fo" }, +} + +local COLOR_CHIP_BG = 0x202020 -- RGB (alpha added at draw) +local COLOR_CHIP_BG_ACT = 0xE0E0E0 +local COLOR_CHIP_TEXT = 0xFFFFFF +local COLOR_CHIP_TEXT_ACT = 0x202020 +local COLOR_CHIP_BORDER = 0xFFFFFF +local COLOR_DIVIDER = 0xFFFFFF +local COLOR_STRIKE = 0xFF4040 -- hidden-layer overlay line +local COLOR_PICKER_BG = 0x303030 +local COLOR_STATUS_BG = 0x303030 +local COLOR_STATUS_TEXT = 0xFFFFFF + +-- ===================================================================== +-- Helpers +-- ===================================================================== + +local function rgba(rgb, alpha) + return (rgb << 8) | (alpha & 0xFF) +end + +local function in_rect(mx, my, x, y, w, h) + return mx >= x and mx < x + w and my >= y and my < y + h +end + +-- Layer-chip layout: vertical stack on the left edge, starting at y_top. +-- Returns: array of { name, label, is_roof, x, y, w, h, is_divider_after? } +local function chip_layout() + local out = {} + local x = CHIP_MARGIN + local y = CHIP_MARGIN + for i, chip in ipairs(LAYER_CHIPS) do + local entry = { + name=chip.name, label=chip.label, is_roof=chip.is_roof, + x=x, y=y, w=CHIP_SIZE, h=CHIP_SIZE, + } + -- Roof-Toggle gets a thicker gap below it + if chip.is_roof then + entry.gap_after = CHIP_SIZE / 2 + elseif chip.name == "lower_wall" then + entry.gap_after = CHIP_SIZE / 2 -- entity-slot divider + entry.divider_after = true + else + entry.gap_after = CHIP_MARGIN + end + out[i] = entry + y = y + entry.h + entry.gap_after + end + return out +end + +-- Picker layout: collapsed = bottom-left corner; expanded = grows upward+right. +local function picker_layout() + local screen_w, screen_h = engine.window.size() + if state.is_picker_open() then + return { + x = PICKER_MARGIN + CHIP_SIZE + CHIP_MARGIN, -- right of layer-chips + y = screen_h - PICKER_EXPANDED_H - PICKER_MARGIN, + w = PICKER_EXPANDED_W, + h = PICKER_EXPANDED_H, + } + else + return { + x = PICKER_MARGIN + CHIP_SIZE + CHIP_MARGIN, + y = screen_h - PICKER_COLLAPSED_H - PICKER_MARGIN, + w = PICKER_COLLAPSED_W, + h = PICKER_COLLAPSED_H, + } + end +end + +local function status_layout() + local screen_w, screen_h = engine.window.size() + return { + x = screen_w - STATUS_W - STATUS_MARGIN, + y = screen_h - STATUS_H - STATUS_MARGIN, + w = STATUS_W, + h = STATUS_H, + } +end + +-- ===================================================================== +-- Drawing +-- ===================================================================== + +local function draw_chip(chip, mx, my) + -- State decisions + local is_active + if chip.is_roof then + is_active = state.is_roof_mode() + else + is_active = (state.get_active_layer() == chip.name) + end + local is_hover = in_rect(mx, my, chip.x, chip.y, chip.w, chip.h) + local alpha + if is_active then + alpha = CHIP_ALPHA_ACTIVE + elseif is_hover then + alpha = CHIP_ALPHA_HOVER + else + alpha = CHIP_ALPHA_INACTIVE + end + local bg_color = is_active and rgba(COLOR_CHIP_BG_ACT, alpha) + or rgba(COLOR_CHIP_BG, alpha) + local text_color = is_active and rgba(COLOR_CHIP_TEXT_ACT, 0xFF) + or rgba(COLOR_CHIP_TEXT, 0xFF) + -- Background + engine.render.draw_rect(chip.x, chip.y, chip.w, chip.h, bg_color) + -- Border (active only) + -- NOTE: engine.render.draw_rect_lines does not exist yet (needs engine-binding-task) + if is_active then + engine.render.draw_rect_lines(chip.x, chip.y, chip.w, chip.h, rgba(COLOR_CHIP_BORDER, 0xFF)) + end + -- Label text — centered (approximate using fixed-width assumption) + local text_x = chip.x + (chip.w / 2) - 8 + local text_y = chip.y + (chip.h / 2) - 8 + engine.render.draw_text(chip.label, text_x, text_y, 14, text_color) + -- Hidden overlay (strikethrough for invisible layers) + -- NOTE: engine.render.draw_line does not exist yet (needs engine-binding-task) + if not chip.is_roof and not state.is_layer_visible(chip.name) then + engine.render.draw_line(chip.x, chip.y, chip.x + chip.w, chip.y + chip.h, + rgba(COLOR_STRIKE, 0xFF), 2) + end + -- Entity-slot divider below lower_wall + if chip.divider_after then + local dy = chip.y + chip.h + (chip.gap_after / 2) + -- Dashed: 4 short segments + -- NOTE: engine.render.draw_line does not exist yet (needs engine-binding-task) + for seg = 0, 3 do + local sx = chip.x + seg * 9 + engine.render.draw_line(sx, dy, sx + 6, dy, rgba(COLOR_DIVIDER, 0xFF), 1) + end + end +end + +local function draw_layer_chips(mx, my) + for _, chip in ipairs(chip_layout()) do + draw_chip(chip, mx, my) + end +end + +-- Placeholder tile thumbnail (v0.1.0 limitation: lib-core.maps doesn't +-- expose per-tile UVs to Lua; v0.2.0 task). +local function draw_tile_thumb(atlas_idx, tile_id, x, y, size) + engine.render.draw_rect(x, y, size, size, rgba(0x606060, 0xFF)) + engine.render.draw_text("t" .. tile_id, x + 4, y + size/2 - 6, 10, rgba(0xFFFFFF, 0xFF)) +end + +-- Returns a list of tile IDs for the given atlas. Placeholder for v0.1.0: +-- lib-core.maps does not yet expose atlas.tile_ids() — return just the +-- currently-active tile so the expanded picker has something to render. +local function atlas_tile_list(atlas_idx) + return { state.get_active_tile() } +end + +local function draw_picker(mx, my) + local p = picker_layout() + engine.render.draw_rect(p.x, p.y, p.w, p.h, rgba(COLOR_PICKER_BG, 0xE0)) + -- NOTE: engine.render.draw_rect_lines does not exist yet (needs engine-binding-task) + engine.render.draw_rect_lines(p.x, p.y, p.w, p.h, rgba(0xFFFFFF, 0xFF)) + + if not state.is_picker_open() then + -- Collapsed view: thumbnail of active tile + truncated labels + local atlas_idx = state.get_active_atlas() + local tile_id = state.get_active_tile() + local atlas_id = maps.atlas_id_at(atlas_idx) or "?" + -- Active tile thumbnail (top of chip) + draw_tile_thumb(atlas_idx, tile_id, + p.x + 16, p.y + 8, TILE_THUMB_SIZE) + -- Labels (bottom) + local atlas_label = atlas_id:sub(1, 8) .. (atlas_id:len() > 8 and "…" or "") + engine.render.draw_text(atlas_label, p.x + 4, p.y + 60, 10, rgba(0xCCCCCC, 0xFF)) + return + end + + -- Expanded view: atlas row at top, tile grid below + engine.render.draw_text("Atlas:", p.x + 8, p.y + 4, 11, rgba(0xFFFFFF, 0xFF)) + local atlas_count = maps.atlas_count() + local atlas_x = p.x + 8 + for i = 0, atlas_count - 1 do + local label = maps.atlas_id_at(i) or "?" + local short = label:sub(1, 6) + local color = (i == state.get_active_atlas()) + and rgba(0xFFFFFF, 0xFF) or rgba(0x808080, 0xFF) + engine.render.draw_text("["..short.."]", atlas_x, p.y + 20, 11, color) + atlas_x = atlas_x + 60 -- crude spacing + end + + -- Tile grid (below the atlas row) + engine.render.draw_text("Tiles:", p.x + 8, p.y + 40, 11, rgba(0xFFFFFF, 0xFF)) + local active_atlas = state.get_active_atlas() + local tiles = atlas_tile_list(active_atlas) + local grid_x = p.x + 8 + local grid_y = p.y + 60 + for i, tid in ipairs(tiles) do + local col = (i - 1) % 5 + local row = math.floor((i - 1) / 5) + local tx = grid_x + col * (TILE_THUMB_SIZE + 4) + local ty = grid_y + row * (TILE_THUMB_SIZE + 4) + draw_tile_thumb(active_atlas, tid, tx, ty, TILE_THUMB_SIZE) + if tid == state.get_active_tile() then + -- NOTE: engine.render.draw_rect_lines does not exist yet (needs engine-binding-task) + engine.render.draw_rect_lines(tx, ty, TILE_THUMB_SIZE, TILE_THUMB_SIZE, + rgba(0xFFFF00, 0xFF)) + end + end +end + +local function draw_status(mx, my) + local s = status_layout() + engine.render.draw_rect(s.x, s.y, s.w, s.h, rgba(COLOR_STATUS_BG, 0xC0)) + -- NOTE: engine.render.draw_rect_lines does not exist yet (needs engine-binding-task) + engine.render.draw_rect_lines(s.x, s.y, s.w, s.h, rgba(0xFFFFFF, 0xFF)) + local cell = state.get_mouse_cell() + local cell_str + if cell then + cell_str = string.format("(%d,%d)", cell.x, cell.y) + else + cell_str = "(-,-)" + end + local dirty_str = state.is_dirty() and "*" or " " + engine.render.draw_text(cell_str .. " " .. dirty_str, s.x + 4, s.y + 4, 12, + rgba(COLOR_STATUS_TEXT, 0xFF)) +end + +-- ===================================================================== +-- Public API +-- ===================================================================== + +function M.draw() + local mx, my = engine.input.get_mouse_pos() + draw_layer_chips(mx, my) + draw_picker(mx, my) + draw_status(mx, my) +end + +-- Hit-test mouse click. Returns true if the click was consumed by UI. +-- button: "left" | "right" +function M.handle_click(mx, my, button) + local p = picker_layout() + -- Picker: check expanded panel first (largest area) + if in_rect(mx, my, p.x, p.y, p.w, p.h) then + if state.is_picker_open() then + -- TODO: hit-test individual tile/atlas slots inside the panel + -- For v0.1.0, any click inside expanded picker closes it. + actions.toggle_picker() + return true + else + actions.toggle_picker() + return true + end + end + + -- Layer chips + for _, chip in ipairs(chip_layout()) do + if in_rect(mx, my, chip.x, chip.y, chip.w, chip.h) then + if button == "left" then + if chip.is_roof then + actions.toggle_roof_mode() + else + actions.set_active_layer(chip.name) + end + elseif button == "right" then + if not chip.is_roof then + actions.toggle_layer_visible(chip.name) + end + end + return true + end + end + + -- Status chip — no-op fallback + local s = status_layout() + if in_rect(mx, my, s.x, s.y, s.w, s.h) then + return true -- consumed but no action + end + + return false -- click falls through to map-area handler in init.lua +end + +return M