Tool windows redesign and the Novel Editor workspace

Tool windows:
- Every editor (scenario, scene builder, map, interactive objects,
  lighting, catalog export) is rebuilt on the interface ToolWindow chrome
  and the denser ToolTheme: header with title/close, status bar, Ctrl+S,
  dirty-state confirmation.
- Shared building blocks in src/Common: ToolUi (rows, buttons, sections),
  CollapsibleSection, FilteredList, ModFileSelector, DragHandle and
  BackgroundCanvas (scene background with pan/zoom).
- The map, interactive and light editors draw on canvases
  (MapEditorCanvas, InteractiveEditorCanvas, LightEditorCanvas) instead of
  one Control "handle" node per object; the *Handle classes are removed.
- Scenario editor: tab data model (TabData), automatic graph layout
  (ScenarioGraphLayout), file tree/info/thumbnail panel updates.
- Services: ModFileService (mod file listing and writing),
  SceneBackgroundService; MapFileService/ModResourceService follow.
- Catalog export: per-language name/description editing.
- Locale (en/ru) regrouped under tools/*; unused keys removed.

Novel Editor:
- collections/novel_editor.json: a "game" whose entry scene is an
  authoring workspace over the novel engine and the story content.
- NovelEditorHub (scenes/novel_editor_hub.tscn) lists every installed
  tool through IToolProvider.AddMenuButton; ToolsInitializer registers it
  as the logical scene "novel_editor".
This commit is contained in:
cthangover committed 2026-10-17 14:29:14 +00:00
1 parent 578ba7c5d5
commit 8742aa3da5
45 files changed
+4718 -3463

No files matched your search

+129
View File
@@ -0,0 +1,129 @@
using System;
using Cthangover.Core.UI.Theming;
using Cthangover.Tools.Common;
using Godot;
namespace Cthangover.Core.UI.Tool.LightEditor
{
/// <summary>
/// Night-time preview of a scene with its static lights. The background is
/// drawn through the game's <c>timed_sprite</c> shader (set as this
/// control's material), so the preview lights the picture exactly like the
/// game; the lights' positions are pushed to the shader in this canvas'
/// coordinates and their radii scaled from the 1920-px game width to the
/// drawn background width. Radius rings and handles live in a separate
/// child overlay — anything drawn by this control itself would be shaded.
/// <para/>
/// A click on a handle selects that light and drags it; a double-click on
/// empty background adds a light there.
/// </summary>
public partial class LightEditorCanvas : BackgroundCanvas
{
private readonly Control _overlay;
/// <summary>Data being edited; set once by the window.</summary>
public LightEditorController Controller;
/// <summary>Preview material (may be null when the shader is missing).</summary>
public ShaderMaterial PreviewMaterial
{
get => Material as ShaderMaterial;
set => Material = value;
}
/// <summary>Raised when a light moved or was added on the canvas.</summary>
public event Action Changed;
/// <summary>Raised when the selection changed on the canvas.</summary>
public event Action SelectionChanged;
public LightEditorCanvas()
{
_overlay = new Control { MouseFilter = MouseFilterEnum.Ignore };
AddChild(_overlay);
_overlay.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
_overlay.Connect("draw", Callable.From((Action)DrawRings));
GuiInput += OnGuiInput;
}
/// <summary>One handle per light, coloured like the light.</summary>
public override void RebuildHandles()
{
ClearHandles();
if (Controller == null || GetBackgroundRect().Size.X <= 0f)
return;
for (var i = 0; i < Controller.Lights.Count; i++)
{
var index = i;
var light = Controller.Lights[i];
var handle = AddHandle(new Vector2(light.X, light.Y), light.ToColor(), i, p =>
{
light.X = p.X;
light.Y = p.Y;
Refresh();
Changed?.Invoke();
}, index == Controller.SelectedIndex ? 20f : 16f);
handle.Clicked = () => SelectLight(index);
}
Refresh();
}
/// <summary>Redraws the preview and re-uploads the shader uniforms.</summary>
public void Refresh()
{
QueueRedraw();
_overlay.QueueRedraw();
}
private void SelectLight(int index)
{
if (index == Controller.SelectedIndex)
return;
Controller.Select(index);
SelectionChanged?.Invoke();
CallDeferred(nameof(RebuildHandles));
}
private void OnGuiInput(InputEvent @event)
{
if (Controller == null || @event is not InputEventMouseButton { Pressed: true, ButtonIndex: MouseButton.Left } mb)
return;
if (mb.DoubleClick && Controller.Add(Clamp01(LocalToNorm(mb.Position))) != null)
{
RebuildHandles();
Changed?.Invoke();
SelectionChanged?.Invoke();
}
AcceptEvent();
}
protected override void DrawContent(Rect2 rect)
{
// The shader compares against world_pos (MODEL_MATRIX * VERTEX), i.e.
// canvas coordinates of the window, not this control's local ones.
Controller?.UpdatePreview(PreviewMaterial, n => GlobalPosition + NormToLocal(n), rect.Size.X / LightEditorController.GameWidth);
}
private void DrawRings()
{
var rect = GetBackgroundRect();
if (Controller == null || rect.Size.X <= 0f)
return;
var scale = rect.Size.X / LightEditorController.GameWidth;
for (var i = 0; i < Controller.Lights.Count; i++)
{
var light = Controller.Lights[i];
var selected = i == Controller.SelectedIndex;
var centre = NormToLocal(new Vector2(light.X, light.Y));
var color = selected ? UiTokens.AccentBright : UiTokens.Alpha(light.ToColor(), 0.45f);
_overlay.DrawArc(centre, light.Radius * scale, 0f, Mathf.Tau, 64, color, selected ? 1.5f : 1f, true);
}
}
}
}