add plugin system with plugin/layout managers
- plugin_manager: scans res://plugins/*/plugin.cfg, loads tile definitions - plugin_tile: new base class for plugin tiles, extends ModuleBase - layout_manager: save/restore tile grid positions per named layout - migrate existing cpu/memory/testing tiles into system_monitor plugin - add module_resized signal to DashboardGrid for auto-save - dashboard reads from PluginManager + LayoutManager instead of hardcoded paths - project.godot registers PluginManager and LayoutManager autoloads - AGENTS.md updated with new structure and conventions
This commit is contained in:
parent
63af41ea61
commit
f43676e46c
19 changed files with 528 additions and 39 deletions
150
autoload/layout_manager.gd
Normal file
150
autoload/layout_manager.gd
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
extends Node
|
||||
class_name LayoutManager
|
||||
## Manages saved dashboard layouts.
|
||||
##
|
||||
## Layouts map tile_id -> {col, row, w, h}. The current layout is loaded
|
||||
## on startup and auto-saved when modules are placed or resized.
|
||||
## Supports multiple named layouts with a simple file-per-layout scheme.
|
||||
|
||||
|
||||
signal layout_changed(layout_name: String)
|
||||
signal layout_saved(layout_name: String)
|
||||
signal layout_loaded(layout_name: String)
|
||||
|
||||
const LAYOUT_DIR: String = "res://config/layouts/"
|
||||
|
||||
## Current layout name (e.g. "default").
|
||||
var current_layout_name: String = "default"
|
||||
|
||||
## The active layout data: tile_id -> {col, row, w, h}
|
||||
var _layout: Dictionary = {}
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if not DirAccess.dir_exists_absolute(LAYOUT_DIR):
|
||||
DirAccess.make_dir_recursive_absolute(LAYOUT_DIR)
|
||||
|
||||
var configured_name: String = _get_configured_layout()
|
||||
if not configured_name.is_empty():
|
||||
current_layout_name = configured_name
|
||||
|
||||
if not load_layout(current_layout_name):
|
||||
current_layout_name = "default"
|
||||
_layout.clear()
|
||||
|
||||
|
||||
## Return the layout name from config, or empty.
|
||||
func _get_configured_layout() -> String:
|
||||
if ConfigManager.has_method("get_setting"):
|
||||
var val: Variant = ConfigManager.get_setting("layout_current", "")
|
||||
if val is String and not val.is_empty():
|
||||
return val
|
||||
return ""
|
||||
|
||||
|
||||
## Set the current layout name (does not load or save).
|
||||
func set_current_layout(name: String) -> void:
|
||||
current_layout_name = name
|
||||
if ConfigManager.has_method("set_setting"):
|
||||
ConfigManager.set_setting("layout_current", name)
|
||||
layout_changed.emit(name)
|
||||
|
||||
|
||||
## Get tile_id -> {col, row, w, h} for the current layout.
|
||||
func get_layout() -> Dictionary:
|
||||
return _layout.duplicate(true)
|
||||
|
||||
|
||||
## Set a single tile's position in the current layout.
|
||||
func set_tile_position(tile_id: String, col: int, row: int, w: int, h: int) -> void:
|
||||
_layout[tile_id] = {col = col, row = row, w = w, h = h}
|
||||
|
||||
|
||||
## Remove a tile from the current layout.
|
||||
func remove_tile(tile_id: String) -> void:
|
||||
_layout.erase(tile_id)
|
||||
|
||||
|
||||
## Save the current layout to config/layouts/{name}.cfg
|
||||
func save_layout(name: String = "") -> void:
|
||||
if name.is_empty():
|
||||
name = current_layout_name
|
||||
|
||||
var cfg := ConfigFile.new()
|
||||
cfg.set_value("layout", "name", name)
|
||||
cfg.set_value("layout", "saved_at", Time.get_datetime_string_from_system())
|
||||
|
||||
var tile_ids: Array = _layout.keys()
|
||||
cfg.set_value("tiles", "count", tile_ids.size())
|
||||
for i in tile_ids.size():
|
||||
var tid: String = tile_ids[i] as String
|
||||
var entry: Dictionary = _layout[tid]
|
||||
var section := "tile_%d" % i
|
||||
cfg.set_value(section, "id", tid)
|
||||
cfg.set_value(section, "col", entry.get("col", 0))
|
||||
cfg.set_value(section, "row", entry.get("row", 0))
|
||||
cfg.set_value(section, "w", entry.get("w", 1))
|
||||
cfg.set_value(section, "h", entry.get("h", 1))
|
||||
|
||||
var path := LAYOUT_DIR.path_join(name + ".cfg")
|
||||
var result := cfg.save(path)
|
||||
if result == OK:
|
||||
current_layout_name = name
|
||||
layout_saved.emit(name)
|
||||
|
||||
|
||||
## Load a saved layout, replacing the current in-memory layout.
|
||||
## Returns true if the file existed and was parsed.
|
||||
func load_layout(name: String) -> bool:
|
||||
var path := LAYOUT_DIR.path_join(name + ".cfg")
|
||||
if not FileAccess.file_exists(path):
|
||||
return false
|
||||
|
||||
var cfg := ConfigFile.new()
|
||||
if cfg.load(path) != OK:
|
||||
return false
|
||||
|
||||
_layout.clear()
|
||||
current_layout_name = name
|
||||
|
||||
var tile_count: int = cfg.get_value("tiles", "count", 0)
|
||||
for i in range(tile_count):
|
||||
var section := "tile_%d" % i
|
||||
var tid: String = cfg.get_value(section, "id", "")
|
||||
if tid.is_empty():
|
||||
continue
|
||||
_layout[tid] = {
|
||||
col = cfg.get_value(section, "col", 0),
|
||||
row = cfg.get_value(section, "row", 0),
|
||||
w = cfg.get_value(section, "w", 1),
|
||||
h = cfg.get_value(section, "h", 1),
|
||||
}
|
||||
|
||||
layout_loaded.emit(name)
|
||||
return true
|
||||
|
||||
|
||||
## Get a list of all saved layout names.
|
||||
func get_layout_names() -> PackedStringArray:
|
||||
var dir := DirAccess.open(LAYOUT_DIR)
|
||||
if dir == null:
|
||||
return PackedStringArray()
|
||||
|
||||
var names: PackedStringArray = []
|
||||
dir.list_dir_begin()
|
||||
var entry: String = dir.get_next()
|
||||
while entry != "":
|
||||
if entry.ends_with(".cfg") and not entry.begins_with("."):
|
||||
names.append(entry.trim_suffix(".cfg"))
|
||||
entry = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
return names
|
||||
|
||||
|
||||
## Switch to a different layout: saves current, loads new, emits signal.
|
||||
func switch_layout(name: String) -> void:
|
||||
if name == current_layout_name:
|
||||
return
|
||||
save_layout(current_layout_name)
|
||||
load_layout(name)
|
||||
set_current_layout(name)
|
||||
154
autoload/plugin_manager.gd
Normal file
154
autoload/plugin_manager.gd
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
extends Node
|
||||
class_name PluginManager
|
||||
## Scans res://plugins/ for plugin manifests (plugin.cfg) and provides
|
||||
## access to tile definitions. Instantiate tiles by tile_id.
|
||||
|
||||
|
||||
signal plugin_loaded(plugin_id: String, plugin_name: String)
|
||||
signal plugin_load_failed(plugin_id: String, reason: String)
|
||||
|
||||
## All loaded plugin data: plugin_id -> Dictionary
|
||||
var _plugins: Dictionary = {}
|
||||
|
||||
## All tile definitions: tile_id -> Dictionary
|
||||
var _tiles: Dictionary = {}
|
||||
|
||||
## Plugin-id -> Array[tile_id] for reverse lookup
|
||||
var _plugin_tiles: Dictionary = {}
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_scan_plugins()
|
||||
|
||||
|
||||
## Scan res://plugins/*/plugin.cfg and load all manifests.
|
||||
func _scan_plugins() -> void:
|
||||
var dir := DirAccess.open("res://plugins")
|
||||
if dir == null:
|
||||
return
|
||||
|
||||
dir.list_dir_begin()
|
||||
var entry: String = dir.get_next()
|
||||
while entry != "":
|
||||
if entry.begins_with("."):
|
||||
entry = dir.get_next()
|
||||
continue
|
||||
if dir.current_is_dir():
|
||||
var manifest_path := "res://plugins/%s/plugin.cfg" % entry
|
||||
if FileAccess.file_exists(manifest_path):
|
||||
_load_manifest(entry, manifest_path)
|
||||
else:
|
||||
plugin_load_failed.emit(entry, "Missing plugin.cfg")
|
||||
entry = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
|
||||
|
||||
func _load_manifest(plugin_id: String, path: String) -> void:
|
||||
var cfg := ConfigFile.new()
|
||||
if cfg.load(path) != OK:
|
||||
plugin_load_failed.emit(plugin_id, "Failed to parse plugin.cfg")
|
||||
return
|
||||
|
||||
var plugin_info := {
|
||||
id = plugin_id,
|
||||
name = cfg.get_value("plugin", "name", plugin_id),
|
||||
version = cfg.get_value("plugin", "version", "0.1.0"),
|
||||
description = cfg.get_value("plugin", "description", ""),
|
||||
author = cfg.get_value("plugin", "author", ""),
|
||||
base_path = path.get_base_dir(),
|
||||
}
|
||||
_plugins[plugin_id] = plugin_info
|
||||
|
||||
# Load tile definitions
|
||||
var tile_count: int = cfg.get_value("tiles", "count", 0)
|
||||
for i in range(tile_count):
|
||||
var section := "tile_%d" % i
|
||||
var tile_id: String = cfg.get_value(section, "id", "")
|
||||
if tile_id.is_empty():
|
||||
continue
|
||||
|
||||
# Scoped tile-id: "plugin_id/tile_id" ensures global uniqueness
|
||||
var scoped_id := "%s/%s" % [plugin_id, tile_id]
|
||||
|
||||
if _tiles.has(scoped_id):
|
||||
plugin_load_failed.emit(plugin_id, "Duplicate tile id: %s" % scoped_id)
|
||||
continue
|
||||
|
||||
var tile_def := {
|
||||
id = scoped_id,
|
||||
plugin_id = plugin_id,
|
||||
name = cfg.get_value(section, "name", tile_id),
|
||||
scene = cfg.get_value(section, "scene", ""),
|
||||
min_w = cfg.get_value(section, "min_w", 1),
|
||||
min_h = cfg.get_value(section, "min_h", 1),
|
||||
max_w = cfg.get_value(section, "max_w", 4),
|
||||
max_h = cfg.get_value(section, "max_h", 4),
|
||||
default_w = cfg.get_value(section, "default_w", 1),
|
||||
default_h = cfg.get_value(section, "default_h", 1),
|
||||
}
|
||||
_tiles[scoped_id] = tile_def
|
||||
|
||||
if not _plugin_tiles.has(plugin_id):
|
||||
_plugin_tiles[plugin_id] = []
|
||||
_plugin_tiles[plugin_id].append(scoped_id)
|
||||
|
||||
plugin_loaded.emit(plugin_id, plugin_info.name)
|
||||
|
||||
|
||||
## Returns a list of all loaded plugin IDs.
|
||||
func get_plugin_ids() -> PackedStringArray:
|
||||
var result: PackedStringArray = []
|
||||
for pid in _plugins:
|
||||
result.append(pid)
|
||||
return result
|
||||
|
||||
|
||||
## Returns plugin info Dictionary for the given plugin_id, or null.
|
||||
func get_plugin(plugin_id: String) -> Dictionary:
|
||||
return _plugins.get(plugin_id, {})
|
||||
|
||||
|
||||
## Returns all tile definitions across all plugins.
|
||||
func get_all_tile_defs() -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
for tid in _tiles:
|
||||
result.append(_tiles[tid])
|
||||
return result
|
||||
|
||||
|
||||
## Returns a single tile definition by scoped tile_id, or null.
|
||||
func get_tile_def(tile_id: String) -> Dictionary:
|
||||
return _tiles.get(tile_id, {})
|
||||
|
||||
|
||||
## Returns tile IDs belonging to a specific plugin.
|
||||
func get_plugin_tile_ids(plugin_id: String) -> PackedStringArray:
|
||||
var result: PackedStringArray = []
|
||||
for tid in _plugin_tiles.get(plugin_id, []):
|
||||
result.append(tid)
|
||||
return result
|
||||
|
||||
|
||||
## Instantiate a tile by its scoped tile_id.
|
||||
## Returns the root Control node, or null on failure.
|
||||
func instantiate_tile(tile_id: String) -> Control:
|
||||
var def: Dictionary = _tiles.get(tile_id, {})
|
||||
if def.is_empty():
|
||||
return null
|
||||
var scene_path: String = def.get("scene", "")
|
||||
if scene_path.is_empty():
|
||||
return null
|
||||
var scene := load(scene_path) as PackedScene
|
||||
if scene == null:
|
||||
return null
|
||||
var instance: Control = scene.instantiate() as Control
|
||||
if instance == null:
|
||||
return null
|
||||
|
||||
# Tag the instance so the layout manager can correlate it
|
||||
if instance.has_method("set_tile_id"):
|
||||
instance.set_tile_id(tile_id)
|
||||
if instance.has_method("set_tile_config"):
|
||||
instance.set_tile_config(def)
|
||||
|
||||
return instance
|
||||
Loading…
Add table
Add a link
Reference in a new issue