diff --git a/.gitignore b/.gitignore index 9c38039..8b040c2 100644 --- a/.gitignore +++ b/.gitignore @@ -207,3 +207,4 @@ artifacts/ log*.txt .omo/ +.omx/ diff --git a/openspec/changes/archive/2026-07-07-add-lua-expression-scripts/.openspec.yaml b/openspec/changes/archive/2026-07-07-add-lua-expression-scripts/.openspec.yaml new file mode 100644 index 0000000..aee4ef1 --- /dev/null +++ b/openspec/changes/archive/2026-07-07-add-lua-expression-scripts/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-07 diff --git a/openspec/changes/archive/2026-07-07-add-lua-expression-scripts/design.md b/openspec/changes/archive/2026-07-07-add-lua-expression-scripts/design.md new file mode 100644 index 0000000..043872f --- /dev/null +++ b/openspec/changes/archive/2026-07-07-add-lua-expression-scripts/design.md @@ -0,0 +1,88 @@ +## Context + +ChapterTool currently transforms chapter timestamps through `ExpressionService`, a synchronous custom evaluator for one-line infix and postfix expressions. The Avalonia shell binds one expression string and one apply flag into export options; preview/save projection uses `ChapterExpressionService` through `ChapterOutputProjectionService`. A later expression editor added metadata, token classification, completion, and diagnostics for that custom grammar. + +The revised direction is to replace that expression language as the user-facing feature with Lua, using Lua-CSharp (`LuaCSharp`, from `nuskey8/Lua-CSharp`). The expression surface should become a Lua script surface with presets, external scripts, and Lua-aware editor services rather than preserving the old formula/postfix grammar as a parallel mode. + +## Goals / Non-Goals + +**Goals:** + +- Make Lua the expression language for chapter-time transforms. +- Remove postfix expression authoring/evaluation as a product requirement for the new expression feature. +- Support formula-like Lua expression shorthand (`t + 1`) without requiring users to type `return`, while also supporting explicit `return ...` chunks and reusable `transform(chapter)` functions. +- Add Core-level Lua script presets as structured metadata so UI and tests do not duplicate script text. +- Replace expression authoring metadata/analysis with Lua-oriented symbols, diagnostics, highlighting spans, and completion/LSP-style candidates. +- Allow Avalonia users to load external `.lua` script text through platform picker abstractions. +- Keep preview/save/export projection deterministic: invalid scripts preserve original chapter times and emit warnings. + +**Non-Goals:** + +- Preserve old infix/postfix grammar semantics as a first-class expression mode. +- Add arbitrary file-system access from Core during export; UI may read selected external script text and pass it into Core. +- Provide a full Lua debugger, package manager, or persistent user script library. +- Expose Lua standard-library OS/file/network capabilities to scripts. + +## Decisions + +1. Replace expression options with Lua script options and keep only intentional compatibility seams. + + `ChapterExportOptions` will retain `ApplyExpression` for workflow compatibility, and the expression payload becomes Lua expression/script text plus optional preset/source metadata. The only compatibility seams intentionally preserved are: the `ApplyExpression` enable/disable workflow, the `t`/`fps` variable names, and expression shorthand without `return`. The implementation and tests should not preserve postfix expressions, the old custom parser as a parallel mode, or broad old-grammar equivalence. + +2. Keep Core script execution source-text based. + + External file selection is a UI/platform concern. The Avalonia tool reads the chosen `.lua` file, stores the script text and display path in the ViewModel, then passes script text through export options. This avoids Core depending on paths, current directories, or UI permissions, and it makes tests deterministic. + +3. Define a small Lua contract with expression shorthand, direct `return`, and `transform(context)`. + + For each non-separator chapter, Core sets Lua globals for `t`, `fps`, `index`, `count`, and a `chapter` table. If the input does not contain an explicit Lua `return` or `transform` declaration, Core treats it as a single Lua expression and evaluates `return ()`; this preserves low-friction usage for `t + 1`, `t - 0.5`, `t * fps / fps`, and similar arithmetic. A script may also explicitly `return ` or define `function transform(chapter) ... end`. The numeric result is seconds. Returning nil, non-numeric values, NaN, or infinity is a structured failure. + +4. Use a restricted Lua-CSharp state per evaluation. + + Core creates a fresh `LuaState` for deterministic, isolated evaluation and exposes only safe helpers needed for transforms, especially math helpers. It must not expose OS/file/process IO. Built-in scripts are small enough that per-chapter execution is acceptable; if performance becomes an issue later, cached script plans can be added behind the same service interface. + +5. Model presets as data, not UI literals. + + Core exposes `LuaExpressionScriptPreset` values with stable ids, localization keys/display names, descriptions, and script text. Avalonia binds these presets and copies the selected preset script into the editor/script field. Initial presets: identity (`t` or `return t`), offset seconds, round to nearest frame, and half-frame earlier adjustment. + +6. Formalize a shared preview/save projection pipeline. + + Preview, save, and text preview must not each reimplement expression application. They should call one Core projection service that accepts an immutable `ChapterInfo` snapshot and projection options, then returns projected chapters plus diagnostics. The pipeline order is: + + 1. Start from the current source `ChapterInfo` snapshot; do not mutate it. + 2. If `ApplyExpression` is false, keep chapter times unchanged. If true, evaluate the Lua expression/script for each non-separator chapter. + 3. Normalize successful expression results to valid time bounds and refresh derived frame display/accuracy from the effective frame rate. + 4. Preserve separator rows as structural separators; do not run Lua on separators. + 5. Apply output-only chapter numbering/order shift. + 6. Apply output-only name generation/template substitution. + 7. Return both `Info` (for projected row preview) and `OutputChapters` (non-separator export rows) with accumulated diagnostics. + + The save service then serializes/export-formats only the projected output; UI preview uses the same projected result. If Lua diagnostics occur, the affected chapter keeps its original time and downstream numbering/naming still proceeds on the projected list. + +7. Replace expression authoring with Lua authoring. + + The existing expression editor control should evolve from custom grammar token metadata to Lua tokenization/classification, Lua diagnostics, and completion/LSP-style candidates for provided globals (`t`, `fps`, `index`, `count`, `chapter`), safe math helpers, and preset snippets. If a full LSP server is not embedded in the first pass, the service boundary should still be Lua-oriented and testable so it can be backed by an LSP implementation later without changing ViewModels. + +8. Extend diagnostics rather than throwing. + + Lua compile/runtime/return-shape failures produce `InvalidExpression.Lua*` warning diagnostics. Projection continues to normalize negative/too-large successful results with existing `InvalidExpressionTime` diagnostics, and all diagnostics travel with the projection result so preview and save report the same issues. + +## Risks / Trade-offs + +- [Risk] Removing custom formula/postfix behavior may break users who rely on non-Lua-only syntax such as `M_PI` constants or postfix token lists. → Mitigation: preserve only the agreed low-friction seams (`ApplyExpression`, `t`/`fps`, shorthand like `t + 1`), provide Lua presets/snippets, and avoid rebuilding the old grammar by accident. +- [Risk] Lua-CSharp APIs are asynchronous while export/projection code is synchronous. → Mitigation: isolate blocking inside the Lua expression service with `GetAwaiter().GetResult()` after keeping scripts local and CPU-bound; revisit async projection only if needed. +- [Risk] Opening all Lua standard libraries may expose file or OS operations. → Mitigation: use a restricted standard-library set or explicit safe helper table; tests SHALL verify scripts cannot rely on external file IO through the expression service contract. +- [Risk] Running a script per chapter is slower than the formula evaluator. → Mitigation: scripts are expected to be short; add caching only if measured, and keep preview/save on the same projection path so performance fixes benefit both. +- [Risk] Full Lua LSP integration may be larger than the transform work. → Mitigation: design the Core/UI authoring boundary around Lua completions/diagnostics first and keep any LSP-backed implementation swappable. + +## Migration Plan + +1. Add Core Lua models/options and the LuaCSharp dependency. +2. Implement `LuaExpressionScriptService` with expression shorthand wrapping and integrate it into a shared output projection service as the only user-facing expression evaluator. +3. Add Lua presets and Core tests for script success/failure behavior. +4. Replace expression authoring metadata/analyzer behavior with Lua-oriented classification, completion, and diagnostics. +5. Extend Avalonia ViewModels/services/resources and editor UI for Lua scripts, presets, file picking, and Lua completion/highlighting. +6. Update preview/save projection tests so one projected result path covers UI preview, text preview, save options, diagnostics, order shift, and naming. +7. Run focused/full solution validation. + +Rollback is possible by reverting to the previous custom expression evaluator before archiving this change; once archived, docs and tests should describe Lua as the expression language. diff --git a/openspec/changes/archive/2026-07-07-add-lua-expression-scripts/proposal.md b/openspec/changes/archive/2026-07-07-add-lua-expression-scripts/proposal.md new file mode 100644 index 0000000..74909dc --- /dev/null +++ b/openspec/changes/archive/2026-07-07-add-lua-expression-scripts/proposal.md @@ -0,0 +1,32 @@ +## Why + +Current expression transforms use a custom infix/postfix grammar that is costly to improve and disconnected from standard tooling. Moving the expression feature to Lua gives users a real scripting language for reusable chapter-time transforms, external project scripts, presets, and editor services such as Lua-aware highlighting and completion. + +## What Changes + +- Replace the custom expression grammar as the user-facing expression language with Lua scripts powered by the `LuaCSharp` package from `nuskey8/Lua-CSharp`. +- Stop treating postfix expressions as a required or supported expression authoring target in the new implementation. +- Define a deterministic Lua expression/script contract: users may type simple arithmetic such as `t + 1` without `return`, or write full Lua `return ...` / `transform(chapter)` scripts. +- Add built-in Lua script presets for common workflows such as identity, offset seconds, NTSC half-frame style adjustment, and frame rounding. +- Allow users to choose an external `.lua` script from the Avalonia expression tool and apply it through a shared preview/save projection pipeline. +- Replace expression editor language services with Lua-aware highlighting, diagnostics, and completion/LSP-style authoring support while preserving the low-friction arithmetic input style users already expect. +- Report Lua compile/runtime/invalid-return failures as structured diagnostics without crashing or mutating the source chapter list. + +## Capabilities + +### New Capabilities + +- None. + +### Modified Capabilities + +- `chapter-core-transform-export`: expression transforms and expression authoring metadata/analysis use Lua scripts and Lua diagnostics instead of the custom formula/postfix grammar. +- `avalonia-ui-shell`: expression UI exposes Lua presets, external Lua script picking, and Lua-aware editor highlighting/completion/diagnostics. + +## Impact + +- `src/ChapterTool.Core`: add Lua script expression models/services, replace custom expression transform integration with Lua script evaluation, expose Lua authoring metadata/analysis, and formalize the shared output projection pipeline used by preview/save/export. +- `src/ChapterTool.Avalonia`: update expression editor/tool/main save flow for Lua script text, presets, external script selection, and Lua completion/highlighting. +- `tests/ChapterTool.Core.Tests`: update expression tests for Lua success, presets, external script text, invalid returns, and failure diagnostics; remove postfix-target expectations from the new behavior. +- `tests/ChapterTool.Avalonia.Tests`: update ViewModel/headless tests for Lua editor behavior, presets, file picker interaction, and diagnostics. +- Dependency: add NuGet package `LuaCSharp` to the Core project. diff --git a/openspec/changes/archive/2026-07-07-add-lua-expression-scripts/specs/avalonia-ui-shell/spec.md b/openspec/changes/archive/2026-07-07-add-lua-expression-scripts/specs/avalonia-ui-shell/spec.md new file mode 100644 index 0000000..e5d0444 --- /dev/null +++ b/openspec/changes/archive/2026-07-07-add-lua-expression-scripts/specs/avalonia-ui-shell/spec.md @@ -0,0 +1,76 @@ +## MODIFIED Requirements + +### Requirement: Expression editor authoring experience +The Avalonia shell SHALL use a dedicated Lua expression/script editor for all expression inputs instead of a plain text box or the previous custom formula grammar editor, while allowing simple arithmetic expressions without requiring an explicit `return`. + +#### Scenario: Main expression input uses the Lua expression editor +- **WHEN** the main window is rendered +- **THEN** the expression input SHALL provide Lua syntax highlighting for expression script tokens +- **AND** it SHALL expose Lua-aware completion candidates based on the caret token, including provided globals, safe helper functions, and discoverable `preset.*` entries + +#### Scenario: Expression tool uses the same Lua editor +- **WHEN** the expression tool window is opened +- **THEN** its expression input SHALL use the same Lua editor behavior as the main window + +#### Scenario: Completion items are visually categorized +- **WHEN** the Lua expression completion popup is open +- **THEN** completion items SHALL visually identify whether they are variables, functions, keywords, or presets using category labels and distinct colors + +#### Scenario: Tab accepts Lua completion +- **WHEN** a completion popup is open for a Lua prefix +- **THEN** pressing `Tab` SHALL insert the selected completion +- **AND** focus SHALL remain in the expression editor + +#### Scenario: Lua syntax errors show correction guidance +- **WHEN** the user enters an invalid Lua expression script +- **THEN** the editor SHALL display the specific Lua syntax or validation problem +- **AND** it SHALL display a correction suggestion derived from the diagnostic + +#### Scenario: Valid Lua script clears errors +- **WHEN** the user corrects an invalid Lua expression script to a valid script +- **THEN** the error and suggestion feedback SHALL be cleared + +## ADDED Requirements + +### Requirement: Lua expression script authoring +The Avalonia shell SHALL allow users to apply Lua expression transforms with built-in presets and external script selection. + +#### Scenario: Expression tool exposes Lua script editing +- **WHEN** the expression tool window is opened +- **THEN** it SHALL present Lua script editing as the expression authoring surface +- **AND** it SHALL NOT require users to choose or understand the previous formula/postfix grammar + +#### Scenario: Built-in Lua preset can populate script text +- **WHEN** the user selects a built-in Lua script preset in the expression tool or accepts a `preset.*` completion in the editor +- **THEN** the tool SHALL show or insert the preset script text and apply that script through the owner ViewModel when the user applies the tool + +#### Scenario: External Lua script can be selected +- **WHEN** the user chooses an external `.lua` script from the main expression input or expression tool +- **THEN** the UI SHALL expose the load action as a button at the right side of the Lua expression input +- **AND** it SHALL use the file picker service abstraction to select and read the script text +- **AND** applying or using the loaded script SHALL pass that script text into chapter preview and save options without requiring Core to read the file path + +#### Scenario: Lua expression or script participates in preview and save +- **WHEN** expression application is enabled and the current chapters are previewed or saved +- **THEN** the main ViewModel SHALL project chapter output using Lua expression/script options +- **AND** Lua diagnostics SHALL be surfaced through the same status/log diagnostic path as expression diagnostics + +#### Scenario: Simple arithmetic remains approachable through Lua +- **WHEN** the user enters a simple Lua arithmetic expression such as `t + 1` +- **THEN** preview and save SHALL apply the transform without requiring `return`, a function wrapper, or any legacy postfix expression syntax + +### Requirement: Preview and save use shared Lua projection +The Avalonia shell SHALL route preview, text preview, and save through the same Lua expression projection options exposed by the main ViewModel. + +#### Scenario: Main ViewModel builds one projection option set +- **WHEN** the main ViewModel previews or saves chapters with expression application enabled +- **THEN** it SHALL pass the same Lua expression/script text, preset/source metadata, order shift, naming, format, and language options into the Core projection/export path + +#### Scenario: Save does not re-read external script files +- **WHEN** a user has loaded an external Lua script in the expression tool and later saves chapters +- **THEN** the ViewModel SHALL pass the already-loaded script text to Core +- **AND** save SHALL NOT require Core or the save service to re-read the external script path + +#### Scenario: Preview matches save projection +- **WHEN** the user previews projected chapters and then saves without changing options +- **THEN** the times, generated numbers, generated names, and Lua diagnostics used for preview SHALL match the data used for save output diff --git a/openspec/changes/archive/2026-07-07-add-lua-expression-scripts/specs/chapter-core-transform-export/spec.md b/openspec/changes/archive/2026-07-07-add-lua-expression-scripts/specs/chapter-core-transform-export/spec.md new file mode 100644 index 0000000..14fe247 --- /dev/null +++ b/openspec/changes/archive/2026-07-07-add-lua-expression-scripts/specs/chapter-core-transform-export/spec.md @@ -0,0 +1,103 @@ +## MODIFIED Requirements + +### Requirement: Expression transforms +The system SHALL transform chapter times through Lua scripts using structured success or failure results. + +#### Scenario: Lua script receives chapter time and fps +- **WHEN** a Lua expression script references `t` or `fps` +- **THEN** Core SHALL evaluate it using chapter time in seconds and the current frame rate + +#### Scenario: Invalid expression does not crash +- **WHEN** Lua compilation, Lua execution, or Lua return conversion fails +- **THEN** Core SHALL return a failure diagnostic and preserve original chapter time behavior + +#### Scenario: Postfix expressions are not a required expression target +- **WHEN** expression transform behavior is implemented for this change +- **THEN** Core SHALL NOT require postfix expression authoring or postfix token-list evaluation as part of the user-facing expression workflow + +#### Scenario: Lua script receives chapter context +- **WHEN** Lua expression mode is applied to a non-separator chapter +- **THEN** Core SHALL provide the script with chapter time `t`, frame rate `fps`, one-based chapter `index`, non-separator chapter `count`, and a chapter context table +- **AND** the numeric Lua result SHALL become the chapter time in seconds + +#### Scenario: Lua transform function is supported +- **WHEN** a Lua script defines `transform(chapter)` and that function returns a numeric value +- **THEN** Core SHALL call the function for each non-separator chapter and use its returned seconds value + +#### Scenario: Lua expression shorthand is supported +- **WHEN** the user enters a simple Lua arithmetic expression such as `t + 1` without an explicit `return` +- **THEN** Core SHALL evaluate it as a returned Lua expression +- **AND** the numeric result SHALL become the transformed seconds value for the current chapter + +#### Scenario: Lua direct return is supported +- **WHEN** a Lua script directly returns a numeric expression such as `return t + 1` +- **THEN** Core SHALL use that numeric return as the transformed seconds value for the current chapter + +#### Scenario: Built-in Lua presets are available +- **WHEN** Lua expression presets are requested +- **THEN** Core SHALL expose stable preset identifiers, display names, descriptions, and script text for common transforms including identity, offset seconds, frame rounding, and half-frame earlier adjustment + +#### Scenario: Lua invalid return preserves chapter time +- **WHEN** a Lua script returns nil, a string, a boolean, NaN, infinity, or another non-finite non-numeric result +- **THEN** Core SHALL report an `InvalidExpression.Lua*` diagnostic +- **AND** the original chapter time SHALL be preserved for that chapter + +### Requirement: Expression authoring metadata +The Core expression subsystem SHALL expose Lua expression globals, safe helper functions, script presets, snippets, and editor symbols as structured metadata for UI authoring features. + +#### Scenario: Metadata exposes Lua tokens +- **WHEN** expression authoring metadata is requested +- **THEN** the result SHALL include Lua globals `t`, `fps`, `index`, `count`, and `chapter` +- **AND** it SHALL include supported safe math/helper functions, Lua keywords needed for transforms, and built-in Lua script presets under a discoverable `preset.*` namespace with stable identifiers + +### Requirement: Expression authoring analysis +The Core expression subsystem SHALL analyze Lua expression script text for editor classification, completion, and validation without mutating chapter data. + +#### Scenario: Analyze valid Lua expression shorthand +- **WHEN** the Lua expression text `t + math.floor(fps / 2)` is analyzed +- **THEN** the analysis SHALL classify Lua keyword, global, operator, function/member, punctuation, and number spans +- **AND** it SHALL report no diagnostics + +#### Scenario: Analyze invalid Lua expression shorthand +- **WHEN** the Lua expression text `t +` is analyzed +- **THEN** the analysis SHALL include an `InvalidExpression.Lua*` diagnostic +- **AND** the diagnostic SHALL include a correction suggestion suitable for display to the user + +#### Scenario: Completion uses Lua caret context +- **WHEN** completions are requested after a Lua prefix such as `math.flo` +- **THEN** the result SHALL include `math.floor` or `floor` according to the exposed helper style +- **AND** accepting the completion SHALL identify the replacement span for only the Lua prefix token + +#### Scenario: Preset completion is discoverable +- **WHEN** completions are requested after `pre` or `preset.` +- **THEN** the result SHALL expose a `preset` namespace entry and concrete preset entries such as `preset.round-to-frame` +- **AND** accepting a concrete preset completion SHALL insert that preset script text rather than the display identifier + +#### Scenario: Completion items expose categories +- **WHEN** expression completions are returned +- **THEN** each completion SHALL include a token kind/category suitable for the UI to visually distinguish variables, functions, keywords, and presets + +## ADDED Requirements + +### Requirement: Shared preview and save projection pipeline +The system SHALL use one Core output projection pipeline for preview and save so Lua expression transforms, diagnostics, numbering, and naming are consistent. + +#### Scenario: Projection does not mutate source chapters +- **WHEN** preview or save requests projected output from a source chapter set +- **THEN** Core SHALL return projected chapter data without mutating the original `ChapterInfo` input + +#### Scenario: Projection order is deterministic +- **WHEN** expression application, order shift, and name generation are all enabled +- **THEN** Core SHALL first apply Lua expression transforms to non-separator chapter times +- **AND** it SHALL normalize transformed times and refresh frame display state +- **AND** it SHALL then apply output numbering/order shift and output name generation + +#### Scenario: Preview and save share diagnostics +- **WHEN** Lua expression projection emits diagnostics during preview or save +- **THEN** the projection result SHALL include those diagnostics in the same structured form for both callers +- **AND** chapters whose Lua expression failed SHALL retain their original times while the rest of the output projection continues + +#### Scenario: Separators are structural during projection +- **WHEN** projected output contains separator rows +- **THEN** Core SHALL NOT execute Lua expressions for separator rows +- **AND** exported `OutputChapters` SHALL include only non-separator chapters after projection diff --git a/openspec/changes/archive/2026-07-07-add-lua-expression-scripts/tasks.md b/openspec/changes/archive/2026-07-07-add-lua-expression-scripts/tasks.md new file mode 100644 index 0000000..d36bdd7 --- /dev/null +++ b/openspec/changes/archive/2026-07-07-add-lua-expression-scripts/tasks.md @@ -0,0 +1,29 @@ +## 1. Core Lua expression support + +- [x] 1.1 Add `LuaCSharp` dependency and Lua expression script option models while keeping `ApplyExpression` workflow compatibility. +- [x] 1.2 Add Core Lua script preset metadata for identity, offset seconds, frame rounding, and half-frame earlier adjustment. +- [x] 1.3 Implement a Lua expression script service that evaluates shorthand arithmetic expressions, direct `return` scripts, and `transform(chapter)` scripts with `t`, `fps`, `index`, `count`, and `chapter` context. +- [x] 1.4 Replace user-facing custom formula/postfix transform integration in projection/export options with Lua expression/script evaluation while preserving only `ApplyExpression`, `t`/`fps`, and shorthand compatibility seams. +- [x] 1.5 Formalize the shared preview/save projection pipeline: source snapshot, Lua transform, normalization/frame refresh, separator preservation, order shift, naming, projected `Info`, `OutputChapters`, and diagnostics. +- [x] 1.6 Add Core tests for Lua shorthand arithmetic (`t+1` without `return`), direct returns, transform functions, presets, invalid returns, compile/runtime failures, normalization, and no-postfix requirement. +- [x] 1.7 Add Core projection tests proving preview/save projection order, non-mutation, separator handling, diagnostics, order shift, and naming consistency. + +## 2. Lua authoring services + +- [x] 2.1 Replace expression authoring metadata with Lua globals, safe helpers, Lua keywords, snippets, and preset metadata. +- [x] 2.2 Replace expression analysis/classification with Lua-oriented token spans, diagnostics, completion replacement ranges, and shorthand-expression validation. +- [x] 2.3 Add tests for Lua authoring metadata, Lua highlighting spans, Lua completion, and Lua diagnostic suggestions. + +## 3. Avalonia Lua expression UI and services + +- [x] 3.1 Extend main ViewModel state and current export options with Lua script text, selected Lua preset, external script display path, and one method for building shared projection/export options. +- [x] 3.2 Extend `ExpressionToolViewModel` with preset application, external `.lua` picker/read command, and apply-to-owner behavior. +- [x] 3.3 Update expression tool and main expression UI to use Lua script editor behavior and responsive layout without formula/postfix mode selection. +- [x] 3.4 Add localized user-facing strings for Lua expression presets, external script selection, script load feedback, and Lua diagnostics. +- [x] 3.5 Add Avalonia ViewModel/headless tests for Lua editor rendering, preset selection, external script loading via picker abstraction, simple `t+1` shorthand preview/save behavior, and preview/save projection consistency. + +## 4. Validation + +- [x] 4.1 Run `openspec validate add-lua-expression-scripts --strict`. +- [x] 4.2 Run focused Core and Avalonia tests affected by expression changes. +- [x] 4.3 Run `dotnet test ChapterTool.Avalonia.slnx --no-restore` after restore/build updates. diff --git a/openspec/specs/avalonia-ui-shell/spec.md b/openspec/specs/avalonia-ui-shell/spec.md index f8e3b9f..486eb65 100644 --- a/openspec/specs/avalonia-ui-shell/spec.md +++ b/openspec/specs/avalonia-ui-shell/spec.md @@ -247,31 +247,78 @@ The Avalonia language tool SHALL allow users to choose Simplified Chinese, Engli - **THEN** the selected culture tag SHALL be persisted to settings and applied to the current application localization manager ### Requirement: Expression editor authoring experience -The Avalonia shell SHALL use a dedicated expression editor for all expression inputs instead of a plain text box. +The Avalonia shell SHALL use a dedicated Lua expression/script editor for all expression inputs instead of a plain text box or the previous custom formula grammar editor, while allowing simple arithmetic expressions without requiring an explicit `return`. -#### Scenario: Main expression input uses the expression editor +#### Scenario: Main expression input uses the Lua expression editor - **WHEN** the main window is rendered -- **THEN** the expression input SHALL provide syntax highlighting for expression tokens -- **AND** it SHALL expose context-aware completion candidates based on the caret token +- **THEN** the expression input SHALL provide Lua syntax highlighting for expression script tokens +- **AND** it SHALL expose Lua-aware completion candidates based on the caret token, including provided globals, safe helper functions, and discoverable `preset.*` entries -#### Scenario: Expression tool uses the same editor +#### Scenario: Expression tool uses the same Lua editor - **WHEN** the expression tool window is opened - **THEN** its expression input SHALL use the same editor behavior as the main window -#### Scenario: Tab accepts completion -- **WHEN** a completion popup is open for an expression prefix +#### Scenario: Completion items are visually categorized +- **WHEN** the Lua expression completion popup is open +- **THEN** completion items SHALL visually identify whether they are variables, functions, keywords, or presets using category labels and distinct colors + +#### Scenario: Tab accepts Lua completion +- **WHEN** a completion popup is open for a Lua prefix - **THEN** pressing `Tab` SHALL insert the selected completion - **AND** focus SHALL remain in the expression editor -#### Scenario: Syntax errors show correction guidance -- **WHEN** the user enters an invalid expression -- **THEN** the editor SHALL display the specific syntax problem +#### Scenario: Lua syntax errors show correction guidance +- **WHEN** the user enters an invalid Lua expression script +- **THEN** the editor SHALL display the specific Lua syntax or validation problem - **AND** it SHALL display a correction suggestion derived from the diagnostic -#### Scenario: Valid expression clears errors -- **WHEN** the user corrects an invalid expression to a valid expression +#### Scenario: Valid Lua script clears errors +- **WHEN** the user corrects an invalid Lua expression script to a valid script - **THEN** the error and suggestion feedback SHALL be cleared +### Requirement: Lua expression script authoring +The Avalonia shell SHALL allow users to apply Lua expression transforms with built-in presets and external script selection. + +#### Scenario: Expression tool exposes Lua script editing +- **WHEN** the expression tool window is opened +- **THEN** it SHALL present Lua script editing as the expression authoring surface +- **AND** it SHALL NOT require users to choose or understand the previous formula/postfix grammar + +#### Scenario: Built-in Lua preset can populate script text +- **WHEN** the user selects a built-in Lua script preset in the expression tool or accepts a `preset.*` completion in the editor +- **THEN** the tool SHALL show or insert the preset script text and apply that script through the owner ViewModel when the user applies the tool + +#### Scenario: External Lua script can be selected +- **WHEN** the user chooses an external `.lua` script from the main expression input or expression tool +- **THEN** the UI SHALL expose the load action as a button at the right side of the Lua expression input +- **AND** it SHALL use the file picker service abstraction to select and read the script text +- **AND** applying or using the loaded script SHALL pass that script text into chapter preview and save options without requiring Core to read the file path + +#### Scenario: Lua expression or script participates in preview and save +- **WHEN** expression application is enabled and the current chapters are previewed or saved +- **THEN** the main ViewModel SHALL project chapter output using Lua expression/script options +- **AND** Lua diagnostics SHALL be surfaced through the same status/log diagnostic path as expression diagnostics + +#### Scenario: Simple arithmetic remains approachable through Lua +- **WHEN** the user enters a simple Lua arithmetic expression such as `t + 1` +- **THEN** preview and save SHALL apply the transform without requiring `return`, a function wrapper, or any legacy postfix expression syntax + +### Requirement: Preview and save use shared Lua projection +The Avalonia shell SHALL route preview, text preview, and save through the same Lua expression projection options exposed by the main ViewModel. + +#### Scenario: Main ViewModel builds one projection option set +- **WHEN** the main ViewModel previews or saves chapters with expression application enabled +- **THEN** it SHALL pass the same Lua expression/script text, preset/source metadata, order shift, naming, format, and language options into the Core projection/export path + +#### Scenario: Save does not re-read external script files +- **WHEN** a user has loaded an external Lua script in the expression tool and later saves chapters +- **THEN** the ViewModel SHALL pass the already-loaded script text to Core +- **AND** save SHALL NOT require Core or the save service to re-read the external script path + +#### Scenario: Preview matches save projection +- **WHEN** the user previews projected chapters and then saves without changing options +- **THEN** the times, generated numbers, generated names, and Lua diagnostics used for preview SHALL match the data used for save output + ### Requirement: Unified settings command The Avalonia shell SHALL expose a unified Settings command that opens a settings panel for durable application, external tool, appearance, and platform preferences. diff --git a/openspec/specs/chapter-core-transform-export/spec.md b/openspec/specs/chapter-core-transform-export/spec.md index 4c804c6..3a851cf 100644 --- a/openspec/specs/chapter-core-transform-export/spec.md +++ b/openspec/specs/chapter-core-transform-export/spec.md @@ -54,19 +54,46 @@ The system SHALL provide deterministic time parsing, formatting, frame-rate sele - **THEN** Core SHALL return the default `Fps23976` option with `FrameRateConfidence.Low` and an evaluated chapter count of zero ### Requirement: Expression transforms -The system SHALL transform chapter times through supported infix and postfix expressions using structured success or failure results. +The system SHALL transform chapter times through Lua scripts using structured success or failure results. -#### Scenario: Expression uses chapter time and fps -- **WHEN** an expression references `t` or `fps` +#### Scenario: Lua script receives chapter time and fps +- **WHEN** a Lua expression script references `t` or `fps` - **THEN** Core SHALL evaluate it using chapter time in seconds and the current frame rate #### Scenario: Invalid expression does not crash -- **WHEN** expression parsing or evaluation fails +- **WHEN** Lua compilation, Lua execution, or Lua return conversion fails - **THEN** Core SHALL return a failure diagnostic and preserve original chapter time behavior -#### Scenario: Unsupported legacy operators are explicit -- **WHEN** `and`, `or`, `xor`, or other incomplete legacy operators are encountered -- **THEN** the behavior SHALL be either tested as supported or reported as unsupported; it SHALL NOT be silently documented as complete +#### Scenario: Postfix expressions are not a required expression target +- **WHEN** expression transform behavior is implemented for this change +- **THEN** Core SHALL NOT require postfix expression authoring or postfix token-list evaluation as part of the user-facing expression workflow + +#### Scenario: Lua script receives chapter context +- **WHEN** Lua expression mode is applied to a non-separator chapter +- **THEN** Core SHALL provide the script with chapter time `t`, frame rate `fps`, one-based chapter `index`, non-separator chapter `count`, and a chapter context table +- **AND** the numeric Lua result SHALL become the chapter time in seconds + +#### Scenario: Lua transform function is supported +- **WHEN** a Lua script defines `transform(chapter)` and that function returns a numeric value +- **THEN** Core SHALL call the function for each non-separator chapter and use its returned seconds value + +#### Scenario: Lua expression shorthand is supported +- **WHEN** the user enters a simple Lua arithmetic expression such as `t + 1` without an explicit `return` +- **THEN** Core SHALL evaluate it as a returned Lua expression +- **AND** the numeric result SHALL become the transformed seconds value for the current chapter + +#### Scenario: Lua direct return is supported +- **WHEN** a Lua script directly returns a numeric expression such as `return t + 1` +- **THEN** Core SHALL use that numeric return as the transformed seconds value for the current chapter + +#### Scenario: Built-in Lua presets are available +- **WHEN** Lua expression presets are requested +- **THEN** Core SHALL expose stable preset identifiers, display names, descriptions, and script text for common transforms including identity, offset seconds, frame rounding, and half-frame earlier adjustment + +#### Scenario: Lua invalid return preserves chapter time +- **WHEN** a Lua script returns nil, a string, a boolean, NaN, infinity, or another non-finite non-numeric result +- **THEN** Core SHALL report an `InvalidExpression.Lua*` diagnostic +- **AND** the original chapter time SHALL be preserved for that chapter ### Requirement: Chapter editing operations The system SHALL expose chapter editing as Core operations callable by ViewModels. @@ -157,26 +184,59 @@ The system SHALL export Matroska chapter XML in a legacy-compatible structured f - **THEN** each `ChapterLanguage` SHALL contain that code exactly when the code is valid ### Requirement: Expression authoring metadata -The Core expression subsystem SHALL expose supported variables, constants, functions, and operators as structured metadata for UI authoring features. +The Core expression subsystem SHALL expose Lua expression globals, safe helper functions, script presets, snippets, and editor symbols as structured metadata for UI authoring features. -#### Scenario: Metadata exposes supported tokens +#### Scenario: Metadata exposes Lua tokens - **WHEN** expression authoring metadata is requested -- **THEN** the result SHALL include variables `t` and `fps`, the supported mathematical constants, supported functions with arity, and supported operators with display text +- **THEN** the result SHALL include Lua globals `t`, `fps`, `index`, `count`, and `chapter` +- **AND** it SHALL include supported safe math/helper functions, Lua keywords needed for transforms, and built-in Lua script presets under a discoverable `preset.*` namespace with stable identifiers ### Requirement: Expression authoring analysis -The Core expression subsystem SHALL analyze expression text for editor classification, completion, and validation without mutating chapter data. +The Core expression subsystem SHALL analyze Lua expression script text for editor classification, completion, and validation without mutating chapter data. -#### Scenario: Analyze valid expression -- **WHEN** the expression `t + floor(fps / 2)` is analyzed -- **THEN** the analysis SHALL classify variable, operator, function, punctuation, and number spans +#### Scenario: Analyze valid Lua expression shorthand +- **WHEN** the Lua expression text `t + math.floor(fps / 2)` is analyzed +- **THEN** the analysis SHALL classify Lua keyword, global, operator, function/member, punctuation, and number spans - **AND** it SHALL report no diagnostics -#### Scenario: Analyze invalid expression -- **WHEN** the expression `t +` is analyzed -- **THEN** the analysis SHALL include an `InvalidExpression.*` diagnostic +#### Scenario: Analyze invalid Lua expression shorthand +- **WHEN** the Lua expression text `t +` is analyzed +- **THEN** the analysis SHALL include an `InvalidExpression.Lua*` diagnostic - **AND** the diagnostic SHALL include a correction suggestion suitable for display to the user -#### Scenario: Completion uses caret context -- **WHEN** completions are requested after the prefix `flo` -- **THEN** the result SHALL include `floor` -- **AND** accepting the completion SHALL identify the replacement span for only the prefix token +#### Scenario: Completion uses Lua caret context +- **WHEN** completions are requested after a Lua prefix such as `math.flo` +- **THEN** the result SHALL include `math.floor` or `floor` according to the exposed helper style +- **AND** accepting the completion SHALL identify the replacement span for only the Lua prefix token + +#### Scenario: Preset completion is discoverable +- **WHEN** completions are requested after `pre` or `preset.` +- **THEN** the result SHALL expose a `preset` namespace entry and concrete preset entries such as `preset.round-to-frame` +- **AND** accepting a concrete preset completion SHALL insert that preset script text rather than the display identifier + +#### Scenario: Completion items expose categories +- **WHEN** expression completions are returned +- **THEN** each completion SHALL include a token kind/category suitable for the UI to visually distinguish variables, functions, keywords, and presets + +### Requirement: Shared preview and save projection pipeline +The system SHALL use one Core output projection pipeline for preview and save so Lua expression transforms, diagnostics, numbering, and naming are consistent. + +#### Scenario: Projection does not mutate source chapters +- **WHEN** preview or save requests projected output from a source chapter set +- **THEN** Core SHALL return projected chapter data without mutating the original `ChapterInfo` input + +#### Scenario: Projection order is deterministic +- **WHEN** expression application, order shift, and name generation are all enabled +- **THEN** Core SHALL first apply Lua expression transforms to non-separator chapter times +- **AND** it SHALL normalize transformed times and refresh frame display state +- **AND** it SHALL then apply output numbering/order shift and output name generation + +#### Scenario: Preview and save share diagnostics +- **WHEN** Lua expression projection emits diagnostics during preview or save +- **THEN** the projection result SHALL include those diagnostics in the same structured form for both callers +- **AND** chapters whose Lua expression failed SHALL retain their original times while the rest of the output projection continues + +#### Scenario: Separators are structural during projection +- **WHEN** projected output contains separator rows +- **THEN** Core SHALL NOT execute Lua expressions for separator rows +- **AND** exported `OutputChapters` SHALL include only non-separator chapters after projection diff --git a/src/ChapterTool.Avalonia/Composition/AppCompositionRoot.cs b/src/ChapterTool.Avalonia/Composition/AppCompositionRoot.cs index 4876bbd..736c3ae 100644 --- a/src/ChapterTool.Avalonia/Composition/AppCompositionRoot.cs +++ b/src/ChapterTool.Avalonia/Composition/AppCompositionRoot.cs @@ -23,7 +23,7 @@ public sealed class AppCompositionRoot : IDisposable { private readonly string? startupPath; private readonly ChapterTimeFormatter formatter = new(); - private readonly ExpressionService expressionService = new(); + private readonly LuaExpressionScriptService expressionService = new(); private readonly FrameRateService frameRateService = new(); private readonly ApplicationLogPanelProvider logService = new(capacity: 500, minimumLevel: LogLevel.Information); private readonly AppLocalizationManager localizationManager = new(); diff --git a/src/ChapterTool.Avalonia/Localization/Resources/Strings.en-US.resx b/src/ChapterTool.Avalonia/Localization/Resources/Strings.en-US.resx index a374b31..8687d30 100644 --- a/src/ChapterTool.Avalonia/Localization/Resources/Strings.en-US.resx +++ b/src/ChapterTool.Avalonia/Localization/Resources/Strings.en-US.resx @@ -803,4 +803,55 @@ Zones + + Open Lua expression script + + + Lua script files + + + Preset + + + Browse Lua... + + + Lua preset selected: {preset} + + + Lua script loaded: {path} + + + Lua expression failed: {message} + + + Lua expression syntax error: {message} + + + Lua expression runtime error: {message} + + + Lua expression must return a finite number. + + + Lua expression did not return a value. + + + Unknown Lua token: {message} + + + Lua expression was canceled. + + + Check the Lua expression syntax. + + + Check that referenced Lua variables and functions exist. + + + Return a finite number of seconds. + + + Load Lua... + diff --git a/src/ChapterTool.Avalonia/Localization/Resources/Strings.ja-JP.resx b/src/ChapterTool.Avalonia/Localization/Resources/Strings.ja-JP.resx index 945ab26..b1bab58 100644 --- a/src/ChapterTool.Avalonia/Localization/Resources/Strings.ja-JP.resx +++ b/src/ChapterTool.Avalonia/Localization/Resources/Strings.ja-JP.resx @@ -803,4 +803,55 @@ Zones + + Lua 式スクリプトを開く + + + Lua スクリプト ファイル + + + プリセット + + + Lua を参照... + + + Lua プリセットを選択しました: {preset} + + + Lua スクリプトを読み込みました: {path} + + + Lua 式に失敗しました: {message} + + + Lua 式の構文エラー: {message} + + + Lua 式の実行時エラー: {message} + + + Lua 式は有限の数値を返す必要があります。 + + + Lua 式が値を返しませんでした。 + + + 不明な Lua トークン: {message} + + + Lua 式はキャンセルされました。 + + + Lua 式の構文を確認してください。 + + + 参照している Lua 変数と関数が存在するか確認してください。 + + + 有限の秒数を返してください。 + + + Lua 読み込み... + diff --git a/src/ChapterTool.Avalonia/Localization/Resources/Strings.zh-CN.resx b/src/ChapterTool.Avalonia/Localization/Resources/Strings.zh-CN.resx index 3c5d82b..6e6d52c 100644 --- a/src/ChapterTool.Avalonia/Localization/Resources/Strings.zh-CN.resx +++ b/src/ChapterTool.Avalonia/Localization/Resources/Strings.zh-CN.resx @@ -803,4 +803,55 @@ Zones + + 打开 Lua 表达式脚本 + + + Lua 脚本文件 + + + 预设 + + + 浏览 Lua... + + + 已选择 Lua 预设:{preset} + + + 已加载 Lua 脚本:{path} + + + Lua 表达式失败:{message} + + + Lua 表达式语法错误:{message} + + + Lua 表达式运行错误:{message} + + + Lua 表达式必须返回有限数字。 + + + Lua 表达式没有返回值。 + + + 未知 Lua 标记:{message} + + + Lua 表达式已取消。 + + + 检查 Lua 表达式语法。 + + + 检查引用的 Lua 变量和函数是否存在。 + + + 返回一个有限的秒数数值。 + + + 加载 Lua... + diff --git a/src/ChapterTool.Avalonia/Services/AvaloniaFilePickerService.cs b/src/ChapterTool.Avalonia/Services/AvaloniaFilePickerService.cs index 235f54f..43453fc 100644 --- a/src/ChapterTool.Avalonia/Services/AvaloniaFilePickerService.cs +++ b/src/ChapterTool.Avalonia/Services/AvaloniaFilePickerService.cs @@ -35,6 +35,14 @@ public sealed class AvaloniaFilePickerService(Window owner, IAppLocalizer locali return files.Count > 0 ? files[0].Path.LocalPath : null; } + public async ValueTask PickLuaExpressionScriptAsync(CancellationToken cancellationToken) + { + var files = await owner.StorageProvider.OpenFilePickerAsync(CreateLuaExpressionScriptOptions(localizer)); + + cancellationToken.ThrowIfCancellationRequested(); + return files.Count > 0 ? files[0].Path.LocalPath : null; + } + public async ValueTask PickSaveDirectoryAsync(CancellationToken cancellationToken) { var folders = await owner.StorageProvider.OpenFolderPickerAsync(CreateSaveDirectoryOptions(localizer)); @@ -82,6 +90,18 @@ internal static FilePickerOpenOptions CreateChapterNameTemplateOptions(IAppLocal ] }; + internal static FilePickerOpenOptions CreateLuaExpressionScriptOptions(IAppLocalizer localizer) => + new() + { + Title = localizer.GetString("FilePicker.OpenLuaExpressionScript.Title"), + AllowMultiple = false, + FileTypeFilter = + [ + new FilePickerFileType(localizer.GetString("FilePicker.LuaScriptFiles")) { Patterns = ["*.lua"] }, + FilePickerFileTypes.All + ] + }; + internal static FolderPickerOpenOptions CreateSaveDirectoryOptions(IAppLocalizer localizer) => new() { diff --git a/src/ChapterTool.Avalonia/Services/AvaloniaWindowService.cs b/src/ChapterTool.Avalonia/Services/AvaloniaWindowService.cs index 2b6c1fc..5e1d7fa 100644 --- a/src/ChapterTool.Avalonia/Services/AvaloniaWindowService.cs +++ b/src/ChapterTool.Avalonia/Services/AvaloniaWindowService.cs @@ -169,7 +169,7 @@ private Control CreateContent(Window window, string id, MainWindowViewModel view }, "color-settings" => new ColorSettingsView { DataContext = new ColorSettingsViewModel(themeSettingsStore, themeApplicationService) }, "language" => new LanguageToolView { DataContext = new LanguageToolViewModel(viewModel) }, - "expression" => new ExpressionToolView { DataContext = new ExpressionToolViewModel(viewModel) }, + "expression" => new ExpressionToolView { DataContext = new ExpressionToolViewModel(viewModel, new AvaloniaFilePickerService(window, localizer)) }, "template-names" => new TemplateNamesToolView { DataContext = new TemplateNamesToolViewModel(viewModel) }, "zones" => new TextToolView { DataContext = new TextToolViewModel(viewModel.CreateZonesText) }, "forward-shift" => new ForwardShiftToolView { DataContext = new ForwardShiftToolViewModel(viewModel) }, diff --git a/src/ChapterTool.Avalonia/Services/IFilePickerService.cs b/src/ChapterTool.Avalonia/Services/IFilePickerService.cs index dab8a73..d6b4c00 100644 --- a/src/ChapterTool.Avalonia/Services/IFilePickerService.cs +++ b/src/ChapterTool.Avalonia/Services/IFilePickerService.cs @@ -8,5 +8,7 @@ public interface IFilePickerService ValueTask PickChapterNameTemplateAsync(CancellationToken cancellationToken); + ValueTask PickLuaExpressionScriptAsync(CancellationToken cancellationToken); + ValueTask PickSaveDirectoryAsync(CancellationToken cancellationToken); } diff --git a/src/ChapterTool.Avalonia/ViewModels/MainWindowViewModel.cs b/src/ChapterTool.Avalonia/ViewModels/MainWindowViewModel.cs index 585dc14..8747480 100644 --- a/src/ChapterTool.Avalonia/ViewModels/MainWindowViewModel.cs +++ b/src/ChapterTool.Avalonia/ViewModels/MainWindowViewModel.cs @@ -68,7 +68,7 @@ public MainWindowViewModel( this.windowService = windowService; this.formatter = formatter; this.frameRateService = frameRateService ?? new FrameRateService(); - outputProjectionService = new ChapterOutputProjectionService(new ExpressionService()); + outputProjectionService = new ChapterOutputProjectionService(); this.logService = logService; this.logger = logger; @@ -497,6 +497,18 @@ public string Expression } } = "t"; + public string LuaExpressionPresetId + { + get; + set => SetProperty(ref field, value); + } = string.Empty; + + public string LuaExpressionSourceName + { + get; + set => SetProperty(ref field, value); + } = string.Empty; + public string? SaveDirectory { get; @@ -635,7 +647,7 @@ public string BuildPreview() var projection = CurrentOutputProjection(); var options = CurrentExportOptionsForProjectedInfo(); - var result = new ChapterExportService(formatter, new ExpressionService()).Export(projection.Info, options); + var result = new ChapterExportService(formatter).Export(projection.Info, options); if (!result.Success) { return string.Join(Environment.NewLine, result.Diagnostics.Select(static diagnostic => diagnostic.Message)); @@ -695,7 +707,9 @@ private ChapterExportOptions CurrentExportOptions() => ChapterNameTemplateText: ChapterNameTemplateText, OrderShift: OrderShift, ApplyExpression: ApplyExpression, - Expression: Expression); + Expression: Expression, + LuaExpressionPresetId: LuaExpressionPresetId, + LuaExpressionSourceName: LuaExpressionSourceName); private async ValueTask LoadPathAsync(string path, CancellationToken cancellationToken) { diff --git a/src/ChapterTool.Avalonia/ViewModels/ToolWindowViewModels.cs b/src/ChapterTool.Avalonia/ViewModels/ToolWindowViewModels.cs index 0e6bd7d..986191a 100644 --- a/src/ChapterTool.Avalonia/ViewModels/ToolWindowViewModels.cs +++ b/src/ChapterTool.Avalonia/ViewModels/ToolWindowViewModels.cs @@ -7,6 +7,7 @@ using ChapterTool.Avalonia.Localization; using ChapterTool.Core.Exporting; using ChapterTool.Core.Services; +using ChapterTool.Core.Transform; using ChapterTool.Infrastructure.Configuration; namespace ChapterTool.Avalonia.ViewModels; @@ -642,34 +643,117 @@ private void ReplaceLanguages(IReadOnlyList options) public sealed record LanguageOptionViewModel(string CultureName, string DisplayName); -public sealed class ExpressionToolViewModel(MainWindowViewModel owner) : ObservableViewModel +public sealed class ExpressionToolViewModel : ObservableViewModel { + private readonly MainWindowViewModel owner; + private readonly IFilePickerService? filePicker; + + public ExpressionToolViewModel(MainWindowViewModel owner, IFilePickerService? filePicker = null) + { + this.owner = owner; + this.filePicker = filePicker; + Expression = owner.Expression; + ApplyExpression = owner.ApplyExpression; + LuaExpressionSourceName = owner.LuaExpressionSourceName; + Presets = new LuaExpressionScriptService().Presets + .Select(static preset => new LuaExpressionPresetViewModel(preset.Id, preset.DisplayName, preset.Description, preset.ScriptText)) + .ToList(); + SelectedPresetIndex = Presets.ToList().FindIndex(preset => string.Equals(preset.Id, owner.LuaExpressionPresetId, StringComparison.Ordinal)); + BrowseScriptCommand = new UiCommand(async (_, token) => await BrowseScriptAsync(token), _ => this.filePicker is not null); + ApplyCommand = new UiCommand((parameter, _) => + { + if (parameter is ExpressionToolViewModel viewModel) + { + owner.Expression = string.IsNullOrWhiteSpace(viewModel.Expression) ? "t" : viewModel.Expression; + owner.ApplyExpression = viewModel.ApplyExpression; + owner.LuaExpressionPresetId = viewModel.SelectedPreset?.Id ?? string.Empty; + owner.LuaExpressionSourceName = viewModel.LuaExpressionSourceName; + } + + return ValueTask.CompletedTask; + }); + } + public IAppLocalizer Localizer => owner.Localizer; + public IReadOnlyList Presets { get; } + + public LuaExpressionPresetViewModel? SelectedPreset => + SelectedPresetIndex >= 0 && SelectedPresetIndex < Presets.Count ? Presets[SelectedPresetIndex] : null; + + public int SelectedPresetIndex + { + get; + set + { + if (!SetProperty(ref field, value)) + { + return; + } + + OnPropertyChanged(nameof(SelectedPreset)); + if (SelectedPreset is { } preset) + { + Expression = preset.ScriptText; + LuaExpressionSourceName = preset.DisplayName; + StatusText = owner.Localizer.Format(LocalizedMessage.Create("Status.LuaExpressionPresetSelected", ("preset", preset.DisplayName))); + } + } + } = -1; + public string Expression { get; set => SetProperty(ref field, value); - } = owner.Expression; + } = "t"; public bool ApplyExpression { get; set => SetProperty(ref field, value); - } = owner.ApplyExpression; + } - public UiCommand ApplyCommand { get; } = new((parameter, _) => + public string LuaExpressionSourceName + { + get; + set => SetProperty(ref field, value); + } = string.Empty; + + public string StatusText + { + get; + private set => SetProperty(ref field, value); + } = string.Empty; + + public bool CanBrowseScript => filePicker is not null; + + public UiCommand BrowseScriptCommand { get; } + + public UiCommand ApplyCommand { get; } + + private async ValueTask BrowseScriptAsync(CancellationToken cancellationToken) { - if (parameter is ExpressionToolViewModel viewModel) + if (filePicker is null) { - owner.Expression = string.IsNullOrWhiteSpace(viewModel.Expression) ? "t" : viewModel.Expression; - owner.ApplyExpression = viewModel.ApplyExpression; + return; } - return ValueTask.CompletedTask; - }); + var path = await filePicker.PickLuaExpressionScriptAsync(cancellationToken); + if (string.IsNullOrWhiteSpace(path)) + { + return; + } + + var text = await File.ReadAllTextAsync(path, cancellationToken); + Expression = text; + LuaExpressionSourceName = Path.GetFileName(path); + SelectedPresetIndex = -1; + StatusText = owner.Localizer.Format(LocalizedMessage.Create("Status.LuaExpressionScriptLoaded", ("path", LuaExpressionSourceName))); + } } +public sealed record LuaExpressionPresetViewModel(string Id, string DisplayName, string Description, string ScriptText); + public sealed class TemplateNamesToolViewModel(MainWindowViewModel owner) : ObservableViewModel { public bool UseTemplateNames diff --git a/src/ChapterTool.Avalonia/Views/Controls/ExpressionEditor.axaml b/src/ChapterTool.Avalonia/Views/Controls/ExpressionEditor.axaml index e3157de..d76971d 100644 --- a/src/ChapterTool.Avalonia/Views/Controls/ExpressionEditor.axaml +++ b/src/ChapterTool.Avalonia/Views/Controls/ExpressionEditor.axaml @@ -1,11 +1,15 @@ + + + + + + - - + + HorizontalAlignment="Center" + VerticalAlignment="Center" + Foreground="{Binding Kind, Converter={StaticResource CompletionKindBrushConverter}}" /> + + Opacity="0.72" + TextTrimming="CharacterEllipsis" + VerticalAlignment="Center" /> + diff --git a/src/ChapterTool.Avalonia/Views/Controls/ExpressionEditor.axaml.cs b/src/ChapterTool.Avalonia/Views/Controls/ExpressionEditor.axaml.cs index 9dd0cbb..ec31d09 100644 --- a/src/ChapterTool.Avalonia/Views/Controls/ExpressionEditor.axaml.cs +++ b/src/ChapterTool.Avalonia/Views/Controls/ExpressionEditor.axaml.cs @@ -1,11 +1,14 @@ +using System.Globalization; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Primitives; using Avalonia.Data; +using Avalonia.Data.Converters; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Media; using Avalonia.Threading; +using Avalonia.VisualTree; using AvaloniaEdit; using AvaloniaEdit.Document; using AvaloniaEdit.Editing; @@ -15,6 +18,46 @@ namespace ChapterTool.Avalonia.Views.Controls; +public sealed class ExpressionCompletionKindBrushConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + var kind = value is ExpressionTokenKind tokenKind ? tokenKind : ExpressionTokenKind.Unknown; + return string.Equals(parameter?.ToString(), "icon", StringComparison.OrdinalIgnoreCase) + ? Icon(kind) + : Foreground(kind); + } + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => throw new NotSupportedException(); + + private static string Icon(ExpressionTokenKind kind) => kind switch + { + ExpressionTokenKind.Variable => "◈", + ExpressionTokenKind.Constant => "◆", + ExpressionTokenKind.Function => "ƒ", + ExpressionTokenKind.Keyword => "K", + ExpressionTokenKind.Snippet => "◇", + ExpressionTokenKind.String => "S", + ExpressionTokenKind.Number => "#", + _ => "•" + }; + + private static IBrush Foreground(ExpressionTokenKind kind) => kind switch + { + ExpressionTokenKind.Variable => Brush("#0550ae"), + ExpressionTokenKind.Constant => Brush("#8250df"), + ExpressionTokenKind.Function => Brush("#953800"), + ExpressionTokenKind.Keyword => Brush("#cf222e"), + ExpressionTokenKind.Snippet => Brush("#6f42c1"), + ExpressionTokenKind.String => Brush("#0a3069"), + ExpressionTokenKind.Number => Brush("#116329"), + _ => Brush("#57606a") + }; + + private static IBrush Brush(string color) => new SolidColorBrush(Color.Parse(color)); +} + + public sealed partial class ExpressionEditor : UserControl { public static readonly StyledProperty TextProperty = @@ -23,6 +66,9 @@ public sealed partial class ExpressionEditor : UserControl public static readonly StyledProperty LocalizerProperty = AvaloniaProperty.Register(nameof(Localizer)); + public static readonly StyledProperty EditorHeightProperty = + AvaloniaProperty.Register(nameof(EditorHeight), 25.6); + private readonly IExpressionAuthoringService authoringService = new ExpressionAuthoringService(); private readonly TextEditor editor; private readonly ExpressionColorizer colorizer; @@ -54,8 +100,8 @@ public ExpressionEditor() Background = Brushes.Transparent, Foreground = Brush("#24292f"), Padding = new Thickness(6, 3), - MinHeight = 25.6, - Height = 25.6, + MinHeight = EditorHeight, + Height = EditorHeight, HorizontalScrollBarVisibility = ScrollBarVisibility.Hidden, VerticalScrollBarVisibility = ScrollBarVisibility.Disabled, Document = new TextDocument(Text) @@ -90,6 +136,12 @@ public IAppLocalizer? Localizer set => SetValue(LocalizerProperty, value); } + public double EditorHeight + { + get => GetValue(EditorHeightProperty); + set => SetValue(EditorHeightProperty, value); + } + public IReadOnlyList CurrentCompletions { get; private set; } = []; public string EditorText => editor.Text; @@ -141,6 +193,12 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang { AnalyzeAndRender(); } + else if (change.Property == EditorHeightProperty && editor is not null) + { + var height = Math.Max(25.6, change.GetNewValue()); + editor.MinHeight = height; + editor.Height = height; + } } public void AcceptFirstCompletion() @@ -228,19 +286,31 @@ private void OnEditorFocusChanged(object? sender, RoutedEventArgs args) private void OnPointerPressed(object? sender, PointerPressedEventArgs args) { + if ((args.Source as Visual)?.FindAncestorOfType() is not null) + { + return; + } + Dispatcher.UIThread.Post(FocusInnerEditor); } private void OnWrapperGotFocus(object? sender, RoutedEventArgs args) { - FocusInnerEditor(); + if (!editor.IsKeyboardFocusWithin) + { + FocusInnerEditor(); + } } private void FocusInnerEditor() { editor.Focus(); editor.TextArea.Focus(); - editor.CaretOffset = Math.Clamp(editor.CaretOffset, 0, editor.Document.TextLength); + var caretOffset = Math.Clamp(editor.CaretOffset, 0, editor.Document.TextLength); + if (editor.CaretOffset != caretOffset) + { + editor.CaretOffset = caretOffset; + } } private void AnalyzeAndRender() @@ -270,7 +340,7 @@ private void RenderDiagnostics(ExpressionAnalysisResult result) private void RenderCompletionPopup() { - if (!shouldShowCompletion || !editor.IsKeyboardFocusWithin || CurrentCompletions.Count == 0) + if (!shouldShowCompletion || (!editor.IsKeyboardFocusWithin && !IsCompletionPopupInteractionActive()) || CurrentCompletions.Count == 0) { CloseCompletionPopup(); return; @@ -290,6 +360,12 @@ private void RenderCompletionPopup() CloseDiagnosticPopup(); } + private bool IsCompletionPopupInteractionActive() + { + return CompletionPopup.IsOpen + && CompletionPanel.IsPointerOver; + } + private void InsertCompletion(ExpressionCompletion completion) { editor.Document.Replace(completion.ReplacementStart, completion.ReplacementLength, completion.InsertText); @@ -466,8 +542,32 @@ private void OnCompletionSelectionChanged(object? sender, SelectionChangedEventA shouldShowCompletion = CurrentCompletions.Count > 0; } + private void OnCompletionListPointerPressed(object? sender, PointerPressedEventArgs args) + { + if (!CompletionPopup.IsOpen || !args.GetCurrentPoint(CompletionList).Properties.IsLeftButtonPressed) + { + return; + } + + var item = (args.Source as Visual)?.FindAncestorOfType(); + if (item?.DataContext is not ExpressionCompletion completion) + { + return; + } + + args.Handled = true; + CompletionList.SelectedItem = completion; + InsertCompletion(completion); + } + private void OnCompletionListDoubleTapped(object? sender, RoutedEventArgs args) { + if (!CompletionPopup.IsOpen) + { + return; + } + + args.Handled = true; AcceptSelectedCompletion(); } @@ -529,6 +629,9 @@ private static IBrush ForegroundFor(ExpressionTokenKind kind) => ExpressionTokenKind.Variable => Brush("#0550ae"), ExpressionTokenKind.Constant => Brush("#8250df"), ExpressionTokenKind.Function => Brush("#953800"), + ExpressionTokenKind.Keyword => Brush("#cf222e"), + ExpressionTokenKind.Snippet => Brush("#8250df"), + ExpressionTokenKind.String => Brush("#0a3069"), ExpressionTokenKind.Operator => Brush("#cf222e"), ExpressionTokenKind.Punctuation => Brush("#57606a"), ExpressionTokenKind.Number => Brush("#116329"), diff --git a/src/ChapterTool.Avalonia/Views/MainWindow.axaml b/src/ChapterTool.Avalonia/Views/MainWindow.axaml index 3e57a26..1c25026 100644 --- a/src/ChapterTool.Avalonia/Views/MainWindow.axaml +++ b/src/ChapterTool.Avalonia/Views/MainWindow.axaml @@ -569,7 +569,7 @@ Classes="optionCell" Grid.Row="2" Grid.Column="0" - ColumnDefinitions="78,78,*" + ColumnDefinitions="78,78,*,Auto" ColumnSpacing="6.4"> +