From 57b36798b940fd6f6344c3c589acc97422bbbd08 Mon Sep 17 00:00:00 2001 From: Eric Smith <5d@fifthdread.com> Date: Thu, 21 May 2026 12:47:25 -0400 Subject: [PATCH] add plugin settings, per-tile refresh, settings menu, touch-friendly UI - Plugin settings infrastructure in PluginManager with config/plugin_settings.cfg storage, manifest [settings] sections, CRUD API, plugin_setting_changed signal - Per-tile refresh intervals: 100ms base tick, per-tile update_interval_ms metadata, dynamic tween durations clamped to refresh window - Settings menu (PopupPanel) with General/Plugins/About tabs, plugin activation toggles, per-plugin settings cog buttons - Plugin settings popup (PopupPanel) with dynamic UI from setting definitions (SpinBox/CheckBox/LineEdit), auto-save on change - Modal behavior: exclusive = true on settings windows - Touch-friendly sizing: enlarged close buttons, cog buttons, controls, spacing - Red corner close button redesign with rounded corners, hover/pressed states - Split system_monitor into local_system_monitor (CPU, Memory) and test plugins - Plugin activation/deactivation with layout preservation --- AGENTS.md | 57 +++- README.md | 29 +- autoload/layout_manager.gd | 20 ++ autoload/plugin_manager.gd | 106 ++++++ config/default.cfg | 7 + config/layouts/Main.cfg | 18 +- config/layouts/default.cfg | 6 +- plugins/local_system_monitor/plugin.cfg | 49 +++ .../tiles/cpu/cpu_collector.gd | 0 .../tiles/cpu/cpu_tile.gd | 6 +- .../tiles/cpu/cpu_tile.tscn | 2 +- .../tiles/memory/memory_collector.gd | 0 .../tiles/memory/memory_tile.gd | 6 +- .../tiles/memory/memory_tile.tscn | 2 +- plugins/system_monitor/plugin.cfg | 41 --- plugins/test/plugin.cfg | 30 ++ .../tiles/testing/testing_tile.gd | 7 +- .../tiles/testing/testing_tile.tscn | 2 +- scenes/dashboard.gd | 157 ++++++++- scenes/plugin_settings_popup.gd | 195 +++++++++++ scenes/plugin_settings_popup.tscn | 6 + scenes/settings_menu.gd | 316 ++++++++++++++++++ scenes/settings_menu.tscn | 6 + scripts/dashboard_grid.gd | 180 +++++----- 24 files changed, 1065 insertions(+), 183 deletions(-) create mode 100644 plugins/local_system_monitor/plugin.cfg rename plugins/{system_monitor => local_system_monitor}/tiles/cpu/cpu_collector.gd (100%) rename plugins/{system_monitor => local_system_monitor}/tiles/cpu/cpu_tile.gd (85%) rename plugins/{system_monitor => local_system_monitor}/tiles/cpu/cpu_tile.tscn (92%) rename plugins/{system_monitor => local_system_monitor}/tiles/memory/memory_collector.gd (100%) rename plugins/{system_monitor => local_system_monitor}/tiles/memory/memory_tile.gd (85%) rename plugins/{system_monitor => local_system_monitor}/tiles/memory/memory_tile.tscn (91%) delete mode 100644 plugins/system_monitor/plugin.cfg create mode 100644 plugins/test/plugin.cfg rename plugins/{system_monitor => test}/tiles/testing/testing_tile.gd (84%) rename plugins/{system_monitor => test}/tiles/testing/testing_tile.tscn (91%) create mode 100644 scenes/plugin_settings_popup.gd create mode 100644 scenes/plugin_settings_popup.tscn create mode 100644 scenes/settings_menu.gd create mode 100644 scenes/settings_menu.tscn diff --git a/AGENTS.md b/AGENTS.md index 1a2b09e..c04a90b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,9 +24,11 @@ V Panel is a Godot Engine project that builds a fancy real-time status monitor. - Modules are direct children of the grid (not cells). Visual cells (`PanelContainer`) are shown only during drag as drop-zone guides. - Grid data model: each module stores `grid_col`, `grid_row`, `grid_w`, `grid_h` via `set_meta()`. A 2D `_grid[row][col]` array tracks cell occupancy (supports multi-cell spans). - `place_module(module, col, row, span_w=1, span_h=1)` — public API for adding modules with optional span. +- `@export var grid_columns: int` / `@export var grid_rows: int` — fixed grid dimensions. Square cells, centered layout. - Drag-and-drop: `_begin_drag` → `_update_drag` → `_end_drag`. Occupancy is cleared during drag and restored on drop. Swaps modules when dropping on occupied cells. Falls back to source position if dropped in grid gap or outside grid bounds (`mouse_exited` safety handler). - Resize: `_try_begin_resize` detects clicks within 10px of any module edge. `_update_resize_preview` shows a ghost `ColorRect` at the target span. `_end_resize` commits the new span after overlap check. All 4 edges and corners supported. -- Double-click: opens `PopupMenu` with shader preset names from `ShaderPresets`. Selection applies all preset params to the module's `ShaderMaterial`. +- Long-press gesture: 500ms hold, 10px drag threshold. Opens action menu with toolbar (⚙, ℹ, ✕/+) and "Themes ▸" submenu. +- `_open_settings(menu_panel)` — opens `settings_menu.tscn` as exclusive modal PopupPanel. - Grid rebuild (`_rebuild_grid`) preserves module metadata (span data lives on modules). Cells are torn down and rebuilt on column/row changes. Orphaned popups cleaned up during rebuild. ### Shaders @@ -63,20 +65,33 @@ V Panel is a Godot Engine project that builds a fancy real-time status monitor. ### Plugin System - `autoload/plugin_manager.gd` — autoload singleton, scans `res://plugins/*/plugin.cfg` at startup. -- Each plugin folder contains a `plugin.cfg` INI file with `[plugin]` metadata and `[tile_N]` entries defining tiles (id, name, scene path, min/max grid spans). +- Each plugin folder contains a `plugin.cfg` INI file with `[plugin]` metadata, `[tile_N]` entries defining tiles, and optional `[settings]` section. - Tile IDs are scoped as `{plugin_id}/{tile_id}` to guarantee global uniqueness. -- `PluginManager.instantiate_tile(tile_id)` loads the tile scene, instantiates it, and tags the root node with `tile_id` and `tile_config` metadata. +- `PluginManager.instantiate_tile(tile_id)` loads the tile scene, instantiates it, and tags the root node with `tile_id` and `tile_config` metadata. Returns null if source plugin is inactive. +- `PluginManager.get_plugin_settings(pid)` — returns setting definitions from manifest. +- `PluginManager.get_plugin_setting(pid, key, default)` — reads persisted value, falls back to manifest default. +- `PluginManager.set_plugin_setting(pid, key, value)` — persists to `config/plugin_settings.cfg`, emits `plugin_setting_changed` signal. +- `plugin_active_changed` signal — emitted when plugin activation state changes via `set_plugin_active(pid, active)`. - `scripts/plugin_tile.gd` — class_name `PluginTile`, extends `ModuleBase`. Plugin tiles extend this instead of ModuleBase directly. Stores `tile_id` and `tile_config` properties, also written to node metadata for grid/layout access. +### Plugin Settings +- Storage: `config/plugin_settings.cfg` using ConfigFile directly (not ConfigManager) to avoid key-flattening conflicts with plugin IDs containing underscores. +- Manifest `[settings]` section: `count=N`, followed by `[setting_N]` entries with `key`, `label`, `type` (int/float/bool/string), optional `default`, `min`, `max`, `step`. +- ConfigFile.get_value treats `null` as "no default" — use `has_section_key()` before reading optional keys that may not exist in the manifest. +- Settings auto-save on every change (no Apply/Cancel pattern). + ### Layout System - `autoload/layout_manager.gd` — autoload singleton for saving/restoring tile grid positions. -- Layout files stored as INI in `res://config/layouts/{name}.cfg`. Each entry maps a `tile_id` to `{col, row, w, h}`. +- Layout files stored as INI in `res://config/layouts/{name}.cfg`. Each entry maps a `tile_id` to `{col, row, w, h}`. `[layout]` section has `grid_columns`/`grid_rows`. - `save_layout()` writes the current in-memory layout to disk. `load_layout(name)` reads it back. - `switch_layout(name)` saves current layout, loads the named one, emits signals. - Current layout name persisted in `ConfigManager` as `layout_current`. - Layout auto-saves on every `module_placed` and `module_resized` signal from `DashboardGrid`. ### Config System +- `autoload/config_manager.gd` — autoload singleton, wraps ConfigFile for app-wide config. +- `config/default.cfg` — shipped defaults. `config/config.cfg` — user overrides (gitignored). +- `ConfigFile` reserved words (`default`, case-insensitive) cannot be used as any value. ### Splash Screen - `scenes/splash.tscn` + `scenes/splash.gd` — entry point (set in `project.godot` as `main_scene`), handles fullscreen, font-size zoom animation, crossfade to dashboard. @@ -84,6 +99,19 @@ V Panel is a Godot Engine project that builds a fancy real-time status monitor. - Flow: Black overlay reveal (0.4s) → font-size animation with TRANS_BACK overshoot (2s) + parallel fade-in (1.8s) → hold (1s) → splash text fades out (1s, EASE_IN) → dashboard instantiated and fades in (1s, EASE_OUT) → reparent to root, queue_free splash. - Dashboard background (`PanelContainer`) and splash background (`ColorRect`) both read from `ConfigManager.get_color("background_color")` for a seamless crossfade — no fade-to-black transition. +### Settings Menu +- `scenes/settings_menu.tscn` + `scenes/settings_menu.gd` — `PopupPanel` with `TabContainer` (General, Plugins, About tabs). +- Opens as exclusive modal via the ⚙ cog in the tile action menu. +- Plugins tab: dynamic list showing plugin name, version, Active checkbox, and ⚙ settings cog (only shown if plugin has setting definitions). +- About tab: project name, version `0.1.0-alpha`, contact, description. +- `scenes/plugin_settings_popup.tscn` + `.gd` — per-plugin settings popup, opened via cog in Plugins tab. Builds UI dynamically from setting definitions (SpinBox for int/float, CheckBox for bool, LineEdit for string). Settings auto-save on every change. + +### Per-Tile Refresh +- `scenes/dashboard.gd` — per-tile refresh tracking. Base timer ticks at 100ms. Each tile has `update_interval_ms` cached as metadata via `_cache_tile_interval()`. +- `_tick_refresh()` checks `now - last_refresh >= interval` per tile. Interval defaults to 1000ms, configurable via plugin settings. +- `_on_plugin_setting_changed()` re-caches intervals on live setting changes. +- Tween durations in tiles are dynamic: `minf(hardcoded_duration, update_interval_ms / divisor)` — CPU/Memory use divisor 2000, Testing uses 1500. + ### Project Structure ``` res:// @@ -94,24 +122,30 @@ res:// │ └── textures/ # noise_100.png (tileable Perlin noise for shader distortion) ├── autoload/ # Singleton/autoload scripts │ ├── config_manager.gd # INI config via ConfigFile -│ ├── plugin_manager.gd # Plugin scanning, tile instantiation +│ ├── plugin_manager.gd # Plugin scanning, tile instantiation, settings CRUD │ └── layout_manager.gd # Layout save/load/switch ├── config/ # Configuration files (INI format) │ ├── default.cfg # Shipped defaults, tracked in git │ ├── config.cfg # User overrides, gitignored (auto-generated) +│ ├── plugin_settings.cfg # Plugin settings storage (auto-created) │ └── layouts/ # Saved layout files (*.cfg) ├── plugins/ # Plugin folders -│ └── system_monitor/ # Built-in system monitoring plugin +│ ├── local_system_monitor/ # CPU + Memory tiles +│ │ ├── plugin.cfg +│ │ └── tiles/ +│ │ ├── cpu/ +│ │ └── memory/ +│ └── test/ # Testing tile │ ├── plugin.cfg │ └── tiles/ -│ ├── cpu/ -│ ├── memory/ │ └── testing/ ├── scenes/ # Root scenes │ ├── splash.tscn # Animated splash → dashboard transition -│ └── dashboard.tscn # Main dashboard (PanelContainer root) +│ ├── dashboard.tscn # Main dashboard (PanelContainer root) +│ ├── settings_menu.tscn # Settings popup +│ └── plugin_settings_popup.tscn # Per-plugin settings popup ├── scripts/ # Shared utility scripts -│ ├── dashboard_grid.gd # Responsive grid with drag, resize, preset popup +│ ├── dashboard_grid.gd # Responsive grid with drag, resize, long-press, action menu │ ├── module_base.gd # Base class for all modules (mouse_filter IGNORE) │ ├── panel_base.gd # Base class for panels │ ├── plugin_tile.gd # Base class for plugin tiles (extends ModuleBase) @@ -129,6 +163,9 @@ res:// - `mouse_exited` signal connected on the grid — aborts drag/resize if the mouse leaves the grid area. - `_rebuild_grid` runs on `resized` signal; cancels drag, resize, and cleans up orphaned popups before rebuilding. - Shader `corner_radius` is in UV space (0–0.5) where 0.05 ≈ 12px on a 240px‑tall cell. +- ConfigFile `get_value(section, key, null)` treats `null` as "no default" — use `has_section_key()` for optional fields. +- `Button.custom_minimum_size` is the writable property (not `minimum_size` or `custom_min_size`). +- `default` is a reserved ConfigFile keyword case-insensitively — cannot be used as any value. ### Git Workflow - Main branch: `main` diff --git a/README.md b/README.md index c3e06c2..7c89390 100644 --- a/README.md +++ b/README.md @@ -9,31 +9,40 @@ V Panel is a visually rich, real-time status monitoring dashboard built entirely ## Features - **Animated splash screen** — Fullscreen splash with "V" and "Panel" text using Orbitron variable font. Font-size zoom animation via `tween_method` with overshoot (TRANS_BACK), then crossfades to dashboard with no black flash. -- **Responsive grid dashboard** — Modules auto-arrange in a dynamic grid that adapts to window size. Drag-and-drop to rearrange. +- **Responsive grid dashboard** — Fixed `grid_columns`/`grid_rows` (settable via @export). Square cells, centered layout. Drag-and-drop to rearrange. - **Variable-size modules** — Modules can span multiple grid cells (1×1, 2×1, 2×2, etc.). Drag edges/corners to resize interactively. - **Shader-based vial fill** — Each module uses a custom `vial_fill.gdshader` instead of progress bars. Features sum-of-sines water surface with edge-damped meniscus, wave distortion, ripple rings, swirl, HSV colour shifting, top-down lighting, sparkle effects, 3D subsurface scattering, gaussian surface foam, and wave-slope specular highlights. -- **Shader preset system** — Double-click any module to open a popup menu with 7 visual presets (Vivid Vial, Emerald Deep, Lava Flow, Neon Dream, Deep Purple, Rainbow Swirl, Frostbite). Presets control all visual shader parameters. -- **INI-based configuration** — Uses Godot's built-in `ConfigFile` class for human-friendly `.cfg` files. `config/default.cfg` for shipped defaults, `config/config.cfg` for user overrides (gitignored). Background color, module visibility, refresh interval, and theme are configurable. +- **Shader preset system** — Long-press (500ms) or double-click any module to open an action menu with 7 visual presets (Vivid Vial, Emerald Deep, Lava Flow, Neon Dream, Deep Purple, Rainbow Swirl, Frostbite). +- **Plugin architecture** — Tiles are defined as plugins under `plugins/*/plugin.cfg`. Each plugin declares metadata, tile definitions, and optional settings. Tile IDs scoped as `{plugin_id}/{tile_id}`. +- **Plugin settings** — Per-plugin configurable values stored in `config/plugin_settings.cfg`. Settings defined in manifest `[settings]` section (int/float/bool/string types with min/max constraints). +- **Per-tile refresh intervals** — Each tile has its own configurable refresh interval (default 1000ms). Base tick runs at 100ms, tiles refresh only when their interval elapses. +- **Animation scaling** — Tween durations dynamically clamp based on each tile's refresh interval to prevent animation from outlasting the update window. +- **Layout persistence** — Named layouts saved/restored as INI files in `config/layouts/`. Grid dimensions (columns/rows) stored per-layout. Auto-saves on drag/resize. +- **Settings menu** — Modal popup with General, Plugins, and About tabs. Plugin activation toggles, per-plugin settings popups with dynamic UI controls. +- **Plugin activation** — Plugins can be deactivated from Settings → Plugins tab. Deactivation saves layout, removes tiles. Reactivation places tiles at saved positions if cells are free. +- **INI-based configuration** — Uses Godot's built-in `ConfigFile` class for human-friendly `.cfg` files. `config/default.cfg` for shipped defaults, `config/config.cfg` for user overrides (gitignored). - **Live system monitoring** — Reads CPU usage from `/proc/stat` and memory usage from `/proc/meminfo` on Linux. Extensible module system for adding new collectors. - **Smooth animations** — All fill level transitions use tweens with cubic easing. ## Modules -| Module | Data Source | Description | -|--------|-------------|-------------| -| CPU | `/proc/stat` | Real-time CPU usage percentage | -| Memory | `/proc/meminfo` | Real-time memory usage percentage | -| Testing | N/A (cycles 0–100%) | Shader visual testing with stepped fill levels | +| Module | Plugin | Data Source | Description | +|--------|--------|-------------|-------------| +| CPU | `local_system_monitor` | `/proc/stat` | Real-time CPU usage percentage | +| Memory | `local_system_monitor` | `/proc/meminfo` | Real-time memory usage percentage | +| Testing | `test` | N/A (cycles 0–100%) | Shader visual testing with stepped fill levels | ## Controls - **Drag module** — Click and drag to rearrange modules on the grid - **Resize module** — Click and drag any edge or corner to resize (snaps to cell grid) -- **Shader presets** — Double-click a module to open the preset selection popup +- **Action menu** — Long-press (500ms hold) or double-click a module to open settings, info, remove, and theme presets +- **Add tile** — Long-press empty grid space to add new tiles +- **Settings** — Press ⚙ in action menu for settings menu with plugin management ## Project Status -Active development. Core framework, drag-and-drop with resize, CPU/memory collectors, shader-based vial fill with 3D surface effects, preset system, animated splash screen with crossfade transition, and INI-based config system are implemented. More system modules (disk, network) are planned. +Version 0.1.0-alpha. Plugin architecture, settings framework, layout persistence, per-tile refresh intervals, and animation scaling are implemented. More system modules (disk, network) are planned. ## License diff --git a/autoload/layout_manager.gd b/autoload/layout_manager.gd index b5bc5b1..66b94f1 100644 --- a/autoload/layout_manager.gd +++ b/autoload/layout_manager.gd @@ -18,6 +18,10 @@ var current_layout_name: String = "Main" ## The active layout data: tile_id -> {col, row, w, h} var _layout: Dictionary = {} +## Grid dimensions for this layout. +var grid_columns: int = 4 +var grid_rows: int = 3 + func _ready() -> void: if not DirAccess.dir_exists_absolute(LAYOUT_DIR): @@ -64,6 +68,17 @@ func remove_tile(tile_id: String) -> void: _layout.erase(tile_id) +## Set grid dimensions stored in this layout. +func set_grid_size(cols: int, rows: int) -> void: + grid_columns = cols + grid_rows = rows + + +## Get grid dimensions for the current layout. +func get_grid_size() -> Dictionary: + return { columns = grid_columns, rows = grid_rows } + + ## Save the current layout to config/layouts/{name}.cfg func save_layout(layout_name: String = "") -> void: if layout_name.is_empty(): @@ -72,6 +87,8 @@ func save_layout(layout_name: String = "") -> void: var cfg := ConfigFile.new() cfg.set_value("layout", "name", layout_name) cfg.set_value("layout", "saved_at", Time.get_datetime_string_from_system()) + cfg.set_value("layout", "grid_columns", grid_columns) + cfg.set_value("layout", "grid_rows", grid_rows) var tile_ids: Array = _layout.keys() cfg.set_value("tiles", "count", tile_ids.size()) @@ -106,6 +123,9 @@ func load_layout(name: String) -> bool: _layout.clear() current_layout_name = name + grid_columns = cfg.get_value("layout", "grid_columns", 4) + grid_rows = cfg.get_value("layout", "grid_rows", 3) + var tile_count: int = cfg.get_value("tiles", "count", 0) for i in range(tile_count): var section := "tile_%d" % i diff --git a/autoload/plugin_manager.gd b/autoload/plugin_manager.gd index f68eaca..ac8a97e 100644 --- a/autoload/plugin_manager.gd +++ b/autoload/plugin_manager.gd @@ -5,19 +5,30 @@ extends Node signal plugin_loaded(plugin_id: String, plugin_name: String) signal plugin_load_failed(plugin_id: String, reason: String) +signal plugin_active_changed(plugin_id: String, active: bool) +signal plugin_setting_changed(plugin_id: String, key: String, value: Variant) ## All loaded plugin data: plugin_id -> Dictionary var _plugins: Dictionary = {} +## Tracks which plugins are active (plugin_id -> bool). Default true. +var _plugin_active: Dictionary = {} + ## All tile definitions: tile_id -> Dictionary var _tiles: Dictionary = {} ## Plugin-id -> Array[tile_id] for reverse lookup var _plugin_tiles: Dictionary = {} +## Per-plugin settings: plugin_id -> { key -> value } +var _plugin_settings: Dictionary = {} + +const PLUGIN_SETTINGS_PATH: String = "res://config/plugin_settings.cfg" + func _ready() -> void: _scan_plugins() + _load_plugin_settings() ## Scan res://plugins/*/plugin.cfg and load all manifests. @@ -57,6 +68,7 @@ func _load_manifest(plugin_id: String, path: String) -> void: base_path = path.get_base_dir(), } _plugins[plugin_id] = plugin_info + _plugin_active[plugin_id] = true # Load tile definitions var tile_count: int = cfg.get_value("tiles", "count", 0) @@ -91,6 +103,31 @@ func _load_manifest(plugin_id: String, path: String) -> void: _plugin_tiles[plugin_id] = [] _plugin_tiles[plugin_id].append(scoped_id) + # Load setting definitions + var setting_defs: Array[Dictionary] = [] + var setting_count: int = cfg.get_value("settings", "count", 0) + for i in range(setting_count): + var s_section := "setting_%d" % i + var skey: String = cfg.get_value(s_section, "key", "") + if skey.is_empty(): + continue + var sd: Dictionary = { + key = skey, + label = cfg.get_value(s_section, "label", skey), + type = cfg.get_value(s_section, "type", "string"), + } + # Read optional fields that don't have a default value + if cfg.has_section_key(s_section, "default"): + sd.default = cfg.get_value(s_section, "default") + if cfg.has_section_key(s_section, "min"): + sd.min = cfg.get_value(s_section, "min") + if cfg.has_section_key(s_section, "max"): + sd.max = cfg.get_value(s_section, "max") + if cfg.has_section_key(s_section, "step"): + sd.step = cfg.get_value(s_section, "step") + setting_defs.append(sd) + plugin_info.settings = setting_defs + plugin_loaded.emit(plugin_id, plugin_info.name) @@ -107,6 +144,71 @@ func get_plugin(plugin_id: String) -> Dictionary: return _plugins.get(plugin_id, {}) +## Returns true if the plugin is active (tiles can be instantiated). +func is_plugin_active(plugin_id: String) -> bool: + return _plugin_active.get(plugin_id, true) + + +## Enable or disable a plugin. When disabled, tiles cannot be instantiated. +func set_plugin_active(plugin_id: String, active: bool) -> void: + if _plugin_active.get(plugin_id, true) == active: + return + _plugin_active[plugin_id] = active + plugin_active_changed.emit(plugin_id, active) + + +## Returns setting definitions for a plugin (from its manifest). +func get_plugin_settings(plugin_id: String) -> Array[Dictionary]: + var info: Dictionary = _plugins.get(plugin_id, {}) + var defs: Array = info.get("settings", []) + return defs.duplicate() + + +## Read a plugin setting value, falling back to the default from the manifest. +func get_plugin_setting(plugin_id: String, key: String, default_value: Variant = null) -> Variant: + if _plugin_settings.has(plugin_id) and _plugin_settings[plugin_id].has(key): + return _plugin_settings[plugin_id][key] + # Fall back to manifest default + var info: Dictionary = _plugins.get(plugin_id, {}) + for sd in info.get("settings", []): + if sd.get("key", "") == key: + return sd.get("default", default_value) + return default_value + + +## Persist a plugin setting and emit change signal. +func set_plugin_setting(plugin_id: String, key: String, value: Variant) -> void: + if not _plugin_settings.has(plugin_id): + _plugin_settings[plugin_id] = {} + if _plugin_settings[plugin_id].get(key) == value: + return + _plugin_settings[plugin_id][key] = value + _save_plugin_settings() + plugin_setting_changed.emit(plugin_id, key, value) + + +func _load_plugin_settings() -> void: + var cfg := ConfigFile.new() + if cfg.load(PLUGIN_SETTINGS_PATH) != OK: + return + for section in cfg.get_sections(): + _plugin_settings[section] = {} + for key in cfg.get_section_keys(section): + _plugin_settings[section][key] = cfg.get_value(section, key) + + +func _save_plugin_settings() -> void: + var cfg := ConfigFile.new() + for pid in _plugin_settings: + for key in _plugin_settings[pid]: + cfg.set_value(pid, key, _plugin_settings[pid][key]) + # Only write if there's data + if cfg.get_sections().is_empty(): + return + DirAccess.make_dir_recursive_absolute("res://config") + cfg.save(PLUGIN_SETTINGS_PATH) + + ## Returns all tile definitions across all plugins. func get_all_tile_defs() -> Array[Dictionary]: var result: Array[Dictionary] = [] @@ -134,6 +236,10 @@ func instantiate_tile(tile_id: String) -> Control: var def: Dictionary = _tiles.get(tile_id, {}) if def.is_empty(): return null + # Don't instantiate tiles from inactive plugins + var pid: String = def.get("plugin_id", "") + if not pid.is_empty() and not is_plugin_active(pid): + return null var scene_path: String = def.get("scene", "") if scene_path.is_empty(): return null diff --git a/config/default.cfg b/config/default.cfg index 56197c1..b9b6796 100644 --- a/config/default.cfg +++ b/config/default.cfg @@ -9,3 +9,10 @@ show_disk=true [performance] refresh_interval=1.0 + +[grid] +columns=4 +rows=3 + +[project] +version=0.1.0-alpha diff --git a/config/layouts/Main.cfg b/config/layouts/Main.cfg index b01fcbd..92cd5e9 100644 --- a/config/layouts/Main.cfg +++ b/config/layouts/Main.cfg @@ -1,7 +1,9 @@ [layout] name="Main" -saved_at="2026-05-21T09:16:49" +saved_at="2026-05-21T12:08:02" +grid_columns=4 +grid_rows=3 [tiles] @@ -9,24 +11,24 @@ count=3 [tile_0] -id="system_monitor/cpu" -col=1 +id="local_system_monitor/cpu" +col=3 row=0 w=1 h=1 [tile_1] -id="system_monitor/memory" -col=2 +id="local_system_monitor/memory" +col=1 row=0 w=1 h=1 [tile_2] -id="system_monitor/testing" -col=0 +id="test/testing" +col=2 row=0 w=1 -h=3 +h=1 diff --git a/config/layouts/default.cfg b/config/layouts/default.cfg index 5273b64..79e0b35 100644 --- a/config/layouts/default.cfg +++ b/config/layouts/default.cfg @@ -9,7 +9,7 @@ count=3 [tile_0] -id="system_monitor/cpu" +id="local_system_monitor/cpu" col=0 row=0 w=1 @@ -17,7 +17,7 @@ h=1 [tile_1] -id="system_monitor/testing" +id="test/testing" col=1 row=0 w=1 @@ -25,7 +25,7 @@ h=1 [tile_2] -id="system_monitor/memory" +id="local_system_monitor/memory" col=2 row=0 w=1 diff --git a/plugins/local_system_monitor/plugin.cfg b/plugins/local_system_monitor/plugin.cfg new file mode 100644 index 0000000..d93fae0 --- /dev/null +++ b/plugins/local_system_monitor/plugin.cfg @@ -0,0 +1,49 @@ +[plugin] +name="Local System Monitor" +version="0.1.0" +description="Monitors local system resources: CPU and memory" +author="Fifthdread" + +[tiles] +count=2 + +[tile_0] +id="cpu" +name="CPU Monitor" +scene="res://plugins/local_system_monitor/tiles/cpu/cpu_tile.tscn" +min_w=1 +min_h=1 +max_w=4 +max_h=4 +default_w=1 +default_h=1 + +[tile_1] +id="memory" +name="Memory Monitor" +scene="res://plugins/local_system_monitor/tiles/memory/memory_tile.tscn" +min_w=1 +min_h=1 +max_w=4 +max_h=4 +default_w=1 +default_h=1 + +[settings] +count=2 + +[setting_0] +key="cpu_update_interval_ms" +label="CPU Update Interval" +type="int" +default=1000 +min=100 +max=60000 + +[setting_1] +key="memory_update_interval_ms" +label="Memory Update Interval" +type="int" +default=1000 +min=100 +max=60000 diff --git a/plugins/system_monitor/tiles/cpu/cpu_collector.gd b/plugins/local_system_monitor/tiles/cpu/cpu_collector.gd similarity index 100% rename from plugins/system_monitor/tiles/cpu/cpu_collector.gd rename to plugins/local_system_monitor/tiles/cpu/cpu_collector.gd diff --git a/plugins/system_monitor/tiles/cpu/cpu_tile.gd b/plugins/local_system_monitor/tiles/cpu/cpu_tile.gd similarity index 85% rename from plugins/system_monitor/tiles/cpu/cpu_tile.gd rename to plugins/local_system_monitor/tiles/cpu/cpu_tile.gd index 7c49564..8c1492a 100644 --- a/plugins/system_monitor/tiles/cpu/cpu_tile.gd +++ b/plugins/local_system_monitor/tiles/cpu/cpu_tile.gd @@ -25,12 +25,14 @@ func refresh(data: Dictionary) -> void: var pct: int = roundi(usage) label.text = "%d%%" % pct - # Smoothly tween the vial fill + # Smoothly tween the vial fill — clamp duration so animation never outlasts the refresh interval var target: float = usage / 100.0 + var update_ms: int = data.get("update_interval_ms", 1000) + var duration: float = minf(0.4, update_ms / 2000.0) if _fill_tween and _fill_tween.is_valid(): _fill_tween.kill() _fill_tween = create_tween() - _fill_tween.tween_method(_set_fill, _displayed_fill, target, 0.4).set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_CUBIC) + _fill_tween.tween_method(_set_fill, _displayed_fill, target, duration).set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_CUBIC) func _set_fill(value: float) -> void: diff --git a/plugins/system_monitor/tiles/cpu/cpu_tile.tscn b/plugins/local_system_monitor/tiles/cpu/cpu_tile.tscn similarity index 92% rename from plugins/system_monitor/tiles/cpu/cpu_tile.tscn rename to plugins/local_system_monitor/tiles/cpu/cpu_tile.tscn index 1979127..d5947e4 100644 --- a/plugins/system_monitor/tiles/cpu/cpu_tile.tscn +++ b/plugins/local_system_monitor/tiles/cpu/cpu_tile.tscn @@ -1,6 +1,6 @@ [gd_scene format=3 uid="uid://bq3bs2hb4r7fb"] -[ext_resource type="Script" path="res://plugins/system_monitor/tiles/cpu/cpu_tile.gd" id="1"] +[ext_resource type="Script" path="res://plugins/local_system_monitor/tiles/cpu/cpu_tile.gd" id="1"] [ext_resource type="Shader" path="res://shaders/vial_fill.gdshader" id="2"] [node name="CpuTile" type="PanelContainer"] diff --git a/plugins/system_monitor/tiles/memory/memory_collector.gd b/plugins/local_system_monitor/tiles/memory/memory_collector.gd similarity index 100% rename from plugins/system_monitor/tiles/memory/memory_collector.gd rename to plugins/local_system_monitor/tiles/memory/memory_collector.gd diff --git a/plugins/system_monitor/tiles/memory/memory_tile.gd b/plugins/local_system_monitor/tiles/memory/memory_tile.gd similarity index 85% rename from plugins/system_monitor/tiles/memory/memory_tile.gd rename to plugins/local_system_monitor/tiles/memory/memory_tile.gd index 0cb57ea..b21db28 100644 --- a/plugins/system_monitor/tiles/memory/memory_tile.gd +++ b/plugins/local_system_monitor/tiles/memory/memory_tile.gd @@ -25,12 +25,14 @@ func refresh(data: Dictionary) -> void: var pct: int = roundi(usage) label.text = "%d%%" % pct - # Smoothly tween the vial fill + # Smoothly tween the vial fill — clamp duration so animation never outlasts the refresh interval var target: float = usage / 100.0 + var update_ms: int = data.get("update_interval_ms", 1000) + var duration: float = minf(0.4, update_ms / 2000.0) if _fill_tween and _fill_tween.is_valid(): _fill_tween.kill() _fill_tween = create_tween() - _fill_tween.tween_method(_set_fill, _displayed_fill, target, 0.4).set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_CUBIC) + _fill_tween.tween_method(_set_fill, _displayed_fill, target, duration).set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_CUBIC) func _set_fill(value: float) -> void: diff --git a/plugins/system_monitor/tiles/memory/memory_tile.tscn b/plugins/local_system_monitor/tiles/memory/memory_tile.tscn similarity index 91% rename from plugins/system_monitor/tiles/memory/memory_tile.tscn rename to plugins/local_system_monitor/tiles/memory/memory_tile.tscn index 2915c70..f6a2c99 100644 --- a/plugins/system_monitor/tiles/memory/memory_tile.tscn +++ b/plugins/local_system_monitor/tiles/memory/memory_tile.tscn @@ -1,6 +1,6 @@ [gd_scene format=3 uid="uid://d2d4uqrd2hh3d"] -[ext_resource type="Script" path="res://plugins/system_monitor/tiles/memory/memory_tile.gd" id="1"] +[ext_resource type="Script" path="res://plugins/local_system_monitor/tiles/memory/memory_tile.gd" id="1"] [ext_resource type="Shader" path="res://shaders/vial_fill.gdshader" id="2"] [node name="MemoryTile" type="PanelContainer"] diff --git a/plugins/system_monitor/plugin.cfg b/plugins/system_monitor/plugin.cfg deleted file mode 100644 index a9910c8..0000000 --- a/plugins/system_monitor/plugin.cfg +++ /dev/null @@ -1,41 +0,0 @@ -[plugin] -name="System Monitor" -version="1.0.0" -description="Monitors system resources: CPU and memory usage" -author="Fifthdread" - -[tiles] -count=3 - -[tile_0] -id="cpu" -name="CPU Monitor" -scene="res://plugins/system_monitor/tiles/cpu/cpu_tile.tscn" -min_w=1 -min_h=1 -max_w=4 -max_h=4 -default_w=1 -default_h=1 - -[tile_1] -id="memory" -name="Memory Monitor" -scene="res://plugins/system_monitor/tiles/memory/memory_tile.tscn" -min_w=1 -min_h=1 -max_w=4 -max_h=4 -default_w=1 -default_h=1 - -[tile_2] -id="testing" -name="Testing" -scene="res://plugins/system_monitor/tiles/testing/testing_tile.tscn" -min_w=1 -min_h=1 -max_w=4 -max_h=4 -default_w=1 -default_h=1 diff --git a/plugins/test/plugin.cfg b/plugins/test/plugin.cfg new file mode 100644 index 0000000..e430722 --- /dev/null +++ b/plugins/test/plugin.cfg @@ -0,0 +1,30 @@ +[plugin] +name="Test" +version="0.1.0" +description="Development testing tiles and framework experiments" +author="Fifthdread" + +[tiles] +count=1 + +[tile_0] +id="testing" +name="Testing" +scene="res://plugins/test/tiles/testing/testing_tile.tscn" +min_w=1 +min_h=1 +max_w=4 +max_h=4 +default_w=1 +default_h=1 + +[settings] +count=1 + +[setting_0] +key="testing_update_interval_ms" +label="Testing Update Interval" +type="int" +default=1000 +min=100 +max=60000 diff --git a/plugins/system_monitor/tiles/testing/testing_tile.gd b/plugins/test/tiles/testing/testing_tile.gd similarity index 84% rename from plugins/system_monitor/tiles/testing/testing_tile.gd rename to plugins/test/tiles/testing/testing_tile.gd index 7cb817f..bc4fbbc 100644 --- a/plugins/system_monitor/tiles/testing/testing_tile.gd +++ b/plugins/test/tiles/testing/testing_tile.gd @@ -17,16 +17,19 @@ func initialize() -> void: _style() -func refresh(_data: Dictionary) -> void: +func refresh(data: Dictionary) -> void: _cycle = (_cycle + 1) % _levels.size() var target: float = _levels[_cycle] label.text = "%d%%" % roundi(target * 100.0) + # Clamp duration so animation never outlasts the refresh interval + var update_ms: int = data.get("update_interval_ms", 1000) + var duration: float = minf(0.6, update_ms / 1500.0) if _fill_tween and _fill_tween.is_valid(): _fill_tween.kill() _fill_tween = create_tween() - _fill_tween.tween_method(_set_fill, _displayed_fill, target, 0.6).set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_CUBIC) + _fill_tween.tween_method(_set_fill, _displayed_fill, target, duration).set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_CUBIC) func _set_fill(value: float) -> void: diff --git a/plugins/system_monitor/tiles/testing/testing_tile.tscn b/plugins/test/tiles/testing/testing_tile.tscn similarity index 91% rename from plugins/system_monitor/tiles/testing/testing_tile.tscn rename to plugins/test/tiles/testing/testing_tile.tscn index 4c453f6..939af7d 100644 --- a/plugins/system_monitor/tiles/testing/testing_tile.tscn +++ b/plugins/test/tiles/testing/testing_tile.tscn @@ -1,6 +1,6 @@ [gd_scene format=3] -[ext_resource type="Script" path="res://plugins/system_monitor/tiles/testing/testing_tile.gd" id="1"] +[ext_resource type="Script" path="res://plugins/test/tiles/testing/testing_tile.gd" id="1"] [ext_resource type="Shader" path="res://shaders/vial_fill.gdshader" id="2"] [node name="TestingTile" type="PanelContainer"] diff --git a/scenes/dashboard.gd b/scenes/dashboard.gd index fef891c..0a46a79 100644 --- a/scenes/dashboard.gd +++ b/scenes/dashboard.gd @@ -5,6 +5,7 @@ extends PanelContainer @onready var grid: DashboardGrid = %DashboardGrid var _tile_instances: Array[Control] = [] +var _last_refresh: Dictionary = {} # tile_instance -> last tick ms func _ready() -> void: @@ -32,6 +33,13 @@ func _load_tiles_from_plugins() -> void: if all_tile_defs.is_empty(): return + # Set grid dimensions from layout before placing tiles + if LayoutManager.has_method("get_grid_size"): + var ls := LayoutManager.get_grid_size() + grid.grid_columns = ls.columns + grid.grid_rows = ls.rows + grid._rebuild_grid() + # Load saved layout (if any) var layout_data: Dictionary = {} if LayoutManager.has_method("get_layout"): @@ -42,13 +50,14 @@ func _load_tiles_from_plugins() -> void: # First pass: place tiles that have a saved layout position for tid in layout_data: - var pos: Dictionary = layout_data[tid] var def := _find_tile_def(all_tile_defs, tid) if def.is_empty(): + # Plugin/tile not installed — skip gracefully continue var instance := PluginManager.instantiate_tile(tid) if instance == null: continue + var pos: Dictionary = layout_data[tid] grid.place_module(instance, pos.col, pos.row, pos.w, pos.h) _tile_instances.append(instance) placed_ids.append(tid) @@ -79,36 +88,152 @@ func _load_tiles_from_plugins() -> void: next_col = 0 next_row = gs.y - # Connect auto-save signals - grid.module_placed.connect(_save_layout) + # Connect auto-save signals and tile tracking + grid.module_placed.connect(_on_module_placed) if grid.has_signal("module_resized"): grid.module_resized.connect(_save_layout) - # Start refresh timer - _start_refresh_timer() + # Listen for plugin activation changes + if PluginManager.has_signal("plugin_active_changed"): + PluginManager.plugin_active_changed.connect(_on_plugin_active_changed) + + # Listen for plugin setting changes to update cached intervals + if PluginManager.has_signal("plugin_setting_changed"): + PluginManager.plugin_setting_changed.connect(_on_plugin_setting_changed) + + # Cache intervals for all placed tiles + for mod in _tile_instances: + if is_instance_valid(mod): + _cache_tile_interval(mod) + + # Start per-tile refresh tick + _start_tick_timer() -func _start_refresh_timer() -> void: - var interval: float = 1.0 - if ConfigManager.has_method("get_setting"): - var val: Variant = ConfigManager.get_setting("performance_refresh_interval", 1.0) - if typeof(val) == TYPE_FLOAT or typeof(val) == TYPE_INT: - interval = float(val) - +func _start_tick_timer() -> void: var timer := Timer.new() - timer.timeout.connect(_refresh_modules) + timer.timeout.connect(_tick_refresh) timer.autostart = true - timer.wait_time = interval + timer.wait_time = 0.1 add_child(timer) -func _refresh_modules() -> void: +func _tick_refresh() -> void: + var now := Time.get_ticks_msec() var data: Dictionary = {} for mod in _tile_instances: - if is_instance_valid(mod) and mod.has_method("refresh"): + if not is_instance_valid(mod) or not mod.has_method("refresh"): + continue + var interval: int = mod.get_meta("update_interval_ms", 1000) + var last: int = _last_refresh.get(mod, 0) + if now - last >= interval: + _last_refresh[mod] = now + data["update_interval_ms"] = interval mod.refresh(data) +func _cache_tile_interval(mod: Control) -> void: + var tid: String = mod.get_meta("tile_id", "") + if tid.is_empty(): + return + var parts := tid.split("/") + if parts.size() < 2: + return + var pid: String = parts[0] + var tile_local: String = parts[1] + if PluginManager.has_method("get_plugin_setting"): + var interval: int = PluginManager.get_plugin_setting(pid, tile_local + "_update_interval_ms", 1000) + mod.set_meta("update_interval_ms", interval) + + +func _on_module_placed(mod: Control, col: int, row: int) -> void: + _save_layout(mod, col, row) + if mod not in _tile_instances: + _tile_instances.append(mod) + _cache_tile_interval(mod) + # Give the new tile a chance to set up before the next refresh + if mod.has_method("refresh"): + mod.refresh({}) + + +func _on_plugin_setting_changed(plugin_id: String, key: String, value: Variant) -> void: + # Update cached interval on affected tiles + var suffix := "_update_interval_ms" + if not key.ends_with(suffix): + return + for mod in _tile_instances: + if not is_instance_valid(mod): + continue + var tid: String = mod.get_meta("tile_id", "") + if tid.begins_with(plugin_id + "/"): + _cache_tile_interval(mod) + + +func _on_plugin_active_changed(plugin_id: String, active: bool) -> void: + if active: + _activate_plugin_tiles(plugin_id) + else: + _deactivate_plugin_tiles(plugin_id) + + +func _deactivate_plugin_tiles(plugin_id: String) -> void: + # Save layout first to preserve tile positions + _save_layout() + + var to_remove: Array[Control] = [] + for mod in _tile_instances: + if not is_instance_valid(mod): + continue + var tid: String = mod.get_meta("tile_id", "") + if tid.begins_with(plugin_id + "/"): + to_remove.append(mod) + + for mod in to_remove: + grid.remove_module(mod) + _tile_instances.erase(mod) + _last_refresh.erase(mod) + mod.queue_free() + + # Do NOT save layout again — old positions are preserved in the saved layout + + +func _activate_plugin_tiles(plugin_id: String) -> void: + var tile_ids: PackedStringArray = PluginManager.get_plugin_tile_ids(plugin_id) + if tile_ids.is_empty(): + return + + var layout_data: Dictionary = {} + if LayoutManager.has_method("get_layout"): + layout_data = LayoutManager.get_layout() + + var gs := grid.get_grid_size() + for tid in tile_ids: + var instance := PluginManager.instantiate_tile(tid) + if instance == null: + continue + + var pos: Dictionary = layout_data.get(tid, {}) + if pos.is_empty(): + # No saved position — skip + instance.queue_free() + continue + + var col := clampi(pos.get("col", 0), 0, gs.x - 1) + var row := clampi(pos.get("row", 0), 0, gs.y - 1) + var w := clampi(pos.get("w", 1), 1, gs.x - col) + var h := clampi(pos.get("h", 1), 1, gs.y - row) + + # Only place if the cell is free + var existing := grid.get_module_at(col, row) + if existing != null: + instance.queue_free() + continue + + grid.place_module(instance, col, row, w, h) + _tile_instances.append(instance) + _cache_tile_interval(instance) + + func _save_layout(_mod: Control = null, _a: int = 0, _b: int = 0) -> void: if not LayoutManager.has_method("set_tile_position"): return diff --git a/scenes/plugin_settings_popup.gd b/scenes/plugin_settings_popup.gd new file mode 100644 index 0000000..cf86452 --- /dev/null +++ b/scenes/plugin_settings_popup.gd @@ -0,0 +1,195 @@ +extends PopupPanel + +## Dynamic settings popup for a specific plugin. +## Touch-friendly: large close button, exclusive modal, ample control sizes. + +const CLOSE_BTN_SIZE: Vector2 = Vector2(56, 56) +const WINDOW_CORNER_RADIUS: float = 12.0 + +var _plugin_id: String = "" +var _controls: Dictionary = {} # setting_key -> Control + + +func setup(plugin_id: String) -> void: + _plugin_id = plugin_id + exclusive = true + _build_ui() + + +func _build_ui() -> void: + # Window background with rounded corners + var bg := StyleBoxFlat.new() + bg.bg_color = Color(0.12, 0.12, 0.16, 1.0) + bg.corner_radius_top_left = WINDOW_CORNER_RADIUS + bg.corner_radius_top_right = WINDOW_CORNER_RADIUS + bg.corner_radius_bottom_left = WINDOW_CORNER_RADIUS + bg.corner_radius_bottom_right = WINDOW_CORNER_RADIUS + add_theme_stylebox_override("panel", bg) + + # Content area — leave right margin for the overlaid close button + var mc := MarginContainer.new() + mc.add_theme_constant_override("margin_left", 16) + mc.add_theme_constant_override("margin_right", 16 + CLOSE_BTN_SIZE.x) + mc.add_theme_constant_override("margin_top", 12) + mc.add_theme_constant_override("margin_bottom", 12) + add_child(mc) + + var vbox := VBoxContainer.new() + vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL + vbox.size_flags_vertical = Control.SIZE_EXPAND_FILL + mc.add_child(vbox) + + # Title bar + var title_bar := HBoxContainer.new() + var title_label := Label.new() + var info: Dictionary = PluginManager.get_plugin(_plugin_id) + title_label.text = "%s Settings" % info.get("name", _plugin_id) + title_label.add_theme_font_size_override("font_size", 20) + title_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + title_bar.add_child(title_label) + vbox.add_child(title_bar) + + vbox.add_child(HSeparator.new()) + + # Setting rows + var settings: Array[Dictionary] = PluginManager.get_plugin_settings(_plugin_id) + if settings.is_empty(): + var empty := Label.new() + empty.text = "No configurable settings for this plugin." + empty.modulate = Color(0.5, 0.5, 0.55) + empty.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + vbox.add_child(empty) + return + + var inner := MarginContainer.new() + inner.add_theme_constant_override("margin_left", 4) + inner.add_theme_constant_override("margin_right", 4) + inner.add_theme_constant_override("margin_top", 10) + inner.size_flags_vertical = Control.SIZE_EXPAND_FILL + vbox.add_child(inner) + + var scroll := ScrollContainer.new() + scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + inner.add_child(scroll) + + var form := VBoxContainer.new() + form.add_theme_constant_override("separation", 14) + form.size_flags_horizontal = Control.SIZE_EXPAND_FILL + scroll.add_child(form) + + for sd in settings: + var key: String = sd.get("key", "") + if key.is_empty(): + continue + var label_text: String = sd.get("label", key) + var stype: String = sd.get("type", "string") + var default_val: Variant = sd.get("default", null) + var current_val: Variant = PluginManager.get_plugin_setting(_plugin_id, key, default_val) + + var row := HBoxContainer.new() + row.size_flags_horizontal = Control.SIZE_EXPAND_FILL + + var lbl := Label.new() + lbl.text = label_text + lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL + lbl.mouse_filter = Control.MOUSE_FILTER_PASS + row.add_child(lbl) + + var ctrl := _build_control(stype, key, current_val, sd) + if ctrl != null: + ctrl.custom_minimum_size = Vector2(180, 40) + _controls[key] = ctrl + row.add_child(ctrl) + + form.add_child(row) + + # Red corner close button (overlaid, anchored top-right, flush to edge) + var close_button := Button.new() + close_button.text = "✕" + close_button.add_theme_font_size_override("font_size", 22) + close_button.flat = true + close_button.pressed.connect(_close) + close_button.custom_minimum_size = CLOSE_BTN_SIZE + + var btn_style := StyleBoxFlat.new() + btn_style.bg_color = Color(1.0, 0.0, 0.0, 1.0) + btn_style.corner_radius_top_right = WINDOW_CORNER_RADIUS + btn_style.corner_radius_bottom_left = WINDOW_CORNER_RADIUS + btn_style.corner_radius_top_left = 0 + btn_style.corner_radius_bottom_right = 0 + btn_style.set_corner_radius(Corner.CORNER_TOP_RIGHT, WINDOW_CORNER_RADIUS) + btn_style.set_corner_radius(Corner.CORNER_BOTTOM_LEFT, WINDOW_CORNER_RADIUS) + close_button.add_theme_stylebox_override("normal", btn_style) + + var hover_style := StyleBoxFlat.new() + hover_style.bg_color = Color(0.85, 0.0, 0.0, 1.0) + hover_style.corner_radius_top_right = WINDOW_CORNER_RADIUS + hover_style.corner_radius_bottom_left = WINDOW_CORNER_RADIUS + hover_style.corner_radius_top_left = 0 + hover_style.corner_radius_bottom_right = 0 + close_button.add_theme_stylebox_override("hover", hover_style) + + var pressed_style := StyleBoxFlat.new() + pressed_style.bg_color = Color(0.7, 0.0, 0.0, 1.0) + pressed_style.corner_radius_top_right = WINDOW_CORNER_RADIUS + pressed_style.corner_radius_bottom_left = WINDOW_CORNER_RADIUS + pressed_style.corner_radius_top_left = 0 + pressed_style.corner_radius_bottom_right = 0 + close_button.add_theme_stylebox_override("pressed", pressed_style) + + close_button.set_anchors_preset(Control.PRESET_TOP_RIGHT) + add_child(close_button) + + +func _build_control(stype: String, key: String, val: Variant, defn: Dictionary) -> Control: + match stype: + "int": + var sb := SpinBox.new() + sb.min_value = defn.get("min", -999999) + sb.max_value = defn.get("max", 999999) + sb.step = 1 + sb.value = int(val) if val != null else int(defn.get("default", 0)) + sb.custom_minimum_size = Vector2(0, 40) + sb.value_changed.connect(_on_value_changed.bind(key)) + return sb + + "float": + var sb := SpinBox.new() + sb.min_value = defn.get("min", -999999.0) + sb.max_value = defn.get("max", 999999.0) + sb.step = defn.get("step", 0.1) + sb.value = float(val) if val != null else float(defn.get("default", 0.0)) + sb.custom_minimum_size = Vector2(0, 40) + sb.value_changed.connect(_on_value_changed.bind(key)) + return sb + + "bool": + var cb := CheckBox.new() + cb.button_pressed = bool(val) if val != null else bool(defn.get("default", false)) + cb.custom_minimum_size = Vector2(0, 40) + cb.toggled.connect(_on_toggled.bind(key)) + return cb + + _: + # string / default + var le := LineEdit.new() + le.text = str(val) if val != null else str(defn.get("default", "")) + le.custom_minimum_size = Vector2(0, 40) + le.text_changed.connect(_on_text_changed.bind(key)) + return le + + +func _on_value_changed(value: float, key: String) -> void: + PluginManager.set_plugin_setting(_plugin_id, key, value) + + +func _on_toggled(checked: bool, key: String) -> void: + PluginManager.set_plugin_setting(_plugin_id, key, checked) + + +func _on_text_changed(text: String, key: String) -> void: + PluginManager.set_plugin_setting(_plugin_id, key, text) + + +func _close() -> void: + queue_free() diff --git a/scenes/plugin_settings_popup.tscn b/scenes/plugin_settings_popup.tscn new file mode 100644 index 0000000..49b8292 --- /dev/null +++ b/scenes/plugin_settings_popup.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scenes/plugin_settings_popup.gd" id="1"] + +[node name="PluginSettingsPopup" type="PopupPanel"] +script = ExtResource("1") diff --git a/scenes/settings_menu.gd b/scenes/settings_menu.gd new file mode 100644 index 0000000..d10ac93 --- /dev/null +++ b/scenes/settings_menu.gd @@ -0,0 +1,316 @@ +extends PopupPanel + +## Settings menu with tabbed interface: General, Plugins, About. +## Touch-friendly: large close button, ample spacing, exclusive modal. + +const VERSION: String = "0.1.0-alpha" +const CONTACT_EMAIL: String = "5d@fifthdread.com" + +const CLOSE_BTN_SIZE: Vector2 = Vector2(56, 56) +const WINDOW_CORNER_RADIUS: float = 12.0 + +var _tab_container: TabContainer +var _close_button: Button + + +func _ready() -> void: + exclusive = true + _build_ui() + _popup_centered_clamp(Vector2i(720, 540)) + + +## Clamp popup to fit within screen, handling multi-monitor edge cases. +func _popup_centered_clamp(min_size: Vector2i) -> void: + popup_centered(min_size) + # Ensure the popup doesn't extend past screen edges + var screen_rect := DisplayServer.screen_get_usable_rect( + DisplayServer.window_get_current_screen() + ) + var max_pos := Vector2( + screen_rect.position.x + screen_rect.size.x - size.x, + screen_rect.position.y + screen_rect.size.y - size.y + ) + position = Vector2i( + mini(maxi(position.x, screen_rect.position.x), max_pos.x), + mini(maxi(position.y, screen_rect.position.y), max_pos.y) + ) + + +func _build_ui() -> void: + # Window background with rounded corners + var bg_style := StyleBoxFlat.new() + bg_style.bg_color = Color(0.12, 0.12, 0.16, 1.0) + bg_style.corner_radius_top_left = WINDOW_CORNER_RADIUS + bg_style.corner_radius_top_right = WINDOW_CORNER_RADIUS + bg_style.corner_radius_bottom_left = WINDOW_CORNER_RADIUS + bg_style.corner_radius_bottom_right = WINDOW_CORNER_RADIUS + add_theme_stylebox_override("panel", bg_style) + + # Content area — leave right margin for the overlaid close button + var mc := MarginContainer.new() + mc.add_theme_constant_override("margin_left", 16) + mc.add_theme_constant_override("margin_right", 16 + CLOSE_BTN_SIZE.x) + mc.add_theme_constant_override("margin_top", 12) + mc.add_theme_constant_override("margin_bottom", 12) + add_child(mc) + + var vbox := VBoxContainer.new() + vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL + vbox.size_flags_vertical = Control.SIZE_EXPAND_FILL + mc.add_child(vbox) + + # Title bar (no close button — it's overlaid in the corner) + var title_bar := HBoxContainer.new() + var title_label := Label.new() + title_label.text = "Settings" + title_label.add_theme_font_size_override("font_size", 24) + title_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + title_bar.add_child(title_label) + vbox.add_child(title_bar) + + # Tab container + _tab_container = TabContainer.new() + _tab_container.size_flags_vertical = Control.SIZE_EXPAND_FILL + vbox.add_child(_tab_container) + + # Build tabs + _build_general_tab() + _build_plugins_tab() + _build_about_tab() + + # Red corner close button (overlaid, anchored top-right, flush to edge) + _close_button = Button.new() + _close_button.text = "✕" + _close_button.add_theme_font_size_override("font_size", 22) + _close_button.flat = true + _close_button.pressed.connect(_close) + _close_button.custom_minimum_size = CLOSE_BTN_SIZE + + var btn_style := StyleBoxFlat.new() + btn_style.bg_color = Color(1.0, 0.0, 0.0, 1.0) + btn_style.corner_radius_top_right = WINDOW_CORNER_RADIUS + btn_style.corner_radius_bottom_left = WINDOW_CORNER_RADIUS + btn_style.corner_radius_top_left = 0 + btn_style.corner_radius_bottom_right = 0 + btn_style.set_corner_radius(Corner.CORNER_TOP_RIGHT, WINDOW_CORNER_RADIUS) + btn_style.set_corner_radius(Corner.CORNER_BOTTOM_LEFT, WINDOW_CORNER_RADIUS) + _close_button.add_theme_stylebox_override("normal", btn_style) + + var hover_style := StyleBoxFlat.new() + hover_style.bg_color = Color(0.85, 0.0, 0.0, 1.0) + hover_style.corner_radius_top_right = WINDOW_CORNER_RADIUS + hover_style.corner_radius_bottom_left = WINDOW_CORNER_RADIUS + hover_style.corner_radius_top_left = 0 + hover_style.corner_radius_bottom_right = 0 + _close_button.add_theme_stylebox_override("hover", hover_style) + + var pressed_style := StyleBoxFlat.new() + pressed_style.bg_color = Color(0.7, 0.0, 0.0, 1.0) + pressed_style.corner_radius_top_right = WINDOW_CORNER_RADIUS + pressed_style.corner_radius_bottom_left = WINDOW_CORNER_RADIUS + pressed_style.corner_radius_top_left = 0 + pressed_style.corner_radius_bottom_right = 0 + _close_button.add_theme_stylebox_override("pressed", pressed_style) + + _close_button.set_anchors_preset(Control.PRESET_TOP_RIGHT) + add_child(_close_button) + + +func _build_general_tab() -> void: + var tab := VBoxContainer.new() + tab.name = "General" + + var inner := MarginContainer.new() + inner.add_theme_constant_override("margin_left", 4) + inner.add_theme_constant_override("margin_right", 4) + inner.add_theme_constant_override("margin_top", 12) + tab.add_child(inner) + + var vbox := VBoxContainer.new() + vbox.add_theme_constant_override("separation", 14) + inner.add_child(vbox) + + for i in range(1, 4): + var row := HBoxContainer.new() + row.size_flags_horizontal = Control.SIZE_EXPAND_FILL + + var label := Label.new() + label.text = "Setting %d:" % i + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_child(label) + + var val := Label.new() + val.text = "[configure %d]" % i + val.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + val.modulate = Color(0.6, 0.6, 0.65) + val.custom_minimum_size = Vector2(200, 0) + row.add_child(val) + + vbox.add_child(row) + + _tab_container.add_child(tab) + + +func _build_plugins_tab() -> void: + var tab := VBoxContainer.new() + tab.name = "Plugins" + + var inner := MarginContainer.new() + inner.add_theme_constant_override("margin_left", 4) + inner.add_theme_constant_override("margin_right", 4) + inner.add_theme_constant_override("margin_top", 12) + tab.add_child(inner) + + var vbox := VBoxContainer.new() + vbox.add_theme_constant_override("separation", 10) + inner.add_child(vbox) + + # Header row + var header := HBoxContainer.new() + header.size_flags_horizontal = Control.SIZE_EXPAND_FILL + + var hdr_name := Label.new() + hdr_name.text = "Plugin" + hdr_name.add_theme_font_size_override("font_size", 14) + hdr_name.modulate = Color(0.6, 0.6, 0.65) + hdr_name.size_flags_horizontal = Control.SIZE_EXPAND_FILL + header.add_child(hdr_name) + + var hdr_ver := Label.new() + hdr_ver.text = "Version" + hdr_ver.add_theme_font_size_override("font_size", 14) + hdr_ver.modulate = Color(0.6, 0.6, 0.65) + hdr_ver.custom_minimum_size = Vector2(80, 0) + header.add_child(hdr_ver) + + var hdr_active := Label.new() + hdr_active.text = "Active" + hdr_active.add_theme_font_size_override("font_size", 14) + hdr_active.modulate = Color(0.5, 0.5, 0.55) + hdr_active.custom_minimum_size = Vector2(60, 0) + hdr_active.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + header.add_child(hdr_active) + + vbox.add_child(header) + vbox.add_child(HSeparator.new()) + + # Plugin rows + if not PluginManager.has_method("get_plugin_ids"): + _tab_container.add_child(tab) + return + + var plugin_ids: PackedStringArray = PluginManager.get_plugin_ids() + if plugin_ids.is_empty(): + var empty := Label.new() + empty.text = "No plugins loaded." + empty.modulate = Color(0.5, 0.5, 0.55) + empty.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + vbox.add_child(empty) + _tab_container.add_child(tab) + return + + for pid in plugin_ids: + var info: Dictionary = PluginManager.get_plugin(pid) + var setting_defs := PluginManager.get_plugin_settings(pid) if PluginManager.has_method("get_plugin_settings") else [] + var row := HBoxContainer.new() + row.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_theme_constant_override("separation", 8) + + var name_lbl := Label.new() + name_lbl.text = info.get("name", pid) + name_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_child(name_lbl) + + var ver_lbl := Label.new() + ver_lbl.text = "v%s" % info.get("version", "?") + ver_lbl.custom_minimum_size = Vector2(80, 0) + row.add_child(ver_lbl) + + var cb := CheckBox.new() + cb.button_pressed = PluginManager.is_plugin_active(pid) + cb.toggled.connect(_on_plugin_toggled.bind(pid)) + cb.custom_minimum_size = Vector2(60, 48) + cb.size_flags_horizontal = Control.SIZE_SHRINK_CENTER + row.add_child(cb) + + if not setting_defs.is_empty(): + var cog := Button.new() + cog.text = "⚙" + cog.flat = true + cog.tooltip_text = "Settings for %s" % info.get("name", pid) + cog.custom_minimum_size = Vector2(44, 44) + cog.add_theme_font_size_override("font_size", 20) + cog.pressed.connect(_on_plugin_settings_pressed.bind(pid)) + row.add_child(cog) + + vbox.add_child(row) + + _tab_container.add_child(tab) + + +func _on_plugin_toggled(checked: bool, plugin_id: String) -> void: + if PluginManager.has_method("set_plugin_active"): + PluginManager.set_plugin_active(plugin_id, checked) + + +func _on_plugin_settings_pressed(plugin_id: String) -> void: + var popup := preload("res://scenes/plugin_settings_popup.tscn").instantiate() + popup.setup(plugin_id) + add_sibling(popup) + popup.popup_centered(Vector2i(720, 540)) + + +func _build_about_tab() -> void: + var tab := VBoxContainer.new() + tab.name = "About" + + var center := VBoxContainer.new() + center.alignment = BoxContainer.ALIGNMENT_CENTER + center.size_flags_horizontal = Control.SIZE_EXPAND_FILL + center.size_flags_vertical = Control.SIZE_EXPAND_FILL + tab.add_child(center) + + # Logo + var tex := load("res://assets/icons/icon.svg") + if tex != null: + var logo := TextureRect.new() + logo.texture = tex + logo.custom_minimum_size = Vector2(80, 80) + logo.expand_mode = TextureRect.EXPAND_FIT_WIDTH + logo.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + logo.size_flags_horizontal = Control.SIZE_SHRINK_CENTER + center.add_child(logo) + + center.add_spacer(false) + + var name_lbl := Label.new() + name_lbl.text = "V Panel" + name_lbl.add_theme_font_size_override("font_size", 28) + name_lbl.size_flags_horizontal = Control.SIZE_SHRINK_CENTER + center.add_child(name_lbl) + + center.add_spacer(false) + + var ver_lbl := Label.new() + ver_lbl.text = "Version %s" % VERSION + ver_lbl.size_flags_horizontal = Control.SIZE_SHRINK_CENTER + center.add_child(ver_lbl) + + var contact_lbl := Label.new() + contact_lbl.text = "Contact: %s" % CONTACT_EMAIL + contact_lbl.size_flags_horizontal = Control.SIZE_SHRINK_CENTER + center.add_child(contact_lbl) + + center.add_spacer(false) + + var desc_lbl := Label.new() + desc_lbl.text = "A real-time system status monitor built with Godot 4" + desc_lbl.size_flags_horizontal = Control.SIZE_SHRINK_CENTER + desc_lbl.modulate = Color(0.6, 0.6, 0.65) + center.add_child(desc_lbl) + + _tab_container.add_child(tab) + + +func _close() -> void: + queue_free() diff --git a/scenes/settings_menu.tscn b/scenes/settings_menu.tscn new file mode 100644 index 0000000..4cc9709 --- /dev/null +++ b/scenes/settings_menu.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scenes/settings_menu.gd" id="1"] + +[node name="SettingsMenu" type="PopupPanel"] +script = ExtResource("1") diff --git a/scripts/dashboard_grid.gd b/scripts/dashboard_grid.gd index 00bea11..5474a70 100644 --- a/scripts/dashboard_grid.gd +++ b/scripts/dashboard_grid.gd @@ -7,10 +7,12 @@ signal module_placed(module: Control, col: int, row: int) signal module_removed(module: Control) signal module_resized(module: Control, col: int, row: int) -@export var cell_min_size: Vector2 = Vector2(300, 240) +@export var grid_columns: int = 4 +@export var grid_rows: int = 3 @export var cell_spacing: float = 8.0 @export var margin: float = 16.0 +## Internal grid dimensions (synced to grid_columns / grid_rows on rebuild). var columns: int = 0 var rows: int = 0 @@ -49,6 +51,10 @@ var _resize_preview: ColorRect = null # ghost showing new size var _cell_w: float = 1.0 var _cell_h: float = 1.0 +# Grid origin offset (centered within the control area) +var _base_x: float = 0.0 +var _base_y: float = 0.0 + # Resize edge detection threshold (pixels) const RESIZE_THRESHOLD: float = 10.0 const EDGE_LEFT: int = 1 @@ -161,6 +167,7 @@ func _show_tile_menu(mouse_pos: Vector2) -> void: vbox.add_child(toolbar) var settings_btn := _make_menu_button("⚙", "Settings") + settings_btn.pressed.connect(_open_settings.bind(panel)) toolbar.add_child(settings_btn) var info_btn := _make_menu_button("ℹ", "Info") @@ -269,19 +276,36 @@ func _add_tile_from_def(def: Dictionary) -> void: if instance == null: return - # Find a free position at the end of the grid - var gs := get_grid_size() - var target_col := 0 - var target_row := gs.y - # Try current row first var dw: int = def.get("default_w", 1) var dh: int = def.get("default_h", 1) - for c in range(gs.x): - if _span_fits(c, target_row, dw, dh, null): - target_col = c - break - place_module(instance, target_col, target_row, dw, dh) + # Find a free position within the grid bounds + for r in range(grid_rows): + for c in range(grid_columns): + if _span_fits(c, r, dw, dh, null): + place_module(instance, c, r, dw, dh) + return + + # No room — try shrinking span to 1x1 + if dw > 1 or dh > 1: + for r in range(grid_rows): + for c in range(grid_columns): + if _span_fits(c, r, 1, 1, null): + place_module(instance, c, r, 1, 1) + return + + # Grid completely full — queue the instance for cleanup + if is_instance_valid(instance): + instance.queue_free() + + +func _open_settings(menu_panel: PopupPanel) -> void: + if is_instance_valid(menu_panel): + menu_panel.queue_free() + var settings: PopupPanel = load("res://scenes/settings_menu.tscn").instantiate() + add_child(settings) + settings.exclusive = true + settings.popup_centered(Vector2i(720, 540)) # -------------------------------------------------------------------------- @@ -400,17 +424,16 @@ func _clear_occupancy(col: int, row: int, w: int, h: int) -> void: func _ensure_occupancy_for_span(col: int, row: int, w: int, h: int) -> void: - var needed_rows := row + h - var needed_cols := col + w + var needed_rows := mini(row + h, grid_rows) + var needed_cols := mini(col + w, grid_columns) while _grid.size() < needed_rows: _grid.append([]) for r in range(_grid.size()): while _grid[r].size() < needed_cols: _grid[r].append(null) - if columns < needed_cols: - columns = needed_cols - if rows < needed_rows: - rows = needed_rows + # Never expand beyond the configured fixed grid + columns = grid_columns + rows = grid_rows func _is_valid_cell(col: int, row: int) -> bool: @@ -442,10 +465,8 @@ func _cell_at_position(pos: Vector2) -> Vector2i: if columns == 0 or rows == 0 or _cell_w <= 0 or _cell_h <= 0: return Vector2i(-1, -1) - var inner_x := margin + cell_spacing - var inner_y := margin + cell_spacing - var col := int((pos.x - inner_x) / (_cell_w + cell_spacing)) - var row := int((pos.y - inner_y) / (_cell_h + cell_spacing)) + var col := int((pos.x - _base_x) / (_cell_w + cell_spacing)) + var row := int((pos.y - _base_y) / (_cell_h + cell_spacing)) col = clampi(col, 0, columns - 1) row = clampi(row, 0, rows - 1) return Vector2i(col, row) @@ -758,8 +779,8 @@ func _finish_drag() -> void: # -------------------------------------------------------------------------- func _get_module_rect(col: int, row: int, w: int, h: int) -> Rect2: - var x := margin + cell_spacing + col * (_cell_w + cell_spacing) - var y := margin + cell_spacing + row * (_cell_h + cell_spacing) + var x := _base_x + col * (_cell_w + cell_spacing) + var y := _base_y + row * (_cell_h + cell_spacing) var mw := w * _cell_w + (w - 1) * cell_spacing var mh := h * _cell_h + (h - 1) * cell_spacing return Rect2(x, y, mw, mh) @@ -805,48 +826,16 @@ func _rebuild_grid() -> void: if child is PopupMenu or child is PopupPanel: child.queue_free() - var avail := size - Vector2(margin * 2.0, margin * 2.0) - - var new_cols: int - var new_rows: int - - if avail.x < cell_min_size.x or avail.y < cell_min_size.y: - if _cells.size() > 0: - _layout_cells() - return - new_cols = 1 - new_rows = 1 - if new_cols == columns and new_rows == rows and _cells.size() > 0: - _layout_cells() - return - columns = new_cols - rows = new_rows + # If grid dimensions changed, rebuild cells and re-place modules + if columns != grid_columns or rows != grid_rows: + columns = grid_columns + rows = grid_rows _save_modules() _teardown_cells() _build_cells() _restore_modules() + else: _layout_cells() - return - - new_cols = maxi(1, int(avail.x / (cell_min_size.x + cell_spacing))) - - var module_count := _module_list.size() - var min_rows := maxi(1, ceili(float(module_count) / float(new_cols))) - var avail_rows := maxi(1, int(avail.y / (cell_min_size.y + cell_spacing))) - new_rows = maxi(avail_rows, min_rows) - - if new_cols == columns and new_rows == rows and _cells.size() > 0: - _layout_cells() - return - - columns = new_cols - rows = new_rows - - _save_modules() - _teardown_cells() - _build_cells() - _restore_modules() - _layout_cells() func _cancel_drag() -> void: @@ -955,12 +944,23 @@ func _layout_cells() -> void: return _ensure_cells_match_grid() - var inner_w := maxf(0.0, size.x - margin * 2.0 - cell_spacing) - var inner_h := maxf(0.0, size.y - margin * 2.0 - cell_spacing) - var gap_x := cell_spacing * (columns - 1) - var gap_y := cell_spacing * (rows - 1) - _cell_w = maxf(1.0, (inner_w - gap_x) / columns) - _cell_h = maxf(1.0, (inner_h - gap_y) / rows) + var gap_x := cell_spacing * maxi(0, columns - 1) + var gap_y := cell_spacing * maxi(0, rows - 1) + + # Compute square cell size that fits within the available area + var avail_x := maxf(0.0, size.x - margin * 2.0) + var avail_y := maxf(0.0, size.y - margin * 2.0) + var cell_w_from_x := maxf(1.0, (avail_x - gap_x) / columns) + var cell_h_from_y := maxf(1.0, (avail_y - gap_y) / rows) + var cell_size := minf(cell_w_from_x, cell_h_from_y) + _cell_w = cell_size + _cell_h = cell_size + + # Center the grid within the available space + var grid_w := columns * _cell_w + gap_x + var grid_h := rows * _cell_h + gap_y + _base_x = margin + maxf(0.0, (size.x - margin * 2.0 - grid_w) / 2.0) + _base_y = margin + maxf(0.0, (size.y - margin * 2.0 - grid_h) / 2.0) # Position cells for row in range(rows): @@ -970,8 +970,8 @@ func _layout_cells() -> void: var cell: PanelContainer = _cells[row][col] if not is_instance_valid(cell): continue - var x := margin + cell_spacing + col * (_cell_w + cell_spacing) - var y := margin + cell_spacing + row * (_cell_h + cell_spacing) + var x := _base_x + col * (_cell_w + cell_spacing) + var y := _base_y + row * (_cell_h + cell_spacing) cell.set_position(Vector2(x, y)) cell.set_size(Vector2(_cell_w, _cell_h)) @@ -1057,31 +1057,39 @@ func _ensure_occupancy_from_grid() -> void: if _grid[r][c] != null: max_c = maxi(max_c, c + 1) max_r = maxi(max_r, r + 1) - columns = maxi(columns, max_c) - rows = maxi(rows, max_r) + # Clamp to fixed grid — never expand beyond configured size + columns = clampi(maxi(columns, max_c), 1, grid_columns) + rows = clampi(maxi(rows, max_r), 1, grid_rows) func _find_and_place(module: Control) -> void: var d := _get_module_grid_data(module) - # Try the desired position first - if _span_fits(d.col, d.row, d.w, d.h, null): - _ensure_occupancy_for_span(d.col, d.row, d.w, d.h) - _occupy_cells(module, d.col, d.row, d.w, d.h) + # Clamp span to grid bounds + var sw := mini(d.w, columns) + var sh := mini(d.h, rows) + + # Try the desired position first (clamped) + var try_col := clampi(d.col, 0, columns - sw) + var try_row := clampi(d.row, 0, rows - sh) + if _span_fits(try_col, try_row, sw, sh, null): + _set_module_grid_data(module, try_col, try_row, sw, sh) + _ensure_occupancy_for_span(try_col, try_row, sw, sh) + _occupy_cells(module, try_col, try_row, sw, sh) return - # Find first available position + # Find first available position within bounds for r in range(rows): for c in range(columns): - if _span_fits(c, r, d.w, d.h, null): - _set_module_grid_data(module, c, r, d.w, d.h) - _ensure_occupancy_for_span(c, r, d.w, d.h) - _occupy_cells(module, c, r, d.w, d.h) + var fit_sw := mini(sw, columns - c) + var fit_sh := mini(sh, rows - r) + if _span_fits(c, r, fit_sw, fit_sh, null): + _set_module_grid_data(module, c, r, fit_sw, fit_sh) + _ensure_occupancy_for_span(c, r, fit_sw, fit_sh) + _occupy_cells(module, c, r, fit_sw, fit_sh) return - # No room — expand grid - var new_col := columns - var new_row := 0 - _ensure_occupancy_for_span(new_col, new_row, d.w, d.h) - columns = new_col + d.w - _set_module_grid_data(module, new_col, new_row, d.w, d.h) - _occupy_cells(module, new_col, new_row, d.w, d.h) + # No room — place at (0,0), nudging whatever is there + _clear_occupancy(0, 0, sw, sh) + _set_module_grid_data(module, 0, 0, sw, sh) + _ensure_occupancy_for_span(0, 0, sw, sh) + _occupy_cells(module, 0, 0, sw, sh)