Merge remote-tracking branch 'origin/dev-homelab-20260414-1418' into dev
This commit is contained in:
commit
58308d74cc
118 changed files with 9120 additions and 2937 deletions
55
CLAUDE.md
55
CLAUDE.md
|
|
@ -38,38 +38,43 @@ The display `Service` registers with `forge.lthn.ai/core/go`'s service container
|
|||
|
||||
All Wails application APIs are abstracted behind interfaces in `interfaces.go` (`App`, `WindowManager`, `MenuManager`, `DialogManager`, etc.). The `wailsApp` adapter wraps the real Wails app. Tests inject a `mockApp` instead — see `mocks_test.go` and the `newServiceWithMockApp(t)` helper.
|
||||
|
||||
### Key files in pkg/display/
|
||||
### Package structure (pkg/)
|
||||
|
||||
| File | Responsibility |
|
||||
|------|---------------|
|
||||
| `display.go` | Service struct, lifecycle (`Startup`), window CRUD, screen queries, tiling/snapping/layout, workflow presets |
|
||||
| `window.go` | `WindowOption` functional options pattern, `Window` type alias for `application.WebviewWindowOptions` |
|
||||
| `window_state.go` | `WindowStateManager` — persists window position/size across restarts |
|
||||
| `layout.go` | `LayoutManager` — save/restore named window arrangements |
|
||||
| `events.go` | `WSEventManager` — WebSocket pub/sub for window/theme/screen events |
|
||||
| `interfaces.go` | Abstract interfaces + Wails adapter implementations |
|
||||
| `actions.go` | `ActionOpenWindow` IPC message type |
|
||||
| `menu.go` | Application menu construction |
|
||||
| `tray.go` | System tray setup |
|
||||
| `dialog.go` | File/directory dialogs |
|
||||
| `clipboard.go` | Clipboard read/write |
|
||||
| `notification.go` | System notifications |
|
||||
| `theme.go` | Dark/light mode detection |
|
||||
| `mocks_test.go` | Mock implementations of all interfaces for testing |
|
||||
| Package | Responsibility |
|
||||
|---------|---------------|
|
||||
| `display` | Orchestrator service — bridges sub-service IPC to WebSocket events, menu/tray setup, config persistence |
|
||||
| `window` | Window lifecycle, `Manager`, `StateManager` (position persistence), `LayoutManager` (named arrangements), tiling/snapping |
|
||||
| `menu` | Application menu construction via platform abstraction |
|
||||
| `systray` | System tray icon, tooltip, menu via platform abstraction |
|
||||
| `dialog` | File open/save, message, confirm, and prompt dialogs |
|
||||
| `clipboard` | Clipboard read/write/clear |
|
||||
| `notification` | System notifications with permission management |
|
||||
| `screen` | Screen/monitor queries (list, primary, at-point, work areas) |
|
||||
| `environment` | Theme detection (dark/light) and OS environment info |
|
||||
| `keybinding` | Global keyboard shortcut registration |
|
||||
| `contextmenu` | Named context menu registration and lifecycle |
|
||||
| `browser` | Open URLs and files in the default browser |
|
||||
| `dock` | macOS dock icon visibility and badge |
|
||||
| `lifecycle` | Application lifecycle events (start, terminate, suspend, resume) |
|
||||
| `webview` | Webview automation (eval, click, type, screenshot, DOM queries) |
|
||||
| `mcp` | MCP tool subsystem — exposes all services as Model Context Protocol tools |
|
||||
|
||||
### Patterns used throughout
|
||||
|
||||
- **Functional options**: `WindowOption` functions (`WindowName()`, `WindowTitle()`, `WindowWidth()`, etc.) configure `application.WebviewWindowOptions`
|
||||
- **Type alias**: `Window = application.WebviewWindowOptions` — direct alias, not a wrapper
|
||||
- **Platform abstraction**: Each sub-service defines a `Platform` interface and `PlatformWindow`/`PlatformTray`/etc. types; tests inject mocks
|
||||
- **Functional options**: `WindowOption` functions (`WithName()`, `WithTitle()`, `WithSize()`, etc.) configure `window.Window` descriptors
|
||||
- **IPC message bus**: Sub-services communicate via `core.QUERY`, `core.PERFORM`, and `core.ACTION` — display orchestrates and bridges to WebSocket events
|
||||
- **Event broadcasting**: `WSEventManager` uses gorilla/websocket with a buffered channel (`eventBuffer`) and per-client subscription filtering (supports `"*"` wildcard)
|
||||
- **Window lookup by name**: Most Service methods iterate `s.app.Window().GetAll()` and type-assert to `*application.WebviewWindow`, then match by `Name()`
|
||||
- **Error handling**: All errors use `coreerr.E(op, msg, err)` from `forge.lthn.ai/core/go-log` (aliased as `coreerr`), never `fmt.Errorf`
|
||||
- **File I/O**: Use `forge.lthn.ai/core/go-io` (`coreio.Local`) for filesystem operations, never `os.ReadFile`/`os.WriteFile`
|
||||
|
||||
## Testing
|
||||
|
||||
- Framework: `testify` (assert + require)
|
||||
- Pattern: `newServiceWithMockApp(t)` creates a `Service` with mock Wails app — no real window system needed
|
||||
- `newTestCore(t)` creates a real `core.Core` instance for integration-style tests
|
||||
- Some tests use `defer func() { recover() }()` to handle nil panics from mock methods that return nil pointers (e.g., `Dialog().Info()`)
|
||||
- Each sub-package has its own `*_test.go` with mock platform implementations
|
||||
- `pkg/window`: `NewManagerWithDir` / `NewStateManagerWithDir` / `NewLayoutManagerWithDir` accept custom config dirs for isolated tests
|
||||
- `pkg/display`: `newTestCore(t)` creates a real `core.Core` instance for integration-style tests
|
||||
- Sub-services use `mock_platform.go` or `mock_test.go` for platform mocks
|
||||
|
||||
## CI/CD
|
||||
|
||||
|
|
@ -82,9 +87,13 @@ Both use reusable workflows from `core/go-devops`.
|
|||
## Dependencies
|
||||
|
||||
- `forge.lthn.ai/core/go` — Core framework with service container and DI
|
||||
- `forge.lthn.ai/core/go-log` — Structured errors (`coreerr.E()`)
|
||||
- `forge.lthn.ai/core/go-io` — Filesystem abstraction (`coreio.Local`)
|
||||
- `forge.lthn.ai/core/config` — Configuration file management
|
||||
- `github.com/wailsapp/wails/v3` — Desktop app framework (alpha.74)
|
||||
- `github.com/gorilla/websocket` — WebSocket for real-time events
|
||||
- `github.com/stretchr/testify` — Test assertions
|
||||
- `github.com/modelcontextprotocol/go-sdk` — MCP tool registration
|
||||
|
||||
## Repository migration note
|
||||
|
||||
|
|
|
|||
440
docs/RFC-CORE-008-AGENT-EXPERIENCE.md
Normal file
440
docs/RFC-CORE-008-AGENT-EXPERIENCE.md
Normal file
|
|
@ -0,0 +1,440 @@
|
|||
# RFC-025: Agent Experience (AX) Design Principles
|
||||
|
||||
- **Status:** Draft
|
||||
- **Authors:** Snider, Cladius
|
||||
- **Date:** 2026-03-19
|
||||
- **Applies to:** All Core ecosystem packages (CoreGO, CorePHP, CoreTS, core-agent)
|
||||
|
||||
## Abstract
|
||||
|
||||
Agent Experience (AX) is a design paradigm for software systems where the primary code consumer is an AI agent, not a human developer. AX sits alongside User Experience (UX) and Developer Experience (DX) as the third era of interface design.
|
||||
|
||||
This RFC establishes AX as a formal design principle for the Core ecosystem and defines the conventions that follow from it.
|
||||
|
||||
## Motivation
|
||||
|
||||
As of early 2026, AI agents write, review, and maintain the majority of code in the Core ecosystem. The original author has not manually edited code (outside of Core struct design) since October 2025. Code is processed semantically — agents reason about intent, not characters.
|
||||
|
||||
Design patterns inherited from the human-developer era optimise for the wrong consumer:
|
||||
|
||||
- **Short names** save keystrokes but increase semantic ambiguity
|
||||
- **Functional option chains** are fluent for humans but opaque for agents tracing configuration
|
||||
- **Error-at-every-call-site** produces 50% boilerplate that obscures intent
|
||||
- **Generic type parameters** force agents to carry type context that the runtime already has
|
||||
- **Panic-hiding conventions** (`Must*`) create implicit control flow that agents must special-case
|
||||
|
||||
AX acknowledges this shift and provides principles for designing code, APIs, file structures, and conventions that serve AI agents as first-class consumers.
|
||||
|
||||
## The Three Eras
|
||||
|
||||
| Era | Primary Consumer | Optimises For | Key Metric |
|
||||
|-----|-----------------|---------------|------------|
|
||||
| UX | End users | Discoverability, forgiveness, visual clarity | Task completion time |
|
||||
| DX | Developers | Typing speed, IDE support, convention familiarity | Time to first commit |
|
||||
| AX | AI agents | Predictability, composability, semantic navigation | Correct-on-first-pass rate |
|
||||
|
||||
AX does not replace UX or DX. End users still need good UX. Developers still need good DX. But when the primary code author and maintainer is an AI agent, the codebase should be designed for that consumer first.
|
||||
|
||||
## Principles
|
||||
|
||||
### 1. Predictable Names Over Short Names
|
||||
|
||||
Names are tokens that agents pattern-match across languages and contexts. Abbreviations introduce mapping overhead.
|
||||
|
||||
```
|
||||
Config not Cfg
|
||||
Service not Srv
|
||||
Embed not Emb
|
||||
Error not Err (as a subsystem name; err for local variables is fine)
|
||||
Options not Opts
|
||||
```
|
||||
|
||||
**Rule:** If a name would require a comment to explain, it is too short.
|
||||
|
||||
**Exception:** Industry-standard abbreviations that are universally understood (`HTTP`, `URL`, `ID`, `IPC`, `I18n`) are acceptable. The test: would an agent trained on any mainstream language recognise it without context?
|
||||
|
||||
### 2. Comments as Usage Examples
|
||||
|
||||
The function signature tells WHAT. The comment shows HOW with real values.
|
||||
|
||||
```go
|
||||
// Detect the project type from files present
|
||||
setup.Detect("/path/to/project")
|
||||
|
||||
// Set up a workspace with auto-detected template
|
||||
setup.Run(setup.Options{Path: ".", Template: "auto"})
|
||||
|
||||
// Scaffold a PHP module workspace
|
||||
setup.Run(setup.Options{Path: "./my-module", Template: "php"})
|
||||
```
|
||||
|
||||
**Rule:** If a comment restates what the type signature already says, delete it. If a comment shows a concrete usage with realistic values, keep it.
|
||||
|
||||
**Rationale:** Agents learn from examples more effectively than from descriptions. A comment like "Run executes the setup process" adds zero information. A comment like `setup.Run(setup.Options{Path: ".", Template: "auto"})` teaches an agent exactly how to call the function.
|
||||
|
||||
### 3. Path Is Documentation
|
||||
|
||||
File and directory paths should be self-describing. An agent navigating the filesystem should understand what it is looking at without reading a README.
|
||||
|
||||
```
|
||||
flow/deploy/to/homelab.yaml — deploy TO the homelab
|
||||
flow/deploy/from/github.yaml — deploy FROM GitHub
|
||||
flow/code/review.yaml — code review flow
|
||||
template/file/go/struct.go.tmpl — Go struct file template
|
||||
template/dir/workspace/php/ — PHP workspace scaffold
|
||||
```
|
||||
|
||||
**Rule:** If an agent needs to read a file to understand what a directory contains, the directory naming has failed.
|
||||
|
||||
**Corollary:** The unified path convention (folder structure = HTTP route = CLI command = test path) is AX-native. One path, every surface.
|
||||
|
||||
### 4. Templates Over Freeform
|
||||
|
||||
When an agent generates code from a template, the output is constrained to known-good shapes. When an agent writes freeform, the output varies.
|
||||
|
||||
```go
|
||||
// Template-driven — consistent output
|
||||
lib.RenderFile("php/action", data)
|
||||
lib.ExtractDir("php", targetDir, data)
|
||||
|
||||
// Freeform — variance in output
|
||||
"write a PHP action class that..."
|
||||
```
|
||||
|
||||
**Rule:** For any code pattern that recurs, provide a template. Templates are guardrails for agents.
|
||||
|
||||
**Scope:** Templates apply to file generation, workspace scaffolding, config generation, and commit messages. They do NOT apply to novel logic — agents should write business logic freeform with the domain knowledge available.
|
||||
|
||||
### 5. Declarative Over Imperative
|
||||
|
||||
Agents reason better about declarations of intent than sequences of operations.
|
||||
|
||||
```yaml
|
||||
# Declarative — agent sees what should happen
|
||||
steps:
|
||||
- name: build
|
||||
flow: tools/docker-build
|
||||
with:
|
||||
context: "{{ .app_dir }}"
|
||||
image_name: "{{ .image_name }}"
|
||||
|
||||
- name: deploy
|
||||
flow: deploy/with/docker
|
||||
with:
|
||||
host: "{{ .host }}"
|
||||
```
|
||||
|
||||
```go
|
||||
// Imperative — agent must trace execution
|
||||
cmd := exec.Command("docker", "build", "--platform", "linux/amd64", "-t", imageName, ".")
|
||||
cmd.Dir = appDir
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("docker build: %w", err)
|
||||
}
|
||||
```
|
||||
|
||||
**Rule:** Orchestration, configuration, and pipeline logic should be declarative (YAML/JSON). Implementation logic should be imperative (Go/PHP/TS). The boundary is: if an agent needs to compose or modify the logic, make it declarative.
|
||||
|
||||
### 6. Universal Types (Core Primitives)
|
||||
|
||||
Every component in the ecosystem accepts and returns the same primitive types. An agent processing any level of the tree sees identical shapes.
|
||||
|
||||
```go
|
||||
// Universal contract
|
||||
setup.Run(core.Options{Path: ".", Template: "auto"})
|
||||
brain.New(core.Options{Name: "openbrain"})
|
||||
deploy.Run(core.Options{Flow: "deploy/to/homelab"})
|
||||
|
||||
// Fractal — Core itself is a Service
|
||||
core.New(core.Options{
|
||||
Services: []core.Service{
|
||||
process.New(core.Options{Name: "process"}),
|
||||
brain.New(core.Options{Name: "brain"}),
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
**Core primitive types:**
|
||||
|
||||
| Type | Purpose |
|
||||
|------|---------|
|
||||
| `core.Options` | Input configuration (what you want) |
|
||||
| `core.Config` | Runtime settings (what is active) |
|
||||
| `core.Data` | Embedded or stored content |
|
||||
| `core.Service` | A managed component with lifecycle |
|
||||
| `core.Result[T]` | Return value with OK/fail state |
|
||||
|
||||
**What this replaces:**
|
||||
|
||||
| Go Convention | Core AX | Why |
|
||||
|--------------|---------|-----|
|
||||
| `func With*(v) Option` | `core.Options{Field: v}` | Struct literal is parseable; option chain requires tracing |
|
||||
| `func Must*(v) T` | `core.Result[T]` | No hidden panics; errors flow through Core |
|
||||
| `func *For[T](c) T` | `c.Service("name")` | String lookup is greppable; generics require type context |
|
||||
| `val, err :=` everywhere | Single return via `core.Result` | Intent not obscured by error handling |
|
||||
| `_ = err` | Never needed | Core handles all errors internally |
|
||||
|
||||
### 7. Directory as Semantics
|
||||
|
||||
The directory structure tells an agent the intent before it reads a word. Top-level directories are semantic categories, not organisational bins.
|
||||
|
||||
```
|
||||
plans/
|
||||
├── code/ # Pure primitives — read for WHAT exists
|
||||
├── project/ # Products — read for WHAT we're building and WHY
|
||||
└── rfc/ # Contracts — read for constraints and rules
|
||||
```
|
||||
|
||||
**Rule:** An agent should know what kind of document it's reading from the path alone. `code/core/go/io/RFC.md` = a lib primitive spec. `project/ofm/RFC.md` = a product spec that cross-references code/. `rfc/snider/borg/RFC-BORG-006-SMSG-FORMAT.md` = an immutable contract for the Borg SMSG protocol.
|
||||
|
||||
**Corollary:** The three-way split (code/project/rfc) extends principle 3 (Path Is Documentation) from files to entire subtrees. The path IS the metadata.
|
||||
|
||||
### 8. Lib Never Imports Consumer
|
||||
|
||||
Dependency flows one direction. Libraries define primitives. Consumers compose from them. A new feature in a consumer can never break a library.
|
||||
|
||||
```
|
||||
code/core/go/* → lib tier (stable foundation)
|
||||
code/core/agent/ → consumer tier (composes from go/*)
|
||||
code/core/cli/ → consumer tier (composes from go/*)
|
||||
code/core/gui/ → consumer tier (composes from go/*)
|
||||
```
|
||||
|
||||
**Rule:** If package A is in `go/` and package B is in the consumer tier, B may import A but A must never import B. The repo naming convention enforces this: `go-{name}` = lib, bare `{name}` = consumer.
|
||||
|
||||
**Why this matters for agents:** When an agent is dispatched to implement a feature in `core/agent`, it can freely import from `go-io`, `go-scm`, `go-process`. But if an agent is dispatched to `go-io`, it knows its changes are foundational — every consumer depends on it, so the contract must not break.
|
||||
|
||||
### 9. Issues Are N+(rounds) Deep
|
||||
|
||||
Problems in code and specs are layered. Surface issues mask deeper issues. Fixing the surface reveals the next layer. This is not a failure mode — it is the discovery process.
|
||||
|
||||
```
|
||||
Pass 1: Find 16 issues (surface — naming, imports, obvious errors)
|
||||
Pass 2: Find 11 issues (structural — contradictions, missing types)
|
||||
Pass 3: Find 5 issues (architectural — signature mismatches, registration gaps)
|
||||
Pass 4: Find 4 issues (contract — cross-spec API mismatches)
|
||||
Pass 5: Find 2 issues (mechanical — path format, nil safety)
|
||||
Pass N: Findings are trivial → spec/code is complete
|
||||
```
|
||||
|
||||
**Rule:** Iteration is required, not a failure. Each pass sees what the previous pass could not, because the context changed. An agent dispatched with the same task on the same repo will find different things each time — this is correct behaviour.
|
||||
|
||||
**Corollary:** The cheapest model should do the most passes (surface work). The frontier model should arrive last, when only deep issues remain. Tiered iteration: grunt model grinds → mid model pre-warms → frontier model polishes.
|
||||
|
||||
**Anti-pattern:** One-shot generation expecting valid output. No model, no human, produces correct-on-first-pass for non-trivial work. Expecting it wastes the first pass on surface issues that a cheaper pass would have caught.
|
||||
|
||||
### 10. CLI Tests as Artifact Validation
|
||||
|
||||
Unit tests verify the code. CLI tests verify the binary. The directory structure IS the command structure — path maps to command, Taskfile runs the test.
|
||||
|
||||
```
|
||||
tests/cli/
|
||||
├── core/
|
||||
│ └── lint/
|
||||
│ ├── Taskfile.yaml ← test `core-lint` (root)
|
||||
│ ├── run/
|
||||
│ │ ├── Taskfile.yaml ← test `core-lint run`
|
||||
│ │ └── fixtures/
|
||||
│ ├── go/
|
||||
│ │ ├── Taskfile.yaml ← test `core-lint go`
|
||||
│ │ └── fixtures/
|
||||
│ └── security/
|
||||
│ ├── Taskfile.yaml ← test `core-lint security`
|
||||
│ └── fixtures/
|
||||
```
|
||||
|
||||
**Rule:** Every CLI command has a matching `tests/cli/{path}/Taskfile.yaml`. The Taskfile runs the compiled binary against fixtures with known inputs and validates the output. If the CLI test passes, the underlying actions work — because CLI commands call actions, MCP tools call actions, API endpoints call actions. Test the CLI, trust the rest.
|
||||
|
||||
**Pattern:**
|
||||
|
||||
```yaml
|
||||
# tests/cli/core/lint/go/Taskfile.yaml
|
||||
version: '3'
|
||||
tasks:
|
||||
test:
|
||||
cmds:
|
||||
- core-lint go --output json fixtures/ > /tmp/result.json
|
||||
- jq -e '.findings | length > 0' /tmp/result.json
|
||||
- jq -e '.summary.passed == false' /tmp/result.json
|
||||
```
|
||||
|
||||
**Why this matters for agents:** An agent can validate its own work by running `task test` in the matching `tests/cli/` directory. No test framework, no mocking, no setup — just the binary, fixtures, and `jq` assertions. The agent builds the binary, runs the test, sees the result. If it fails, the agent can read the fixture, read the output, and fix the code.
|
||||
|
||||
**Corollary:** Fixtures are planted bugs. Each fixture file has a known issue that the linter must find. If the linter doesn't find it, the test fails. Fixtures are the spec for what the tool must detect — they ARE the test cases, not descriptions of test cases.
|
||||
|
||||
## Applying AX to Existing Patterns
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
# AX-native: path describes content
|
||||
core/agent/
|
||||
├── go/ # Go source
|
||||
├── php/ # PHP source
|
||||
├── ui/ # Frontend source
|
||||
├── claude/ # Claude Code plugin
|
||||
└── codex/ # Codex plugin
|
||||
|
||||
# Not AX: generic names requiring README
|
||||
src/
|
||||
├── lib/
|
||||
├── utils/
|
||||
└── helpers/
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```go
|
||||
// AX-native: errors are infrastructure, not application logic
|
||||
svc := c.Service("brain")
|
||||
cfg := c.Config().Get("database.host")
|
||||
// Errors logged by Core. Code reads like a spec.
|
||||
|
||||
// Not AX: errors dominate the code
|
||||
svc, err := c.ServiceFor[brain.Service]()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get brain service: %w", err)
|
||||
}
|
||||
cfg, err := c.Config().Get("database.host")
|
||||
if err != nil {
|
||||
_ = err // silenced because "it'll be fine"
|
||||
}
|
||||
```
|
||||
|
||||
### API Design
|
||||
|
||||
```go
|
||||
// AX-native: one shape, every surface
|
||||
core.New(core.Options{
|
||||
Name: "my-app",
|
||||
Services: []core.Service{...},
|
||||
Config: core.Config{...},
|
||||
})
|
||||
|
||||
// Not AX: multiple patterns for the same thing
|
||||
core.New(
|
||||
core.WithName("my-app"),
|
||||
core.WithService(factory1),
|
||||
core.WithService(factory2),
|
||||
core.WithConfig(cfg),
|
||||
)
|
||||
```
|
||||
|
||||
## The Plans Convention — AX Development Lifecycle
|
||||
|
||||
The `plans/` directory structure encodes a development methodology designed for how generative AI actually works: iterative refinement across structured phases, not one-shot generation.
|
||||
|
||||
### The Three-Way Split
|
||||
|
||||
```
|
||||
plans/
|
||||
├── project/ # 1. WHAT and WHY — start here
|
||||
├── rfc/ # 2. CONSTRAINTS — immutable contracts
|
||||
└── code/ # 3. HOW — implementation specs
|
||||
```
|
||||
|
||||
Each directory is a phase. Work flows from project → rfc → code. Each transition forces a refinement pass — you cannot write a code spec without discovering gaps in the project spec, and you cannot write an RFC without discovering assumptions in both.
|
||||
|
||||
**Three places for data that can't be written simultaneously = three guaranteed iterations of "actually, this needs changing."** Refinement is baked into the structure, not bolted on as a review step.
|
||||
|
||||
### Phase 1: Project (Vision)
|
||||
|
||||
Start with `project/`. No code exists yet. Define:
|
||||
- What the product IS and who it serves
|
||||
- What existing primitives it consumes (cross-ref to `code/`)
|
||||
- What constraints it operates under (cross-ref to `rfc/`)
|
||||
|
||||
This is where creativity lives. Map features to building blocks. Connect systems. The project spec is integrative — it references everything else.
|
||||
|
||||
### Phase 2: RFC (Contracts)
|
||||
|
||||
Extract the immutable rules into `rfc/`. These are constraints that don't change with implementation:
|
||||
- Wire formats, protocols, hash algorithms
|
||||
- Security properties that must hold
|
||||
- Compatibility guarantees
|
||||
|
||||
RFCs are numbered per component (`RFC-BORG-006-SMSG-FORMAT.md`) and never modified after acceptance. If the contract changes, write a new RFC.
|
||||
|
||||
### Phase 3: Code (Implementation Specs)
|
||||
|
||||
Define the implementation in `code/`. Each component gets an RFC.md that an agent can implement from:
|
||||
- Struct definitions (the DTOs — see principle 6)
|
||||
- Method signatures and behaviour
|
||||
- Error conditions and edge cases
|
||||
- Cross-references to other code/ specs
|
||||
|
||||
The code spec IS the product. Write the spec → dispatch to an agent → review output → iterate.
|
||||
|
||||
### Pre-Launch: Alignment Protocol
|
||||
|
||||
Before dispatching for implementation, verify spec-model alignment:
|
||||
|
||||
```
|
||||
1. REVIEW — The implementation model (Codex/Jules) reads the spec
|
||||
and reports missing elements. This surfaces the delta between
|
||||
the model's training and the spec's assumptions.
|
||||
|
||||
"I need X, Y, Z to implement this" is the model saying
|
||||
"I hear you but I'm missing context" — without asking.
|
||||
|
||||
2. ADJUST — Update the spec to close the gaps. Add examples,
|
||||
clarify ambiguities, provide the context the model needs.
|
||||
This is shared alignment, not compromise.
|
||||
|
||||
3. VERIFY — A different model (or sub-agent) reviews the adjusted
|
||||
spec without the planner's bias. Fresh eyes on the contract.
|
||||
"Does this make sense to someone who wasn't in the room?"
|
||||
|
||||
4. READY — When the review findings are trivial or deployment-
|
||||
related (not architectural), the spec is ready to dispatch.
|
||||
```
|
||||
|
||||
### Implementation: Iterative Dispatch
|
||||
|
||||
Same prompt, multiple runs. Each pass sees deeper because the context evolved:
|
||||
|
||||
```
|
||||
Round 1: Build features (the obvious gaps)
|
||||
Round 2: Write tests (verify what was built)
|
||||
Round 3: Harden security (what can go wrong?)
|
||||
Round 4: Next RFC section (what's still missing?)
|
||||
Round N: Findings are trivial → implementation is complete
|
||||
```
|
||||
|
||||
Re-running is not failure. It is the process. Each pass changes the codebase, which changes what the next pass can see. The iteration IS the refinement.
|
||||
|
||||
### Post-Implementation: Auto-Documentation
|
||||
|
||||
The QA/verify chain produces artefacts that feed forward:
|
||||
- Test results document the contract (what works, what doesn't)
|
||||
- Coverage reports surface untested paths
|
||||
- Diff summaries prep the changelog for the next release
|
||||
- Doc site updates from the spec (the spec IS the documentation)
|
||||
|
||||
The output of one cycle is the input to the next. The plans repo stays current because the specs drive the code, not the other way round.
|
||||
|
||||
## Compatibility
|
||||
|
||||
AX conventions are valid, idiomatic Go/PHP/TS. They do not require language extensions, code generation, or non-standard tooling. An AX-designed codebase compiles, tests, and deploys with standard toolchains.
|
||||
|
||||
The conventions diverge from community patterns (functional options, Must/For, etc.) but do not violate language specifications. This is a style choice, not a fork.
|
||||
|
||||
## Adoption
|
||||
|
||||
AX applies to all new code in the Core ecosystem. Existing code migrates incrementally as it is touched — no big-bang rewrite.
|
||||
|
||||
Priority order:
|
||||
1. **Public APIs** (package-level functions, struct constructors)
|
||||
2. **File structure** (path naming, template locations)
|
||||
3. **Internal fields** (struct field names, local variables)
|
||||
|
||||
## References
|
||||
|
||||
- dAppServer unified path convention (2024)
|
||||
- CoreGO DTO pattern refactor (2026-03-18)
|
||||
- Core primitives design (2026-03-19)
|
||||
- Go Proverbs, Rob Pike (2015) — AX provides an updated lens
|
||||
|
||||
## Changelog
|
||||
|
||||
- 2026-03-19: Initial draft
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
module github.com/wailsapp/wails/v3
|
||||
|
||||
go 1.24
|
||||
go 1.26.0
|
||||
|
|
|
|||
9
docs/ref/wails-v3/src/application/assets/index.html
Normal file
9
docs/ref/wails-v3/src/application/assets/index.html
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Wails Assets Placeholder</title>
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
</html>
|
||||
15
go.mod
15
go.mod
|
|
@ -3,20 +3,23 @@ module dappco.re/go/core/gui
|
|||
go 1.26.0
|
||||
|
||||
require (
|
||||
dappco.re/go/core/config v0.1.8
|
||||
dappco.re/go/core v0.3.3
|
||||
dappco.re/go/core/webview v0.1.7
|
||||
forge.lthn.ai/core/config v0.1.8
|
||||
forge.lthn.ai/core/go v0.3.3
|
||||
forge.lthn.ai/core/go-io v0.1.7
|
||||
forge.lthn.ai/core/go-log v0.0.4
|
||||
forge.lthn.ai/core/go-webview v0.1.7
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/modelcontextprotocol/go-sdk v1.4.1
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.74
|
||||
)
|
||||
|
||||
replace github.com/wailsapp/wails/v3 => ./stubs/wails/v3
|
||||
replace github.com/wailsapp/wails/v3 => ./stubs/wails
|
||||
|
||||
require (
|
||||
dappco.re/go/core/io v0.1.7 // indirect
|
||||
dappco.re/go/core/log v0.0.4 // indirect
|
||||
dappco.re/go/core v0.8.0-alpha.1 // indirect
|
||||
dappco.re/go/core/io v0.2.0 // indirect
|
||||
dappco.re/go/core/log v0.1.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
|
|
|
|||
33
go.sum
33
go.sum
|
|
@ -1,7 +1,9 @@
|
|||
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||
dappco.re/go/core v0.3.3 h1:TaE7/SObQ3YEZj4ov1BAXWwLJEIttkrxzncVGewR3Bs=
|
||||
dappco.re/go/core v0.3.3/go.mod h1:Cp4ac25pghvO2iqOu59t1GyngTKVOzKB5/VPdhRi9CQ=
|
||||
forge.lthn.ai/Snider/Borg v0.3.1/go.mod h1:Z7DJD0yHXsxSyM7Mjl6/g4gH1NBsIz44Bf5AFlV76Wg=
|
||||
dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk=
|
||||
dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
|
||||
dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4=
|
||||
dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E=
|
||||
dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc=
|
||||
dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs=
|
||||
forge.lthn.ai/core/config v0.1.8 h1:xP2hys7T94QGVF/OTh84/Zr5Dm/dL/0vzjht8zi+LOg=
|
||||
forge.lthn.ai/core/config v0.1.8/go.mod h1:8epZrkwoCt+5ayrqdinOUU/+w6UoxOyv9ZrdgVOgYfQ=
|
||||
forge.lthn.ai/core/go v0.3.3 h1:kYYZ2nRYy0/Be3cyuLJspRjLqTMxpckVyhb/7Sw2gd0=
|
||||
|
|
@ -13,22 +15,8 @@ forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
|
|||
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
|
||||
forge.lthn.ai/core/go-webview v0.1.7 h1:9+aEHeAvNcPX8Zwr+UGu0/T+menRm5T1YOmqZ9dawDc=
|
||||
forge.lthn.ai/core/go-webview v0.1.7/go.mod h1:5n1tECD1wBV/uFZRY9ZjfPFO5TYZrlaR3mQFwvO2nek=
|
||||
github.com/ProtonMail/go-crypto v1.4.0/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21/go.mod h1:UUxgWxofmOdAMuqEsSppbDtGKLfR04HGsD0HXzvhI1k=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12/go.mod h1:v2pNpJbRNl4vEUWEh5ytQok0zACAKfdmKS51Hotc3pQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20/go.mod h1:4TLZCmVJDM3FOu5P5TJP0zOlu9zWgDWU7aUxWbr+rcw=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1/go.mod h1:qXVal5H0ChqXP63t6jze5LmFalc7+ZE7wOdLtZ0LCP0=
|
||||
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
|
|
@ -41,22 +29,18 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
|||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
||||
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc=
|
||||
github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
||||
|
|
@ -65,7 +49,6 @@ github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
|||
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
|
||||
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
|
|
@ -74,7 +57,6 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
|||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
|
|
@ -83,11 +65,8 @@ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zI
|
|||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
|
|
|
|||
1
internal/wails3
Submodule
1
internal/wails3
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit bb4fbf95744fafe5acf84e143a419bfffc2159e6
|
||||
|
|
@ -1,10 +1,9 @@
|
|||
// pkg/browser/register.go
|
||||
package browser
|
||||
|
||||
import "forge.lthn.ai/core/go/pkg/core"
|
||||
|
||||
// Register creates a factory closure that captures the Platform adapter.
|
||||
// The returned function has the signature WithService requires: func(*Core) (any, error).
|
||||
// Register(p) binds the browser service to a Core instance.
|
||||
// core.WithService(browser.Register(wailsBrowser))
|
||||
func Register(p Platform) func(*core.Core) (any, error) {
|
||||
return func(c *core.Core) (any, error) {
|
||||
return &Service{
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// pkg/browser/service.go
|
||||
package browser
|
||||
|
||||
import (
|
||||
|
|
@ -7,29 +6,22 @@ import (
|
|||
"forge.lthn.ai/core/go/pkg/core"
|
||||
)
|
||||
|
||||
// Options holds configuration for the browser service.
|
||||
type Options struct{}
|
||||
|
||||
// Service is a core.Service that delegates browser/file-open operations
|
||||
// to the platform. It is stateless — no queries, no actions.
|
||||
type Service struct {
|
||||
*core.ServiceRuntime[Options]
|
||||
platform Platform
|
||||
}
|
||||
|
||||
// OnStartup registers IPC handlers.
|
||||
func (s *Service) OnStartup(ctx context.Context) error {
|
||||
s.Core().RegisterTask(s.handleTask)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleIPCEvents is auto-discovered and registered by core.WithService.
|
||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Task Handlers ---
|
||||
|
||||
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
||||
switch t := t.(type) {
|
||||
case TaskOpenURL:
|
||||
|
|
|
|||
|
|
@ -4,9 +4,15 @@ package clipboard
|
|||
// QueryText reads the clipboard. Result: ClipboardContent
|
||||
type QueryText struct{}
|
||||
|
||||
// QueryImage reads image data from the clipboard. Result: ImageContent
|
||||
type QueryImage struct{}
|
||||
|
||||
// TaskSetText writes text to the clipboard. Result: bool (success)
|
||||
type TaskSetText struct{ Text string }
|
||||
|
||||
// TaskSetImage writes image data to the clipboard. Result: bool (success)
|
||||
type TaskSetImage struct{ Data []byte }
|
||||
|
||||
// TaskClear clears the clipboard. Result: bool (success)
|
||||
type TaskClear struct{}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,27 +9,20 @@ type Platform interface {
|
|||
SetText(text string) bool
|
||||
}
|
||||
|
||||
// ImagePlatform is an optional extension for clipboard backends that support images.
|
||||
type ImagePlatform interface {
|
||||
Image() ([]byte, bool)
|
||||
SetImage(data []byte) bool
|
||||
}
|
||||
|
||||
// ClipboardContent is the result of QueryText.
|
||||
type ClipboardContent struct {
|
||||
Text string `json:"text"`
|
||||
HasContent bool `json:"hasContent"`
|
||||
}
|
||||
|
||||
// imageReader is an optional clipboard capability for image reads.
|
||||
type imageReader interface {
|
||||
Image() ([]byte, bool)
|
||||
}
|
||||
|
||||
// imageWriter is an optional clipboard capability for image writes.
|
||||
type imageWriter interface {
|
||||
SetImage(data []byte) bool
|
||||
}
|
||||
|
||||
// encodeImageContent converts raw bytes to transport-safe clipboard image content.
|
||||
func encodeImageContent(data []byte) ClipboardImageContent {
|
||||
return ClipboardImageContent{
|
||||
Base64: base64.StdEncoding.EncodeToString(data),
|
||||
MimeType: "image/png",
|
||||
HasContent: len(data) > 0,
|
||||
}
|
||||
// ImageContent is the result of QueryImage.
|
||||
type ImageContent struct {
|
||||
Data []byte `json:"data"`
|
||||
HasImage bool `json:"hasImage"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,22 +4,19 @@ package clipboard
|
|||
import (
|
||||
"context"
|
||||
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"forge.lthn.ai/core/go/pkg/core"
|
||||
)
|
||||
|
||||
// Options configures the clipboard service.
|
||||
// Use: core.WithService(clipboard.Register(platform))
|
||||
type Options struct{}
|
||||
|
||||
// Service manages clipboard operations via Core queries and tasks.
|
||||
// Use: svc := &clipboard.Service{}
|
||||
type Service struct {
|
||||
*core.ServiceRuntime[Options]
|
||||
platform Platform
|
||||
}
|
||||
|
||||
// Register creates a Core service factory for the clipboard backend.
|
||||
// Use: core.New(core.WithService(clipboard.Register(platform)))
|
||||
// Register(p) binds the clipboard service to a Core instance.
|
||||
// c.WithService(clipboard.Register(wailsClipboard))
|
||||
func Register(p Platform) func(*core.Core) (any, error) {
|
||||
return func(c *core.Core) (any, error) {
|
||||
return &Service{
|
||||
|
|
@ -29,15 +26,12 @@ func Register(p Platform) func(*core.Core) (any, error) {
|
|||
}
|
||||
}
|
||||
|
||||
// OnStartup registers clipboard handlers with Core.
|
||||
// Use: _ = svc.OnStartup(context.Background())
|
||||
func (s *Service) OnStartup(ctx context.Context) error {
|
||||
s.Core().RegisterQuery(s.handleQuery)
|
||||
s.Core().RegisterTask(s.handleTask)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleIPCEvents satisfies Core's IPC hook.
|
||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -48,11 +42,12 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
|
|||
text, ok := s.platform.Text()
|
||||
return ClipboardContent{Text: text, HasContent: ok && text != ""}, true, nil
|
||||
case QueryImage:
|
||||
if reader, ok := s.platform.(imageReader); ok {
|
||||
data, _ := reader.Image()
|
||||
return encodeImageContent(data), true, nil
|
||||
imgPlatform, ok := s.platform.(ImagePlatform)
|
||||
if !ok {
|
||||
return ImageContent{}, true, nil
|
||||
}
|
||||
return ClipboardImageContent{MimeType: "image/png"}, true, nil
|
||||
data, hasImage := imgPlatform.Image()
|
||||
return ImageContent{Data: data, HasImage: hasImage && len(data) > 0}, true, nil
|
||||
default:
|
||||
return nil, false, nil
|
||||
}
|
||||
|
|
@ -62,18 +57,18 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
|||
switch t := t.(type) {
|
||||
case TaskSetText:
|
||||
return s.platform.SetText(t.Text), true, nil
|
||||
case TaskClear:
|
||||
_ = s.platform.SetText("")
|
||||
if writer, ok := s.platform.(imageWriter); ok {
|
||||
// Best-effort clear for image-aware clipboard backends.
|
||||
_ = writer.SetImage(nil)
|
||||
}
|
||||
return true, true, nil
|
||||
case TaskSetImage:
|
||||
if writer, ok := s.platform.(imageWriter); ok {
|
||||
return writer.SetImage(t.Data), true, nil
|
||||
imgPlatform, ok := s.platform.(ImagePlatform)
|
||||
if !ok {
|
||||
return nil, true, coreerr.E("clipboard.handleTask", "clipboard image operations are not supported by this platform", nil)
|
||||
}
|
||||
return false, true, core.E("clipboard.handleTask", "clipboard image write not supported", nil)
|
||||
return imgPlatform.SetImage(t.Data), true, nil
|
||||
case TaskClear:
|
||||
success := s.platform.SetText("")
|
||||
if imgPlatform, ok := s.platform.(ImagePlatform); ok {
|
||||
success = imgPlatform.SetImage(nil) && success
|
||||
}
|
||||
return success, true, nil
|
||||
default:
|
||||
return nil, false, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,10 +11,10 @@ import (
|
|||
)
|
||||
|
||||
type mockPlatform struct {
|
||||
text string
|
||||
ok bool
|
||||
img []byte
|
||||
imgOk bool
|
||||
text string
|
||||
ok bool
|
||||
image []byte
|
||||
hasImage bool
|
||||
}
|
||||
|
||||
func (m *mockPlatform) Text() (string, bool) { return m.text, m.ok }
|
||||
|
|
@ -23,10 +23,10 @@ func (m *mockPlatform) SetText(text string) bool {
|
|||
m.ok = text != ""
|
||||
return true
|
||||
}
|
||||
func (m *mockPlatform) Image() ([]byte, bool) { return m.img, m.imgOk }
|
||||
func (m *mockPlatform) Image() ([]byte, bool) { return m.image, m.hasImage }
|
||||
func (m *mockPlatform) SetImage(data []byte) bool {
|
||||
m.img = data
|
||||
m.imgOk = len(data) > 0
|
||||
m.image = append([]byte(nil), data...)
|
||||
m.hasImage = len(data) > 0
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -89,32 +89,15 @@ func TestTaskClear_Good(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestQueryImage_Good(t *testing.T) {
|
||||
mock := &mockPlatform{img: []byte{1, 2, 3}, imgOk: true}
|
||||
c, err := core.New(
|
||||
core.WithService(Register(mock)),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
_, c := newTestService(t)
|
||||
_, handled, err := c.PERFORM(TaskSetImage{Data: []byte{0x89, 0x50, 0x4e, 0x47}})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
||||
assert.True(t, handled)
|
||||
|
||||
result, handled, err := c.QUERY(QueryImage{})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
image := result.(ClipboardImageContent)
|
||||
assert.True(t, image.HasContent)
|
||||
}
|
||||
|
||||
func TestTaskSetImage_Good(t *testing.T) {
|
||||
mock := &mockPlatform{}
|
||||
c, err := core.New(
|
||||
core.WithService(Register(mock)),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
||||
|
||||
_, handled, err := c.PERFORM(TaskSetImage{Data: []byte{9, 8, 7}})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.True(t, mock.imgOk)
|
||||
content := result.(ImageContent)
|
||||
assert.True(t, content.HasImage)
|
||||
assert.Equal(t, []byte{0x89, 0x50, 0x4e, 0x47}, content.Data)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,42 +1,57 @@
|
|||
// pkg/contextmenu/messages.go
|
||||
package contextmenu
|
||||
|
||||
import corego "dappco.re/go/core"
|
||||
import core "dappco.re/go/core"
|
||||
|
||||
// ErrMenuNotFound is returned when attempting to remove or get a menu
|
||||
// that does not exist in the registry.
|
||||
var ErrMenuNotFound = corego.NewError("contextmenu: menu not found")
|
||||
var ErrorMenuNotFound = core.E("contextmenu", "menu not found", nil)
|
||||
|
||||
// --- Queries ---
|
||||
|
||||
// QueryGet returns a single context menu by name. Result: *ContextMenuDef (nil if not found)
|
||||
// QueryGet returns a named context menu definition. Result: *ContextMenuDef (nil if not found)
|
||||
//
|
||||
// result := c.QUERY(contextmenu.QueryGet{Name: "editor"})
|
||||
type QueryGet struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// QueryList returns all registered context menus. Result: map[string]ContextMenuDef
|
||||
//
|
||||
// result := c.QUERY(contextmenu.QueryList{})
|
||||
type QueryList struct{}
|
||||
|
||||
// --- Tasks ---
|
||||
// QueryGetAll returns all context menus as a slice. Result: []ContextMenuDef
|
||||
//
|
||||
// result := c.QUERY(contextmenu.QueryGetAll{})
|
||||
type QueryGetAll struct{}
|
||||
|
||||
// TaskAdd registers a context menu. Result: nil
|
||||
// If a menu with the same name already exists it is replaced (remove + re-add).
|
||||
// TaskAdd registers a named context menu. Replaces if already exists.
|
||||
//
|
||||
// c.PERFORM(contextmenu.TaskAdd{Name: "editor", Menu: def})
|
||||
type TaskAdd struct {
|
||||
Name string `json:"name"`
|
||||
Menu ContextMenuDef `json:"menu"`
|
||||
}
|
||||
|
||||
// TaskRemove unregisters a context menu. Result: nil
|
||||
// Returns ErrMenuNotFound if the menu does not exist.
|
||||
// TaskRemove unregisters a context menu by name. Error: ErrorMenuNotFound if missing.
|
||||
//
|
||||
// c.PERFORM(contextmenu.TaskRemove{Name: "editor"})
|
||||
type TaskRemove struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// --- Actions ---
|
||||
// TaskUpdate replaces a context menu definition. Error: ErrorMenuNotFound if missing.
|
||||
//
|
||||
// c.PERFORM(contextmenu.TaskUpdate{Name: "editor", Menu: newDef})
|
||||
type TaskUpdate struct {
|
||||
Name string `json:"name"`
|
||||
Menu ContextMenuDef `json:"menu"`
|
||||
}
|
||||
|
||||
// TaskDestroy removes a context menu and releases resources.
|
||||
//
|
||||
// c.PERFORM(contextmenu.TaskDestroy{Name: "editor"})
|
||||
type TaskDestroy struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// ActionItemClicked is broadcast when a context menu item is clicked.
|
||||
// The Data field is populated from the CSS --custom-contextmenu-data property
|
||||
// on the element that triggered the context menu.
|
||||
type ActionItemClicked struct {
|
||||
MenuName string `json:"menuName"`
|
||||
ActionID string `json:"actionId"`
|
||||
|
|
|
|||
|
|
@ -1,16 +1,15 @@
|
|||
// pkg/contextmenu/register.go
|
||||
package contextmenu
|
||||
|
||||
import "forge.lthn.ai/core/go/pkg/core"
|
||||
|
||||
// Register creates a factory closure that captures the Platform adapter.
|
||||
// The returned function has the signature WithService requires: func(*Core) (any, error).
|
||||
// Register(p) binds the context menu service to a Core instance.
|
||||
// core.WithService(contextmenu.Register(wailsContextMenu))
|
||||
func Register(p Platform) func(*core.Core) (any, error) {
|
||||
return func(c *core.Core) (any, error) {
|
||||
return &Service{
|
||||
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
|
||||
platform: p,
|
||||
menus: make(map[string]ContextMenuDef),
|
||||
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
|
||||
platform: p,
|
||||
registeredMenus: make(map[string]ContextMenuDef),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,30 +4,24 @@ package contextmenu
|
|||
import (
|
||||
"context"
|
||||
|
||||
corego "dappco.re/go/core"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"forge.lthn.ai/core/go/pkg/core"
|
||||
)
|
||||
|
||||
// Options holds configuration for the context menu service.
|
||||
type Options struct{}
|
||||
|
||||
// Service is a core.Service managing context menus via IPC.
|
||||
// It maintains an in-memory registry of menus (map[string]ContextMenuDef)
|
||||
// and delegates platform-level registration to the Platform interface.
|
||||
type Service struct {
|
||||
*core.ServiceRuntime[Options]
|
||||
platform Platform
|
||||
menus map[string]ContextMenuDef
|
||||
platform Platform
|
||||
registeredMenus map[string]ContextMenuDef
|
||||
}
|
||||
|
||||
// OnStartup registers IPC handlers.
|
||||
func (s *Service) OnStartup(ctx context.Context) error {
|
||||
s.Core().RegisterQuery(s.handleQuery)
|
||||
s.Core().RegisterTask(s.handleTask)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleIPCEvents is auto-discovered and registered by core.WithService.
|
||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -40,24 +34,28 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
|
|||
return s.queryGet(q), true, nil
|
||||
case QueryList:
|
||||
return s.queryList(), true, nil
|
||||
case QueryGetAll:
|
||||
menus := make([]ContextMenuDef, 0, len(s.registeredMenus))
|
||||
for _, menu := range s.registeredMenus {
|
||||
menus = append(menus, menu)
|
||||
}
|
||||
return menus, true, nil
|
||||
default:
|
||||
return nil, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// queryGet returns a single menu definition by name, or nil if not found.
|
||||
func (s *Service) queryGet(q QueryGet) *ContextMenuDef {
|
||||
menu, ok := s.menus[q.Name]
|
||||
menu, ok := s.registeredMenus[q.Name]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return &menu
|
||||
}
|
||||
|
||||
// queryList returns a copy of all registered menus.
|
||||
func (s *Service) queryList() map[string]ContextMenuDef {
|
||||
result := make(map[string]ContextMenuDef, len(s.menus))
|
||||
for k, v := range s.menus {
|
||||
result := make(map[string]ContextMenuDef, len(s.registeredMenus))
|
||||
for k, v := range s.registeredMenus {
|
||||
result[k] = v
|
||||
}
|
||||
return result
|
||||
|
|
@ -71,6 +69,20 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
|||
return nil, true, s.taskAdd(t)
|
||||
case TaskRemove:
|
||||
return nil, true, s.taskRemove(t)
|
||||
case TaskUpdate:
|
||||
if _, exists := s.registeredMenus[t.Name]; !exists {
|
||||
return nil, true, ErrorMenuNotFound
|
||||
}
|
||||
_ = s.platform.Remove(t.Name)
|
||||
delete(s.registeredMenus, t.Name)
|
||||
return nil, true, s.taskAdd(TaskAdd{Name: t.Name, Menu: t.Menu})
|
||||
case TaskDestroy:
|
||||
if _, exists := s.registeredMenus[t.Name]; !exists {
|
||||
return nil, true, ErrorMenuNotFound
|
||||
}
|
||||
_ = s.platform.Remove(t.Name)
|
||||
delete(s.registeredMenus, t.Name)
|
||||
return nil, true, nil
|
||||
default:
|
||||
return nil, false, nil
|
||||
}
|
||||
|
|
@ -78,9 +90,9 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
|||
|
||||
func (s *Service) taskAdd(t TaskAdd) error {
|
||||
// If menu already exists, remove it first (replace semantics)
|
||||
if _, exists := s.menus[t.Name]; exists {
|
||||
if _, exists := s.registeredMenus[t.Name]; exists {
|
||||
_ = s.platform.Remove(t.Name)
|
||||
delete(s.menus, t.Name)
|
||||
delete(s.registeredMenus, t.Name)
|
||||
}
|
||||
|
||||
// Register on platform with a callback that broadcasts ActionItemClicked
|
||||
|
|
@ -92,23 +104,23 @@ func (s *Service) taskAdd(t TaskAdd) error {
|
|||
})
|
||||
})
|
||||
if err != nil {
|
||||
return corego.Wrap(err, "contextmenu.add", "platform add failed")
|
||||
return coreerr.E("contextmenu.taskAdd", "platform add failed", err)
|
||||
}
|
||||
|
||||
s.menus[t.Name] = t.Menu
|
||||
s.registeredMenus[t.Name] = t.Menu
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) taskRemove(t TaskRemove) error {
|
||||
if _, exists := s.menus[t.Name]; !exists {
|
||||
return ErrMenuNotFound
|
||||
if _, exists := s.registeredMenus[t.Name]; !exists {
|
||||
return ErrorMenuNotFound
|
||||
}
|
||||
|
||||
err := s.platform.Remove(t.Name)
|
||||
if err != nil {
|
||||
return corego.Wrap(err, "contextmenu.remove", "platform remove failed")
|
||||
return coreerr.E("contextmenu.taskRemove", "platform remove failed", err)
|
||||
}
|
||||
|
||||
delete(s.menus, t.Name)
|
||||
delete(s.registeredMenus, t.Name)
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -171,7 +171,7 @@ func TestTaskRemove_Bad_NotFound(t *testing.T) {
|
|||
|
||||
_, handled, err := c.PERFORM(TaskRemove{Name: "nonexistent"})
|
||||
assert.True(t, handled)
|
||||
assert.ErrorIs(t, err, ErrMenuNotFound)
|
||||
assert.ErrorIs(t, err, ErrorMenuNotFound)
|
||||
}
|
||||
|
||||
func TestQueryGet_Good(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,67 @@
|
|||
// pkg/dialog/messages.go
|
||||
package dialog
|
||||
|
||||
// TaskOpenFile shows an open file dialog. Result: []string (paths)
|
||||
type TaskOpenFile struct{ Opts OpenFileOptions }
|
||||
// TaskOpenFile opens a file picker dialog. Result: []string (selected paths)
|
||||
type TaskOpenFile struct{ Options OpenFileOptions }
|
||||
|
||||
// TaskSaveFile shows a save file dialog. Result: string (path)
|
||||
type TaskSaveFile struct{ Opts SaveFileOptions }
|
||||
// TaskOpenFileWithOptions opens a file picker, applying caller-supplied options. Result: []string
|
||||
// paths, _, err := c.PERFORM(TaskOpenFileWithOptions{Title: "Import", AllowMultiple: true})
|
||||
type TaskOpenFileWithOptions struct {
|
||||
Title string
|
||||
Directory string
|
||||
Filename string
|
||||
Filters []FileFilter
|
||||
AllowMultiple bool
|
||||
CanChooseDirectories bool
|
||||
CanChooseFiles bool
|
||||
ShowHiddenFiles bool
|
||||
}
|
||||
|
||||
// TaskOpenDirectory shows a directory picker. Result: string (path)
|
||||
type TaskOpenDirectory struct{ Opts OpenDirectoryOptions }
|
||||
// TaskSaveFile opens a save file dialog. Result: string (chosen path)
|
||||
type TaskSaveFile struct{ Options SaveFileOptions }
|
||||
|
||||
// TaskMessageDialog shows a message dialog. Result: string (button clicked)
|
||||
type TaskMessageDialog struct{ Opts MessageDialogOptions }
|
||||
// TaskSaveFileWithOptions opens a save dialog with caller-supplied options. Result: string
|
||||
// path, _, err := c.PERFORM(TaskSaveFileWithOptions{Title: "Export", Filename: "out.csv"})
|
||||
type TaskSaveFileWithOptions struct {
|
||||
Title string
|
||||
Directory string
|
||||
Filename string
|
||||
Filters []FileFilter
|
||||
}
|
||||
|
||||
// TaskOpenDirectory opens a directory picker. Result: string
|
||||
type TaskOpenDirectory struct{ Options OpenDirectoryOptions }
|
||||
|
||||
// TaskMessageDialog opens an arbitrary message dialog. Result: string (button clicked)
|
||||
type TaskMessageDialog struct{ Options MessageDialogOptions }
|
||||
|
||||
// TaskInfo shows an informational dialog. Result: string (button clicked)
|
||||
// result, _, err := c.PERFORM(TaskInfo{Title: "Done", Message: "File saved."})
|
||||
type TaskInfo struct {
|
||||
Title string
|
||||
Message string
|
||||
Buttons []string
|
||||
}
|
||||
|
||||
// TaskQuestion shows a question dialog. Result: string (button clicked)
|
||||
// result, _, err := c.PERFORM(TaskQuestion{Title: "Confirm", Message: "Delete?", Buttons: []string{"Yes","No"}})
|
||||
type TaskQuestion struct {
|
||||
Title string
|
||||
Message string
|
||||
Buttons []string
|
||||
}
|
||||
|
||||
// TaskWarning shows a warning dialog. Result: string (button clicked)
|
||||
// result, _, err := c.PERFORM(TaskWarning{Title: "Warning", Message: "File exists."})
|
||||
type TaskWarning struct {
|
||||
Title string
|
||||
Message string
|
||||
Buttons []string
|
||||
}
|
||||
|
||||
// TaskError shows an error dialog. Result: string (button clicked)
|
||||
// result, _, err := c.PERFORM(TaskError{Title: "Error", Message: "Write failed."})
|
||||
type TaskError struct {
|
||||
Title string
|
||||
Message string
|
||||
Buttons []string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ package dialog
|
|||
|
||||
// Platform abstracts the native dialog backend.
|
||||
type Platform interface {
|
||||
OpenFile(opts OpenFileOptions) ([]string, error)
|
||||
SaveFile(opts SaveFileOptions) (string, error)
|
||||
OpenDirectory(opts OpenDirectoryOptions) (string, error)
|
||||
MessageDialog(opts MessageDialogOptions) (string, error)
|
||||
OpenFile(options OpenFileOptions) ([]string, error)
|
||||
SaveFile(options SaveFileOptions) (string, error)
|
||||
OpenDirectory(options OpenDirectoryOptions) (string, error)
|
||||
MessageDialog(options MessageDialogOptions) (string, error)
|
||||
}
|
||||
|
||||
// DialogType represents the type of message dialog.
|
||||
|
|
@ -21,11 +21,14 @@ const (
|
|||
|
||||
// OpenFileOptions contains options for the open file dialog.
|
||||
type OpenFileOptions struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
Directory string `json:"directory,omitempty"`
|
||||
Filename string `json:"filename,omitempty"`
|
||||
Filters []FileFilter `json:"filters,omitempty"`
|
||||
AllowMultiple bool `json:"allowMultiple,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Directory string `json:"directory,omitempty"`
|
||||
Filename string `json:"filename,omitempty"`
|
||||
Filters []FileFilter `json:"filters,omitempty"`
|
||||
AllowMultiple bool `json:"allowMultiple,omitempty"`
|
||||
CanChooseDirectories bool `json:"canChooseDirectories,omitempty"`
|
||||
CanChooseFiles bool `json:"canChooseFiles,omitempty"`
|
||||
ShowHiddenFiles bool `json:"showHiddenFiles,omitempty"`
|
||||
}
|
||||
|
||||
// SaveFileOptions contains options for the save file dialog.
|
||||
|
|
|
|||
|
|
@ -7,16 +7,13 @@ import (
|
|||
"forge.lthn.ai/core/go/pkg/core"
|
||||
)
|
||||
|
||||
// Options holds configuration for the dialog service.
|
||||
type Options struct{}
|
||||
|
||||
// Service is a core.Service managing native dialogs via IPC.
|
||||
type Service struct {
|
||||
*core.ServiceRuntime[Options]
|
||||
platform Platform
|
||||
}
|
||||
|
||||
// Register creates a factory closure that captures the Platform adapter.
|
||||
func Register(p Platform) func(*core.Core) (any, error) {
|
||||
return func(c *core.Core) (any, error) {
|
||||
return &Service{
|
||||
|
|
@ -26,13 +23,11 @@ func Register(p Platform) func(*core.Core) (any, error) {
|
|||
}
|
||||
}
|
||||
|
||||
// OnStartup registers IPC handlers.
|
||||
func (s *Service) OnStartup(ctx context.Context) error {
|
||||
s.Core().RegisterTask(s.handleTask)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleIPCEvents is auto-discovered by core.WithService.
|
||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -40,16 +35,68 @@ func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
|||
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
||||
switch t := t.(type) {
|
||||
case TaskOpenFile:
|
||||
paths, err := s.platform.OpenFile(t.Opts)
|
||||
paths, err := s.platform.OpenFile(t.Options)
|
||||
return paths, true, err
|
||||
case TaskOpenFileWithOptions:
|
||||
paths, err := s.platform.OpenFile(OpenFileOptions{
|
||||
Title: t.Title,
|
||||
Directory: t.Directory,
|
||||
Filename: t.Filename,
|
||||
Filters: t.Filters,
|
||||
AllowMultiple: t.AllowMultiple,
|
||||
CanChooseDirectories: t.CanChooseDirectories,
|
||||
CanChooseFiles: t.CanChooseFiles,
|
||||
ShowHiddenFiles: t.ShowHiddenFiles,
|
||||
})
|
||||
return paths, true, err
|
||||
case TaskSaveFile:
|
||||
path, err := s.platform.SaveFile(t.Opts)
|
||||
path, err := s.platform.SaveFile(t.Options)
|
||||
return path, true, err
|
||||
case TaskSaveFileWithOptions:
|
||||
path, err := s.platform.SaveFile(SaveFileOptions{
|
||||
Title: t.Title,
|
||||
Directory: t.Directory,
|
||||
Filename: t.Filename,
|
||||
Filters: t.Filters,
|
||||
})
|
||||
return path, true, err
|
||||
case TaskOpenDirectory:
|
||||
path, err := s.platform.OpenDirectory(t.Opts)
|
||||
path, err := s.platform.OpenDirectory(t.Options)
|
||||
return path, true, err
|
||||
case TaskMessageDialog:
|
||||
button, err := s.platform.MessageDialog(t.Opts)
|
||||
button, err := s.platform.MessageDialog(t.Options)
|
||||
return button, true, err
|
||||
case TaskInfo:
|
||||
button, err := s.platform.MessageDialog(MessageDialogOptions{
|
||||
Type: DialogInfo,
|
||||
Title: t.Title,
|
||||
Message: t.Message,
|
||||
Buttons: t.Buttons,
|
||||
})
|
||||
return button, true, err
|
||||
case TaskQuestion:
|
||||
button, err := s.platform.MessageDialog(MessageDialogOptions{
|
||||
Type: DialogQuestion,
|
||||
Title: t.Title,
|
||||
Message: t.Message,
|
||||
Buttons: t.Buttons,
|
||||
})
|
||||
return button, true, err
|
||||
case TaskWarning:
|
||||
button, err := s.platform.MessageDialog(MessageDialogOptions{
|
||||
Type: DialogWarning,
|
||||
Title: t.Title,
|
||||
Message: t.Message,
|
||||
Buttons: t.Buttons,
|
||||
})
|
||||
return button, true, err
|
||||
case TaskError:
|
||||
button, err := s.platform.MessageDialog(MessageDialogOptions{
|
||||
Type: DialogError,
|
||||
Title: t.Title,
|
||||
Message: t.Message,
|
||||
Buttons: t.Buttons,
|
||||
})
|
||||
return button, true, err
|
||||
default:
|
||||
return nil, false, nil
|
||||
|
|
|
|||
|
|
@ -11,18 +11,18 @@ import (
|
|||
)
|
||||
|
||||
type mockPlatform struct {
|
||||
openFilePaths []string
|
||||
saveFilePath string
|
||||
openDirPath string
|
||||
messageButton string
|
||||
openFileErr error
|
||||
saveFileErr error
|
||||
openDirErr error
|
||||
messageErr error
|
||||
lastOpenOpts OpenFileOptions
|
||||
lastSaveOpts SaveFileOptions
|
||||
lastDirOpts OpenDirectoryOptions
|
||||
lastMsgOpts MessageDialogOptions
|
||||
openFilePaths []string
|
||||
saveFilePath string
|
||||
openDirPath string
|
||||
messageButton string
|
||||
openFileErr error
|
||||
saveFileErr error
|
||||
openDirErr error
|
||||
messageErr error
|
||||
lastOpenOpts OpenFileOptions
|
||||
lastSaveOpts SaveFileOptions
|
||||
lastDirOpts OpenDirectoryOptions
|
||||
lastMsgOpts MessageDialogOptions
|
||||
}
|
||||
|
||||
func (m *mockPlatform) OpenFile(opts OpenFileOptions) ([]string, error) {
|
||||
|
|
@ -70,7 +70,7 @@ func TestTaskOpenFile_Good(t *testing.T) {
|
|||
mock.openFilePaths = []string{"/a.txt", "/b.txt"}
|
||||
|
||||
result, handled, err := c.PERFORM(TaskOpenFile{
|
||||
Opts: OpenFileOptions{Title: "Pick", AllowMultiple: true},
|
||||
Options: OpenFileOptions{Title: "Pick", AllowMultiple: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
|
@ -83,7 +83,7 @@ func TestTaskOpenFile_Good(t *testing.T) {
|
|||
func TestTaskSaveFile_Good(t *testing.T) {
|
||||
_, c := newTestService(t)
|
||||
result, handled, err := c.PERFORM(TaskSaveFile{
|
||||
Opts: SaveFileOptions{Filename: "out.txt"},
|
||||
Options: SaveFileOptions{Filename: "out.txt"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
|
@ -93,7 +93,7 @@ func TestTaskSaveFile_Good(t *testing.T) {
|
|||
func TestTaskOpenDirectory_Good(t *testing.T) {
|
||||
_, c := newTestService(t)
|
||||
result, handled, err := c.PERFORM(TaskOpenDirectory{
|
||||
Opts: OpenDirectoryOptions{Title: "Pick Dir"},
|
||||
Options: OpenDirectoryOptions{Title: "Pick Dir"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
|
@ -105,7 +105,7 @@ func TestTaskMessageDialog_Good(t *testing.T) {
|
|||
mock.messageButton = "Yes"
|
||||
|
||||
result, handled, err := c.PERFORM(TaskMessageDialog{
|
||||
Opts: MessageDialogOptions{
|
||||
Options: MessageDialogOptions{
|
||||
Type: DialogQuestion, Title: "Confirm",
|
||||
Message: "Sure?", Buttons: []string{"Yes", "No"},
|
||||
},
|
||||
|
|
@ -121,3 +121,137 @@ func TestTaskOpenFile_Bad(t *testing.T) {
|
|||
_, handled, _ := c.PERFORM(TaskOpenFile{})
|
||||
assert.False(t, handled)
|
||||
}
|
||||
|
||||
func TestTaskOpenFileWithOptions_Good(t *testing.T) {
|
||||
mock, c := newTestService(t)
|
||||
mock.openFilePaths = []string{"/docs/report.pdf"}
|
||||
|
||||
result, handled, err := c.PERFORM(TaskOpenFileWithOptions{
|
||||
Title: "Select Document",
|
||||
AllowMultiple: false,
|
||||
ShowHiddenFiles: true,
|
||||
CanChooseFiles: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
paths := result.([]string)
|
||||
assert.Equal(t, []string{"/docs/report.pdf"}, paths)
|
||||
assert.Equal(t, "Select Document", mock.lastOpenOpts.Title)
|
||||
assert.True(t, mock.lastOpenOpts.ShowHiddenFiles)
|
||||
assert.True(t, mock.lastOpenOpts.CanChooseFiles)
|
||||
}
|
||||
|
||||
func TestTaskOpenFileWithOptions_CanChooseDirectories_Good(t *testing.T) {
|
||||
mock, c := newTestService(t)
|
||||
mock.openFilePaths = []string{"/home/user/projects"}
|
||||
|
||||
_, handled, err := c.PERFORM(TaskOpenFileWithOptions{
|
||||
Title: "Pick folder",
|
||||
CanChooseDirectories: true,
|
||||
CanChooseFiles: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.True(t, mock.lastOpenOpts.CanChooseDirectories)
|
||||
assert.False(t, mock.lastOpenOpts.CanChooseFiles)
|
||||
}
|
||||
|
||||
func TestTaskOpenFileWithOptions_Bad_NoService(t *testing.T) {
|
||||
c, _ := core.New(core.WithServiceLock())
|
||||
_, handled, _ := c.PERFORM(TaskOpenFileWithOptions{})
|
||||
assert.False(t, handled)
|
||||
}
|
||||
|
||||
func TestTaskSaveFileWithOptions_Good(t *testing.T) {
|
||||
mock, c := newTestService(t)
|
||||
mock.saveFilePath = "/exports/data.csv"
|
||||
|
||||
result, handled, err := c.PERFORM(TaskSaveFileWithOptions{
|
||||
Title: "Export CSV",
|
||||
Filename: "data.csv",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.Equal(t, "/exports/data.csv", result)
|
||||
assert.Equal(t, "Export CSV", mock.lastSaveOpts.Title)
|
||||
assert.Equal(t, "data.csv", mock.lastSaveOpts.Filename)
|
||||
}
|
||||
|
||||
func TestTaskSaveFileWithOptions_Bad_NoService(t *testing.T) {
|
||||
c, _ := core.New(core.WithServiceLock())
|
||||
_, handled, _ := c.PERFORM(TaskSaveFileWithOptions{})
|
||||
assert.False(t, handled)
|
||||
}
|
||||
|
||||
func TestTaskInfo_Good(t *testing.T) {
|
||||
mock, c := newTestService(t)
|
||||
mock.messageButton = "OK"
|
||||
|
||||
result, handled, err := c.PERFORM(TaskInfo{Title: "Done", Message: "Saved successfully."})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.Equal(t, "OK", result)
|
||||
assert.Equal(t, DialogInfo, mock.lastMsgOpts.Type)
|
||||
assert.Equal(t, "Done", mock.lastMsgOpts.Title)
|
||||
}
|
||||
|
||||
func TestTaskQuestion_Good(t *testing.T) {
|
||||
mock, c := newTestService(t)
|
||||
mock.messageButton = "Yes"
|
||||
|
||||
result, handled, err := c.PERFORM(TaskQuestion{
|
||||
Title: "Confirm",
|
||||
Message: "Delete this file?",
|
||||
Buttons: []string{"Yes", "No"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.Equal(t, "Yes", result)
|
||||
assert.Equal(t, DialogQuestion, mock.lastMsgOpts.Type)
|
||||
}
|
||||
|
||||
func TestTaskWarning_Good(t *testing.T) {
|
||||
mock, c := newTestService(t)
|
||||
mock.messageButton = "OK"
|
||||
|
||||
result, handled, err := c.PERFORM(TaskWarning{Title: "Warning", Message: "File exists."})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.Equal(t, "OK", result)
|
||||
assert.Equal(t, DialogWarning, mock.lastMsgOpts.Type)
|
||||
}
|
||||
|
||||
func TestTaskError_Good(t *testing.T) {
|
||||
mock, c := newTestService(t)
|
||||
mock.messageButton = "OK"
|
||||
|
||||
result, handled, err := c.PERFORM(TaskError{Title: "Error", Message: "Write failed."})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.Equal(t, "OK", result)
|
||||
assert.Equal(t, DialogError, mock.lastMsgOpts.Type)
|
||||
}
|
||||
|
||||
func TestTaskInfo_Bad_NoService(t *testing.T) {
|
||||
c, _ := core.New(core.WithServiceLock())
|
||||
_, handled, _ := c.PERFORM(TaskInfo{})
|
||||
assert.False(t, handled)
|
||||
}
|
||||
|
||||
func TestTaskQuestion_Bad_NoService(t *testing.T) {
|
||||
c, _ := core.New(core.WithServiceLock())
|
||||
_, handled, _ := c.PERFORM(TaskQuestion{})
|
||||
assert.False(t, handled)
|
||||
}
|
||||
|
||||
func TestTaskWarning_Bad_NoService(t *testing.T) {
|
||||
c, _ := core.New(core.WithServiceLock())
|
||||
_, handled, _ := c.PERFORM(TaskWarning{})
|
||||
assert.False(t, handled)
|
||||
}
|
||||
|
||||
func TestTaskError_Bad_NoService(t *testing.T) {
|
||||
c, _ := core.New(core.WithServiceLock())
|
||||
_, handled, _ := c.PERFORM(TaskError{})
|
||||
assert.False(t, handled)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@ This document tracks the implementation of display server features that enable A
|
|||
### Phase 3 - Layouts (DONE)
|
||||
- [x] layout_save, layout_restore, layout_list
|
||||
- [x] layout_delete, layout_get
|
||||
- [ ] layout_tile, layout_beside_editor (future)
|
||||
- [x] layout_tile, layout_beside_editor
|
||||
|
||||
### Phase 4 - WebView Debug (DONE)
|
||||
- [x] webview_screenshot, webview_screenshot_element
|
||||
|
|
@ -203,7 +203,7 @@ This document tracks the implementation of display server features that enable A
|
|||
- [x] webview_scroll, webview_hover, webview_select, webview_check
|
||||
- [x] webview_highlight, webview_errors
|
||||
- [x] webview_performance, webview_resources
|
||||
- [ ] webview_network, webview_devtools (future)
|
||||
- [~] webview_network complete; webview_devtools pending
|
||||
|
||||
### Phase 5 - System Integration (DONE)
|
||||
- [x] clipboard_read, clipboard_write, clipboard_has, clipboard_clear
|
||||
|
|
@ -236,8 +236,9 @@ This document tracks the implementation of display server features that enable A
|
|||
- [x] `tray_info` - Get tray status
|
||||
|
||||
### Phase 8 - Remaining Features (Future)
|
||||
- [ ] layout_beside_editor, layout_suggest
|
||||
- [ ] window_opacity (true opacity if Wails adds support)
|
||||
- [x] layout_beside_editor, layout_suggest
|
||||
- [ ] webview_devtools_open, webview_devtools_close
|
||||
- [ ] clipboard_read_image, clipboard_write_image
|
||||
- [ ] notification_with_actions, notification_clear
|
||||
- [ ] tray_show_message - Balloon notifications
|
||||
- [x] clipboard_read_image, clipboard_write_image
|
||||
- [x] notification_with_actions, notification_clear
|
||||
- [x] tray_show_message - Balloon notifications
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -2,21 +2,14 @@ package display
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
coreio "forge.lthn.ai/core/go-io"
|
||||
"forge.lthn.ai/core/go/pkg/core"
|
||||
"dappco.re/go/core/gui/pkg/clipboard"
|
||||
"dappco.re/go/core/gui/pkg/dialog"
|
||||
"dappco.re/go/core/gui/pkg/environment"
|
||||
"dappco.re/go/core/gui/pkg/menu"
|
||||
"dappco.re/go/core/gui/pkg/notification"
|
||||
"dappco.re/go/core/gui/pkg/screen"
|
||||
"dappco.re/go/core/gui/pkg/systray"
|
||||
"dappco.re/go/core/gui/pkg/webview"
|
||||
"dappco.re/go/core/gui/pkg/window"
|
||||
coreutil "dappco.re/go/core"
|
||||
"forge.lthn.ai/core/gui/pkg/menu"
|
||||
"forge.lthn.ai/core/gui/pkg/systray"
|
||||
"forge.lthn.ai/core/gui/pkg/window"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
|
@ -201,84 +194,29 @@ func newTestConclave(t *testing.T) *core.Core {
|
|||
return c
|
||||
}
|
||||
|
||||
func newExtendedTestConclave(t *testing.T) *core.Core {
|
||||
func requireCreateWindow(t *testing.T, svc *Service, options CreateWindowOptions) {
|
||||
t.Helper()
|
||||
fixture := newExtendedTestConclaveWithMocks(t)
|
||||
return fixture.core
|
||||
}
|
||||
|
||||
type extendedTestConclave struct {
|
||||
core *core.Core
|
||||
clipboardPlatform *mockClipboardPlatform
|
||||
notificationPlatform *mockNotificationPlatform
|
||||
dialogPlatform *mockDialogPlatform
|
||||
environmentPlatform *mockEnvironmentPlatform
|
||||
}
|
||||
|
||||
func newExtendedTestConclaveWithMocks(t *testing.T) *extendedTestConclave {
|
||||
t.Helper()
|
||||
clipboardPlatform := &mockClipboardPlatform{text: "hello", ok: true, image: []byte{1, 2, 3}, imgOk: true}
|
||||
notificationPlatform := &mockNotificationPlatform{permGranted: true}
|
||||
dialogPlatform := &mockDialogPlatform{button: "OK"}
|
||||
environmentPlatform := &mockEnvironmentPlatform{
|
||||
isDark: true,
|
||||
accent: "rgb(0,122,255)",
|
||||
info: environment.EnvironmentInfo{
|
||||
OS: "darwin", Arch: "arm64",
|
||||
Platform: environment.PlatformInfo{Name: "macOS", Version: "14.0"},
|
||||
},
|
||||
}
|
||||
|
||||
c, err := core.New(
|
||||
core.WithService(Register(nil)),
|
||||
core.WithService(window.Register(window.NewMockPlatform())),
|
||||
core.WithService(screen.Register(&mockScreenPlatform{
|
||||
screens: []screen.Screen{{
|
||||
ID: "primary", Name: "Primary", IsPrimary: true,
|
||||
Size: screen.Size{Width: 2560, Height: 1440},
|
||||
Bounds: screen.Rect{X: 0, Y: 0, Width: 2560, Height: 1440},
|
||||
WorkArea: screen.Rect{X: 0, Y: 0, Width: 2560, Height: 1440},
|
||||
}},
|
||||
})),
|
||||
core.WithService(systray.Register(systray.NewMockPlatform())),
|
||||
core.WithService(menu.Register(menu.NewMockPlatform())),
|
||||
core.WithService(clipboard.Register(clipboardPlatform)),
|
||||
core.WithService(notification.Register(notificationPlatform)),
|
||||
core.WithService(dialog.Register(dialogPlatform)),
|
||||
core.WithService(environment.Register(environmentPlatform)),
|
||||
core.WithService(webview.Register(webview.Options{})),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
_, err := svc.CreateWindow(options)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
||||
return &extendedTestConclave{
|
||||
core: c,
|
||||
clipboardPlatform: clipboardPlatform,
|
||||
notificationPlatform: notificationPlatform,
|
||||
dialogPlatform: dialogPlatform,
|
||||
environmentPlatform: environmentPlatform,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
t.Run("creates service successfully", func(t *testing.T) {
|
||||
service, err := New()
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, service, "New() should return a non-nil service instance")
|
||||
})
|
||||
|
||||
t.Run("returns independent instances", func(t *testing.T) {
|
||||
service1, err1 := New()
|
||||
service2, err2 := New()
|
||||
assert.NoError(t, err1)
|
||||
assert.NoError(t, err2)
|
||||
assert.NotSame(t, service1, service2, "New() should return different instances")
|
||||
})
|
||||
func TestNewService_Good(t *testing.T) {
|
||||
service, err := NewService()
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, service)
|
||||
}
|
||||
|
||||
func TestRegisterClosure_Good(t *testing.T) {
|
||||
func TestNewService_Good_IndependentInstances(t *testing.T) {
|
||||
service1, err1 := NewService()
|
||||
service2, err2 := NewService()
|
||||
assert.NoError(t, err1)
|
||||
assert.NoError(t, err2)
|
||||
assert.NotSame(t, service1, service2)
|
||||
}
|
||||
|
||||
func TestRegister_Good(t *testing.T) {
|
||||
factory := Register(nil) // nil wailsApp for testing
|
||||
assert.NotNil(t, factory)
|
||||
|
||||
|
|
@ -320,7 +258,7 @@ func TestConfigTask_Good(t *testing.T) {
|
|||
_, c := newTestDisplayService(t)
|
||||
|
||||
newCfg := map[string]any{"default_width": 800}
|
||||
_, handled, err := c.PERFORM(window.TaskSaveConfig{Value: newCfg})
|
||||
_, handled, err := c.PERFORM(window.TaskSaveConfig{Config: newCfg})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
|
|
@ -337,7 +275,7 @@ func TestServiceConclave_Good(t *testing.T) {
|
|||
|
||||
// Open a window via IPC
|
||||
result, handled, err := c.PERFORM(window.TaskOpenWindow{
|
||||
Opts: []window.WindowOption{window.WithName("main")},
|
||||
Window: &window.Window{Name: "main"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
|
@ -379,7 +317,7 @@ func TestServiceConclave_Bad(t *testing.T) {
|
|||
|
||||
// --- IPC delegation tests (full conclave) ---
|
||||
|
||||
func TestOpenWindow_Good(t *testing.T) {
|
||||
func TestOpenWindow_Compatibility_Good(t *testing.T) {
|
||||
c := newTestConclave(t)
|
||||
svc := core.MustServiceFor[*Service](c, "display")
|
||||
|
||||
|
|
@ -392,17 +330,16 @@ func TestOpenWindow_Good(t *testing.T) {
|
|||
assert.GreaterOrEqual(t, len(infos), 1)
|
||||
})
|
||||
|
||||
t.Run("creates window with custom options", func(t *testing.T) {
|
||||
err := svc.OpenWindow(
|
||||
window.WithName("custom-window"),
|
||||
window.WithTitle("Custom Title"),
|
||||
window.WithSize(640, 480),
|
||||
window.WithURL("/custom"),
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
t.Run("creates window with declarative options", func(t *testing.T) {
|
||||
info, err := svc.CreateWindow(CreateWindowOptions{
|
||||
Name: "custom-window",
|
||||
Title: "Custom Title",
|
||||
URL: "/custom",
|
||||
Width: 640,
|
||||
Height: 480,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, _, _ := c.QUERY(window.QueryWindowByName{Name: "custom-window"})
|
||||
info := result.(*window.WindowInfo)
|
||||
assert.Equal(t, "custom-window", info.Name)
|
||||
})
|
||||
}
|
||||
|
|
@ -411,10 +348,7 @@ func TestGetWindowInfo_Good(t *testing.T) {
|
|||
c := newTestConclave(t)
|
||||
svc := core.MustServiceFor[*Service](c, "display")
|
||||
|
||||
_ = svc.OpenWindow(
|
||||
window.WithName("test-win"),
|
||||
window.WithSize(800, 600),
|
||||
)
|
||||
requireCreateWindow(t, svc, CreateWindowOptions{Name: "test-win", Width: 800, Height: 600})
|
||||
|
||||
// Modify position via IPC
|
||||
_, _, _ = c.PERFORM(window.TaskSetPosition{Name: "test-win", X: 100, Y: 200})
|
||||
|
|
@ -463,9 +397,8 @@ func TestListWindowInfos_Good(t *testing.T) {
|
|||
c := newTestConclave(t)
|
||||
svc := core.MustServiceFor[*Service](c, "display")
|
||||
|
||||
_ = svc.OpenWindow(window.WithName("win-1"))
|
||||
_ = svc.OpenWindow(window.WithName("win-2"))
|
||||
_ = svc.MinimizeWindow("win-2")
|
||||
requireCreateWindow(t, svc, CreateWindowOptions{Name: "win-1"})
|
||||
requireCreateWindow(t, svc, CreateWindowOptions{Name: "win-2"})
|
||||
|
||||
infos := svc.ListWindowInfos()
|
||||
assert.Len(t, infos, 2)
|
||||
|
|
@ -484,7 +417,7 @@ func TestListWindowInfos_Good(t *testing.T) {
|
|||
func TestSetWindowPosition_Good(t *testing.T) {
|
||||
c := newTestConclave(t)
|
||||
svc := core.MustServiceFor[*Service](c, "display")
|
||||
_ = svc.OpenWindow(window.WithName("pos-win"))
|
||||
requireCreateWindow(t, svc, CreateWindowOptions{Name: "pos-win"})
|
||||
|
||||
err := svc.SetWindowPosition("pos-win", 300, 400)
|
||||
assert.NoError(t, err)
|
||||
|
|
@ -505,7 +438,7 @@ func TestSetWindowPosition_Bad(t *testing.T) {
|
|||
func TestSetWindowSize_Good(t *testing.T) {
|
||||
c := newTestConclave(t)
|
||||
svc := core.MustServiceFor[*Service](c, "display")
|
||||
_ = svc.OpenWindow(window.WithName("size-win"))
|
||||
requireCreateWindow(t, svc, CreateWindowOptions{Name: "size-win"})
|
||||
|
||||
err := svc.SetWindowSize("size-win", 1024, 768)
|
||||
assert.NoError(t, err)
|
||||
|
|
@ -518,7 +451,7 @@ func TestSetWindowSize_Good(t *testing.T) {
|
|||
func TestMaximizeWindow_Good(t *testing.T) {
|
||||
c := newTestConclave(t)
|
||||
svc := core.MustServiceFor[*Service](c, "display")
|
||||
_ = svc.OpenWindow(window.WithName("max-win"))
|
||||
requireCreateWindow(t, svc, CreateWindowOptions{Name: "max-win"})
|
||||
|
||||
err := svc.MaximizeWindow("max-win")
|
||||
assert.NoError(t, err)
|
||||
|
|
@ -530,7 +463,7 @@ func TestMaximizeWindow_Good(t *testing.T) {
|
|||
func TestRestoreWindow_Good(t *testing.T) {
|
||||
c := newTestConclave(t)
|
||||
svc := core.MustServiceFor[*Service](c, "display")
|
||||
_ = svc.OpenWindow(window.WithName("restore-win"))
|
||||
requireCreateWindow(t, svc, CreateWindowOptions{Name: "restore-win"})
|
||||
_ = svc.MaximizeWindow("restore-win")
|
||||
|
||||
err := svc.RestoreWindow("restore-win")
|
||||
|
|
@ -543,7 +476,7 @@ func TestRestoreWindow_Good(t *testing.T) {
|
|||
func TestFocusWindow_Good(t *testing.T) {
|
||||
c := newTestConclave(t)
|
||||
svc := core.MustServiceFor[*Service](c, "display")
|
||||
_ = svc.OpenWindow(window.WithName("focus-win"))
|
||||
requireCreateWindow(t, svc, CreateWindowOptions{Name: "focus-win"})
|
||||
|
||||
err := svc.FocusWindow("focus-win")
|
||||
assert.NoError(t, err)
|
||||
|
|
@ -555,7 +488,7 @@ func TestFocusWindow_Good(t *testing.T) {
|
|||
func TestCloseWindow_Good(t *testing.T) {
|
||||
c := newTestConclave(t)
|
||||
svc := core.MustServiceFor[*Service](c, "display")
|
||||
_ = svc.OpenWindow(window.WithName("close-win"))
|
||||
requireCreateWindow(t, svc, CreateWindowOptions{Name: "close-win"})
|
||||
|
||||
err := svc.CloseWindow("close-win")
|
||||
assert.NoError(t, err)
|
||||
|
|
@ -568,7 +501,7 @@ func TestCloseWindow_Good(t *testing.T) {
|
|||
func TestSetWindowVisibility_Good(t *testing.T) {
|
||||
c := newTestConclave(t)
|
||||
svc := core.MustServiceFor[*Service](c, "display")
|
||||
_ = svc.OpenWindow(window.WithName("vis-win"))
|
||||
requireCreateWindow(t, svc, CreateWindowOptions{Name: "vis-win"})
|
||||
|
||||
err := svc.SetWindowVisibility("vis-win", false)
|
||||
assert.NoError(t, err)
|
||||
|
|
@ -586,7 +519,7 @@ func TestSetWindowVisibility_Good(t *testing.T) {
|
|||
func TestSetWindowAlwaysOnTop_Good(t *testing.T) {
|
||||
c := newTestConclave(t)
|
||||
svc := core.MustServiceFor[*Service](c, "display")
|
||||
_ = svc.OpenWindow(window.WithName("ontop-win"))
|
||||
requireCreateWindow(t, svc, CreateWindowOptions{Name: "ontop-win"})
|
||||
|
||||
err := svc.SetWindowAlwaysOnTop("ontop-win", true)
|
||||
assert.NoError(t, err)
|
||||
|
|
@ -595,7 +528,7 @@ func TestSetWindowAlwaysOnTop_Good(t *testing.T) {
|
|||
func TestSetWindowTitle_Good(t *testing.T) {
|
||||
c := newTestConclave(t)
|
||||
svc := core.MustServiceFor[*Service](c, "display")
|
||||
_ = svc.OpenWindow(window.WithName("title-win"))
|
||||
requireCreateWindow(t, svc, CreateWindowOptions{Name: "title-win"})
|
||||
|
||||
err := svc.SetWindowTitle("title-win", "New Title")
|
||||
assert.NoError(t, err)
|
||||
|
|
@ -604,18 +537,18 @@ func TestSetWindowTitle_Good(t *testing.T) {
|
|||
func TestGetFocusedWindow_Good(t *testing.T) {
|
||||
c := newTestConclave(t)
|
||||
svc := core.MustServiceFor[*Service](c, "display")
|
||||
_ = svc.OpenWindow(window.WithName("win-a"))
|
||||
_ = svc.OpenWindow(window.WithName("win-b"))
|
||||
requireCreateWindow(t, svc, CreateWindowOptions{Name: "win-a"})
|
||||
requireCreateWindow(t, svc, CreateWindowOptions{Name: "win-b"})
|
||||
_ = svc.FocusWindow("win-b")
|
||||
|
||||
focused := svc.GetFocusedWindow()
|
||||
assert.Equal(t, "win-b", focused)
|
||||
}
|
||||
|
||||
func TestGetFocusedWindow_NoneSelected(t *testing.T) {
|
||||
func TestGetFocusedWindow_Good_NoneSelected(t *testing.T) {
|
||||
c := newTestConclave(t)
|
||||
svc := core.MustServiceFor[*Service](c, "display")
|
||||
_ = svc.OpenWindow(window.WithName("win-a"))
|
||||
requireCreateWindow(t, svc, CreateWindowOptions{Name: "win-a"})
|
||||
|
||||
focused := svc.GetFocusedWindow()
|
||||
assert.Equal(t, "", focused)
|
||||
|
|
@ -835,7 +768,7 @@ func TestHandleIPCEvents_WindowOpened_Good(t *testing.T) {
|
|||
// Open a window — this should trigger ActionWindowOpened
|
||||
// which HandleIPCEvents should convert to a WS event
|
||||
result, handled, err := c.PERFORM(window.TaskOpenWindow{
|
||||
Opts: []window.WindowOption{window.WithName("test")},
|
||||
Window: &window.Window{Name: "test"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
|
@ -866,9 +799,9 @@ func TestWSEventManager_Good(t *testing.T) {
|
|||
func TestLoadConfig_Good(t *testing.T) {
|
||||
// Create temp config file
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, ".core", "gui", "config.yaml")
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(cfgPath), 0o755))
|
||||
require.NoError(t, os.WriteFile(cfgPath, []byte(`
|
||||
cfgPath := coreutil.JoinPath(dir, ".core", "gui", "config.yaml")
|
||||
require.NoError(t, coreio.Local.EnsureDir(coreutil.JoinPath(dir, ".core", "gui")))
|
||||
require.NoError(t, coreio.Local.Write(cfgPath, `
|
||||
window:
|
||||
default_width: 1280
|
||||
default_height: 720
|
||||
|
|
@ -876,9 +809,9 @@ systray:
|
|||
tooltip: "Test App"
|
||||
menu:
|
||||
show_dev_tools: false
|
||||
`), 0o644))
|
||||
`))
|
||||
|
||||
s, _ := New()
|
||||
s, _ := NewService()
|
||||
s.loadConfigFrom(cfgPath)
|
||||
|
||||
// Verify configData was populated from file
|
||||
|
|
@ -888,8 +821,8 @@ menu:
|
|||
}
|
||||
|
||||
func TestLoadConfig_Bad_MissingFile(t *testing.T) {
|
||||
s, _ := New()
|
||||
s.loadConfigFrom(filepath.Join(t.TempDir(), "nonexistent.yaml"))
|
||||
s, _ := NewService()
|
||||
s.loadConfigFrom(coreutil.JoinPath(t.TempDir(), "nonexistent.yaml"))
|
||||
|
||||
// Should not panic, configData stays at empty defaults
|
||||
assert.Empty(t, s.configData["window"])
|
||||
|
|
@ -899,9 +832,9 @@ func TestLoadConfig_Bad_MissingFile(t *testing.T) {
|
|||
|
||||
func TestHandleConfigTask_Persists_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.yaml")
|
||||
cfgPath := coreutil.JoinPath(dir, "config.yaml")
|
||||
|
||||
s, _ := New()
|
||||
s, _ := NewService()
|
||||
s.loadConfigFrom(cfgPath) // Creates empty config (file doesn't exist yet)
|
||||
|
||||
// Simulate a TaskSaveConfig through the handler
|
||||
|
|
@ -915,15 +848,15 @@ func TestHandleConfigTask_Persists_Good(t *testing.T) {
|
|||
c.ServiceStartup(context.Background(), nil)
|
||||
|
||||
_, handled, err := c.PERFORM(window.TaskSaveConfig{
|
||||
Value: map[string]any{"default_width": 1920},
|
||||
Config: map[string]any{"default_width": 1920},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
// Verify file was written
|
||||
data, err := os.ReadFile(cfgPath)
|
||||
data, err := coreio.Local.Read(cfgPath)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(data), "default_width")
|
||||
assert.Contains(t, data, "default_width")
|
||||
}
|
||||
|
||||
func TestHandleWSMessage_Extended_Good(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -3,11 +3,12 @@ package display
|
|||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"dappco.re/go/core"
|
||||
"dappco.re/go/core/gui/pkg/window"
|
||||
core "dappco.re/go/core"
|
||||
"forge.lthn.ai/core/gui/pkg/window"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
|
|
@ -41,6 +42,11 @@ const (
|
|||
EventContextMenuClick EventType = "contextmenu.item-clicked"
|
||||
EventWebviewConsole EventType = "webview.console"
|
||||
EventWebviewException EventType = "webview.exception"
|
||||
EventCustomEvent EventType = "event.custom"
|
||||
EventDockProgress EventType = "dock.progress"
|
||||
EventDockBounce EventType = "dock.bounce"
|
||||
EventNotificationAction EventType = "notification.action"
|
||||
EventNotificationDismiss EventType = "notification.dismiss"
|
||||
)
|
||||
|
||||
// Event represents a display event sent to subscribers.
|
||||
|
|
@ -143,13 +149,13 @@ func (em *WSEventManager) sendEvent(conn *websocket.Conn, event Event) {
|
|||
return
|
||||
}
|
||||
|
||||
r := core.JSONMarshal(event)
|
||||
if !r.OK {
|
||||
result := core.JSONMarshal(event)
|
||||
if !result.OK {
|
||||
return
|
||||
}
|
||||
|
||||
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if err := conn.WriteMessage(websocket.TextMessage, r.Value.([]byte)); err != nil {
|
||||
if err := conn.WriteMessage(websocket.TextMessage, result.Value.([]byte)); err != nil {
|
||||
em.removeClient(conn)
|
||||
}
|
||||
}
|
||||
|
|
@ -187,7 +193,7 @@ func (em *WSEventManager) handleMessages(conn *websocket.Conn) {
|
|||
EventTypes []EventType `json:"eventTypes,omitempty"`
|
||||
}
|
||||
|
||||
if r := core.JSONUnmarshal(message, &msg); !r.OK {
|
||||
if !core.JSONUnmarshal(message, &msg).OK {
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -216,7 +222,7 @@ func (em *WSEventManager) subscribe(conn *websocket.Conn, id string, eventTypes
|
|||
if id == "" {
|
||||
em.mu.Lock()
|
||||
em.nextSubID++
|
||||
id = core.Sprintf("sub-%d", em.nextSubID)
|
||||
id = "sub-" + strconv.Itoa(em.nextSubID)
|
||||
em.mu.Unlock()
|
||||
}
|
||||
|
||||
|
|
@ -233,8 +239,7 @@ func (em *WSEventManager) subscribe(conn *websocket.Conn, id string, eventTypes
|
|||
"id": id,
|
||||
"eventTypes": eventTypes,
|
||||
}
|
||||
r := core.JSONMarshal(response)
|
||||
if r.OK {
|
||||
if r := core.JSONMarshal(response); r.OK {
|
||||
conn.WriteMessage(websocket.TextMessage, r.Value.([]byte))
|
||||
}
|
||||
}
|
||||
|
|
@ -258,8 +263,7 @@ func (em *WSEventManager) unsubscribe(conn *websocket.Conn, id string) {
|
|||
"type": "unsubscribed",
|
||||
"id": id,
|
||||
}
|
||||
r := core.JSONMarshal(response)
|
||||
if r.OK {
|
||||
if r := core.JSONMarshal(response); r.OK {
|
||||
conn.WriteMessage(websocket.TextMessage, r.Value.([]byte))
|
||||
}
|
||||
}
|
||||
|
|
@ -285,8 +289,7 @@ func (em *WSEventManager) listSubscriptions(conn *websocket.Conn) {
|
|||
"type": "subscriptions",
|
||||
"subscriptions": subs,
|
||||
}
|
||||
r := core.JSONMarshal(response)
|
||||
if r.OK {
|
||||
if r := core.JSONMarshal(response); r.OK {
|
||||
conn.WriteMessage(websocket.TextMessage, r.Value.([]byte))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,31 @@ type TaskSetBadge struct{ Label string }
|
|||
// TaskRemoveBadge removes the dock/taskbar badge. Result: nil
|
||||
type TaskRemoveBadge struct{}
|
||||
|
||||
// TaskSetProgressBar sets the progress indicator on the dock/taskbar icon.
|
||||
// Value must be in the range [0.0, 1.0]. Use -1.0 to remove the bar.
|
||||
// Result: nil
|
||||
// _, _, err := c.PERFORM(TaskSetProgressBar{Value: 0.75})
|
||||
type TaskSetProgressBar struct{ Value float64 }
|
||||
|
||||
// TaskBounce animates the dock icon to attract the user's attention. Result: int (bounce ID)
|
||||
// _, result, err := c.PERFORM(TaskBounce{Type: BounceCritical})
|
||||
// bounceID := result.(int)
|
||||
type TaskBounce struct{ Type BounceType }
|
||||
|
||||
// TaskStopBounce cancels a running dock bounce animation. Result: nil
|
||||
// _, _, err := c.PERFORM(TaskStopBounce{BounceID: bounceID})
|
||||
type TaskStopBounce struct{ BounceID int }
|
||||
|
||||
// --- Actions (broadcasts) ---
|
||||
|
||||
// ActionVisibilityChanged is broadcast after a successful TaskShowIcon or TaskHideIcon.
|
||||
type ActionVisibilityChanged struct{ Visible bool }
|
||||
|
||||
// ActionProgressChanged is broadcast after a successful TaskSetProgressBar.
|
||||
type ActionProgressChanged struct{ Value float64 }
|
||||
|
||||
// ActionBounceStarted is broadcast after a successful TaskBounce.
|
||||
type ActionBounceStarted struct {
|
||||
BounceID int `json:"bounceId"`
|
||||
Type BounceType `json:"type"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,19 @@
|
|||
// pkg/dock/platform.go
|
||||
package dock
|
||||
|
||||
// BounceType controls the style of the macOS dock icon bounce animation.
|
||||
type BounceType int
|
||||
|
||||
const (
|
||||
// BounceInformational bounces the dock icon once briefly.
|
||||
BounceInformational BounceType = iota
|
||||
// BounceCritical bounces the dock icon continuously until the app is focused.
|
||||
BounceCritical
|
||||
)
|
||||
|
||||
// Platform abstracts the dock/taskbar backend (Wails v3).
|
||||
// macOS: dock icon show/hide + badge.
|
||||
// Windows: taskbar badge only (show/hide not supported).
|
||||
// macOS: dock icon show/hide + badge + bounce + progress.
|
||||
// Windows: taskbar badge and progress only (show/hide/bounce not supported).
|
||||
// Linux: not supported — adapter returns nil for all operations.
|
||||
type Platform interface {
|
||||
ShowIcon() error
|
||||
|
|
@ -11,4 +21,15 @@ type Platform interface {
|
|||
SetBadge(label string) error
|
||||
RemoveBadge() error
|
||||
IsVisible() bool
|
||||
// SetProgressBar sets a progress indicator on the dock/taskbar icon.
|
||||
// value is in the range [0.0, 1.0]. Use -1.0 to remove the progress bar.
|
||||
// p.SetProgressBar(0.5) // 50% progress
|
||||
SetProgressBar(value float64) error
|
||||
// Bounce animates the dock icon to attract the user's attention.
|
||||
// bounceType controls whether the animation loops (BounceCritical) or plays once (BounceInformational).
|
||||
// Returns a bounce ID that can be passed to StopBounce.
|
||||
Bounce(bounceType BounceType) (int, error)
|
||||
// StopBounce cancels a running bounce animation identified by bounceID.
|
||||
// p.StopBounce(id)
|
||||
StopBounce(bounceID int) error
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
// pkg/dock/register.go
|
||||
package dock
|
||||
|
||||
import "forge.lthn.ai/core/go/pkg/core"
|
||||
|
||||
// Register creates a factory closure that captures the Platform adapter.
|
||||
// The returned function has the signature WithService requires: func(*Core) (any, error).
|
||||
// Register(p) binds the dock service to a Core instance.
|
||||
// core.WithService(dock.Register(wailsDock))
|
||||
func Register(p Platform) func(*core.Core) (any, error) {
|
||||
return func(c *core.Core) (any, error) {
|
||||
return &Service{
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// pkg/dock/service.go
|
||||
package dock
|
||||
|
||||
import (
|
||||
|
|
@ -7,30 +6,23 @@ import (
|
|||
"forge.lthn.ai/core/go/pkg/core"
|
||||
)
|
||||
|
||||
// Options holds configuration for the dock service.
|
||||
type Options struct{}
|
||||
|
||||
// Service is a core.Service managing dock/taskbar operations via IPC.
|
||||
// It embeds ServiceRuntime for Core access and delegates to Platform.
|
||||
type Service struct {
|
||||
*core.ServiceRuntime[Options]
|
||||
platform Platform
|
||||
}
|
||||
|
||||
// OnStartup registers IPC handlers.
|
||||
func (s *Service) OnStartup(ctx context.Context) error {
|
||||
s.Core().RegisterQuery(s.handleQuery)
|
||||
s.Core().RegisterTask(s.handleTask)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleIPCEvents is auto-discovered and registered by core.WithService.
|
||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Query Handlers ---
|
||||
|
||||
func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
|
||||
switch q.(type) {
|
||||
case QueryVisible:
|
||||
|
|
@ -40,8 +32,6 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
|
|||
}
|
||||
}
|
||||
|
||||
// --- Task Handlers ---
|
||||
|
||||
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
||||
switch t := t.(type) {
|
||||
case TaskShowIcon:
|
||||
|
|
@ -66,6 +56,24 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
|||
return nil, true, err
|
||||
}
|
||||
return nil, true, nil
|
||||
case TaskSetProgressBar:
|
||||
if err := s.platform.SetProgressBar(t.Value); err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
_ = s.Core().ACTION(ActionProgressChanged{Value: t.Value})
|
||||
return nil, true, nil
|
||||
case TaskBounce:
|
||||
bounceID, err := s.platform.Bounce(t.Type)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
_ = s.Core().ACTION(ActionBounceStarted{BounceID: bounceID, Type: t.Type})
|
||||
return bounceID, true, nil
|
||||
case TaskStopBounce:
|
||||
if err := s.platform.StopBounce(t.BounceID); err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
return nil, true, nil
|
||||
default:
|
||||
return nil, false, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,13 +13,21 @@ import (
|
|||
// --- Mock Platform ---
|
||||
|
||||
type mockPlatform struct {
|
||||
visible bool
|
||||
badge string
|
||||
hasBadge bool
|
||||
showErr error
|
||||
hideErr error
|
||||
badgeErr error
|
||||
removeErr error
|
||||
visible bool
|
||||
badge string
|
||||
hasBadge bool
|
||||
progress float64
|
||||
hasProgress bool
|
||||
bounceID int
|
||||
bounceCount int
|
||||
stopBounceIDs []int
|
||||
showErr error
|
||||
hideErr error
|
||||
badgeErr error
|
||||
removeErr error
|
||||
progressErr error
|
||||
bounceErr error
|
||||
stopBounceErr error
|
||||
}
|
||||
|
||||
func (m *mockPlatform) ShowIcon() error {
|
||||
|
|
@ -58,6 +66,32 @@ func (m *mockPlatform) RemoveBadge() error {
|
|||
|
||||
func (m *mockPlatform) IsVisible() bool { return m.visible }
|
||||
|
||||
func (m *mockPlatform) SetProgressBar(value float64) error {
|
||||
if m.progressErr != nil {
|
||||
return m.progressErr
|
||||
}
|
||||
m.progress = value
|
||||
m.hasProgress = value >= 0
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockPlatform) Bounce(bounceType BounceType) (int, error) {
|
||||
if m.bounceErr != nil {
|
||||
return 0, m.bounceErr
|
||||
}
|
||||
m.bounceCount++
|
||||
m.bounceID++
|
||||
return m.bounceID, nil
|
||||
}
|
||||
|
||||
func (m *mockPlatform) StopBounce(bounceID int) error {
|
||||
if m.stopBounceErr != nil {
|
||||
return m.stopBounceErr
|
||||
}
|
||||
m.stopBounceIDs = append(m.stopBounceIDs, bounceID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Test helpers ---
|
||||
|
||||
func newTestDockService(t *testing.T) (*Service, *core.Core, *mockPlatform) {
|
||||
|
|
@ -192,3 +226,127 @@ func TestTaskSetBadge_Bad(t *testing.T) {
|
|||
assert.True(t, handled)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestTaskSetProgressBar_Good(t *testing.T) {
|
||||
_, c, mock := newTestDockService(t)
|
||||
|
||||
var received *ActionProgressChanged
|
||||
c.RegisterAction(func(_ *core.Core, msg core.Message) error {
|
||||
if a, ok := msg.(ActionProgressChanged); ok {
|
||||
received = &a
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
_, handled, err := c.PERFORM(TaskSetProgressBar{Value: 0.75})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.Equal(t, 0.75, mock.progress)
|
||||
assert.True(t, mock.hasProgress)
|
||||
require.NotNil(t, received)
|
||||
assert.Equal(t, 0.75, received.Value)
|
||||
}
|
||||
|
||||
func TestTaskSetProgressBar_Remove_Good(t *testing.T) {
|
||||
_, c, mock := newTestDockService(t)
|
||||
|
||||
_, _, _ = c.PERFORM(TaskSetProgressBar{Value: 0.5})
|
||||
_, handled, err := c.PERFORM(TaskSetProgressBar{Value: -1.0})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.False(t, mock.hasProgress)
|
||||
}
|
||||
|
||||
func TestTaskSetProgressBar_Bad(t *testing.T) {
|
||||
_, c, mock := newTestDockService(t)
|
||||
mock.progressErr = assert.AnError
|
||||
|
||||
_, handled, err := c.PERFORM(TaskSetProgressBar{Value: 0.5})
|
||||
assert.True(t, handled)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestTaskBounce_Good(t *testing.T) {
|
||||
_, c, mock := newTestDockService(t)
|
||||
|
||||
var received *ActionBounceStarted
|
||||
c.RegisterAction(func(_ *core.Core, msg core.Message) error {
|
||||
if a, ok := msg.(ActionBounceStarted); ok {
|
||||
received = &a
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
result, handled, err := c.PERFORM(TaskBounce{Type: BounceInformational})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.Equal(t, 1, mock.bounceCount)
|
||||
bounceID := result.(int)
|
||||
assert.Equal(t, 1, bounceID)
|
||||
require.NotNil(t, received)
|
||||
assert.Equal(t, 1, received.BounceID)
|
||||
assert.Equal(t, BounceInformational, received.Type)
|
||||
}
|
||||
|
||||
func TestTaskBounce_Critical_Good(t *testing.T) {
|
||||
_, c, mock := newTestDockService(t)
|
||||
|
||||
result, handled, err := c.PERFORM(TaskBounce{Type: BounceCritical})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.Equal(t, 1, mock.bounceCount)
|
||||
assert.Equal(t, 1, result.(int))
|
||||
}
|
||||
|
||||
func TestTaskBounce_Bad(t *testing.T) {
|
||||
_, c, mock := newTestDockService(t)
|
||||
mock.bounceErr = assert.AnError
|
||||
|
||||
_, handled, err := c.PERFORM(TaskBounce{Type: BounceInformational})
|
||||
assert.True(t, handled)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestTaskStopBounce_Good(t *testing.T) {
|
||||
_, c, mock := newTestDockService(t)
|
||||
|
||||
result, _, _ := c.PERFORM(TaskBounce{Type: BounceInformational})
|
||||
bounceID := result.(int)
|
||||
|
||||
_, handled, err := c.PERFORM(TaskStopBounce{BounceID: bounceID})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.Contains(t, mock.stopBounceIDs, bounceID)
|
||||
}
|
||||
|
||||
func TestTaskStopBounce_Bad(t *testing.T) {
|
||||
_, c, mock := newTestDockService(t)
|
||||
mock.stopBounceErr = assert.AnError
|
||||
|
||||
_, handled, err := c.PERFORM(TaskStopBounce{BounceID: 99})
|
||||
assert.True(t, handled)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestBounceType_Ugly(t *testing.T) {
|
||||
// BounceInformational and BounceCritical must be distinct constants.
|
||||
assert.NotEqual(t, BounceInformational, BounceCritical)
|
||||
}
|
||||
|
||||
func TestTaskSetProgressBar_Bad_NoService(t *testing.T) {
|
||||
c, _ := core.New(core.WithServiceLock())
|
||||
_, handled, _ := c.PERFORM(TaskSetProgressBar{})
|
||||
assert.False(t, handled)
|
||||
}
|
||||
|
||||
func TestTaskBounce_Bad_NoService(t *testing.T) {
|
||||
c, _ := core.New(core.WithServiceLock())
|
||||
_, handled, _ := c.PERFORM(TaskBounce{})
|
||||
assert.False(t, handled)
|
||||
}
|
||||
|
||||
func TestTaskStopBounce_Bad_NoService(t *testing.T) {
|
||||
c, _ := core.New(core.WithServiceLock())
|
||||
_, handled, _ := c.PERFORM(TaskStopBounce{})
|
||||
assert.False(t, handled)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,18 +10,21 @@ type QueryInfo struct{}
|
|||
// QueryAccentColour returns the system accent colour. Result: string
|
||||
type QueryAccentColour struct{}
|
||||
|
||||
// TaskSetTheme overrides the application theme. Theme values: "light", "dark", "system".
|
||||
type TaskSetTheme struct {
|
||||
Theme string `json:"theme"`
|
||||
}
|
||||
|
||||
// TaskOpenFileManager opens the system file manager. Result: error only
|
||||
type TaskOpenFileManager struct {
|
||||
Path string `json:"path"`
|
||||
Select bool `json:"select"`
|
||||
}
|
||||
|
||||
// TaskSetTheme applies an application theme override when supported.
|
||||
// Theme values: "dark", "light", or "system".
|
||||
type TaskSetTheme struct {
|
||||
Theme string `json:"theme,omitempty"`
|
||||
IsDark bool `json:"isDark,omitempty"`
|
||||
}
|
||||
// QueryFocusFollowsMouse returns whether focus-follows-mouse is enabled. Result: bool
|
||||
//
|
||||
// result := c.QUERY(environment.QueryFocusFollowsMouse{})
|
||||
type QueryFocusFollowsMouse struct{}
|
||||
|
||||
// ActionThemeChanged is broadcast when the system theme changes.
|
||||
type ActionThemeChanged struct {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ type Platform interface {
|
|||
Info() EnvironmentInfo
|
||||
AccentColour() string
|
||||
OpenFileManager(path string, selectFile bool) error
|
||||
HasFocusFollowsMouse() bool
|
||||
OnThemeChange(handler func(isDark bool)) func() // returns cancel func
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,23 +3,23 @@ package environment
|
|||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
corego "dappco.re/go/core"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"forge.lthn.ai/core/go/pkg/core"
|
||||
)
|
||||
|
||||
// Options holds configuration for the environment service.
|
||||
type Options struct{}
|
||||
|
||||
// Service is a core.Service providing environment queries and theme change events via IPC.
|
||||
type Service struct {
|
||||
*core.ServiceRuntime[Options]
|
||||
platform Platform
|
||||
cancelTheme func() // cancel function for theme change listener
|
||||
overrideDark *bool
|
||||
platform Platform
|
||||
cancelTheme func() // returned by Platform.OnThemeChange — called on shutdown
|
||||
override *bool
|
||||
}
|
||||
|
||||
// Register creates a factory closure that captures the Platform adapter.
|
||||
// Register(p) binds the environment service to a Core instance.
|
||||
// core.WithService(environment.Register(wailsEnvironment))
|
||||
func Register(p Platform) func(*core.Core) (any, error) {
|
||||
return func(c *core.Core) (any, error) {
|
||||
return &Service{
|
||||
|
|
@ -29,19 +29,20 @@ func Register(p Platform) func(*core.Core) (any, error) {
|
|||
}
|
||||
}
|
||||
|
||||
// OnStartup registers IPC handlers and the theme change listener.
|
||||
func (s *Service) OnStartup(ctx context.Context) error {
|
||||
s.Core().RegisterQuery(s.handleQuery)
|
||||
s.Core().RegisterTask(s.handleTask)
|
||||
|
||||
// Register theme change callback — broadcasts ActionThemeChanged via IPC
|
||||
s.cancelTheme = s.platform.OnThemeChange(func(isDark bool) {
|
||||
if s.override != nil {
|
||||
isDark = *s.override
|
||||
}
|
||||
_ = s.Core().ACTION(ActionThemeChanged{IsDark: isDark})
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// OnShutdown cancels the theme change listener.
|
||||
func (s *Service) OnShutdown(ctx context.Context) error {
|
||||
if s.cancelTheme != nil {
|
||||
s.cancelTheme()
|
||||
|
|
@ -49,7 +50,6 @@ func (s *Service) OnShutdown(ctx context.Context) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// HandleIPCEvents is auto-discovered by core.WithService.
|
||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -57,7 +57,7 @@ func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
|||
func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
|
||||
switch q.(type) {
|
||||
case QueryTheme:
|
||||
isDark := s.currentTheme()
|
||||
isDark := s.currentThemeIsDark()
|
||||
theme := "light"
|
||||
if isDark {
|
||||
theme = "dark"
|
||||
|
|
@ -67,6 +67,8 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
|
|||
return s.platform.Info(), true, nil
|
||||
case QueryAccentColour:
|
||||
return s.platform.AccentColour(), true, nil
|
||||
case QueryFocusFollowsMouse:
|
||||
return s.platform.HasFocusFollowsMouse(), true, nil
|
||||
default:
|
||||
return nil, false, nil
|
||||
}
|
||||
|
|
@ -74,6 +76,8 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
|
|||
|
||||
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
||||
switch t := t.(type) {
|
||||
case TaskSetTheme:
|
||||
return nil, true, s.setThemeOverride(strings.ToLower(strings.TrimSpace(t.Theme)))
|
||||
case TaskOpenFileManager:
|
||||
return nil, true, s.platform.OpenFileManager(t.Path, t.Select)
|
||||
case TaskSetTheme:
|
||||
|
|
@ -86,42 +90,27 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
|||
}
|
||||
}
|
||||
|
||||
func (s *Service) taskSetTheme(task TaskSetTheme) error {
|
||||
shouldApplyTheme := false
|
||||
switch task.Theme {
|
||||
case "dark":
|
||||
isDark := true
|
||||
s.overrideDark = &isDark
|
||||
shouldApplyTheme = true
|
||||
case "light":
|
||||
isDark := false
|
||||
s.overrideDark = &isDark
|
||||
shouldApplyTheme = true
|
||||
case "system":
|
||||
s.overrideDark = nil
|
||||
case "":
|
||||
isDark := task.IsDark
|
||||
s.overrideDark = &isDark
|
||||
shouldApplyTheme = true
|
||||
default:
|
||||
return corego.E("environment.setTheme", corego.Sprintf("invalid theme mode: %s", task.Theme), nil)
|
||||
}
|
||||
|
||||
if shouldApplyTheme {
|
||||
if setter, ok := s.platform.(interface{ SetTheme(bool) error }); ok {
|
||||
if err := setter.SetTheme(s.currentTheme()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ = s.Core().ACTION(ActionThemeChanged{IsDark: s.currentTheme()})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) currentTheme() bool {
|
||||
if s.overrideDark != nil {
|
||||
return *s.overrideDark
|
||||
func (s *Service) currentThemeIsDark() bool {
|
||||
if s.override != nil {
|
||||
return *s.override
|
||||
}
|
||||
return s.platform.IsDarkMode()
|
||||
}
|
||||
|
||||
func (s *Service) setThemeOverride(theme string) error {
|
||||
switch theme {
|
||||
case "", "system":
|
||||
s.override = nil
|
||||
case "dark":
|
||||
value := true
|
||||
s.override = &value
|
||||
case "light":
|
||||
value := false
|
||||
s.override = &value
|
||||
default:
|
||||
return coreerr.E("environment.setThemeOverride", "theme must be one of: light, dark, system", nil)
|
||||
}
|
||||
|
||||
_ = s.Core().ACTION(ActionThemeChanged{IsDark: s.currentThemeIsDark()})
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ func (m *mockPlatform) AccentColour() string { return m.accentColour }
|
|||
func (m *mockPlatform) OpenFileManager(path string, selectFile bool) error {
|
||||
return m.openFMErr
|
||||
}
|
||||
func (m *mockPlatform) HasFocusFollowsMouse() bool { return false }
|
||||
func (m *mockPlatform) OnThemeChange(handler func(isDark bool)) func() {
|
||||
m.mu.Lock()
|
||||
m.themeHandler = handler
|
||||
|
|
@ -141,11 +142,11 @@ func TestThemeChange_ActionBroadcast_Good(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestTaskSetTheme_Good(t *testing.T) {
|
||||
mock, c := newTestService(t)
|
||||
_, c := newTestService(t)
|
||||
|
||||
_, handled, err := c.PERFORM(TaskSetTheme{Theme: "light"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.True(t, mock.setThemeSeen)
|
||||
|
||||
result, handled, err := c.QUERY(QueryTheme{})
|
||||
require.NoError(t, err)
|
||||
|
|
@ -155,12 +156,16 @@ func TestTaskSetTheme_Good(t *testing.T) {
|
|||
assert.Equal(t, "light", theme.Theme)
|
||||
}
|
||||
|
||||
func TestTaskSetTheme_Compatibility_Good(t *testing.T) {
|
||||
func TestTaskSetTheme_Good_SystemClearsOverride(t *testing.T) {
|
||||
mock, c := newTestService(t)
|
||||
_, handled, err := c.PERFORM(TaskSetTheme{IsDark: true})
|
||||
|
||||
_, _, err := c.PERFORM(TaskSetTheme{Theme: "light"})
|
||||
require.NoError(t, err)
|
||||
|
||||
mock.isDark = true
|
||||
_, handled, err := c.PERFORM(TaskSetTheme{Theme: "system"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.True(t, mock.setThemeSeen)
|
||||
|
||||
result, handled, err := c.QUERY(QueryTheme{})
|
||||
require.NoError(t, err)
|
||||
|
|
|
|||
34
pkg/events/messages.go
Normal file
34
pkg/events/messages.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// pkg/events/messages.go
|
||||
package events
|
||||
|
||||
// TaskEmit fires a custom event by name with optional data.
|
||||
// c.PERFORM(events.TaskEmit{Name: "build:done", Data: result})
|
||||
type TaskEmit struct {
|
||||
Name string
|
||||
Data any
|
||||
}
|
||||
|
||||
// TaskOn registers a persistent listener for a named event.
|
||||
// The listener ID returned in the result can be used with TaskOff.
|
||||
// c.PERFORM(events.TaskOn{Name: "build:done"})
|
||||
type TaskOn struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
// TaskOff removes all listeners for a named event.
|
||||
// c.PERFORM(events.TaskOff{Name: "build:done"})
|
||||
type TaskOff struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
// QueryListeners returns the count of listeners registered for a named event.
|
||||
// count := c.QUERY(events.QueryListeners{Name: "build:done"})
|
||||
type QueryListeners struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
// ActionEventFired is broadcast when a custom event is emitted via TaskEmit.
|
||||
type ActionEventFired struct {
|
||||
Name string
|
||||
Data any
|
||||
}
|
||||
26
pkg/events/platform.go
Normal file
26
pkg/events/platform.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// pkg/events/platform.go
|
||||
package events
|
||||
|
||||
// Platform abstracts the custom event backend (Wails v3 EventManager).
|
||||
// Emit fires an event by name with optional data arguments.
|
||||
// On registers a persistent listener; returns a cancel function.
|
||||
// Off removes all listeners for the named event.
|
||||
// OnMultiple registers a listener that auto-deregisters after counter firings.
|
||||
// Reset removes all custom event listeners.
|
||||
//
|
||||
// cancel := platform.On("build:done", func(e *CustomEvent) { ... })
|
||||
// defer cancel()
|
||||
// platform.Emit("build:done", result)
|
||||
type Platform interface {
|
||||
Emit(name string, data ...any) bool
|
||||
On(name string, callback func(event *CustomEvent)) func()
|
||||
Off(name string)
|
||||
OnMultiple(name string, callback func(event *CustomEvent), counter int)
|
||||
Reset()
|
||||
}
|
||||
|
||||
// CustomEvent is the event object delivered to On/OnMultiple listeners.
|
||||
type CustomEvent struct {
|
||||
Name string
|
||||
Data any
|
||||
}
|
||||
16
pkg/events/register.go
Normal file
16
pkg/events/register.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// pkg/events/register.go
|
||||
package events
|
||||
|
||||
import "forge.lthn.ai/core/go/pkg/core"
|
||||
|
||||
// Register(p) binds the events service to a Core instance.
|
||||
// core.WithService(events.Register(wailsEventManager))
|
||||
func Register(p Platform) func(*core.Core) (any, error) {
|
||||
return func(c *core.Core) (any, error) {
|
||||
return &Service{
|
||||
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
|
||||
platform: p,
|
||||
counts: make(map[string]int),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
103
pkg/events/service.go
Normal file
103
pkg/events/service.go
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
// pkg/events/service.go
|
||||
package events
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"forge.lthn.ai/core/go/pkg/core"
|
||||
)
|
||||
|
||||
// Options holds configuration for the events service (currently none).
|
||||
type Options struct{}
|
||||
|
||||
// Service bridges the platform custom event system to the Core IPC bus.
|
||||
type Service struct {
|
||||
*core.ServiceRuntime[Options]
|
||||
platform Platform
|
||||
mu sync.RWMutex
|
||||
counts map[string]int // listener count per event name
|
||||
cancels []func()
|
||||
}
|
||||
|
||||
func (s *Service) OnStartup(ctx context.Context) error {
|
||||
s.Core().RegisterQuery(s.handleQuery)
|
||||
s.Core().RegisterTask(s.handleTask)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) OnShutdown(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, cancel := range s.cancels {
|
||||
cancel()
|
||||
}
|
||||
s.cancels = nil
|
||||
s.counts = make(map[string]int)
|
||||
s.platform.Reset()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
|
||||
switch q := q.(type) {
|
||||
case QueryListeners:
|
||||
s.mu.RLock()
|
||||
count := s.counts[q.Name]
|
||||
s.mu.RUnlock()
|
||||
return count, true, nil
|
||||
default:
|
||||
return nil, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
||||
switch t := t.(type) {
|
||||
case TaskEmit:
|
||||
return s.taskEmit(t)
|
||||
case TaskOn:
|
||||
return s.taskOn(t)
|
||||
case TaskOff:
|
||||
return nil, true, s.taskOff(t)
|
||||
default:
|
||||
return nil, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) taskEmit(t TaskEmit) (any, bool, error) {
|
||||
if t.Name == "" {
|
||||
return nil, true, coreerr.E("events.taskEmit", "event name is required", nil)
|
||||
}
|
||||
cancelled := s.platform.Emit(t.Name, t.Data)
|
||||
_ = s.Core().ACTION(ActionEventFired{Name: t.Name, Data: t.Data})
|
||||
return cancelled, true, nil
|
||||
}
|
||||
|
||||
func (s *Service) taskOn(t TaskOn) (any, bool, error) {
|
||||
if t.Name == "" {
|
||||
return nil, true, coreerr.E("events.taskOn", "event name is required", nil)
|
||||
}
|
||||
cancel := s.platform.On(t.Name, func(event *CustomEvent) {
|
||||
_ = s.Core().ACTION(ActionEventFired{Name: event.Name, Data: event.Data})
|
||||
})
|
||||
s.mu.Lock()
|
||||
s.cancels = append(s.cancels, cancel)
|
||||
s.counts[t.Name]++
|
||||
s.mu.Unlock()
|
||||
return nil, true, nil
|
||||
}
|
||||
|
||||
func (s *Service) taskOff(t TaskOff) error {
|
||||
if t.Name == "" {
|
||||
return coreerr.E("events.taskOff", "event name is required", nil)
|
||||
}
|
||||
s.platform.Off(t.Name)
|
||||
s.mu.Lock()
|
||||
delete(s.counts, t.Name)
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
272
pkg/events/service_test.go
Normal file
272
pkg/events/service_test.go
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
// pkg/events/service_test.go
|
||||
package events
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// --- Mock Platform ---
|
||||
|
||||
type mockPlatform struct {
|
||||
mu sync.Mutex
|
||||
listeners map[string][]*mockListener
|
||||
emitted []mockEmit
|
||||
}
|
||||
|
||||
type mockListener struct {
|
||||
callback func(*CustomEvent)
|
||||
}
|
||||
|
||||
type mockEmit struct {
|
||||
name string
|
||||
data any
|
||||
}
|
||||
|
||||
func newMockPlatform() *mockPlatform {
|
||||
return &mockPlatform{
|
||||
listeners: make(map[string][]*mockListener),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockPlatform) Emit(name string, data ...any) bool {
|
||||
m.mu.Lock()
|
||||
var payload any
|
||||
if len(data) == 1 {
|
||||
payload = data[0]
|
||||
} else if len(data) > 1 {
|
||||
payload = data
|
||||
}
|
||||
m.emitted = append(m.emitted, mockEmit{name: name, data: payload})
|
||||
listeners := append([]*mockListener(nil), m.listeners[name]...)
|
||||
m.mu.Unlock()
|
||||
|
||||
event := &CustomEvent{Name: name, Data: payload}
|
||||
for _, l := range listeners {
|
||||
l.callback(event)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *mockPlatform) On(name string, callback func(*CustomEvent)) func() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
listener := &mockListener{callback: callback}
|
||||
m.listeners[name] = append(m.listeners[name], listener)
|
||||
return func() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
slice := m.listeners[name]
|
||||
for i, l := range slice {
|
||||
if l == listener {
|
||||
m.listeners[name] = append(slice[:i], slice[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockPlatform) Off(name string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.listeners, name)
|
||||
}
|
||||
|
||||
func (m *mockPlatform) OnMultiple(name string, callback func(*CustomEvent), counter int) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
listener := &mockListener{callback: callback}
|
||||
m.listeners[name] = append(m.listeners[name], listener)
|
||||
}
|
||||
|
||||
func (m *mockPlatform) Reset() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.listeners = make(map[string][]*mockListener)
|
||||
}
|
||||
|
||||
func (m *mockPlatform) listenerCount(name string) int {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return len(m.listeners[name])
|
||||
}
|
||||
|
||||
func (m *mockPlatform) emitCount() int {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return len(m.emitted)
|
||||
}
|
||||
|
||||
// --- Test helpers ---
|
||||
|
||||
func newTestEventsService(t *testing.T) (*Service, *core.Core, *mockPlatform) {
|
||||
t.Helper()
|
||||
mock := newMockPlatform()
|
||||
c, err := core.New(
|
||||
core.WithService(Register(mock)),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
||||
svc := core.MustServiceFor[*Service](c, "events")
|
||||
return svc, c, mock
|
||||
}
|
||||
|
||||
// --- Good tests ---
|
||||
|
||||
func TestRegister_Good(t *testing.T) {
|
||||
svc, _, _ := newTestEventsService(t)
|
||||
assert.NotNil(t, svc)
|
||||
}
|
||||
|
||||
func TestTaskEmit_Good(t *testing.T) {
|
||||
_, c, mock := newTestEventsService(t)
|
||||
|
||||
var fired ActionEventFired
|
||||
c.RegisterAction(func(_ *core.Core, msg core.Message) error {
|
||||
if a, ok := msg.(ActionEventFired); ok {
|
||||
fired = a
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
_, handled, err := c.PERFORM(TaskEmit{Name: "ping", Data: "hello"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.Equal(t, 1, mock.emitCount())
|
||||
assert.Equal(t, "ping", fired.Name)
|
||||
assert.Equal(t, "hello", fired.Data)
|
||||
}
|
||||
|
||||
func TestTaskOn_Good(t *testing.T) {
|
||||
_, c, mock := newTestEventsService(t)
|
||||
|
||||
_, handled, err := c.PERFORM(TaskOn{Name: "build:done"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.Equal(t, 1, mock.listenerCount("build:done"))
|
||||
}
|
||||
|
||||
func TestTaskOff_Good(t *testing.T) {
|
||||
_, c, mock := newTestEventsService(t)
|
||||
|
||||
_, _, _ = c.PERFORM(TaskOn{Name: "build:done"})
|
||||
assert.Equal(t, 1, mock.listenerCount("build:done"))
|
||||
|
||||
_, handled, err := c.PERFORM(TaskOff{Name: "build:done"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.Equal(t, 0, mock.listenerCount("build:done"))
|
||||
}
|
||||
|
||||
func TestQueryListeners_Good(t *testing.T) {
|
||||
_, c, _ := newTestEventsService(t)
|
||||
|
||||
_, _, _ = c.PERFORM(TaskOn{Name: "my:event"})
|
||||
_, _, _ = c.PERFORM(TaskOn{Name: "my:event"})
|
||||
|
||||
result, handled, err := c.QUERY(QueryListeners{Name: "my:event"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.Equal(t, 2, result.(int))
|
||||
}
|
||||
|
||||
func TestOnShutdown_ResetsAll_Good(t *testing.T) {
|
||||
svc, _, mock := newTestEventsService(t)
|
||||
|
||||
_, _, _ = svc.Core().PERFORM(TaskOn{Name: "a"})
|
||||
_, _, _ = svc.Core().PERFORM(TaskOn{Name: "b"})
|
||||
|
||||
err := svc.OnShutdown(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 0, mock.listenerCount("a"))
|
||||
assert.Equal(t, 0, mock.listenerCount("b"))
|
||||
|
||||
result, _, _ := svc.Core().QUERY(QueryListeners{Name: "a"})
|
||||
assert.Equal(t, 0, result.(int))
|
||||
}
|
||||
|
||||
// --- Bad tests ---
|
||||
|
||||
func TestTaskEmit_Bad_EmptyName(t *testing.T) {
|
||||
_, c, _ := newTestEventsService(t)
|
||||
_, handled, err := c.PERFORM(TaskEmit{Name: ""})
|
||||
assert.True(t, handled)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestTaskOn_Bad_EmptyName(t *testing.T) {
|
||||
_, c, _ := newTestEventsService(t)
|
||||
_, handled, err := c.PERFORM(TaskOn{Name: ""})
|
||||
assert.True(t, handled)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestTaskOff_Bad_EmptyName(t *testing.T) {
|
||||
_, c, _ := newTestEventsService(t)
|
||||
_, handled, err := c.PERFORM(TaskOff{Name: ""})
|
||||
assert.True(t, handled)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestQueryListeners_Bad_NoService(t *testing.T) {
|
||||
// No events service registered — query is not handled
|
||||
c, err := core.New(core.WithServiceLock())
|
||||
require.NoError(t, err)
|
||||
_, handled, _ := c.QUERY(QueryListeners{Name: "anything"})
|
||||
assert.False(t, handled)
|
||||
}
|
||||
|
||||
// --- Ugly tests ---
|
||||
|
||||
func TestTaskEmit_Ugly_NilData(t *testing.T) {
|
||||
_, c, mock := newTestEventsService(t)
|
||||
_, handled, err := c.PERFORM(TaskEmit{Name: "null:event", Data: nil})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.Equal(t, 1, mock.emitCount())
|
||||
}
|
||||
|
||||
func TestTaskOn_Ugly_MultipleListenersSameEvent(t *testing.T) {
|
||||
_, c, mock := newTestEventsService(t)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
_, _, _ = c.PERFORM(TaskOn{Name: "flood"})
|
||||
}
|
||||
|
||||
result, _, _ := c.QUERY(QueryListeners{Name: "flood"})
|
||||
assert.Equal(t, 5, result.(int))
|
||||
assert.Equal(t, 5, mock.listenerCount("flood"))
|
||||
}
|
||||
|
||||
func TestTaskOff_Ugly_NonExistentEvent(t *testing.T) {
|
||||
// Off on unknown event name should not error
|
||||
_, c, _ := newTestEventsService(t)
|
||||
_, handled, err := c.PERFORM(TaskOff{Name: "never-registered"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
}
|
||||
|
||||
func TestTaskEmit_Ugly_ActionBroadcastOnEachEmit(t *testing.T) {
|
||||
_, c, _ := newTestEventsService(t)
|
||||
|
||||
var count int
|
||||
c.RegisterAction(func(_ *core.Core, msg core.Message) error {
|
||||
if _, ok := msg.(ActionEventFired); ok {
|
||||
count++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
_, _, _ = c.PERFORM(TaskEmit{Name: "tick"})
|
||||
}
|
||||
|
||||
assert.Equal(t, 3, count)
|
||||
}
|
||||
|
|
@ -1,40 +1,41 @@
|
|||
// pkg/keybinding/messages.go
|
||||
package keybinding
|
||||
|
||||
import corego "dappco.re/go/core"
|
||||
import coreerr "forge.lthn.ai/core/go-log"
|
||||
|
||||
// ErrAlreadyRegistered is returned when attempting to add a binding
|
||||
// that already exists. Callers must TaskRemove first to rebind.
|
||||
var ErrAlreadyRegistered = corego.NewError("keybinding: accelerator already registered")
|
||||
// ErrorAlreadyRegistered is returned by TaskAdd when the accelerator is already bound.
|
||||
var ErrorAlreadyRegistered = coreerr.NewError("keybinding: accelerator already registered")
|
||||
|
||||
// BindingInfo describes a registered keyboard shortcut.
|
||||
// ErrorNotRegistered is returned by TaskRemove and TaskProcess when the accelerator is unknown.
|
||||
var ErrorNotRegistered = coreerr.NewError("keybinding: accelerator not registered")
|
||||
|
||||
// BindingInfo describes a registered global key binding.
|
||||
type BindingInfo struct {
|
||||
Accelerator string `json:"accelerator"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// --- Queries ---
|
||||
|
||||
// QueryList returns all registered bindings. Result: []BindingInfo
|
||||
// QueryList returns all registered key bindings. Result: []BindingInfo
|
||||
type QueryList struct{}
|
||||
|
||||
// --- Tasks ---
|
||||
|
||||
// TaskAdd registers a new keyboard shortcut. Result: nil
|
||||
// Returns ErrAlreadyRegistered if the accelerator is already bound.
|
||||
// TaskAdd registers a global key binding. Error: ErrorAlreadyRegistered if accelerator taken.
|
||||
type TaskAdd struct {
|
||||
Accelerator string `json:"accelerator"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// TaskRemove unregisters a keyboard shortcut. Result: nil
|
||||
// TaskRemove unregisters a global key binding by accelerator.
|
||||
type TaskRemove struct {
|
||||
Accelerator string `json:"accelerator"`
|
||||
}
|
||||
|
||||
// --- Actions ---
|
||||
// TaskProcess programmatically triggers a registered key binding as if the user pressed it.
|
||||
// Error: ErrorNotRegistered if the accelerator has not been registered.
|
||||
// _, _, err := c.PERFORM(TaskProcess{Accelerator: "Ctrl+S"})
|
||||
type TaskProcess struct {
|
||||
Accelerator string `json:"accelerator"`
|
||||
}
|
||||
|
||||
// ActionTriggered is broadcast when a registered shortcut is activated.
|
||||
// ActionTriggered is broadcast when a registered key binding fires.
|
||||
type ActionTriggered struct {
|
||||
Accelerator string `json:"accelerator"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@ type Platform interface {
|
|||
// Remove unregisters a previously registered keyboard shortcut.
|
||||
Remove(accelerator string) error
|
||||
|
||||
// Process programmatically triggers the shortcut as if the user pressed it.
|
||||
// Returns an error if the platform cannot trigger the shortcut.
|
||||
// p.Process("Ctrl+S")
|
||||
Process(accelerator string) error
|
||||
|
||||
// GetAll returns all currently registered accelerator strings.
|
||||
// Used for adapter-level reconciliation only — not read by QueryList.
|
||||
GetAll() []string
|
||||
|
|
|
|||
|
|
@ -1,16 +1,15 @@
|
|||
// pkg/keybinding/register.go
|
||||
package keybinding
|
||||
|
||||
import "forge.lthn.ai/core/go/pkg/core"
|
||||
|
||||
// Register creates a factory closure that captures the Platform adapter.
|
||||
// The returned function has the signature WithService requires: func(*Core) (any, error).
|
||||
// Register(p) binds the keybinding service to a Core instance.
|
||||
// core.WithService(keybinding.Register(wailsKeybinding))
|
||||
func Register(p Platform) func(*core.Core) (any, error) {
|
||||
return func(c *core.Core) (any, error) {
|
||||
return &Service{
|
||||
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
|
||||
platform: p,
|
||||
bindings: make(map[string]BindingInfo),
|
||||
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
|
||||
platform: p,
|
||||
registeredBindings: make(map[string]BindingInfo),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,30 +4,24 @@ package keybinding
|
|||
import (
|
||||
"context"
|
||||
|
||||
corego "dappco.re/go/core"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"forge.lthn.ai/core/go/pkg/core"
|
||||
)
|
||||
|
||||
// Options holds configuration for the keybinding service.
|
||||
type Options struct{}
|
||||
|
||||
// Service is a core.Service managing keyboard shortcuts via IPC.
|
||||
// It maintains an in-memory registry of bindings and delegates
|
||||
// platform-level registration to the Platform interface.
|
||||
type Service struct {
|
||||
*core.ServiceRuntime[Options]
|
||||
platform Platform
|
||||
bindings map[string]BindingInfo
|
||||
platform Platform
|
||||
registeredBindings map[string]BindingInfo
|
||||
}
|
||||
|
||||
// OnStartup registers IPC handlers.
|
||||
func (s *Service) OnStartup(ctx context.Context) error {
|
||||
s.Core().RegisterQuery(s.handleQuery)
|
||||
s.Core().RegisterTask(s.handleTask)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleIPCEvents is auto-discovered and registered by core.WithService.
|
||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -43,10 +37,9 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
|
|||
}
|
||||
}
|
||||
|
||||
// queryList reads from the in-memory registry (not platform.GetAll()).
|
||||
func (s *Service) queryList() []BindingInfo {
|
||||
result := make([]BindingInfo, 0, len(s.bindings))
|
||||
for _, info := range s.bindings {
|
||||
result := make([]BindingInfo, 0, len(s.registeredBindings))
|
||||
for _, info := range s.registeredBindings {
|
||||
result = append(result, info)
|
||||
}
|
||||
return result
|
||||
|
|
@ -60,14 +53,16 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
|||
return nil, true, s.taskAdd(t)
|
||||
case TaskRemove:
|
||||
return nil, true, s.taskRemove(t)
|
||||
case TaskProcess:
|
||||
return nil, true, s.taskProcess(t)
|
||||
default:
|
||||
return nil, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) taskAdd(t TaskAdd) error {
|
||||
if _, exists := s.bindings[t.Accelerator]; exists {
|
||||
return ErrAlreadyRegistered
|
||||
if _, exists := s.registeredBindings[t.Accelerator]; exists {
|
||||
return ErrorAlreadyRegistered
|
||||
}
|
||||
|
||||
// Register on platform with a callback that broadcasts ActionTriggered
|
||||
|
|
@ -75,10 +70,10 @@ func (s *Service) taskAdd(t TaskAdd) error {
|
|||
_ = s.Core().ACTION(ActionTriggered{Accelerator: t.Accelerator})
|
||||
})
|
||||
if err != nil {
|
||||
return corego.Wrap(err, "keybinding.add", "platform add failed")
|
||||
return coreerr.E("keybinding.taskAdd", "platform add failed", err)
|
||||
}
|
||||
|
||||
s.bindings[t.Accelerator] = BindingInfo{
|
||||
s.registeredBindings[t.Accelerator] = BindingInfo{
|
||||
Accelerator: t.Accelerator,
|
||||
Description: t.Description,
|
||||
}
|
||||
|
|
@ -86,15 +81,28 @@ func (s *Service) taskAdd(t TaskAdd) error {
|
|||
}
|
||||
|
||||
func (s *Service) taskRemove(t TaskRemove) error {
|
||||
if _, exists := s.bindings[t.Accelerator]; !exists {
|
||||
return corego.E("keybinding.remove", corego.Sprintf("not registered: %s", t.Accelerator), nil)
|
||||
if _, exists := s.registeredBindings[t.Accelerator]; !exists {
|
||||
return ErrorNotRegistered
|
||||
}
|
||||
|
||||
err := s.platform.Remove(t.Accelerator)
|
||||
if err != nil {
|
||||
return corego.Wrap(err, "keybinding.remove", "platform remove failed")
|
||||
return coreerr.E("keybinding.taskRemove", "platform remove failed", err)
|
||||
}
|
||||
|
||||
delete(s.registeredBindings, t.Accelerator)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) taskProcess(t TaskProcess) error {
|
||||
if _, exists := s.registeredBindings[t.Accelerator]; !exists {
|
||||
return ErrorNotRegistered
|
||||
}
|
||||
|
||||
err := s.platform.Process(t.Accelerator)
|
||||
if err != nil {
|
||||
return coreerr.E("keybinding.taskProcess", "platform process failed", err)
|
||||
}
|
||||
|
||||
delete(s.bindings, t.Accelerator)
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,11 +11,13 @@ import (
|
|||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// mockPlatform records Add/Remove calls and allows triggering shortcuts.
|
||||
// mockPlatform records Add/Remove/Process calls and allows triggering shortcuts.
|
||||
type mockPlatform struct {
|
||||
mu sync.Mutex
|
||||
handlers map[string]func()
|
||||
removed []string
|
||||
mu sync.Mutex
|
||||
handlers map[string]func()
|
||||
removed []string
|
||||
processed []string
|
||||
processErr error
|
||||
}
|
||||
|
||||
func newMockPlatform() *mockPlatform {
|
||||
|
|
@ -37,6 +39,16 @@ func (m *mockPlatform) Remove(accelerator string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (m *mockPlatform) Process(accelerator string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.processErr != nil {
|
||||
return m.processErr
|
||||
}
|
||||
m.processed = append(m.processed, accelerator)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockPlatform) GetAll() []string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
|
@ -99,7 +111,7 @@ func TestTaskAdd_Bad_Duplicate(t *testing.T) {
|
|||
// Second add with same accelerator should fail
|
||||
_, handled, err := c.PERFORM(TaskAdd{Accelerator: "Ctrl+S", Description: "Save Again"})
|
||||
assert.True(t, handled)
|
||||
assert.ErrorIs(t, err, ErrAlreadyRegistered)
|
||||
assert.ErrorIs(t, err, ErrorAlreadyRegistered)
|
||||
}
|
||||
|
||||
func TestTaskRemove_Good(t *testing.T) {
|
||||
|
|
@ -121,7 +133,7 @@ func TestTaskRemove_Bad_NotFound(t *testing.T) {
|
|||
|
||||
_, handled, err := c.PERFORM(TaskRemove{Accelerator: "Ctrl+X"})
|
||||
assert.True(t, handled)
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrorNotRegistered)
|
||||
}
|
||||
|
||||
func TestQueryList_Good(t *testing.T) {
|
||||
|
|
@ -199,3 +211,47 @@ func TestQueryList_Bad_NoService(t *testing.T) {
|
|||
_, handled, _ := c.QUERY(QueryList{})
|
||||
assert.False(t, handled)
|
||||
}
|
||||
|
||||
func TestTaskProcess_Good(t *testing.T) {
|
||||
mp := newMockPlatform()
|
||||
_, c := newTestKeybindingService(t, mp)
|
||||
|
||||
_, _, _ = c.PERFORM(TaskAdd{Accelerator: "Ctrl+P", Description: "Print"})
|
||||
|
||||
_, handled, err := c.PERFORM(TaskProcess{Accelerator: "Ctrl+P"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.Contains(t, mp.processed, "Ctrl+P")
|
||||
}
|
||||
|
||||
func TestTaskProcess_Bad_NotRegistered(t *testing.T) {
|
||||
mp := newMockPlatform()
|
||||
_, c := newTestKeybindingService(t, mp)
|
||||
|
||||
_, handled, err := c.PERFORM(TaskProcess{Accelerator: "Ctrl+Z"})
|
||||
assert.True(t, handled)
|
||||
assert.ErrorIs(t, err, ErrorNotRegistered)
|
||||
}
|
||||
|
||||
func TestTaskProcess_Bad_NoService(t *testing.T) {
|
||||
c, _ := core.New(core.WithServiceLock())
|
||||
_, handled, _ := c.PERFORM(TaskProcess{})
|
||||
assert.False(t, handled)
|
||||
}
|
||||
|
||||
func TestTaskProcess_Bad_PlatformError(t *testing.T) {
|
||||
mp := newMockPlatform()
|
||||
mp.processErr = assert.AnError
|
||||
_, c := newTestKeybindingService(t, mp)
|
||||
|
||||
_, _, _ = c.PERFORM(TaskAdd{Accelerator: "Ctrl+P", Description: "Print"})
|
||||
_, handled, err := c.PERFORM(TaskProcess{Accelerator: "Ctrl+P"})
|
||||
assert.True(t, handled)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestErrorNotRegistered_Ugly(t *testing.T) {
|
||||
// ErrorNotRegistered and ErrorAlreadyRegistered must be distinct sentinels.
|
||||
assert.NotEqual(t, ErrorNotRegistered, ErrorAlreadyRegistered)
|
||||
assert.NotErrorIs(t, ErrorNotRegistered, ErrorAlreadyRegistered)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
// pkg/lifecycle/register.go
|
||||
package lifecycle
|
||||
|
||||
import "forge.lthn.ai/core/go/pkg/core"
|
||||
|
||||
// Register creates a factory closure that captures the Platform adapter.
|
||||
// The returned function has the signature WithService requires: func(*Core) (any, error).
|
||||
// Register(p) binds the lifecycle service to a Core instance.
|
||||
// core.WithService(lifecycle.Register(wailsLifecycle))
|
||||
func Register(p Platform) func(*core.Core) (any, error) {
|
||||
return func(c *core.Core) (any, error) {
|
||||
return &Service{
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// pkg/lifecycle/service.go
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
|
|
@ -7,22 +6,15 @@ import (
|
|||
"forge.lthn.ai/core/go/pkg/core"
|
||||
)
|
||||
|
||||
// Options holds configuration for the lifecycle service.
|
||||
type Options struct{}
|
||||
|
||||
// Service is a core.Service that registers platform lifecycle callbacks
|
||||
// and broadcasts corresponding IPC Actions. It implements both Startable
|
||||
// and Stoppable: OnStartup registers all callbacks, OnShutdown cancels them.
|
||||
type Service struct {
|
||||
*core.ServiceRuntime[Options]
|
||||
platform Platform
|
||||
cancels []func()
|
||||
}
|
||||
|
||||
// OnStartup registers a platform callback for each EventType and for file-open.
|
||||
// Each callback broadcasts the corresponding Action via s.Core().ACTION().
|
||||
func (s *Service) OnStartup(ctx context.Context) error {
|
||||
// Register fire-and-forget event callbacks
|
||||
eventActions := map[EventType]func(){
|
||||
EventApplicationStarted: func() { _ = s.Core().ACTION(ActionApplicationStarted{}) },
|
||||
EventWillTerminate: func() { _ = s.Core().ACTION(ActionWillTerminate{}) },
|
||||
|
|
@ -38,7 +30,6 @@ func (s *Service) OnStartup(ctx context.Context) error {
|
|||
s.cancels = append(s.cancels, cancel)
|
||||
}
|
||||
|
||||
// Register file-open callback (carries data)
|
||||
cancel := s.platform.OnOpenedWithFile(func(path string) {
|
||||
_ = s.Core().ACTION(ActionOpenedWithFile{Path: path})
|
||||
})
|
||||
|
|
@ -47,7 +38,6 @@ func (s *Service) OnStartup(ctx context.Context) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// OnShutdown cancels all registered platform callbacks.
|
||||
func (s *Service) OnShutdown(ctx context.Context) error {
|
||||
for _, cancel := range s.cancels {
|
||||
cancel()
|
||||
|
|
@ -56,8 +46,6 @@ func (s *Service) OnShutdown(ctx context.Context) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// HandleIPCEvents is auto-discovered and registered by core.WithService.
|
||||
// Lifecycle events are all outbound (platform -> IPC) so there is nothing to handle here.
|
||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
195
pkg/mcp/layout_helpers.go
Normal file
195
pkg/mcp/layout_helpers.go
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
package mcp
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/core"
|
||||
"forge.lthn.ai/core/gui/pkg/screen"
|
||||
"forge.lthn.ai/core/gui/pkg/window"
|
||||
)
|
||||
|
||||
func (s *Subsystem) allWindows() ([]window.WindowInfo, error) {
|
||||
result, _, err := s.core.QUERY(window.QueryWindowList{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
windows, _ := result.([]window.WindowInfo)
|
||||
return windows, nil
|
||||
}
|
||||
|
||||
func (s *Subsystem) allScreens() ([]screen.Screen, error) {
|
||||
result, _, err := s.core.QUERY(screen.QueryAll{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
screens, _ := result.([]screen.Screen)
|
||||
return screens, nil
|
||||
}
|
||||
|
||||
func (s *Subsystem) primaryScreen() (*screen.Screen, error) {
|
||||
result, _, err := s.core.QUERY(screen.QueryPrimary{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scr, _ := result.(*screen.Screen)
|
||||
return scr, nil
|
||||
}
|
||||
|
||||
func (s *Subsystem) screenByID(id string) (*screen.Screen, error) {
|
||||
if id == "" {
|
||||
return nil, nil
|
||||
}
|
||||
result, _, err := s.core.QUERY(screen.QueryByID{ID: id})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scr, _ := result.(*screen.Screen)
|
||||
return scr, nil
|
||||
}
|
||||
|
||||
func screenForWindowInfo(screens []screen.Screen, info window.WindowInfo) *screen.Screen {
|
||||
cx := info.X + info.Width/2
|
||||
cy := info.Y + info.Height/2
|
||||
for i := range screens {
|
||||
if screens[i].Bounds.Contains(cx, cy) {
|
||||
return &screens[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func chooseScreenByIDOrPrimary(screens []screen.Screen, screenID string) *screen.Screen {
|
||||
if screenID != "" {
|
||||
for i := range screens {
|
||||
if screens[i].ID == screenID {
|
||||
return &screens[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range screens {
|
||||
if screens[i].IsPrimary {
|
||||
return &screens[i]
|
||||
}
|
||||
}
|
||||
if len(screens) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &screens[0]
|
||||
}
|
||||
|
||||
func workAreaRect(scr *screen.Screen) screen.Rect {
|
||||
if scr == nil {
|
||||
return screen.Rect{}
|
||||
}
|
||||
if scr.WorkArea.Width > 0 && scr.WorkArea.Height > 0 {
|
||||
return scr.WorkArea
|
||||
}
|
||||
return scr.Bounds
|
||||
}
|
||||
|
||||
func uniqueSorted(values []int) []int {
|
||||
sort.Ints(values)
|
||||
if len(values) == 0 {
|
||||
return values
|
||||
}
|
||||
out := values[:1]
|
||||
for _, value := range values[1:] {
|
||||
if value != out[len(out)-1] {
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func clipRectToWorkArea(rect, workArea screen.Rect) (screen.Rect, bool) {
|
||||
x1 := max(rect.X, workArea.X)
|
||||
y1 := max(rect.Y, workArea.Y)
|
||||
x2 := min(rect.X+rect.Width, workArea.X+workArea.Width)
|
||||
y2 := min(rect.Y+rect.Height, workArea.Y+workArea.Height)
|
||||
if x2 <= x1 || y2 <= y1 {
|
||||
return screen.Rect{}, false
|
||||
}
|
||||
return screen.Rect{X: x1, Y: y1, Width: x2 - x1, Height: y2 - y1}, true
|
||||
}
|
||||
|
||||
func findLargestFreeRect(workArea screen.Rect, occupied []screen.Rect, minWidth, minHeight int) (screen.Rect, bool) {
|
||||
xs := []int{workArea.X, workArea.X + workArea.Width}
|
||||
ys := []int{workArea.Y, workArea.Y + workArea.Height}
|
||||
|
||||
for _, rect := range occupied {
|
||||
clipped, ok := clipRectToWorkArea(rect, workArea)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
xs = append(xs, clipped.X, clipped.X+clipped.Width)
|
||||
ys = append(ys, clipped.Y, clipped.Y+clipped.Height)
|
||||
}
|
||||
|
||||
xs = uniqueSorted(xs)
|
||||
ys = uniqueSorted(ys)
|
||||
|
||||
bestArea := -1
|
||||
best := screen.Rect{}
|
||||
|
||||
for xi := 0; xi < len(xs)-1; xi++ {
|
||||
for xj := xi + 1; xj < len(xs); xj++ {
|
||||
width := xs[xj] - xs[xi]
|
||||
if width < minWidth {
|
||||
continue
|
||||
}
|
||||
for yi := 0; yi < len(ys)-1; yi++ {
|
||||
for yj := yi + 1; yj < len(ys); yj++ {
|
||||
height := ys[yj] - ys[yi]
|
||||
if height < minHeight {
|
||||
continue
|
||||
}
|
||||
candidate := screen.Rect{X: xs[xi], Y: ys[yi], Width: width, Height: height}
|
||||
if candidate.X < workArea.X || candidate.Y < workArea.Y ||
|
||||
candidate.X+candidate.Width > workArea.X+workArea.Width ||
|
||||
candidate.Y+candidate.Height > workArea.Y+workArea.Height {
|
||||
continue
|
||||
}
|
||||
overlaps := false
|
||||
for _, occ := range occupied {
|
||||
if candidate.Overlaps(occ) {
|
||||
overlaps = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if overlaps {
|
||||
continue
|
||||
}
|
||||
area := candidate.Width * candidate.Height
|
||||
if area > bestArea || (area == bestArea && (candidate.Y < best.Y || (candidate.Y == best.Y && candidate.X < best.X))) {
|
||||
bestArea = area
|
||||
best = candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best, bestArea >= 0
|
||||
}
|
||||
|
||||
func applyRect(c *core.Core, windowName string, rect screen.Rect) error {
|
||||
if _, _, err := c.PERFORM(window.TaskSetPosition{Name: windowName, X: rect.X, Y: rect.Y}); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _, err := c.PERFORM(window.TaskSetSize{Name: windowName, Width: rect.Width, Height: rect.Height})
|
||||
return err
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
|
@ -6,13 +6,10 @@ import (
|
|||
"testing"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/core"
|
||||
"dappco.re/go/core/gui/pkg/clipboard"
|
||||
"dappco.re/go/core/gui/pkg/display"
|
||||
"dappco.re/go/core/gui/pkg/environment"
|
||||
"dappco.re/go/core/gui/pkg/notification"
|
||||
"dappco.re/go/core/gui/pkg/screen"
|
||||
"dappco.re/go/core/gui/pkg/webview"
|
||||
"dappco.re/go/core/gui/pkg/window"
|
||||
"forge.lthn.ai/core/gui/pkg/clipboard"
|
||||
"forge.lthn.ai/core/gui/pkg/environment"
|
||||
"forge.lthn.ai/core/gui/pkg/screen"
|
||||
"forge.lthn.ai/core/gui/pkg/window"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
|
@ -20,13 +17,13 @@ import (
|
|||
|
||||
func TestSubsystem_Good_Name(t *testing.T) {
|
||||
c, _ := core.New(core.WithServiceLock())
|
||||
sub := New(c)
|
||||
sub := NewSubsystem(c)
|
||||
assert.Equal(t, "display", sub.Name())
|
||||
}
|
||||
|
||||
func TestSubsystem_Good_RegisterTools(t *testing.T) {
|
||||
c, _ := core.New(core.WithServiceLock())
|
||||
sub := New(c)
|
||||
sub := NewSubsystem(c)
|
||||
// RegisterTools should not panic with a real mcp.Server
|
||||
server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.1.0"}, nil)
|
||||
assert.NotPanics(t, func() { sub.RegisterTools(server) })
|
||||
|
|
@ -35,61 +32,19 @@ func TestSubsystem_Good_RegisterTools(t *testing.T) {
|
|||
// Integration test: verify the IPC round-trip that MCP tool handlers use.
|
||||
|
||||
type mockClipPlatform struct {
|
||||
text string
|
||||
ok bool
|
||||
text string
|
||||
ok bool
|
||||
image []byte
|
||||
hasImage bool
|
||||
}
|
||||
|
||||
func (m *mockClipPlatform) Text() (string, bool) { return m.text, m.ok }
|
||||
func (m *mockClipPlatform) SetText(t string) bool { m.text = t; m.ok = t != ""; return true }
|
||||
|
||||
type mockNotificationPlatform struct {
|
||||
sendCalled bool
|
||||
lastOpts notification.NotificationOptions
|
||||
}
|
||||
|
||||
func (m *mockNotificationPlatform) Send(opts notification.NotificationOptions) error {
|
||||
m.sendCalled = true
|
||||
m.lastOpts = opts
|
||||
return nil
|
||||
}
|
||||
func (m *mockNotificationPlatform) RequestPermission() (bool, error) { return true, nil }
|
||||
func (m *mockNotificationPlatform) CheckPermission() (bool, error) { return true, nil }
|
||||
|
||||
type mockEnvironmentPlatform struct {
|
||||
isDark bool
|
||||
}
|
||||
|
||||
func (m *mockEnvironmentPlatform) IsDarkMode() bool { return m.isDark }
|
||||
func (m *mockEnvironmentPlatform) Info() environment.EnvironmentInfo {
|
||||
return environment.EnvironmentInfo{}
|
||||
}
|
||||
func (m *mockEnvironmentPlatform) AccentColour() string { return "" }
|
||||
func (m *mockEnvironmentPlatform) OpenFileManager(path string, selectFile bool) error {
|
||||
return nil
|
||||
}
|
||||
func (m *mockEnvironmentPlatform) OnThemeChange(handler func(isDark bool)) func() {
|
||||
return func() {}
|
||||
}
|
||||
func (m *mockEnvironmentPlatform) SetTheme(isDark bool) error {
|
||||
m.isDark = isDark
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockScreenPlatform struct {
|
||||
screens []screen.Screen
|
||||
}
|
||||
|
||||
func (m *mockScreenPlatform) GetAll() []screen.Screen { return m.screens }
|
||||
func (m *mockScreenPlatform) GetPrimary() *screen.Screen {
|
||||
for i := range m.screens {
|
||||
if m.screens[i].IsPrimary {
|
||||
return &m.screens[i]
|
||||
}
|
||||
}
|
||||
if len(m.screens) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &m.screens[0]
|
||||
func (m *mockClipPlatform) Image() ([]byte, bool) { return m.image, m.hasImage }
|
||||
func (m *mockClipPlatform) SetImage(data []byte) bool {
|
||||
m.image = append([]byte(nil), data...)
|
||||
m.hasImage = len(data) > 0
|
||||
return true
|
||||
}
|
||||
|
||||
func TestMCP_Good_ClipboardRoundTrip(t *testing.T) {
|
||||
|
|
@ -280,3 +235,87 @@ func TestMCP_Bad_NoServices(t *testing.T) {
|
|||
_, handled, _ := c.QUERY(clipboard.QueryText{})
|
||||
assert.False(t, handled)
|
||||
}
|
||||
|
||||
type mockEnvPlatform struct {
|
||||
isDark bool
|
||||
}
|
||||
|
||||
func (m *mockEnvPlatform) IsDarkMode() bool { return m.isDark }
|
||||
func (m *mockEnvPlatform) Info() environment.EnvironmentInfo { return environment.EnvironmentInfo{} }
|
||||
func (m *mockEnvPlatform) AccentColour() string { return "" }
|
||||
func (m *mockEnvPlatform) OpenFileManager(path string, selectFile bool) error { return nil }
|
||||
func (m *mockEnvPlatform) HasFocusFollowsMouse() bool { return false }
|
||||
func (m *mockEnvPlatform) OnThemeChange(handler func(isDark bool)) func() {
|
||||
return func() {}
|
||||
}
|
||||
|
||||
type mockScreenPlatform struct {
|
||||
screens []screen.Screen
|
||||
}
|
||||
|
||||
func (m *mockScreenPlatform) GetAll() []screen.Screen { return m.screens }
|
||||
func (m *mockScreenPlatform) GetPrimary() *screen.Screen {
|
||||
for i := range m.screens {
|
||||
if m.screens[i].IsPrimary {
|
||||
return &m.screens[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (m *mockScreenPlatform) GetCurrent() *screen.Screen { return m.GetPrimary() }
|
||||
|
||||
func TestMCP_Good_ThemeSetRoundTrip(t *testing.T) {
|
||||
c, err := core.New(
|
||||
core.WithService(environment.Register(&mockEnvPlatform{isDark: true})),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
||||
|
||||
sub := NewSubsystem(c)
|
||||
_, output, err := sub.themeSet(context.Background(), nil, ThemeSetInput{Theme: "light"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, output.Success)
|
||||
|
||||
result, handled, err := c.QUERY(environment.QueryTheme{})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
theme := result.(environment.ThemeInfo)
|
||||
assert.Equal(t, "light", theme.Theme)
|
||||
assert.False(t, theme.IsDark)
|
||||
}
|
||||
|
||||
func TestMCP_Good_ScreenFindSpaceAndArrangePair(t *testing.T) {
|
||||
c, err := core.New(
|
||||
core.WithService(screen.Register(&mockScreenPlatform{screens: []screen.Screen{
|
||||
{
|
||||
ID: "1", Name: "Primary", IsPrimary: true,
|
||||
Bounds: screen.Rect{X: 0, Y: 0, Width: 1600, Height: 900},
|
||||
WorkArea: screen.Rect{X: 0, Y: 0, Width: 1600, Height: 900},
|
||||
},
|
||||
}})),
|
||||
core.WithService(window.Register(window.NewMockPlatform())),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
||||
|
||||
_, _, err = c.PERFORM(window.TaskOpenWindow{Window: &window.Window{Name: "editor", X: 0, Y: 0, Width: 800, Height: 900}})
|
||||
require.NoError(t, err)
|
||||
_, _, err = c.PERFORM(window.TaskOpenWindow{Window: &window.Window{Name: "preview", X: 800, Y: 0, Width: 800, Height: 450}})
|
||||
require.NoError(t, err)
|
||||
|
||||
sub := NewSubsystem(c)
|
||||
|
||||
_, free, err := sub.screenFindSpace(context.Background(), nil, ScreenFindSpaceInput{Width: 300, Height: 300})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "1", free.ScreenID)
|
||||
assert.Equal(t, screen.Rect{X: 800, Y: 450, Width: 800, Height: 450}, free.Bounds)
|
||||
|
||||
_, arranged, err := sub.windowArrangePair(context.Background(), nil, WindowArrangePairInput{
|
||||
First: "editor", Second: "preview",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, screen.Rect{X: 0, Y: 0, Width: 800, Height: 900}, arranged.FirstBounds)
|
||||
assert.Equal(t, screen.Rect{X: 800, Y: 0, Width: 800, Height: 900}, arranged.SecondBounds)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,21 +6,24 @@ import (
|
|||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// Subsystem implements the MCP Subsystem interface via structural typing.
|
||||
// It registers GUI tools that translate MCP tool calls to IPC messages.
|
||||
// Subsystem translates MCP tool calls to Core IPC messages for GUI operations.
|
||||
type Subsystem struct {
|
||||
core *core.Core
|
||||
}
|
||||
|
||||
// New creates a display MCP subsystem backed by the given Core instance.
|
||||
func New(c *core.Core) *Subsystem {
|
||||
// NewSubsystem creates the display MCP bridge for a Core instance.
|
||||
// sub := mcp.NewSubsystem(c); sub.RegisterTools(server)
|
||||
func NewSubsystem(c *core.Core) *Subsystem {
|
||||
return &Subsystem{core: c}
|
||||
}
|
||||
|
||||
// Name returns the subsystem identifier.
|
||||
// Deprecated: use NewSubsystem(c).
|
||||
func New(c *core.Core) *Subsystem {
|
||||
return NewSubsystem(c)
|
||||
}
|
||||
|
||||
func (s *Subsystem) Name() string { return "display" }
|
||||
|
||||
// RegisterTools registers all GUI tools with the MCP server.
|
||||
func (s *Subsystem) RegisterTools(server *mcp.Server) {
|
||||
s.registerWebviewTools(server)
|
||||
s.registerWindowTools(server)
|
||||
|
|
@ -36,4 +39,5 @@ func (s *Subsystem) RegisterTools(server *mcp.Server) {
|
|||
s.registerKeybindingTools(server)
|
||||
s.registerDockTools(server)
|
||||
s.registerLifecycleTools(server)
|
||||
s.registerEventTools(server)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,10 @@ package mcp
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
|
||||
corego "dappco.re/go/core"
|
||||
"dappco.re/go/core/gui/pkg/clipboard"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"forge.lthn.ai/core/gui/pkg/clipboard"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
|
|
@ -23,7 +24,7 @@ func (s *Subsystem) clipboardRead(_ context.Context, _ *mcp.CallToolRequest, _ C
|
|||
}
|
||||
content, ok := result.(clipboard.ClipboardContent)
|
||||
if !ok {
|
||||
return nil, ClipboardReadOutput{}, corego.E("mcp.clipboard", "unexpected result type from clipboard read query", nil)
|
||||
return nil, ClipboardReadOutput{}, coreerr.E("mcp.clipboardRead", "unexpected result type", nil)
|
||||
}
|
||||
return nil, ClipboardReadOutput{Content: content.Text}, nil
|
||||
}
|
||||
|
|
@ -44,7 +45,7 @@ func (s *Subsystem) clipboardWrite(_ context.Context, _ *mcp.CallToolRequest, in
|
|||
}
|
||||
success, ok := result.(bool)
|
||||
if !ok {
|
||||
return nil, ClipboardWriteOutput{}, corego.E("mcp.clipboard", "unexpected result type from clipboard write task", nil)
|
||||
return nil, ClipboardWriteOutput{}, coreerr.E("mcp.clipboardWrite", "unexpected result type", nil)
|
||||
}
|
||||
return nil, ClipboardWriteOutput{Success: success}, nil
|
||||
}
|
||||
|
|
@ -63,7 +64,7 @@ func (s *Subsystem) clipboardHas(_ context.Context, _ *mcp.CallToolRequest, _ Cl
|
|||
}
|
||||
content, ok := result.(clipboard.ClipboardContent)
|
||||
if !ok {
|
||||
return nil, ClipboardHasOutput{}, corego.E("mcp.clipboard", "unexpected result type from clipboard has query", nil)
|
||||
return nil, ClipboardHasOutput{}, coreerr.E("mcp.clipboardHas", "unexpected result type", nil)
|
||||
}
|
||||
return nil, ClipboardHasOutput{HasContent: content.HasContent}, nil
|
||||
}
|
||||
|
|
@ -82,7 +83,7 @@ func (s *Subsystem) clipboardClear(_ context.Context, _ *mcp.CallToolRequest, _
|
|||
}
|
||||
success, ok := result.(bool)
|
||||
if !ok {
|
||||
return nil, ClipboardClearOutput{}, corego.E("mcp.clipboard", "unexpected result type from clipboard clear task", nil)
|
||||
return nil, ClipboardClearOutput{}, coreerr.E("mcp.clipboardClear", "unexpected result type", nil)
|
||||
}
|
||||
return nil, ClipboardClearOutput{Success: success}, nil
|
||||
}
|
||||
|
|
@ -91,7 +92,7 @@ func (s *Subsystem) clipboardClear(_ context.Context, _ *mcp.CallToolRequest, _
|
|||
|
||||
type ClipboardReadImageInput struct{}
|
||||
type ClipboardReadImageOutput struct {
|
||||
Image clipboard.ClipboardImageContent `json:"image"`
|
||||
Base64 string `json:"base64"`
|
||||
}
|
||||
|
||||
func (s *Subsystem) clipboardReadImage(_ context.Context, _ *mcp.CallToolRequest, _ ClipboardReadImageInput) (*mcp.CallToolResult, ClipboardReadImageOutput, error) {
|
||||
|
|
@ -99,28 +100,39 @@ func (s *Subsystem) clipboardReadImage(_ context.Context, _ *mcp.CallToolRequest
|
|||
if err != nil {
|
||||
return nil, ClipboardReadImageOutput{}, err
|
||||
}
|
||||
image, ok := result.(clipboard.ClipboardImageContent)
|
||||
content, ok := result.(clipboard.ImageContent)
|
||||
if !ok {
|
||||
return nil, ClipboardReadImageOutput{}, corego.E("mcp.clipboard", "unexpected result type from clipboard image query", nil)
|
||||
return nil, ClipboardReadImageOutput{}, coreerr.E("mcp.clipboardReadImage", "unexpected result type", nil)
|
||||
}
|
||||
return nil, ClipboardReadImageOutput{Image: image}, nil
|
||||
if !content.HasImage {
|
||||
return nil, ClipboardReadImageOutput{}, nil
|
||||
}
|
||||
return nil, ClipboardReadImageOutput{Base64: base64.StdEncoding.EncodeToString(content.Data)}, nil
|
||||
}
|
||||
|
||||
// --- clipboard_write_image ---
|
||||
|
||||
type ClipboardWriteImageInput struct {
|
||||
Data []byte `json:"data"`
|
||||
Base64 string `json:"base64"`
|
||||
}
|
||||
type ClipboardWriteImageOutput struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
func (s *Subsystem) clipboardWriteImage(_ context.Context, _ *mcp.CallToolRequest, input ClipboardWriteImageInput) (*mcp.CallToolResult, ClipboardWriteImageOutput, error) {
|
||||
_, _, err := s.core.PERFORM(clipboard.TaskSetImage{Data: input.Data})
|
||||
data, err := base64.StdEncoding.DecodeString(input.Base64)
|
||||
if err != nil {
|
||||
return nil, ClipboardWriteImageOutput{}, err
|
||||
}
|
||||
return nil, ClipboardWriteImageOutput{Success: true}, nil
|
||||
result, _, err := s.core.PERFORM(clipboard.TaskSetImage{Data: data})
|
||||
if err != nil {
|
||||
return nil, ClipboardWriteImageOutput{}, err
|
||||
}
|
||||
success, ok := result.(bool)
|
||||
if !ok {
|
||||
return nil, ClipboardWriteImageOutput{}, coreerr.E("mcp.clipboardWriteImage", "unexpected result type", nil)
|
||||
}
|
||||
return nil, ClipboardWriteImageOutput{Success: success}, nil
|
||||
}
|
||||
|
||||
// --- Registration ---
|
||||
|
|
@ -129,6 +141,8 @@ func (s *Subsystem) registerClipboardTools(server *mcp.Server) {
|
|||
mcp.AddTool(server, &mcp.Tool{Name: "clipboard_read", Description: "Read the current clipboard content"}, s.clipboardRead)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "clipboard_write", Description: "Write text to the clipboard"}, s.clipboardWrite)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "clipboard_has", Description: "Check if the clipboard has content"}, s.clipboardHas)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "clipboard_read_image", Description: "Read image data from the clipboard as base64"}, s.clipboardReadImage)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "clipboard_write_image", Description: "Write base64 image data to the clipboard"}, s.clipboardWriteImage)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "clipboard_clear", Description: "Clear the clipboard"}, s.clipboardClear)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "clipboard_read_image", Description: "Read an image from the clipboard"}, s.clipboardReadImage)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "clipboard_write_image", Description: "Write an image to the clipboard"}, s.clipboardWriteImage)
|
||||
|
|
|
|||
|
|
@ -3,9 +3,10 @@ package mcp
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
corego "dappco.re/go/core"
|
||||
"dappco.re/go/core/gui/pkg/contextmenu"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"forge.lthn.ai/core/gui/pkg/contextmenu"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
|
|
@ -24,13 +25,13 @@ type ContextMenuAddOutput struct {
|
|||
|
||||
func (s *Subsystem) contextMenuAdd(_ context.Context, _ *mcp.CallToolRequest, input ContextMenuAddInput) (*mcp.CallToolResult, ContextMenuAddOutput, error) {
|
||||
// Convert map[string]any to ContextMenuDef via JSON round-trip
|
||||
r := corego.JSONMarshal(input.Menu)
|
||||
if !r.OK {
|
||||
return nil, ContextMenuAddOutput{}, corego.Wrap(r.Value.(error), "mcp.contextmenu", "failed to marshal menu definition")
|
||||
menuJSON, err := json.Marshal(input.Menu)
|
||||
if err != nil {
|
||||
return nil, ContextMenuAddOutput{}, coreerr.E("mcp.contextMenuAdd", "failed to marshal menu definition", err)
|
||||
}
|
||||
var menuDef contextmenu.ContextMenuDef
|
||||
if r2 := corego.JSONUnmarshal(r.Value.([]byte), &menuDef); !r2.OK {
|
||||
return nil, ContextMenuAddOutput{}, corego.Wrap(r2.Value.(error), "mcp.contextmenu", "failed to unmarshal menu definition")
|
||||
if err := json.Unmarshal(menuJSON, &menuDef); err != nil {
|
||||
return nil, ContextMenuAddOutput{}, coreerr.E("mcp.contextMenuAdd", "failed to unmarshal menu definition", err)
|
||||
}
|
||||
_, _, err = s.core.PERFORM(contextmenu.TaskAdd{Name: input.Name, Menu: menuDef})
|
||||
if err != nil {
|
||||
|
|
@ -72,19 +73,19 @@ func (s *Subsystem) contextMenuGet(_ context.Context, _ *mcp.CallToolRequest, in
|
|||
}
|
||||
menu, ok := result.(*contextmenu.ContextMenuDef)
|
||||
if !ok {
|
||||
return nil, ContextMenuGetOutput{}, corego.E("mcp.contextmenu", "unexpected result type from context menu get query", nil)
|
||||
return nil, ContextMenuGetOutput{}, coreerr.E("mcp.contextMenuGet", "unexpected result type", nil)
|
||||
}
|
||||
if menu == nil {
|
||||
return nil, ContextMenuGetOutput{}, nil
|
||||
}
|
||||
// Convert to map[string]any via JSON round-trip to avoid cyclic type in schema
|
||||
r := corego.JSONMarshal(menu)
|
||||
if !r.OK {
|
||||
return nil, ContextMenuGetOutput{}, corego.Wrap(r.Value.(error), "mcp.contextmenu", "failed to marshal context menu")
|
||||
menuJSON, err := json.Marshal(menu)
|
||||
if err != nil {
|
||||
return nil, ContextMenuGetOutput{}, coreerr.E("mcp.contextMenuGet", "failed to marshal context menu", err)
|
||||
}
|
||||
var menuMap map[string]any
|
||||
if r2 := corego.JSONUnmarshal(r.Value.([]byte), &menuMap); !r2.OK {
|
||||
return nil, ContextMenuGetOutput{}, corego.Wrap(r2.Value.(error), "mcp.contextmenu", "failed to unmarshal context menu")
|
||||
if err := json.Unmarshal(menuJSON, &menuMap); err != nil {
|
||||
return nil, ContextMenuGetOutput{}, coreerr.E("mcp.contextMenuGet", "failed to unmarshal context menu", err)
|
||||
}
|
||||
return nil, ContextMenuGetOutput{Menu: menuMap}, nil
|
||||
}
|
||||
|
|
@ -103,16 +104,16 @@ func (s *Subsystem) contextMenuList(_ context.Context, _ *mcp.CallToolRequest, _
|
|||
}
|
||||
menus, ok := result.(map[string]contextmenu.ContextMenuDef)
|
||||
if !ok {
|
||||
return nil, ContextMenuListOutput{}, corego.E("mcp.contextmenu", "unexpected result type from context menu list query", nil)
|
||||
return nil, ContextMenuListOutput{}, coreerr.E("mcp.contextMenuList", "unexpected result type", nil)
|
||||
}
|
||||
// Convert to map[string]any via JSON round-trip to avoid cyclic type in schema
|
||||
r := corego.JSONMarshal(menus)
|
||||
if !r.OK {
|
||||
return nil, ContextMenuListOutput{}, corego.Wrap(r.Value.(error), "mcp.contextmenu", "failed to marshal context menus")
|
||||
menusJSON, err := json.Marshal(menus)
|
||||
if err != nil {
|
||||
return nil, ContextMenuListOutput{}, coreerr.E("mcp.contextMenuList", "failed to marshal context menus", err)
|
||||
}
|
||||
var menusMap map[string]any
|
||||
if r2 := corego.JSONUnmarshal(r.Value.([]byte), &menusMap); !r2.OK {
|
||||
return nil, ContextMenuListOutput{}, corego.Wrap(r2.Value.(error), "mcp.contextmenu", "failed to unmarshal context menus")
|
||||
if err := json.Unmarshal(menusJSON, &menusMap); err != nil {
|
||||
return nil, ContextMenuListOutput{}, coreerr.E("mcp.contextMenuList", "failed to unmarshal context menus", err)
|
||||
}
|
||||
return nil, ContextMenuListOutput{Menus: menusMap}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ package mcp
|
|||
import (
|
||||
"context"
|
||||
|
||||
corego "dappco.re/go/core"
|
||||
"dappco.re/go/core/gui/pkg/dialog"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"forge.lthn.ai/core/gui/pkg/dialog"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
|
|
@ -22,7 +22,7 @@ type DialogOpenFileOutput struct {
|
|||
}
|
||||
|
||||
func (s *Subsystem) dialogOpenFile(_ context.Context, _ *mcp.CallToolRequest, input DialogOpenFileInput) (*mcp.CallToolResult, DialogOpenFileOutput, error) {
|
||||
result, _, err := s.core.PERFORM(dialog.TaskOpenFile{Opts: dialog.OpenFileOptions{
|
||||
result, _, err := s.core.PERFORM(dialog.TaskOpenFile{Options: dialog.OpenFileOptions{
|
||||
Title: input.Title,
|
||||
Directory: input.Directory,
|
||||
Filters: input.Filters,
|
||||
|
|
@ -33,7 +33,7 @@ func (s *Subsystem) dialogOpenFile(_ context.Context, _ *mcp.CallToolRequest, in
|
|||
}
|
||||
paths, ok := result.([]string)
|
||||
if !ok {
|
||||
return nil, DialogOpenFileOutput{}, corego.E("mcp.dialog", "unexpected result type from open file dialog", nil)
|
||||
return nil, DialogOpenFileOutput{}, coreerr.E("mcp.dialogOpenFile", "unexpected result type", nil)
|
||||
}
|
||||
return nil, DialogOpenFileOutput{Paths: paths}, nil
|
||||
}
|
||||
|
|
@ -51,7 +51,7 @@ type DialogSaveFileOutput struct {
|
|||
}
|
||||
|
||||
func (s *Subsystem) dialogSaveFile(_ context.Context, _ *mcp.CallToolRequest, input DialogSaveFileInput) (*mcp.CallToolResult, DialogSaveFileOutput, error) {
|
||||
result, _, err := s.core.PERFORM(dialog.TaskSaveFile{Opts: dialog.SaveFileOptions{
|
||||
result, _, err := s.core.PERFORM(dialog.TaskSaveFile{Options: dialog.SaveFileOptions{
|
||||
Title: input.Title,
|
||||
Directory: input.Directory,
|
||||
Filename: input.Filename,
|
||||
|
|
@ -62,7 +62,7 @@ func (s *Subsystem) dialogSaveFile(_ context.Context, _ *mcp.CallToolRequest, in
|
|||
}
|
||||
path, ok := result.(string)
|
||||
if !ok {
|
||||
return nil, DialogSaveFileOutput{}, corego.E("mcp.dialog", "unexpected result type from save file dialog", nil)
|
||||
return nil, DialogSaveFileOutput{}, coreerr.E("mcp.dialogSaveFile", "unexpected result type", nil)
|
||||
}
|
||||
return nil, DialogSaveFileOutput{Path: path}, nil
|
||||
}
|
||||
|
|
@ -78,7 +78,7 @@ type DialogOpenDirectoryOutput struct {
|
|||
}
|
||||
|
||||
func (s *Subsystem) dialogOpenDirectory(_ context.Context, _ *mcp.CallToolRequest, input DialogOpenDirectoryInput) (*mcp.CallToolResult, DialogOpenDirectoryOutput, error) {
|
||||
result, _, err := s.core.PERFORM(dialog.TaskOpenDirectory{Opts: dialog.OpenDirectoryOptions{
|
||||
result, _, err := s.core.PERFORM(dialog.TaskOpenDirectory{Options: dialog.OpenDirectoryOptions{
|
||||
Title: input.Title,
|
||||
Directory: input.Directory,
|
||||
}})
|
||||
|
|
@ -87,11 +87,40 @@ func (s *Subsystem) dialogOpenDirectory(_ context.Context, _ *mcp.CallToolReques
|
|||
}
|
||||
path, ok := result.(string)
|
||||
if !ok {
|
||||
return nil, DialogOpenDirectoryOutput{}, corego.E("mcp.dialog", "unexpected result type from open directory dialog", nil)
|
||||
return nil, DialogOpenDirectoryOutput{}, coreerr.E("mcp.dialogOpenDirectory", "unexpected result type", nil)
|
||||
}
|
||||
return nil, DialogOpenDirectoryOutput{Path: path}, nil
|
||||
}
|
||||
|
||||
// --- dialog_message ---
|
||||
|
||||
type DialogMessageInput struct {
|
||||
Type dialog.DialogType `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Buttons []string `json:"buttons,omitempty"`
|
||||
}
|
||||
type DialogMessageOutput struct {
|
||||
Button string `json:"button"`
|
||||
}
|
||||
|
||||
func (s *Subsystem) dialogMessage(_ context.Context, _ *mcp.CallToolRequest, input DialogMessageInput) (*mcp.CallToolResult, DialogMessageOutput, error) {
|
||||
result, _, err := s.core.PERFORM(dialog.TaskMessageDialog{Options: dialog.MessageDialogOptions{
|
||||
Type: input.Type,
|
||||
Title: input.Title,
|
||||
Message: input.Message,
|
||||
Buttons: input.Buttons,
|
||||
}})
|
||||
if err != nil {
|
||||
return nil, DialogMessageOutput{}, err
|
||||
}
|
||||
button, ok := result.(string)
|
||||
if !ok {
|
||||
return nil, DialogMessageOutput{}, coreerr.E("mcp.dialogMessage", "unexpected result type", nil)
|
||||
}
|
||||
return nil, DialogMessageOutput{Button: button}, nil
|
||||
}
|
||||
|
||||
// --- dialog_confirm ---
|
||||
|
||||
type DialogConfirmInput struct {
|
||||
|
|
@ -104,7 +133,7 @@ type DialogConfirmOutput struct {
|
|||
}
|
||||
|
||||
func (s *Subsystem) dialogConfirm(_ context.Context, _ *mcp.CallToolRequest, input DialogConfirmInput) (*mcp.CallToolResult, DialogConfirmOutput, error) {
|
||||
result, _, err := s.core.PERFORM(dialog.TaskMessageDialog{Opts: dialog.MessageDialogOptions{
|
||||
result, _, err := s.core.PERFORM(dialog.TaskMessageDialog{Options: dialog.MessageDialogOptions{
|
||||
Type: dialog.DialogQuestion,
|
||||
Title: input.Title,
|
||||
Message: input.Message,
|
||||
|
|
@ -115,7 +144,7 @@ func (s *Subsystem) dialogConfirm(_ context.Context, _ *mcp.CallToolRequest, inp
|
|||
}
|
||||
button, ok := result.(string)
|
||||
if !ok {
|
||||
return nil, DialogConfirmOutput{}, corego.E("mcp.dialog", "unexpected result type from confirm dialog", nil)
|
||||
return nil, DialogConfirmOutput{}, coreerr.E("mcp.dialogConfirm", "unexpected result type", nil)
|
||||
}
|
||||
return nil, DialogConfirmOutput{Button: button}, nil
|
||||
}
|
||||
|
|
@ -131,7 +160,7 @@ type DialogPromptOutput struct {
|
|||
}
|
||||
|
||||
func (s *Subsystem) dialogPrompt(_ context.Context, _ *mcp.CallToolRequest, input DialogPromptInput) (*mcp.CallToolResult, DialogPromptOutput, error) {
|
||||
result, _, err := s.core.PERFORM(dialog.TaskMessageDialog{Opts: dialog.MessageDialogOptions{
|
||||
result, _, err := s.core.PERFORM(dialog.TaskMessageDialog{Options: dialog.MessageDialogOptions{
|
||||
Type: dialog.DialogInfo,
|
||||
Title: input.Title,
|
||||
Message: input.Message,
|
||||
|
|
@ -142,7 +171,7 @@ func (s *Subsystem) dialogPrompt(_ context.Context, _ *mcp.CallToolRequest, inpu
|
|||
}
|
||||
button, ok := result.(string)
|
||||
if !ok {
|
||||
return nil, DialogPromptOutput{}, corego.E("mcp.dialog", "unexpected result type from prompt dialog", nil)
|
||||
return nil, DialogPromptOutput{}, coreerr.E("mcp.dialogPrompt", "unexpected result type", nil)
|
||||
}
|
||||
return nil, DialogPromptOutput{Button: button}, nil
|
||||
}
|
||||
|
|
@ -153,6 +182,7 @@ func (s *Subsystem) registerDialogTools(server *mcp.Server) {
|
|||
mcp.AddTool(server, &mcp.Tool{Name: "dialog_open_file", Description: "Show an open file dialog"}, s.dialogOpenFile)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "dialog_save_file", Description: "Show a save file dialog"}, s.dialogSaveFile)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "dialog_open_directory", Description: "Show a directory picker dialog"}, s.dialogOpenDirectory)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "dialog_message", Description: "Show a message dialog"}, s.dialogMessage)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "dialog_confirm", Description: "Show a confirmation dialog"}, s.dialogConfirm)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "dialog_prompt", Description: "Show a prompt dialog"}, s.dialogPrompt)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ package mcp
|
|||
import (
|
||||
"context"
|
||||
|
||||
corego "dappco.re/go/core"
|
||||
"dappco.re/go/core/gui/pkg/environment"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"forge.lthn.ai/core/gui/pkg/environment"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
|
|
@ -23,7 +23,7 @@ func (s *Subsystem) themeGet(_ context.Context, _ *mcp.CallToolRequest, _ ThemeG
|
|||
}
|
||||
theme, ok := result.(environment.ThemeInfo)
|
||||
if !ok {
|
||||
return nil, ThemeGetOutput{}, corego.E("mcp.environment", "unexpected result type from theme query", nil)
|
||||
return nil, ThemeGetOutput{}, coreerr.E("mcp.themeGet", "unexpected result type", nil)
|
||||
}
|
||||
return nil, ThemeGetOutput{Theme: theme}, nil
|
||||
}
|
||||
|
|
@ -42,7 +42,7 @@ func (s *Subsystem) themeSystem(_ context.Context, _ *mcp.CallToolRequest, _ The
|
|||
}
|
||||
info, ok := result.(environment.EnvironmentInfo)
|
||||
if !ok {
|
||||
return nil, ThemeSystemOutput{}, corego.E("mcp.environment", "unexpected result type from environment info query", nil)
|
||||
return nil, ThemeSystemOutput{}, coreerr.E("mcp.themeSystem", "unexpected result type", nil)
|
||||
}
|
||||
return nil, ThemeSystemOutput{Info: info}, nil
|
||||
}
|
||||
|
|
@ -53,7 +53,7 @@ type ThemeSetInput struct {
|
|||
Theme string `json:"theme"`
|
||||
}
|
||||
type ThemeSetOutput struct {
|
||||
Theme environment.ThemeInfo `json:"theme"`
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
func (s *Subsystem) themeSet(_ context.Context, _ *mcp.CallToolRequest, input ThemeSetInput) (*mcp.CallToolResult, ThemeSetOutput, error) {
|
||||
|
|
@ -61,21 +61,14 @@ func (s *Subsystem) themeSet(_ context.Context, _ *mcp.CallToolRequest, input Th
|
|||
if err != nil {
|
||||
return nil, ThemeSetOutput{}, err
|
||||
}
|
||||
result, _, err := s.core.QUERY(environment.QueryTheme{})
|
||||
if err != nil {
|
||||
return nil, ThemeSetOutput{}, err
|
||||
}
|
||||
theme, ok := result.(environment.ThemeInfo)
|
||||
if !ok {
|
||||
return nil, ThemeSetOutput{}, corego.E("mcp.environment", "unexpected result type from theme query", nil)
|
||||
}
|
||||
return nil, ThemeSetOutput{Theme: theme}, nil
|
||||
return nil, ThemeSetOutput{Success: true}, nil
|
||||
}
|
||||
|
||||
// --- Registration ---
|
||||
|
||||
func (s *Subsystem) registerEnvironmentTools(server *mcp.Server) {
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "theme_get", Description: "Get the current application theme"}, s.themeGet)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "theme_set", Description: "Override the application theme to light, dark, or system"}, s.themeSet)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "theme_system", Description: "Get system environment and theme information"}, s.themeSystem)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "theme_set", Description: "Set the application theme override"}, s.themeSet)
|
||||
}
|
||||
|
|
|
|||
100
pkg/mcp/tools_events.go
Normal file
100
pkg/mcp/tools_events.go
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
// pkg/mcp/tools_events.go
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"forge.lthn.ai/core/gui/pkg/events"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// --- event_emit ---
|
||||
|
||||
type EventEmitInput struct {
|
||||
Name string `json:"name"`
|
||||
Data any `json:"data,omitempty"`
|
||||
}
|
||||
type EventEmitOutput struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
// eventEmit fires a custom event by name with optional data.
|
||||
// c.PERFORM(events.TaskEmit{Name: "build:done", Data: result})
|
||||
func (s *Subsystem) eventEmit(_ context.Context, _ *mcp.CallToolRequest, input EventEmitInput) (*mcp.CallToolResult, EventEmitOutput, error) {
|
||||
_, _, err := s.core.PERFORM(events.TaskEmit{Name: input.Name, Data: input.Data})
|
||||
if err != nil {
|
||||
return nil, EventEmitOutput{}, err
|
||||
}
|
||||
return nil, EventEmitOutput{Success: true}, nil
|
||||
}
|
||||
|
||||
// --- event_on ---
|
||||
|
||||
type EventOnInput struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
type EventOnOutput struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
// eventOn registers a persistent listener for a named event.
|
||||
// c.PERFORM(events.TaskOn{Name: "build:done"})
|
||||
func (s *Subsystem) eventOn(_ context.Context, _ *mcp.CallToolRequest, input EventOnInput) (*mcp.CallToolResult, EventOnOutput, error) {
|
||||
_, _, err := s.core.PERFORM(events.TaskOn{Name: input.Name})
|
||||
if err != nil {
|
||||
return nil, EventOnOutput{}, err
|
||||
}
|
||||
return nil, EventOnOutput{Success: true}, nil
|
||||
}
|
||||
|
||||
// --- event_off ---
|
||||
|
||||
type EventOffInput struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
type EventOffOutput struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
// eventOff removes all listeners for a named event.
|
||||
// c.PERFORM(events.TaskOff{Name: "build:done"})
|
||||
func (s *Subsystem) eventOff(_ context.Context, _ *mcp.CallToolRequest, input EventOffInput) (*mcp.CallToolResult, EventOffOutput, error) {
|
||||
_, _, err := s.core.PERFORM(events.TaskOff{Name: input.Name})
|
||||
if err != nil {
|
||||
return nil, EventOffOutput{}, err
|
||||
}
|
||||
return nil, EventOffOutput{Success: true}, nil
|
||||
}
|
||||
|
||||
// --- event_list ---
|
||||
|
||||
type EventListInput struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
type EventListOutput struct {
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// eventList returns the number of listeners registered for a named event.
|
||||
// count := c.QUERY(events.QueryListeners{Name: "build:done"})
|
||||
func (s *Subsystem) eventList(_ context.Context, _ *mcp.CallToolRequest, input EventListInput) (*mcp.CallToolResult, EventListOutput, error) {
|
||||
result, _, err := s.core.QUERY(events.QueryListeners{Name: input.Name})
|
||||
if err != nil {
|
||||
return nil, EventListOutput{}, err
|
||||
}
|
||||
count, ok := result.(int)
|
||||
if !ok {
|
||||
return nil, EventListOutput{}, coreerr.E("mcp.eventList", "unexpected result type", nil)
|
||||
}
|
||||
return nil, EventListOutput{Count: count}, nil
|
||||
}
|
||||
|
||||
// --- Registration ---
|
||||
|
||||
func (s *Subsystem) registerEventTools(server *mcp.Server) {
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "event_emit", Description: "Fire a custom event by name with optional data"}, s.eventEmit)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "event_on", Description: "Register a persistent listener for a named event"}, s.eventOn)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "event_off", Description: "Remove all listeners for a named event"}, s.eventOff)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "event_list", Description: "Return the number of listeners registered for a named event"}, s.eventList)
|
||||
}
|
||||
|
|
@ -3,11 +3,11 @@ package mcp
|
|||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
corego "dappco.re/go/core"
|
||||
"dappco.re/go/core/gui/pkg/screen"
|
||||
"dappco.re/go/core/gui/pkg/window"
|
||||
"forge.lthn.ai/core/go/pkg/core"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"forge.lthn.ai/core/gui/pkg/screen"
|
||||
"forge.lthn.ai/core/gui/pkg/window"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
|
|
@ -59,7 +59,7 @@ func (s *Subsystem) layoutList(_ context.Context, _ *mcp.CallToolRequest, _ Layo
|
|||
}
|
||||
layouts, ok := result.([]window.LayoutInfo)
|
||||
if !ok {
|
||||
return nil, LayoutListOutput{}, corego.E("mcp.layout", "unexpected result type from layout list query", nil)
|
||||
return nil, LayoutListOutput{}, coreerr.E("mcp.layoutList", "unexpected result type", nil)
|
||||
}
|
||||
return nil, LayoutListOutput{Layouts: layouts}, nil
|
||||
}
|
||||
|
|
@ -97,7 +97,7 @@ func (s *Subsystem) layoutGet(_ context.Context, _ *mcp.CallToolRequest, input L
|
|||
}
|
||||
layout, ok := result.(*window.Layout)
|
||||
if !ok {
|
||||
return nil, LayoutGetOutput{}, corego.E("mcp.layout", "unexpected result type from layout get query", nil)
|
||||
return nil, LayoutGetOutput{}, coreerr.E("mcp.layoutGet", "unexpected result type", nil)
|
||||
}
|
||||
return nil, LayoutGetOutput{Layout: layout}, nil
|
||||
}
|
||||
|
|
@ -138,145 +138,19 @@ func (s *Subsystem) layoutSnap(_ context.Context, _ *mcp.CallToolRequest, input
|
|||
return nil, LayoutSnapOutput{Success: true}, nil
|
||||
}
|
||||
|
||||
// --- layout_beside_editor ---
|
||||
|
||||
type LayoutBesideEditorInput struct {
|
||||
Editor string `json:"editor,omitempty"`
|
||||
Window string `json:"window,omitempty"`
|
||||
}
|
||||
type LayoutBesideEditorOutput struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
func (s *Subsystem) layoutBesideEditor(_ context.Context, _ *mcp.CallToolRequest, input LayoutBesideEditorInput) (*mcp.CallToolResult, LayoutBesideEditorOutput, error) {
|
||||
_, _, err := s.core.PERFORM(window.TaskBesideEditor{Editor: input.Editor, Window: input.Window})
|
||||
if err != nil {
|
||||
return nil, LayoutBesideEditorOutput{}, err
|
||||
}
|
||||
return nil, LayoutBesideEditorOutput{Success: true}, nil
|
||||
}
|
||||
|
||||
// --- layout_suggest ---
|
||||
|
||||
type LayoutSuggestInput struct {
|
||||
WindowCount int `json:"windowCount,omitempty"`
|
||||
ScreenWidth int `json:"screenWidth,omitempty"`
|
||||
ScreenHeight int `json:"screenHeight,omitempty"`
|
||||
}
|
||||
type LayoutSuggestOutput struct {
|
||||
Suggestion window.LayoutSuggestion `json:"suggestion"`
|
||||
}
|
||||
|
||||
func (s *Subsystem) layoutSuggest(_ context.Context, _ *mcp.CallToolRequest, input LayoutSuggestInput) (*mcp.CallToolResult, LayoutSuggestOutput, error) {
|
||||
windowCount := input.WindowCount
|
||||
if windowCount <= 0 {
|
||||
result, _, err := s.core.QUERY(window.QueryWindowList{})
|
||||
if err != nil {
|
||||
return nil, LayoutSuggestOutput{}, err
|
||||
}
|
||||
windows, ok := result.([]window.WindowInfo)
|
||||
if !ok {
|
||||
return nil, LayoutSuggestOutput{}, corego.E("mcp.layout", "unexpected result type from window list query", nil)
|
||||
}
|
||||
windowCount = len(windows)
|
||||
}
|
||||
screenW, screenH := input.ScreenWidth, input.ScreenHeight
|
||||
if screenW <= 0 || screenH <= 0 {
|
||||
screenW, screenH = primaryScreenSize(s.core)
|
||||
}
|
||||
result, handled, err := s.core.QUERY(window.QueryLayoutSuggestion{
|
||||
WindowCount: windowCount,
|
||||
ScreenWidth: screenW,
|
||||
ScreenHeight: screenH,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, LayoutSuggestOutput{}, err
|
||||
}
|
||||
if !handled {
|
||||
return nil, LayoutSuggestOutput{}, corego.E("mcp.layout", "window service not available", nil)
|
||||
}
|
||||
suggestion, ok := result.(window.LayoutSuggestion)
|
||||
if !ok {
|
||||
return nil, LayoutSuggestOutput{}, corego.E("mcp.layout", "unexpected result type from layout suggestion query", nil)
|
||||
}
|
||||
return nil, LayoutSuggestOutput{Suggestion: suggestion}, nil
|
||||
}
|
||||
|
||||
// --- screen_find_space ---
|
||||
|
||||
type ScreenFindSpaceInput struct {
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
}
|
||||
type ScreenFindSpaceOutput struct {
|
||||
Space window.SpaceInfo `json:"space"`
|
||||
}
|
||||
|
||||
func (s *Subsystem) screenFindSpace(_ context.Context, _ *mcp.CallToolRequest, input ScreenFindSpaceInput) (*mcp.CallToolResult, ScreenFindSpaceOutput, error) {
|
||||
screenW, screenH := primaryScreenSize(s.core)
|
||||
if screenW <= 0 || screenH <= 0 {
|
||||
screenW, screenH = 1920, 1080
|
||||
}
|
||||
result, handled, err := s.core.QUERY(window.QueryFindSpace{
|
||||
Width: input.Width,
|
||||
Height: input.Height,
|
||||
ScreenWidth: screenW,
|
||||
ScreenHeight: screenH,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, ScreenFindSpaceOutput{}, err
|
||||
}
|
||||
if !handled {
|
||||
return nil, ScreenFindSpaceOutput{}, corego.E("mcp.layout", "window service not available", nil)
|
||||
}
|
||||
space, ok := result.(window.SpaceInfo)
|
||||
if !ok {
|
||||
return nil, ScreenFindSpaceOutput{}, corego.E("mcp.layout", "unexpected result type from find space query", nil)
|
||||
}
|
||||
if space.ScreenWidth == 0 {
|
||||
space.ScreenWidth = screenW
|
||||
}
|
||||
if space.ScreenHeight == 0 {
|
||||
space.ScreenHeight = screenH
|
||||
}
|
||||
return nil, ScreenFindSpaceOutput{Space: space}, nil
|
||||
}
|
||||
|
||||
// --- window_arrange_pair ---
|
||||
|
||||
type WindowArrangePairInput struct {
|
||||
First string `json:"first"`
|
||||
Second string `json:"second"`
|
||||
}
|
||||
type WindowArrangePairOutput struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
func (s *Subsystem) windowArrangePair(_ context.Context, _ *mcp.CallToolRequest, input WindowArrangePairInput) (*mcp.CallToolResult, WindowArrangePairOutput, error) {
|
||||
_, _, err := s.core.PERFORM(window.TaskArrangePair{First: input.First, Second: input.Second})
|
||||
if err != nil {
|
||||
return nil, WindowArrangePairOutput{}, err
|
||||
}
|
||||
return nil, WindowArrangePairOutput{Success: true}, nil
|
||||
}
|
||||
|
||||
// --- layout_stack ---
|
||||
|
||||
type LayoutStackInput struct {
|
||||
Windows []string `json:"windows,omitempty"`
|
||||
OffsetX int `json:"offsetX,omitempty"`
|
||||
OffsetY int `json:"offsetY,omitempty"`
|
||||
OffsetX int `json:"offsetX"`
|
||||
OffsetY int `json:"offsetY"`
|
||||
}
|
||||
type LayoutStackOutput struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
func (s *Subsystem) layoutStack(_ context.Context, _ *mcp.CallToolRequest, input LayoutStackInput) (*mcp.CallToolResult, LayoutStackOutput, error) {
|
||||
_, _, err := s.core.PERFORM(window.TaskStackWindows{
|
||||
Windows: input.Windows,
|
||||
OffsetX: input.OffsetX,
|
||||
OffsetY: input.OffsetY,
|
||||
})
|
||||
_, _, err := s.core.PERFORM(window.TaskStackWindows{Windows: input.Windows, OffsetX: input.OffsetX, OffsetY: input.OffsetY})
|
||||
if err != nil {
|
||||
return nil, LayoutStackOutput{}, err
|
||||
}
|
||||
|
|
@ -294,20 +168,186 @@ type LayoutWorkflowOutput struct {
|
|||
}
|
||||
|
||||
func (s *Subsystem) layoutWorkflow(_ context.Context, _ *mcp.CallToolRequest, input LayoutWorkflowInput) (*mcp.CallToolResult, LayoutWorkflowOutput, error) {
|
||||
workflow, ok := window.ParseWorkflowLayout(input.Workflow)
|
||||
if !ok {
|
||||
return nil, LayoutWorkflowOutput{}, corego.E("mcp.layout", corego.Sprintf("unknown workflow: %s", input.Workflow), nil)
|
||||
}
|
||||
_, _, err := s.core.PERFORM(window.TaskApplyWorkflow{
|
||||
Workflow: workflow,
|
||||
Windows: input.Windows,
|
||||
})
|
||||
_, _, err := s.core.PERFORM(window.TaskApplyWorkflow{Workflow: input.Workflow, Windows: input.Windows})
|
||||
if err != nil {
|
||||
return nil, LayoutWorkflowOutput{}, err
|
||||
}
|
||||
return nil, LayoutWorkflowOutput{Success: true}, nil
|
||||
}
|
||||
|
||||
// --- layout_suggest ---
|
||||
|
||||
type LayoutSuggestInput struct {
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
WindowCount int `json:"windowCount"`
|
||||
}
|
||||
type LayoutSuggestOutput struct {
|
||||
Mode string `json:"mode"`
|
||||
Placements []screen.Rect `json:"placements"`
|
||||
}
|
||||
|
||||
func (s *Subsystem) layoutSuggest(_ context.Context, _ *mcp.CallToolRequest, input LayoutSuggestInput) (*mcp.CallToolResult, LayoutSuggestOutput, error) {
|
||||
width := input.Width
|
||||
height := input.Height
|
||||
if width <= 0 {
|
||||
width = 1920
|
||||
}
|
||||
if height <= 0 {
|
||||
height = 1080
|
||||
}
|
||||
count := input.WindowCount
|
||||
if count <= 0 {
|
||||
count = 1
|
||||
}
|
||||
|
||||
workArea := screen.Rect{X: 0, Y: 0, Width: width, Height: height}
|
||||
switch {
|
||||
case count == 1:
|
||||
return nil, LayoutSuggestOutput{Mode: "full", Placements: []screen.Rect{workArea}}, nil
|
||||
case count == 2:
|
||||
if width >= height {
|
||||
half := width / 2
|
||||
return nil, LayoutSuggestOutput{
|
||||
Mode: "side-by-side",
|
||||
Placements: []screen.Rect{
|
||||
{X: 0, Y: 0, Width: half, Height: height},
|
||||
{X: half, Y: 0, Width: width - half, Height: height},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
half := height / 2
|
||||
return nil, LayoutSuggestOutput{
|
||||
Mode: "stacked",
|
||||
Placements: []screen.Rect{
|
||||
{X: 0, Y: 0, Width: width, Height: half},
|
||||
{X: 0, Y: half, Width: width, Height: height - half},
|
||||
},
|
||||
}, nil
|
||||
case count == 3 && width >= height:
|
||||
mainWidth := width * 2 / 3
|
||||
sideHeight := height / 2
|
||||
return nil, LayoutSuggestOutput{
|
||||
Mode: "editor-plus-stack",
|
||||
Placements: []screen.Rect{
|
||||
{X: 0, Y: 0, Width: mainWidth, Height: height},
|
||||
{X: mainWidth, Y: 0, Width: width - mainWidth, Height: sideHeight},
|
||||
{X: mainWidth, Y: sideHeight, Width: width - mainWidth, Height: height - sideHeight},
|
||||
},
|
||||
}, nil
|
||||
default:
|
||||
cols := 2
|
||||
if count > 4 {
|
||||
cols = 3
|
||||
}
|
||||
rows := (count + cols - 1) / cols
|
||||
cellWidth := width / cols
|
||||
cellHeight := height / rows
|
||||
placements := make([]screen.Rect, 0, count)
|
||||
for i := 0; i < count; i++ {
|
||||
row := i / cols
|
||||
col := i % cols
|
||||
placements = append(placements, screen.Rect{
|
||||
X: col * cellWidth, Y: row * cellHeight,
|
||||
Width: cellWidth, Height: cellHeight,
|
||||
})
|
||||
}
|
||||
return nil, LayoutSuggestOutput{Mode: "grid", Placements: placements}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// --- layout_beside_editor ---
|
||||
|
||||
type LayoutBesideEditorInput struct {
|
||||
Name string `json:"name"`
|
||||
EditorNames []string `json:"editorNames,omitempty"`
|
||||
}
|
||||
type LayoutBesideEditorOutput struct {
|
||||
Editor string `json:"editor"`
|
||||
Bounds screen.Rect `json:"bounds"`
|
||||
}
|
||||
|
||||
func (s *Subsystem) layoutBesideEditor(_ context.Context, _ *mcp.CallToolRequest, input LayoutBesideEditorInput) (*mcp.CallToolResult, LayoutBesideEditorOutput, error) {
|
||||
windows, err := s.allWindows()
|
||||
if err != nil {
|
||||
return nil, LayoutBesideEditorOutput{}, err
|
||||
}
|
||||
screens, err := s.allScreens()
|
||||
if err != nil {
|
||||
return nil, LayoutBesideEditorOutput{}, err
|
||||
}
|
||||
|
||||
editorHints := map[string]struct{}{}
|
||||
for _, name := range input.EditorNames {
|
||||
editorHints[strings.ToLower(name)] = struct{}{}
|
||||
}
|
||||
defaultHints := []string{"code", "cursor", "vscode", "studio", "goland", "intellij", "webstorm", "xcode", "vim", "nvim", "emacs", "editor"}
|
||||
|
||||
var editor *window.WindowInfo
|
||||
for i := range windows {
|
||||
if windows[i].Name == input.Name {
|
||||
continue
|
||||
}
|
||||
name := strings.ToLower(windows[i].Name)
|
||||
title := strings.ToLower(windows[i].Title)
|
||||
if _, ok := editorHints[name]; ok {
|
||||
editor = &windows[i]
|
||||
break
|
||||
}
|
||||
for _, hint := range defaultHints {
|
||||
if strings.Contains(name, hint) || strings.Contains(title, hint) {
|
||||
editor = &windows[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if editor != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if editor == nil {
|
||||
return nil, LayoutBesideEditorOutput{}, coreerr.E("mcp.layoutBesideEditor", "no editor window detected", nil)
|
||||
}
|
||||
|
||||
editorScreen := screenForWindowInfo(screens, *editor)
|
||||
if editorScreen == nil {
|
||||
editorScreen = chooseScreenByIDOrPrimary(screens, "")
|
||||
}
|
||||
workArea := workAreaRect(editorScreen)
|
||||
|
||||
editorRect := screen.Rect{X: editor.X, Y: editor.Y, Width: editor.Width, Height: editor.Height}
|
||||
candidates := []screen.Rect{
|
||||
{X: workArea.X, Y: workArea.Y, Width: max(0, editorRect.X-workArea.X), Height: workArea.Height},
|
||||
{X: editorRect.X + editorRect.Width, Y: workArea.Y, Width: max(0, workArea.X+workArea.Width-(editorRect.X+editorRect.Width)), Height: workArea.Height},
|
||||
{X: workArea.X, Y: workArea.Y, Width: workArea.Width, Height: max(0, editorRect.Y-workArea.Y)},
|
||||
{X: workArea.X, Y: editorRect.Y + editorRect.Height, Width: workArea.Width, Height: max(0, workArea.Y+workArea.Height-(editorRect.Y+editorRect.Height))},
|
||||
}
|
||||
|
||||
best := screen.Rect{}
|
||||
bestArea := -1
|
||||
for _, candidate := range candidates {
|
||||
area := candidate.Width * candidate.Height
|
||||
if candidate.Width <= 0 || candidate.Height <= 0 {
|
||||
continue
|
||||
}
|
||||
if area > bestArea {
|
||||
bestArea = area
|
||||
best = candidate
|
||||
}
|
||||
}
|
||||
if bestArea <= 0 {
|
||||
arranged, err := s.arrangePairOnScreen(editor.Name, input.Name, editorScreen, "")
|
||||
if err != nil {
|
||||
return nil, LayoutBesideEditorOutput{}, err
|
||||
}
|
||||
return nil, LayoutBesideEditorOutput{Editor: editor.Name, Bounds: arranged.Second}, nil
|
||||
}
|
||||
|
||||
if err := applyRect(s.core, input.Name, best); err != nil {
|
||||
return nil, LayoutBesideEditorOutput{}, err
|
||||
}
|
||||
return nil, LayoutBesideEditorOutput{Editor: editor.Name, Bounds: best}, nil
|
||||
}
|
||||
|
||||
// --- Registration ---
|
||||
|
||||
func (s *Subsystem) registerLayoutTools(server *mcp.Server) {
|
||||
|
|
@ -317,29 +357,9 @@ func (s *Subsystem) registerLayoutTools(server *mcp.Server) {
|
|||
mcp.AddTool(server, &mcp.Tool{Name: "layout_delete", Description: "Delete a saved layout"}, s.layoutDelete)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "layout_get", Description: "Get a specific layout by name"}, s.layoutGet)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "layout_tile", Description: "Tile windows in a grid arrangement"}, s.layoutTile)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "layout_suggest", Description: "Suggest an optimal arrangement for the given screen size and window count"}, s.layoutSuggest)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "layout_beside_editor", Description: "Place a window beside a detected editor or IDE window"}, s.layoutBesideEditor)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "layout_snap", Description: "Snap a window to a screen edge or corner"}, s.layoutSnap)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "layout_beside_editor", Description: "Place a window beside a detected editor window"}, s.layoutBesideEditor)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "layout_suggest", Description: "Suggest an optimal layout for the current screen"}, s.layoutSuggest)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "screen_find_space", Description: "Find an empty area for a new window"}, s.screenFindSpace)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "window_arrange_pair", Description: "Arrange two windows side-by-side"}, s.windowArrangePair)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "layout_stack", Description: "Cascade windows with an offset"}, s.layoutStack)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "layout_workflow", Description: "Apply a predefined workflow layout"}, s.layoutWorkflow)
|
||||
}
|
||||
|
||||
func primaryScreenSize(c *core.Core) (int, int) {
|
||||
result, handled, err := c.QUERY(screen.QueryPrimary{})
|
||||
if err == nil && handled {
|
||||
if scr, ok := result.(*screen.Screen); ok && scr != nil {
|
||||
if scr.WorkArea.Width > 0 && scr.WorkArea.Height > 0 {
|
||||
return scr.WorkArea.Width, scr.WorkArea.Height
|
||||
}
|
||||
if scr.Bounds.Width > 0 && scr.Bounds.Height > 0 {
|
||||
return scr.Bounds.Width, scr.Bounds.Height
|
||||
}
|
||||
if scr.Size.Width > 0 && scr.Size.Height > 0 {
|
||||
return scr.Size.Width, scr.Size.Height
|
||||
}
|
||||
}
|
||||
}
|
||||
return 1920, 1080
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "layout_stack", Description: "Stack windows in a cascade pattern"}, s.layoutStack)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "layout_workflow", Description: "Apply a preset workflow layout"}, s.layoutWorkflow)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ package mcp
|
|||
import (
|
||||
"context"
|
||||
|
||||
corego "dappco.re/go/core"
|
||||
"dappco.re/go/core/gui/pkg/notification"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"forge.lthn.ai/core/gui/pkg/notification"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
|
|
@ -21,7 +21,7 @@ type NotificationShowOutput struct {
|
|||
}
|
||||
|
||||
func (s *Subsystem) notificationShow(_ context.Context, _ *mcp.CallToolRequest, input NotificationShowInput) (*mcp.CallToolResult, NotificationShowOutput, error) {
|
||||
_, _, err := s.core.PERFORM(notification.TaskSend{Opts: notification.NotificationOptions{
|
||||
_, _, err := s.core.PERFORM(notification.TaskSend{Options: notification.NotificationOptions{
|
||||
Title: input.Title,
|
||||
Message: input.Message,
|
||||
Subtitle: input.Subtitle,
|
||||
|
|
@ -38,14 +38,14 @@ type NotificationWithActionsInput struct {
|
|||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Subtitle string `json:"subtitle,omitempty"`
|
||||
Actions []notification.NotificationAction `json:"actions,omitempty"`
|
||||
Actions []notification.NotificationAction `json:"actions"`
|
||||
}
|
||||
type NotificationWithActionsOutput struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
func (s *Subsystem) notificationWithActions(_ context.Context, _ *mcp.CallToolRequest, input NotificationWithActionsInput) (*mcp.CallToolResult, NotificationWithActionsOutput, error) {
|
||||
_, _, err := s.core.PERFORM(notification.TaskSend{Opts: notification.NotificationOptions{
|
||||
_, _, err := s.core.PERFORM(notification.TaskSend{Options: notification.NotificationOptions{
|
||||
Title: input.Title,
|
||||
Message: input.Message,
|
||||
Subtitle: input.Subtitle,
|
||||
|
|
@ -71,7 +71,7 @@ func (s *Subsystem) notificationPermissionRequest(_ context.Context, _ *mcp.Call
|
|||
}
|
||||
granted, ok := result.(bool)
|
||||
if !ok {
|
||||
return nil, NotificationPermissionRequestOutput{}, corego.E("mcp.notification", "unexpected result type from notification permission request", nil)
|
||||
return nil, NotificationPermissionRequestOutput{}, coreerr.E("mcp.notificationPermissionRequest", "unexpected result type", nil)
|
||||
}
|
||||
return nil, NotificationPermissionRequestOutput{Granted: granted}, nil
|
||||
}
|
||||
|
|
@ -90,65 +90,34 @@ func (s *Subsystem) notificationPermissionCheck(_ context.Context, _ *mcp.CallTo
|
|||
}
|
||||
status, ok := result.(notification.PermissionStatus)
|
||||
if !ok {
|
||||
return nil, NotificationPermissionCheckOutput{}, corego.E("mcp.notification", "unexpected result type from notification permission check", nil)
|
||||
return nil, NotificationPermissionCheckOutput{}, coreerr.E("mcp.notificationPermissionCheck", "unexpected result type", nil)
|
||||
}
|
||||
return nil, NotificationPermissionCheckOutput{Granted: status.Granted}, nil
|
||||
}
|
||||
|
||||
// --- notification_clear ---
|
||||
|
||||
type NotificationClearInput struct{}
|
||||
type NotificationClearInput struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
}
|
||||
type NotificationClearOutput struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
func (s *Subsystem) notificationClear(_ context.Context, _ *mcp.CallToolRequest, _ NotificationClearInput) (*mcp.CallToolResult, NotificationClearOutput, error) {
|
||||
_, _, err := s.core.PERFORM(notification.TaskClear{})
|
||||
func (s *Subsystem) notificationClear(_ context.Context, _ *mcp.CallToolRequest, input NotificationClearInput) (*mcp.CallToolResult, NotificationClearOutput, error) {
|
||||
_, _, err := s.core.PERFORM(notification.TaskClear{ID: input.ID})
|
||||
if err != nil {
|
||||
return nil, NotificationClearOutput{}, err
|
||||
}
|
||||
return nil, NotificationClearOutput{Success: true}, nil
|
||||
}
|
||||
|
||||
// --- dialog_message ---
|
||||
|
||||
type DialogMessageInput struct {
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
}
|
||||
type DialogMessageOutput struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
func (s *Subsystem) dialogMessage(_ context.Context, _ *mcp.CallToolRequest, input DialogMessageInput) (*mcp.CallToolResult, DialogMessageOutput, error) {
|
||||
var severity notification.NotificationSeverity
|
||||
switch input.Kind {
|
||||
case "warning":
|
||||
severity = notification.SeverityWarning
|
||||
case "error":
|
||||
severity = notification.SeverityError
|
||||
default:
|
||||
severity = notification.SeverityInfo
|
||||
}
|
||||
_, _, err := s.core.PERFORM(notification.TaskSend{Opts: notification.NotificationOptions{
|
||||
Title: input.Title,
|
||||
Message: input.Message,
|
||||
Severity: severity,
|
||||
}})
|
||||
if err != nil {
|
||||
return nil, DialogMessageOutput{}, err
|
||||
}
|
||||
return nil, DialogMessageOutput{Success: true}, nil
|
||||
}
|
||||
|
||||
// --- Registration ---
|
||||
|
||||
func (s *Subsystem) registerNotificationTools(server *mcp.Server) {
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "notification_show", Description: "Show a desktop notification"}, s.notificationShow)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "notification_with_actions", Description: "Show a desktop notification with actions"}, s.notificationWithActions)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "notification_with_actions", Description: "Show a desktop notification with action buttons"}, s.notificationWithActions)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "notification_permission_request", Description: "Request notification permission"}, s.notificationPermissionRequest)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "notification_permission_check", Description: "Check notification permission status"}, s.notificationPermissionCheck)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "notification_clear", Description: "Clear notifications when supported"}, s.notificationClear)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "dialog_message", Description: "Show a message dialog using the notification pipeline"}, s.dialogMessage)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "notification_clear", Description: "Clear a notification by ID or clear all notifications"}, s.notificationClear)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,9 @@ package mcp
|
|||
import (
|
||||
"context"
|
||||
|
||||
corego "dappco.re/go/core"
|
||||
"dappco.re/go/core/gui/pkg/display"
|
||||
"dappco.re/go/core/gui/pkg/screen"
|
||||
"forge.lthn.ai/core/go/pkg/core"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"forge.lthn.ai/core/gui/pkg/screen"
|
||||
"forge.lthn.ai/core/gui/pkg/window"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
|
|
@ -25,7 +24,7 @@ func (s *Subsystem) screenList(_ context.Context, _ *mcp.CallToolRequest, _ Scre
|
|||
}
|
||||
screens, ok := result.([]screen.Screen)
|
||||
if !ok {
|
||||
return nil, ScreenListOutput{}, corego.E("mcp.screen", "unexpected result type from screen list query", nil)
|
||||
return nil, ScreenListOutput{}, coreerr.E("mcp.screenList", "unexpected result type", nil)
|
||||
}
|
||||
return nil, ScreenListOutput{Screens: screens}, nil
|
||||
}
|
||||
|
|
@ -46,7 +45,7 @@ func (s *Subsystem) screenGet(_ context.Context, _ *mcp.CallToolRequest, input S
|
|||
}
|
||||
scr, ok := result.(*screen.Screen)
|
||||
if !ok {
|
||||
return nil, ScreenGetOutput{}, corego.E("mcp.screen", "unexpected result type from screen get query", nil)
|
||||
return nil, ScreenGetOutput{}, coreerr.E("mcp.screenGet", "unexpected result type", nil)
|
||||
}
|
||||
return nil, ScreenGetOutput{Screen: scr}, nil
|
||||
}
|
||||
|
|
@ -65,7 +64,7 @@ func (s *Subsystem) screenPrimary(_ context.Context, _ *mcp.CallToolRequest, _ S
|
|||
}
|
||||
scr, ok := result.(*screen.Screen)
|
||||
if !ok {
|
||||
return nil, ScreenPrimaryOutput{}, corego.E("mcp.screen", "unexpected result type from screen primary query", nil)
|
||||
return nil, ScreenPrimaryOutput{}, coreerr.E("mcp.screenPrimary", "unexpected result type", nil)
|
||||
}
|
||||
return nil, ScreenPrimaryOutput{Screen: scr}, nil
|
||||
}
|
||||
|
|
@ -87,7 +86,7 @@ func (s *Subsystem) screenAtPoint(_ context.Context, _ *mcp.CallToolRequest, inp
|
|||
}
|
||||
scr, ok := result.(*screen.Screen)
|
||||
if !ok {
|
||||
return nil, ScreenAtPointOutput{}, corego.E("mcp.screen", "unexpected result type from screen at point query", nil)
|
||||
return nil, ScreenAtPointOutput{}, coreerr.E("mcp.screenAtPoint", "unexpected result type", nil)
|
||||
}
|
||||
return nil, ScreenAtPointOutput{Screen: scr}, nil
|
||||
}
|
||||
|
|
@ -106,35 +105,105 @@ func (s *Subsystem) screenWorkAreas(_ context.Context, _ *mcp.CallToolRequest, _
|
|||
}
|
||||
areas, ok := result.([]screen.Rect)
|
||||
if !ok {
|
||||
return nil, ScreenWorkAreasOutput{}, corego.E("mcp.screen", "unexpected result type from screen work areas query", nil)
|
||||
return nil, ScreenWorkAreasOutput{}, coreerr.E("mcp.screenWorkAreas", "unexpected result type", nil)
|
||||
}
|
||||
return nil, ScreenWorkAreasOutput{WorkAreas: areas}, nil
|
||||
}
|
||||
|
||||
// --- screen_work_area ---
|
||||
|
||||
func (s *Subsystem) screenWorkArea(ctx context.Context, req *mcp.CallToolRequest, input ScreenWorkAreasInput) (*mcp.CallToolResult, ScreenWorkAreasOutput, error) {
|
||||
return s.screenWorkAreas(ctx, req, input)
|
||||
type ScreenWorkAreaInput struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
}
|
||||
type ScreenWorkAreaOutput struct {
|
||||
WorkArea screen.Rect `json:"workArea"`
|
||||
}
|
||||
|
||||
func (s *Subsystem) screenWorkArea(_ context.Context, _ *mcp.CallToolRequest, input ScreenWorkAreaInput) (*mcp.CallToolResult, ScreenWorkAreaOutput, error) {
|
||||
screens, err := s.allScreens()
|
||||
if err != nil {
|
||||
return nil, ScreenWorkAreaOutput{}, err
|
||||
}
|
||||
scr := chooseScreenByIDOrPrimary(screens, input.ID)
|
||||
if scr == nil {
|
||||
return nil, ScreenWorkAreaOutput{}, nil
|
||||
}
|
||||
return nil, ScreenWorkAreaOutput{WorkArea: workAreaRect(scr)}, nil
|
||||
}
|
||||
|
||||
// --- screen_find_space ---
|
||||
|
||||
type ScreenFindSpaceInput struct {
|
||||
ScreenID string `json:"screenId,omitempty"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
}
|
||||
type ScreenFindSpaceOutput struct {
|
||||
ScreenID string `json:"screenId"`
|
||||
Bounds screen.Rect `json:"bounds"`
|
||||
}
|
||||
|
||||
func (s *Subsystem) screenFindSpace(_ context.Context, _ *mcp.CallToolRequest, input ScreenFindSpaceInput) (*mcp.CallToolResult, ScreenFindSpaceOutput, error) {
|
||||
screens, err := s.allScreens()
|
||||
if err != nil {
|
||||
return nil, ScreenFindSpaceOutput{}, err
|
||||
}
|
||||
windows, err := s.allWindows()
|
||||
if err != nil {
|
||||
return nil, ScreenFindSpaceOutput{}, err
|
||||
}
|
||||
|
||||
orderedScreens := make([]screen.Screen, 0, len(screens))
|
||||
if selected := chooseScreenByIDOrPrimary(screens, input.ScreenID); selected != nil {
|
||||
orderedScreens = append(orderedScreens, *selected)
|
||||
for _, scr := range screens {
|
||||
if scr.ID != selected.ID {
|
||||
orderedScreens = append(orderedScreens, scr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, scr := range orderedScreens {
|
||||
workArea := workAreaRect(&scr)
|
||||
occupied := make([]screen.Rect, 0, len(windows))
|
||||
for _, info := range windows {
|
||||
if windowScreen := screenForWindowInfo(screens, info); windowScreen != nil && windowScreen.ID == scr.ID {
|
||||
occupied = append(occupied, screen.Rect{X: info.X, Y: info.Y, Width: info.Width, Height: info.Height})
|
||||
}
|
||||
}
|
||||
if candidate, ok := findLargestFreeRect(workArea, occupied, input.Width, input.Height); ok {
|
||||
return nil, ScreenFindSpaceOutput{ScreenID: scr.ID, Bounds: candidate}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, ScreenFindSpaceOutput{}, nil
|
||||
}
|
||||
|
||||
// --- screen_for_window ---
|
||||
|
||||
type ScreenForWindowInput struct {
|
||||
Window string `json:"window"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
type ScreenForWindowOutput struct {
|
||||
Screen *screen.Screen `json:"screen"`
|
||||
}
|
||||
|
||||
func (s *Subsystem) screenForWindow(_ context.Context, _ *mcp.CallToolRequest, input ScreenForWindowInput) (*mcp.CallToolResult, ScreenForWindowOutput, error) {
|
||||
svc, err := core.ServiceFor[*display.Service](s.core, "display")
|
||||
result, _, err := s.core.QUERY(window.QueryWindowByName{Name: input.Name})
|
||||
if err != nil {
|
||||
return nil, ScreenForWindowOutput{}, err
|
||||
}
|
||||
scr, err := svc.GetScreenForWindow(input.Window)
|
||||
info, _ := result.(*window.WindowInfo)
|
||||
if info == nil {
|
||||
return nil, ScreenForWindowOutput{}, nil
|
||||
}
|
||||
centerX := info.X + info.Width/2
|
||||
centerY := info.Y + info.Height/2
|
||||
screenResult, _, err := s.core.QUERY(screen.QueryAtPoint{X: centerX, Y: centerY})
|
||||
if err != nil {
|
||||
return nil, ScreenForWindowOutput{}, err
|
||||
}
|
||||
scr, _ := screenResult.(*screen.Screen)
|
||||
return nil, ScreenForWindowOutput{Screen: scr}, nil
|
||||
}
|
||||
|
||||
|
|
@ -145,7 +214,8 @@ func (s *Subsystem) registerScreenTools(server *mcp.Server) {
|
|||
mcp.AddTool(server, &mcp.Tool{Name: "screen_get", Description: "Get information about a specific screen"}, s.screenGet)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "screen_primary", Description: "Get the primary screen"}, s.screenPrimary)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "screen_at_point", Description: "Get the screen at a specific point"}, s.screenAtPoint)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "screen_work_area", Description: "Get the work area for a screen"}, s.screenWorkArea)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "screen_work_areas", Description: "Get work areas for all screens"}, s.screenWorkAreas)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "screen_work_area", Description: "Alias for screen_work_areas"}, s.screenWorkArea)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "screen_find_space", Description: "Find the largest empty area on a screen that fits the requested size"}, s.screenFindSpace)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "screen_for_window", Description: "Get the screen containing a window"}, s.screenForWindow)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ type TraySetTooltipOutput struct {
|
|||
}
|
||||
|
||||
func (s *Subsystem) traySetTooltip(_ context.Context, _ *mcp.CallToolRequest, input TraySetTooltipInput) (*mcp.CallToolResult, TraySetTooltipOutput, error) {
|
||||
_, _, err := s.core.PERFORM(systray.TaskSetTooltip{Tooltip: input.Tooltip})
|
||||
_, _, err := s.core.PERFORM(systray.TaskSetTrayTooltip{Tooltip: input.Tooltip})
|
||||
if err != nil {
|
||||
return nil, TraySetTooltipOutput{}, err
|
||||
}
|
||||
|
|
@ -53,13 +53,52 @@ type TraySetLabelOutput struct {
|
|||
}
|
||||
|
||||
func (s *Subsystem) traySetLabel(_ context.Context, _ *mcp.CallToolRequest, input TraySetLabelInput) (*mcp.CallToolResult, TraySetLabelOutput, error) {
|
||||
_, _, err := s.core.PERFORM(systray.TaskSetLabel{Label: input.Label})
|
||||
_, _, err := s.core.PERFORM(systray.TaskSetTrayLabel{Label: input.Label})
|
||||
if err != nil {
|
||||
return nil, TraySetLabelOutput{}, err
|
||||
}
|
||||
return nil, TraySetLabelOutput{Success: true}, nil
|
||||
}
|
||||
|
||||
// --- tray_set_menu ---
|
||||
|
||||
type TraySetMenuInput struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
}
|
||||
type TraySetMenuOutput struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
func (s *Subsystem) traySetMenu(_ context.Context, _ *mcp.CallToolRequest, input TraySetMenuInput) (*mcp.CallToolResult, TraySetMenuOutput, error) {
|
||||
items := make([]systray.TrayMenuItem, 0, len(input.Items))
|
||||
for _, item := range input.Items {
|
||||
items = append(items, decodeTrayMenuItem(item))
|
||||
}
|
||||
_, _, err := s.core.PERFORM(systray.TaskSetTrayMenu{Items: items})
|
||||
if err != nil {
|
||||
return nil, TraySetMenuOutput{}, err
|
||||
}
|
||||
return nil, TraySetMenuOutput{Success: true}, nil
|
||||
}
|
||||
|
||||
// --- tray_show_message ---
|
||||
|
||||
type TrayShowMessageInput struct {
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
type TrayShowMessageOutput struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
func (s *Subsystem) trayShowMessage(_ context.Context, _ *mcp.CallToolRequest, input TrayShowMessageInput) (*mcp.CallToolResult, TrayShowMessageOutput, error) {
|
||||
_, _, err := s.core.PERFORM(systray.TaskShowMessage{Title: input.Title, Message: input.Message})
|
||||
if err != nil {
|
||||
return nil, TrayShowMessageOutput{}, err
|
||||
}
|
||||
return nil, TrayShowMessageOutput{Success: true}, nil
|
||||
}
|
||||
|
||||
// --- tray_info ---
|
||||
|
||||
type TrayInfoInput struct{}
|
||||
|
|
@ -74,7 +113,7 @@ func (s *Subsystem) trayInfo(_ context.Context, _ *mcp.CallToolRequest, _ TrayIn
|
|||
}
|
||||
config, ok := result.(map[string]any)
|
||||
if !ok {
|
||||
return nil, TrayInfoOutput{}, corego.E("mcp.trayInfo", "unexpected result type from tray config query", nil)
|
||||
config = map[string]any{}
|
||||
}
|
||||
return nil, TrayInfoOutput{Config: config}, nil
|
||||
}
|
||||
|
|
@ -103,6 +142,42 @@ func (s *Subsystem) registerTrayTools(server *mcp.Server) {
|
|||
mcp.AddTool(server, &mcp.Tool{Name: "tray_set_icon", Description: "Set the system tray icon"}, s.traySetIcon)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "tray_set_tooltip", Description: "Set the system tray tooltip"}, s.traySetTooltip)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "tray_set_label", Description: "Set the system tray label"}, s.traySetLabel)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "tray_set_menu", Description: "Set the system tray menu"}, s.traySetMenu)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "tray_show_message", Description: "Show a tray balloon or tray message"}, s.trayShowMessage)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "tray_info", Description: "Get system tray configuration"}, s.trayInfo)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "tray_show_message", Description: "Show a tray message or notification"}, s.trayShowMessage)
|
||||
}
|
||||
|
||||
func decodeTrayMenuItem(input map[string]any) systray.TrayMenuItem {
|
||||
item := systray.TrayMenuItem{}
|
||||
if label, ok := input["label"].(string); ok {
|
||||
item.Label = label
|
||||
}
|
||||
if itemType, ok := input["type"].(string); ok {
|
||||
item.Type = itemType
|
||||
}
|
||||
if checked, ok := input["checked"].(bool); ok {
|
||||
item.Checked = checked
|
||||
}
|
||||
if disabled, ok := input["disabled"].(bool); ok {
|
||||
item.Disabled = disabled
|
||||
}
|
||||
if tooltip, ok := input["tooltip"].(string); ok {
|
||||
item.Tooltip = tooltip
|
||||
}
|
||||
if actionID, ok := input["actionId"].(string); ok {
|
||||
item.ActionID = actionID
|
||||
}
|
||||
if actionID, ok := input["action_id"].(string); ok && item.ActionID == "" {
|
||||
item.ActionID = actionID
|
||||
}
|
||||
if rawSubmenu, ok := input["submenu"].([]any); ok {
|
||||
item.Submenu = make([]systray.TrayMenuItem, 0, len(rawSubmenu))
|
||||
for _, child := range rawSubmenu {
|
||||
if childMap, ok := child.(map[string]any); ok {
|
||||
item.Submenu = append(item.Submenu, decodeTrayMenuItem(childMap))
|
||||
}
|
||||
}
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ package mcp
|
|||
import (
|
||||
"context"
|
||||
|
||||
corego "dappco.re/go/core"
|
||||
"dappco.re/go/core/gui/pkg/webview"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"forge.lthn.ai/core/gui/pkg/webview"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
|
|
@ -105,7 +105,7 @@ func (s *Subsystem) webviewScreenshot(_ context.Context, _ *mcp.CallToolRequest,
|
|||
}
|
||||
sr, ok := result.(webview.ScreenshotResult)
|
||||
if !ok {
|
||||
return nil, WebviewScreenshotOutput{}, corego.E("mcp.webview", "unexpected result type from webview screenshot", nil)
|
||||
return nil, WebviewScreenshotOutput{}, coreerr.E("mcp.webviewScreenshot", "unexpected result type", nil)
|
||||
}
|
||||
return nil, WebviewScreenshotOutput{Base64: sr.Base64, MimeType: sr.MimeType}, nil
|
||||
}
|
||||
|
|
@ -272,7 +272,7 @@ func (s *Subsystem) webviewConsole(_ context.Context, _ *mcp.CallToolRequest, in
|
|||
}
|
||||
msgs, ok := result.([]webview.ConsoleMessage)
|
||||
if !ok {
|
||||
return nil, WebviewConsoleOutput{}, corego.E("mcp.webview", "unexpected result type from webview console query", nil)
|
||||
return nil, WebviewConsoleOutput{}, coreerr.E("mcp.webviewConsole", "unexpected result type", nil)
|
||||
}
|
||||
return nil, WebviewConsoleOutput{Messages: msgs}, nil
|
||||
}
|
||||
|
|
@ -370,7 +370,7 @@ func (s *Subsystem) webviewQuery(_ context.Context, _ *mcp.CallToolRequest, inpu
|
|||
}
|
||||
el, ok := result.(*webview.ElementInfo)
|
||||
if !ok {
|
||||
return nil, WebviewQueryOutput{}, corego.E("mcp.webview", "unexpected result type from webview query", nil)
|
||||
return nil, WebviewQueryOutput{}, coreerr.E("mcp.webviewQuery", "unexpected result type", nil)
|
||||
}
|
||||
return nil, WebviewQueryOutput{Element: el}, nil
|
||||
}
|
||||
|
|
@ -399,7 +399,7 @@ func (s *Subsystem) webviewQueryAll(_ context.Context, _ *mcp.CallToolRequest, i
|
|||
}
|
||||
els, ok := result.([]*webview.ElementInfo)
|
||||
if !ok {
|
||||
return nil, WebviewQueryAllOutput{}, corego.E("mcp.webview", "unexpected result type from webview query all", nil)
|
||||
return nil, WebviewQueryAllOutput{}, coreerr.E("mcp.webviewQueryAll", "unexpected result type", nil)
|
||||
}
|
||||
return nil, WebviewQueryAllOutput{Elements: els}, nil
|
||||
}
|
||||
|
|
@ -422,7 +422,7 @@ func (s *Subsystem) webviewDOMTree(_ context.Context, _ *mcp.CallToolRequest, in
|
|||
}
|
||||
html, ok := result.(string)
|
||||
if !ok {
|
||||
return nil, WebviewDOMTreeOutput{}, corego.E("mcp.webview", "unexpected result type from webview DOM tree query", nil)
|
||||
return nil, WebviewDOMTreeOutput{}, coreerr.E("mcp.webviewDOMTree", "unexpected result type", nil)
|
||||
}
|
||||
return nil, WebviewDOMTreeOutput{HTML: html}, nil
|
||||
}
|
||||
|
|
@ -637,7 +637,7 @@ func (s *Subsystem) webviewURL(_ context.Context, _ *mcp.CallToolRequest, input
|
|||
}
|
||||
url, ok := result.(string)
|
||||
if !ok {
|
||||
return nil, WebviewURLOutput{}, corego.E("mcp.webview", "unexpected result type from webview URL query", nil)
|
||||
return nil, WebviewURLOutput{}, coreerr.E("mcp.webviewURL", "unexpected result type", nil)
|
||||
}
|
||||
return nil, WebviewURLOutput{URL: url}, nil
|
||||
}
|
||||
|
|
@ -659,7 +659,7 @@ func (s *Subsystem) webviewTitle(_ context.Context, _ *mcp.CallToolRequest, inpu
|
|||
}
|
||||
title, ok := result.(string)
|
||||
if !ok {
|
||||
return nil, WebviewTitleOutput{}, corego.E("mcp.webview", "unexpected result type from webview title query", nil)
|
||||
return nil, WebviewTitleOutput{}, coreerr.E("mcp.webviewTitle", "unexpected result type", nil)
|
||||
}
|
||||
return nil, WebviewTitleOutput{Title: title}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@ package mcp
|
|||
import (
|
||||
"context"
|
||||
|
||||
corego "dappco.re/go/core"
|
||||
"dappco.re/go/core/gui/pkg/window"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"forge.lthn.ai/core/gui/pkg/screen"
|
||||
"forge.lthn.ai/core/gui/pkg/window"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
|
|
@ -23,7 +24,7 @@ func (s *Subsystem) windowList(_ context.Context, _ *mcp.CallToolRequest, _ Wind
|
|||
}
|
||||
windows, ok := result.([]window.WindowInfo)
|
||||
if !ok {
|
||||
return nil, WindowListOutput{}, corego.E("mcp.window", "unexpected result type from window list query", nil)
|
||||
return nil, WindowListOutput{}, coreerr.E("mcp.windowList", "unexpected result type", nil)
|
||||
}
|
||||
return nil, WindowListOutput{Windows: windows}, nil
|
||||
}
|
||||
|
|
@ -44,7 +45,7 @@ func (s *Subsystem) windowGet(_ context.Context, _ *mcp.CallToolRequest, input W
|
|||
}
|
||||
info, ok := result.(*window.WindowInfo)
|
||||
if !ok {
|
||||
return nil, WindowGetOutput{}, corego.E("mcp.window", "unexpected result type from window get query", nil)
|
||||
return nil, WindowGetOutput{}, coreerr.E("mcp.windowGet", "unexpected result type", nil)
|
||||
}
|
||||
return nil, WindowGetOutput{Window: info}, nil
|
||||
}
|
||||
|
|
@ -63,7 +64,7 @@ func (s *Subsystem) windowFocused(_ context.Context, _ *mcp.CallToolRequest, _ W
|
|||
}
|
||||
windows, ok := result.([]window.WindowInfo)
|
||||
if !ok {
|
||||
return nil, WindowFocusedOutput{}, corego.E("mcp.window", "unexpected result type from window list query", nil)
|
||||
return nil, WindowFocusedOutput{}, coreerr.E("mcp.windowFocused", "unexpected result type", nil)
|
||||
}
|
||||
for _, w := range windows {
|
||||
if w.Focused {
|
||||
|
|
@ -105,7 +106,7 @@ func (s *Subsystem) windowCreate(_ context.Context, _ *mcp.CallToolRequest, inpu
|
|||
}
|
||||
info, ok := result.(window.WindowInfo)
|
||||
if !ok {
|
||||
return nil, WindowCreateOutput{}, corego.E("mcp.window", "unexpected result type from window create task", nil)
|
||||
return nil, WindowCreateOutput{}, coreerr.E("mcp.windowCreate", "unexpected result type", nil)
|
||||
}
|
||||
return nil, WindowCreateOutput{Window: info}, nil
|
||||
}
|
||||
|
|
@ -258,6 +259,23 @@ func (s *Subsystem) windowFocus(_ context.Context, _ *mcp.CallToolRequest, input
|
|||
return nil, WindowFocusOutput{Success: true}, nil
|
||||
}
|
||||
|
||||
// --- focus_set ---
|
||||
|
||||
type FocusSetInput struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
type FocusSetOutput struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
func (s *Subsystem) focusSet(ctx context.Context, req *mcp.CallToolRequest, input FocusSetInput) (*mcp.CallToolResult, FocusSetOutput, error) {
|
||||
_, out, err := s.windowFocus(ctx, req, WindowFocusInput{Name: input.Name})
|
||||
if err != nil {
|
||||
return nil, FocusSetOutput{}, err
|
||||
}
|
||||
return nil, FocusSetOutput{Success: out.Success}, nil
|
||||
}
|
||||
|
||||
// --- window_title ---
|
||||
|
||||
type WindowTitleInput struct {
|
||||
|
|
@ -276,12 +294,6 @@ func (s *Subsystem) windowTitle(_ context.Context, _ *mcp.CallToolRequest, input
|
|||
return nil, WindowTitleOutput{Success: true}, nil
|
||||
}
|
||||
|
||||
// --- window_title_set ---
|
||||
|
||||
func (s *Subsystem) windowTitleSet(ctx context.Context, req *mcp.CallToolRequest, input WindowTitleInput) (*mcp.CallToolResult, WindowTitleOutput, error) {
|
||||
return s.windowTitle(ctx, req, input)
|
||||
}
|
||||
|
||||
// --- window_title_get ---
|
||||
|
||||
type WindowTitleGetInput struct {
|
||||
|
|
@ -362,24 +374,6 @@ func (s *Subsystem) windowBackgroundColour(_ context.Context, _ *mcp.CallToolReq
|
|||
return nil, WindowBackgroundColourOutput{Success: true}, nil
|
||||
}
|
||||
|
||||
// --- window_opacity ---
|
||||
|
||||
type WindowOpacityInput struct {
|
||||
Name string `json:"name"`
|
||||
Opacity float32 `json:"opacity"`
|
||||
}
|
||||
type WindowOpacityOutput struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
func (s *Subsystem) windowOpacity(_ context.Context, _ *mcp.CallToolRequest, input WindowOpacityInput) (*mcp.CallToolResult, WindowOpacityOutput, error) {
|
||||
_, _, err := s.core.PERFORM(window.TaskSetOpacity{Name: input.Name, Opacity: input.Opacity})
|
||||
if err != nil {
|
||||
return nil, WindowOpacityOutput{}, err
|
||||
}
|
||||
return nil, WindowOpacityOutput{Success: true}, nil
|
||||
}
|
||||
|
||||
// --- window_fullscreen ---
|
||||
|
||||
type WindowFullscreenInput struct {
|
||||
|
|
@ -398,6 +392,94 @@ func (s *Subsystem) windowFullscreen(_ context.Context, _ *mcp.CallToolRequest,
|
|||
return nil, WindowFullscreenOutput{Success: true}, nil
|
||||
}
|
||||
|
||||
type arrangedPair struct {
|
||||
First screen.Rect
|
||||
Second screen.Rect
|
||||
}
|
||||
|
||||
func (s *Subsystem) arrangePairOnScreen(firstName, secondName string, scr *screen.Screen, orientation string) (arrangedPair, error) {
|
||||
workArea := workAreaRect(scr)
|
||||
if workArea.Width == 0 || workArea.Height == 0 {
|
||||
return arrangedPair{}, coreerr.E("mcp.arrangePairOnScreen", "screen work area is empty", nil)
|
||||
}
|
||||
if orientation == "" {
|
||||
if workArea.Width >= workArea.Height {
|
||||
orientation = "horizontal"
|
||||
} else {
|
||||
orientation = "vertical"
|
||||
}
|
||||
}
|
||||
|
||||
var firstRect screen.Rect
|
||||
var secondRect screen.Rect
|
||||
switch orientation {
|
||||
case "vertical", "stacked":
|
||||
firstHeight := workArea.Height / 2
|
||||
firstRect = screen.Rect{X: workArea.X, Y: workArea.Y, Width: workArea.Width, Height: firstHeight}
|
||||
secondRect = screen.Rect{X: workArea.X, Y: workArea.Y + firstHeight, Width: workArea.Width, Height: workArea.Height - firstHeight}
|
||||
default:
|
||||
firstWidth := workArea.Width / 2
|
||||
firstRect = screen.Rect{X: workArea.X, Y: workArea.Y, Width: firstWidth, Height: workArea.Height}
|
||||
secondRect = screen.Rect{X: workArea.X + firstWidth, Y: workArea.Y, Width: workArea.Width - firstWidth, Height: workArea.Height}
|
||||
}
|
||||
|
||||
if err := applyRect(s.core, firstName, firstRect); err != nil {
|
||||
return arrangedPair{}, err
|
||||
}
|
||||
if err := applyRect(s.core, secondName, secondRect); err != nil {
|
||||
return arrangedPair{}, err
|
||||
}
|
||||
return arrangedPair{First: firstRect, Second: secondRect}, nil
|
||||
}
|
||||
|
||||
// --- window_arrange_pair ---
|
||||
|
||||
type WindowArrangePairInput struct {
|
||||
First string `json:"first"`
|
||||
Second string `json:"second"`
|
||||
ScreenID string `json:"screenId,omitempty"`
|
||||
Orientation string `json:"orientation,omitempty"`
|
||||
}
|
||||
type WindowArrangePairOutput struct {
|
||||
FirstBounds screen.Rect `json:"firstBounds"`
|
||||
SecondBounds screen.Rect `json:"secondBounds"`
|
||||
}
|
||||
|
||||
func (s *Subsystem) windowArrangePair(_ context.Context, _ *mcp.CallToolRequest, input WindowArrangePairInput) (*mcp.CallToolResult, WindowArrangePairOutput, error) {
|
||||
screens, err := s.allScreens()
|
||||
if err != nil {
|
||||
return nil, WindowArrangePairOutput{}, err
|
||||
}
|
||||
windows, err := s.allWindows()
|
||||
if err != nil {
|
||||
return nil, WindowArrangePairOutput{}, err
|
||||
}
|
||||
|
||||
var targetScreen *screen.Screen
|
||||
if input.ScreenID != "" {
|
||||
targetScreen = chooseScreenByIDOrPrimary(screens, input.ScreenID)
|
||||
} else {
|
||||
for _, info := range windows {
|
||||
if info.Name == input.First {
|
||||
targetScreen = screenForWindowInfo(screens, info)
|
||||
break
|
||||
}
|
||||
}
|
||||
if targetScreen == nil {
|
||||
targetScreen = chooseScreenByIDOrPrimary(screens, "")
|
||||
}
|
||||
}
|
||||
if targetScreen == nil {
|
||||
return nil, WindowArrangePairOutput{}, coreerr.E("mcp.windowArrangePair", "no screen available", nil)
|
||||
}
|
||||
|
||||
arranged, err := s.arrangePairOnScreen(input.First, input.Second, targetScreen, input.Orientation)
|
||||
if err != nil {
|
||||
return nil, WindowArrangePairOutput{}, err
|
||||
}
|
||||
return nil, WindowArrangePairOutput{FirstBounds: arranged.First, SecondBounds: arranged.Second}, nil
|
||||
}
|
||||
|
||||
// --- Registration ---
|
||||
|
||||
func (s *Subsystem) registerWindowTools(server *mcp.Server) {
|
||||
|
|
@ -413,13 +495,12 @@ func (s *Subsystem) registerWindowTools(server *mcp.Server) {
|
|||
mcp.AddTool(server, &mcp.Tool{Name: "window_minimize", Description: "Minimise a window"}, s.windowMinimize)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "window_restore", Description: "Restore a maximised or minimised window"}, s.windowRestore)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "window_focus", Description: "Bring a window to the front"}, s.windowFocus)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "focus_set", Description: "Alias for window_focus"}, s.windowFocus)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "focus_set", Description: "Set focus to a specific window"}, s.focusSet)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "window_title", Description: "Set the title of a window"}, s.windowTitle)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "window_title_set", Description: "Alias for window_title"}, s.windowTitleSet)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "window_title_get", Description: "Get the title of a window"}, s.windowTitleGet)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "window_visibility", Description: "Show or hide a window"}, s.windowVisibility)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "window_always_on_top", Description: "Pin a window above others"}, s.windowAlwaysOnTop)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "window_background_colour", Description: "Set a window background colour"}, s.windowBackgroundColour)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "window_opacity", Description: "Set a window opacity"}, s.windowOpacity)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "window_fullscreen", Description: "Set a window to fullscreen mode"}, s.windowFullscreen)
|
||||
mcp.AddTool(server, &mcp.Tool{Name: "window_arrange_pair", Description: "Arrange two windows side-by-side or stacked on a screen"}, s.windowArrangePair)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,13 +22,13 @@ type Manager struct {
|
|||
}
|
||||
|
||||
// NewManager creates a menu Manager.
|
||||
// Use: mgr := menu.NewManager(platform)
|
||||
// menu.NewManager(menu.NewWailsPlatform(app)).SetApplicationMenu([]menu.MenuItem{{Label: "File"}})
|
||||
func NewManager(platform Platform) *Manager {
|
||||
return &Manager{platform: platform}
|
||||
}
|
||||
|
||||
// Build constructs a PlatformMenu from a tree of MenuItems.
|
||||
// Use: built := mgr.Build([]menu.MenuItem{{Label: "File"}})
|
||||
// menu.NewManager(menu.NewWailsPlatform(app)).Build([]menu.MenuItem{{Label: "File"}})
|
||||
func (m *Manager) Build(items []MenuItem) PlatformMenu {
|
||||
menu := m.platform.NewMenu()
|
||||
m.buildItems(menu, items)
|
||||
|
|
@ -64,7 +64,7 @@ func (m *Manager) buildItems(menu PlatformMenu, items []MenuItem) {
|
|||
}
|
||||
|
||||
// SetApplicationMenu builds and sets the application menu.
|
||||
// Use: mgr.SetApplicationMenu([]menu.MenuItem{{Label: "Quit"}})
|
||||
// menu.NewManager(menu.NewWailsPlatform(app)).SetApplicationMenu([]menu.MenuItem{{Label: "File"}})
|
||||
func (m *Manager) SetApplicationMenu(items []MenuItem) {
|
||||
menu := m.Build(items)
|
||||
m.platform.SetApplicationMenu(menu)
|
||||
|
|
|
|||
|
|
@ -1,17 +1,10 @@
|
|||
// pkg/menu/messages.go
|
||||
package menu
|
||||
|
||||
// QueryConfig requests this service's config section from the display orchestrator.
|
||||
// Result: map[string]any
|
||||
type QueryConfig struct{}
|
||||
|
||||
// QueryGetAppMenu returns the current app menu item descriptors.
|
||||
// Result: []MenuItem
|
||||
type QueryGetAppMenu struct{}
|
||||
|
||||
// TaskSetAppMenu sets the application menu. OnClick closures work because
|
||||
// core/go IPC is in-process (no serialisation boundary).
|
||||
type TaskSetAppMenu struct{ Items []MenuItem }
|
||||
|
||||
// TaskSaveConfig persists this service's config section via the display orchestrator.
|
||||
type TaskSaveConfig struct{ Value map[string]any }
|
||||
type TaskSaveConfig struct{ Config map[string]any }
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ package menu
|
|||
|
||||
import "forge.lthn.ai/core/go/pkg/core"
|
||||
|
||||
// Register creates a factory closure that captures the Platform adapter.
|
||||
// Register(p) binds the menu service to a Core instance.
|
||||
// core.WithService(menu.Register(wailsMenu))
|
||||
func Register(p Platform) func(*core.Core) (any, error) {
|
||||
return func(c *core.Core) (any, error) {
|
||||
return &Service{
|
||||
|
|
|
|||
|
|
@ -7,24 +7,21 @@ import (
|
|||
"forge.lthn.ai/core/go/pkg/core"
|
||||
)
|
||||
|
||||
// Options holds configuration for the menu service.
|
||||
type Options struct{}
|
||||
|
||||
// Service is a core.Service managing application menus via IPC.
|
||||
type Service struct {
|
||||
*core.ServiceRuntime[Options]
|
||||
manager *Manager
|
||||
platform Platform
|
||||
items []MenuItem // last-set menu items for QueryGetAppMenu
|
||||
menuItems []MenuItem
|
||||
showDevTools bool
|
||||
}
|
||||
|
||||
// OnStartup queries config and registers IPC handlers.
|
||||
func (s *Service) OnStartup(ctx context.Context) error {
|
||||
cfg, handled, _ := s.Core().QUERY(QueryConfig{})
|
||||
configValue, handled, _ := s.Core().QUERY(QueryConfig{})
|
||||
if handled {
|
||||
if mCfg, ok := cfg.(map[string]any); ok {
|
||||
s.applyConfig(mCfg)
|
||||
if menuConfig, ok := configValue.(map[string]any); ok {
|
||||
s.applyConfig(menuConfig)
|
||||
}
|
||||
}
|
||||
s.Core().RegisterQuery(s.handleQuery)
|
||||
|
|
@ -32,20 +29,18 @@ func (s *Service) OnStartup(ctx context.Context) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) applyConfig(cfg map[string]any) {
|
||||
if v, ok := cfg["show_dev_tools"]; ok {
|
||||
func (s *Service) applyConfig(configData map[string]any) {
|
||||
if v, ok := configData["show_dev_tools"]; ok {
|
||||
if show, ok := v.(bool); ok {
|
||||
s.showDevTools = show
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ShowDevTools returns whether developer tools menu items should be shown.
|
||||
func (s *Service) ShowDevTools() bool {
|
||||
return s.showDevTools
|
||||
}
|
||||
|
||||
// HandleIPCEvents is auto-discovered and registered by core.WithService.
|
||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -53,7 +48,7 @@ func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
|||
func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
|
||||
switch q.(type) {
|
||||
case QueryGetAppMenu:
|
||||
return s.items, true, nil
|
||||
return s.menuItems, true, nil
|
||||
default:
|
||||
return nil, false, nil
|
||||
}
|
||||
|
|
@ -62,7 +57,7 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
|
|||
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
||||
switch t := t.(type) {
|
||||
case TaskSetAppMenu:
|
||||
s.items = t.Items
|
||||
s.menuItems = t.Items
|
||||
s.manager.SetApplicationMenu(t.Items)
|
||||
return nil, true, nil
|
||||
default:
|
||||
|
|
@ -70,7 +65,6 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
|||
}
|
||||
}
|
||||
|
||||
// Manager returns the underlying menu Manager.
|
||||
func (s *Service) Manager() *Manager {
|
||||
return s.manager
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,37 @@
|
|||
// pkg/notification/messages.go
|
||||
package notification
|
||||
|
||||
// QueryPermission checks notification authorisation. Result: PermissionStatus
|
||||
// QueryPermission returns current notification permission status. Result: PermissionStatus
|
||||
type QueryPermission struct{}
|
||||
|
||||
// TaskSend sends a notification. Falls back to dialog if platform fails.
|
||||
type TaskSend struct{ Opts NotificationOptions }
|
||||
// TaskSend sends a native notification, falling back to dialog on failure.
|
||||
type TaskSend struct{ Options NotificationOptions }
|
||||
|
||||
// TaskRequestPermission requests notification authorisation. Result: bool (granted)
|
||||
// TaskRequestPermission requests notification permission from the OS. Result: bool (granted)
|
||||
type TaskRequestPermission struct{}
|
||||
|
||||
// TaskClear clears pending notifications when the backend supports it.
|
||||
type TaskClear struct{}
|
||||
// TaskRevokePermission revokes previously granted notification permission.
|
||||
// Result: nil
|
||||
// _, _, err := c.PERFORM(TaskRevokePermission{})
|
||||
type TaskRevokePermission struct{}
|
||||
|
||||
// ActionNotificationClicked is broadcast when a notification is clicked.
|
||||
// TaskRegisterCategory registers a notification category with its action buttons.
|
||||
// Must be called before sending notifications that use that category.
|
||||
// _, _, err := c.PERFORM(TaskRegisterCategory{Category: NotificationCategory{ID: "message", Actions: [...]}})
|
||||
type TaskRegisterCategory struct{ Category NotificationCategory }
|
||||
|
||||
// TaskClear removes a notification by ID. An empty ID clears all notifications if supported.
|
||||
type TaskClear struct{ ID string }
|
||||
|
||||
// ActionNotificationClicked is broadcast when the user clicks a notification.
|
||||
type ActionNotificationClicked struct{ ID string }
|
||||
|
||||
// ActionNotificationActionTriggered is broadcast when the user taps an action button on a notification.
|
||||
type ActionNotificationActionTriggered struct {
|
||||
NotificationID string `json:"notificationId"`
|
||||
ActionID string `json:"actionId"`
|
||||
}
|
||||
|
||||
// ActionNotificationDismissed is broadcast when the user dismisses a notification without acting on it.
|
||||
type ActionNotificationDismissed struct {
|
||||
NotificationID string `json:"notificationId"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,16 @@ package notification
|
|||
|
||||
// Platform abstracts the native notification backend.
|
||||
type Platform interface {
|
||||
Send(opts NotificationOptions) error
|
||||
Send(options NotificationOptions) error
|
||||
RequestPermission() (bool, error)
|
||||
CheckPermission() (bool, error)
|
||||
RevokePermission() error
|
||||
RegisterCategory(category NotificationCategory) error
|
||||
}
|
||||
|
||||
// ClearPlatform is an optional extension for removing notifications.
|
||||
type ClearPlatform interface {
|
||||
Clear(id string) error
|
||||
}
|
||||
|
||||
// NotificationAction represents an interactive notification action.
|
||||
|
|
@ -25,12 +32,13 @@ const (
|
|||
|
||||
// NotificationOptions contains options for sending a notification.
|
||||
type NotificationOptions struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Subtitle string `json:"subtitle,omitempty"`
|
||||
Severity NotificationSeverity `json:"severity,omitempty"`
|
||||
Actions []NotificationAction `json:"actions,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Subtitle string `json:"subtitle,omitempty"`
|
||||
Severity NotificationSeverity `json:"severity,omitempty"`
|
||||
CategoryID string `json:"categoryId,omitempty"`
|
||||
Actions []NotificationAction `json:"actions,omitempty"`
|
||||
}
|
||||
|
||||
// PermissionStatus indicates whether notifications are authorised.
|
||||
|
|
@ -38,10 +46,15 @@ type PermissionStatus struct {
|
|||
Granted bool `json:"granted"`
|
||||
}
|
||||
|
||||
type clearer interface {
|
||||
Clear() error
|
||||
// NotificationAction describes a tappable action button on a notification.
|
||||
type NotificationAction struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
type actionSender interface {
|
||||
SendWithActions(opts NotificationOptions) error
|
||||
// NotificationCategory groups a set of actions that can appear on notifications.
|
||||
// Register categories on startup so the OS knows the available action buttons.
|
||||
type NotificationCategory struct {
|
||||
ID string `json:"id"`
|
||||
Actions []NotificationAction `json:"actions"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,26 +3,20 @@ package notification
|
|||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
corego "dappco.re/go/core"
|
||||
"dappco.re/go/core/gui/pkg/dialog"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"forge.lthn.ai/core/go/pkg/core"
|
||||
)
|
||||
|
||||
// Options configures the notification service.
|
||||
// Use: core.WithService(notification.Register(platform))
|
||||
type Options struct{}
|
||||
|
||||
// Service manages notifications via Core tasks and queries.
|
||||
// Use: svc := ¬ification.Service{}
|
||||
type Service struct {
|
||||
*core.ServiceRuntime[Options]
|
||||
platform Platform
|
||||
}
|
||||
|
||||
// Register creates a Core service factory for the notification backend.
|
||||
// Use: core.New(core.WithService(notification.Register(platform)))
|
||||
func Register(p Platform) func(*core.Core) (any, error) {
|
||||
return func(c *core.Core) (any, error) {
|
||||
return &Service{
|
||||
|
|
@ -32,15 +26,12 @@ func Register(p Platform) func(*core.Core) (any, error) {
|
|||
}
|
||||
}
|
||||
|
||||
// OnStartup registers notification handlers with Core.
|
||||
// Use: _ = svc.OnStartup(context.Background())
|
||||
func (s *Service) OnStartup(ctx context.Context) error {
|
||||
s.Core().RegisterQuery(s.handleQuery)
|
||||
s.Core().RegisterTask(s.handleTask)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleIPCEvents satisfies Core's IPC hook.
|
||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -58,47 +49,58 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
|
|||
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
||||
switch t := t.(type) {
|
||||
case TaskSend:
|
||||
return nil, true, s.sendNotification(t.Opts)
|
||||
return nil, true, s.send(t.Options)
|
||||
case TaskRequestPermission:
|
||||
granted, err := s.platform.RequestPermission()
|
||||
return granted, true, err
|
||||
case TaskRevokePermission:
|
||||
return nil, true, s.platform.RevokePermission()
|
||||
case TaskRegisterCategory:
|
||||
return nil, true, s.platform.RegisterCategory(t.Category)
|
||||
case TaskClear:
|
||||
if clr, ok := s.platform.(clearer); ok {
|
||||
return nil, true, clr.Clear()
|
||||
clearPlatform, ok := s.platform.(ClearPlatform)
|
||||
if !ok {
|
||||
return nil, true, coreerr.E("notification.handleTask", "notification clearing is not supported by this platform", nil)
|
||||
}
|
||||
return nil, true, nil
|
||||
return nil, true, clearPlatform.Clear(t.ID)
|
||||
default:
|
||||
return nil, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// sendNotification attempts a native notification and falls back to a dialog via IPC.
|
||||
func (s *Service) sendNotification(opts NotificationOptions) error {
|
||||
// Generate an ID when the caller does not provide one.
|
||||
if opts.ID == "" {
|
||||
opts.ID = corego.Sprintf("core-%d", time.Now().UnixNano())
|
||||
// send attempts native notification, falls back to dialog via IPC.
|
||||
func (s *Service) send(options NotificationOptions) error {
|
||||
// Generate ID if not provided
|
||||
if options.ID == "" {
|
||||
options.ID = "core-" + strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
}
|
||||
|
||||
if len(opts.Actions) > 0 {
|
||||
if sender, ok := s.platform.(actionSender); ok {
|
||||
if err := sender.SendWithActions(opts); err == nil {
|
||||
return nil
|
||||
}
|
||||
if len(options.Actions) > 0 {
|
||||
categoryID := options.CategoryID
|
||||
if categoryID == "" {
|
||||
categoryID = options.ID
|
||||
}
|
||||
if err := s.platform.RegisterCategory(NotificationCategory{
|
||||
ID: categoryID,
|
||||
Actions: options.Actions,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
options.CategoryID = categoryID
|
||||
}
|
||||
|
||||
if err := s.platform.Send(opts); err != nil {
|
||||
// Fall back to a dialog when the native notification fails.
|
||||
return s.showFallbackDialog(opts)
|
||||
if err := s.platform.Send(options); err != nil {
|
||||
// Fallback: show as dialog via IPC
|
||||
return s.fallbackDialog(options)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// showFallbackDialog shows a dialog via IPC when native notifications fail.
|
||||
func (s *Service) showFallbackDialog(opts NotificationOptions) error {
|
||||
// Map severity to dialog type.
|
||||
// fallbackDialog shows a dialog via IPC when native notifications fail.
|
||||
func (s *Service) fallbackDialog(options NotificationOptions) error {
|
||||
// Map severity to dialog type
|
||||
var dt dialog.DialogType
|
||||
switch opts.Severity {
|
||||
switch options.Severity {
|
||||
case SeverityWarning:
|
||||
dt = dialog.DialogWarning
|
||||
case SeverityError:
|
||||
|
|
@ -107,15 +109,15 @@ func (s *Service) showFallbackDialog(opts NotificationOptions) error {
|
|||
dt = dialog.DialogInfo
|
||||
}
|
||||
|
||||
msg := opts.Message
|
||||
if opts.Subtitle != "" {
|
||||
msg = opts.Subtitle + "\n\n" + msg
|
||||
msg := options.Message
|
||||
if options.Subtitle != "" {
|
||||
msg = options.Subtitle + "\n\n" + msg
|
||||
}
|
||||
|
||||
_, _, err := s.Core().PERFORM(dialog.TaskMessageDialog{
|
||||
Opts: dialog.MessageDialogOptions{
|
||||
Options: dialog.MessageDialogOptions{
|
||||
Type: dt,
|
||||
Title: opts.Title,
|
||||
Title: options.Title,
|
||||
Message: msg,
|
||||
Buttons: []string{"OK"},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -13,12 +13,19 @@ import (
|
|||
)
|
||||
|
||||
type mockPlatform struct {
|
||||
sendErr error
|
||||
permGranted bool
|
||||
permErr error
|
||||
lastOpts NotificationOptions
|
||||
sendCalled bool
|
||||
clearCalled bool
|
||||
sendErr error
|
||||
permGranted bool
|
||||
permErr error
|
||||
revokeErr error
|
||||
registerCategoryErr error
|
||||
clearErr error
|
||||
lastOpts NotificationOptions
|
||||
lastCategory NotificationCategory
|
||||
sendCalled bool
|
||||
revokeCalled bool
|
||||
registerCategoryCalled bool
|
||||
clearCalled bool
|
||||
lastClearedID string
|
||||
}
|
||||
|
||||
func (m *mockPlatform) Send(opts NotificationOptions) error {
|
||||
|
|
@ -33,7 +40,20 @@ func (m *mockPlatform) SendWithActions(opts NotificationOptions) error {
|
|||
}
|
||||
func (m *mockPlatform) RequestPermission() (bool, error) { return m.permGranted, m.permErr }
|
||||
func (m *mockPlatform) CheckPermission() (bool, error) { return m.permGranted, m.permErr }
|
||||
func (m *mockPlatform) Clear() error { m.clearCalled = true; return nil }
|
||||
func (m *mockPlatform) RevokePermission() error {
|
||||
m.revokeCalled = true
|
||||
return m.revokeErr
|
||||
}
|
||||
func (m *mockPlatform) RegisterCategory(category NotificationCategory) error {
|
||||
m.registerCategoryCalled = true
|
||||
m.lastCategory = category
|
||||
return m.registerCategoryErr
|
||||
}
|
||||
func (m *mockPlatform) Clear(id string) error {
|
||||
m.clearCalled = true
|
||||
m.lastClearedID = id
|
||||
return m.clearErr
|
||||
}
|
||||
|
||||
// mockDialogPlatform tracks whether MessageDialog was called (for fallback test).
|
||||
type mockDialogPlatform struct {
|
||||
|
|
@ -73,7 +93,7 @@ func TestRegister_Good(t *testing.T) {
|
|||
func TestTaskSend_Good(t *testing.T) {
|
||||
mock, c := newTestService(t)
|
||||
_, handled, err := c.PERFORM(TaskSend{
|
||||
Opts: NotificationOptions{Title: "Test", Message: "Hello"},
|
||||
Options: NotificationOptions{Title: "Test", Message: "Hello"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
|
@ -94,7 +114,7 @@ func TestTaskSend_Fallback_Good(t *testing.T) {
|
|||
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
||||
|
||||
_, handled, err := c.PERFORM(TaskSend{
|
||||
Opts: NotificationOptions{Title: "Warn", Message: "Oops", Severity: SeverityWarning},
|
||||
Options: NotificationOptions{Title: "Warn", Message: "Oops", Severity: SeverityWarning},
|
||||
})
|
||||
assert.True(t, handled)
|
||||
assert.NoError(t, err) // fallback succeeds even though platform failed
|
||||
|
|
@ -125,25 +145,117 @@ func TestTaskSend_Bad(t *testing.T) {
|
|||
assert.False(t, handled)
|
||||
}
|
||||
|
||||
func TestTaskSend_WithActions_Good(t *testing.T) {
|
||||
func TestTaskRevokePermission_Good(t *testing.T) {
|
||||
mock, c := newTestService(t)
|
||||
_, handled, err := c.PERFORM(TaskRevokePermission{})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.True(t, mock.revokeCalled)
|
||||
}
|
||||
|
||||
func TestTaskRevokePermission_Bad_NoService(t *testing.T) {
|
||||
c, _ := core.New(core.WithServiceLock())
|
||||
_, handled, _ := c.PERFORM(TaskRevokePermission{})
|
||||
assert.False(t, handled)
|
||||
}
|
||||
|
||||
func TestTaskRegisterCategory_Good(t *testing.T) {
|
||||
mock, c := newTestService(t)
|
||||
category := NotificationCategory{
|
||||
ID: "message",
|
||||
Actions: []NotificationAction{
|
||||
{ID: "reply", Title: "Reply"},
|
||||
{ID: "dismiss", Title: "Dismiss"},
|
||||
},
|
||||
}
|
||||
|
||||
_, handled, err := c.PERFORM(TaskRegisterCategory{Category: category})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.True(t, mock.registerCategoryCalled)
|
||||
assert.Equal(t, "message", mock.lastCategory.ID)
|
||||
assert.Len(t, mock.lastCategory.Actions, 2)
|
||||
assert.Equal(t, "reply", mock.lastCategory.Actions[0].ID)
|
||||
}
|
||||
|
||||
func TestTaskRegisterCategory_Bad_NoService(t *testing.T) {
|
||||
c, _ := core.New(core.WithServiceLock())
|
||||
_, handled, _ := c.PERFORM(TaskRegisterCategory{})
|
||||
assert.False(t, handled)
|
||||
}
|
||||
|
||||
func TestTaskSendWithActions_Good(t *testing.T) {
|
||||
mock, c := newTestService(t)
|
||||
|
||||
_, handled, err := c.PERFORM(TaskSend{
|
||||
Opts: NotificationOptions{
|
||||
Title: "Test",
|
||||
Message: "Hello",
|
||||
Actions: []NotificationAction{{ID: "ok", Label: "OK"}},
|
||||
Options: NotificationOptions{
|
||||
Title: "Message",
|
||||
Message: "Reply?",
|
||||
Actions: []NotificationAction{
|
||||
{ID: "reply", Title: "Reply"},
|
||||
{ID: "dismiss", Title: "Dismiss"},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.True(t, mock.sendCalled)
|
||||
assert.Len(t, mock.lastOpts.Actions, 1)
|
||||
assert.True(t, mock.registerCategoryCalled)
|
||||
assert.Len(t, mock.lastCategory.Actions, 2)
|
||||
assert.NotEmpty(t, mock.lastOpts.CategoryID)
|
||||
}
|
||||
|
||||
func TestTaskClear_Good(t *testing.T) {
|
||||
mock, c := newTestService(t)
|
||||
_, handled, err := c.PERFORM(TaskClear{})
|
||||
|
||||
_, handled, err := c.PERFORM(TaskClear{ID: "notif-1"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.True(t, mock.clearCalled)
|
||||
assert.Equal(t, "notif-1", mock.lastClearedID)
|
||||
}
|
||||
|
||||
func TestActionNotificationActionTriggered_Ugly(t *testing.T) {
|
||||
// Verify the action structs are distinct types.
|
||||
var triggered ActionNotificationActionTriggered
|
||||
var dismissed ActionNotificationDismissed
|
||||
triggered.NotificationID = "n1"
|
||||
triggered.ActionID = "reply"
|
||||
dismissed.NotificationID = "n1"
|
||||
assert.Equal(t, "n1", triggered.NotificationID)
|
||||
assert.Equal(t, "reply", triggered.ActionID)
|
||||
assert.Equal(t, "n1", dismissed.NotificationID)
|
||||
}
|
||||
|
||||
func TestActionNotificationDismissed_Good(t *testing.T) {
|
||||
_, c := newTestService(t)
|
||||
|
||||
var received *ActionNotificationDismissed
|
||||
c.RegisterAction(func(_ *core.Core, msg core.Message) error {
|
||||
if a, ok := msg.(ActionNotificationDismissed); ok {
|
||||
received = &a
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// Broadcast dismissed action directly (as the platform adapter would).
|
||||
_ = c.ACTION(ActionNotificationDismissed{NotificationID: "notif-42"})
|
||||
require.NotNil(t, received)
|
||||
assert.Equal(t, "notif-42", received.NotificationID)
|
||||
}
|
||||
|
||||
func TestActionNotificationActionTriggered_Good(t *testing.T) {
|
||||
_, c := newTestService(t)
|
||||
|
||||
var received *ActionNotificationActionTriggered
|
||||
c.RegisterAction(func(_ *core.Core, msg core.Message) error {
|
||||
if a, ok := msg.(ActionNotificationActionTriggered); ok {
|
||||
received = &a
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
_ = c.ACTION(ActionNotificationActionTriggered{NotificationID: "notif-7", ActionID: "archive"})
|
||||
require.NotNil(t, received)
|
||||
assert.Equal(t, "notif-7", received.NotificationID)
|
||||
assert.Equal(t, "archive", received.ActionID)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,10 @@ type QueryAll struct{}
|
|||
// Use: result, _, err := c.QUERY(screen.QueryPrimary{})
|
||||
type QueryPrimary struct{}
|
||||
|
||||
// QueryCurrent returns the screen currently in use (e.g. containing the focused window).
|
||||
// Result: *Screen (nil if not determinable)
|
||||
type QueryCurrent struct{}
|
||||
|
||||
// QueryByID returns a screen by ID. Result: *Screen (nil if not found)
|
||||
// Use: result, _, err := c.QUERY(screen.QueryByID{ID: "display-1"})
|
||||
type QueryByID struct{ ID string }
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ package screen
|
|||
type Platform interface {
|
||||
GetAll() []Screen
|
||||
GetPrimary() *Screen
|
||||
GetCurrent() *Screen
|
||||
}
|
||||
|
||||
// Screen describes a display/monitor.
|
||||
|
|
@ -30,9 +31,61 @@ type Rect struct {
|
|||
Height int `json:"height"`
|
||||
}
|
||||
|
||||
// Contains reports whether the point (x, y) lies within the rectangle.
|
||||
//
|
||||
// if rect.Contains(mouseX, mouseY) { handleClick() }
|
||||
func (r Rect) Contains(x, y int) bool {
|
||||
return x >= r.X && x < r.X+r.Width && y >= r.Y && y < r.Y+r.Height
|
||||
}
|
||||
|
||||
// Overlaps reports whether the rectangle r overlaps with other.
|
||||
//
|
||||
// if bounds.Overlaps(workArea) { show() }
|
||||
func (r Rect) Overlaps(other Rect) bool {
|
||||
return r.X < other.X+other.Width &&
|
||||
r.X+r.Width > other.X &&
|
||||
r.Y < other.Y+other.Height &&
|
||||
r.Y+r.Height > other.Y
|
||||
}
|
||||
|
||||
// Center returns the centre point of the rectangle.
|
||||
//
|
||||
// cx, cy := rect.Center()
|
||||
func (r Rect) Center() (x, y int) {
|
||||
return r.X + r.Width/2, r.Y + r.Height/2
|
||||
}
|
||||
|
||||
// Size represents dimensions.
|
||||
// Use: size := screen.Size{Width: 1920, Height: 1080}
|
||||
type Size struct {
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
}
|
||||
|
||||
// ScreenPlacement describes a desired window position relative to a screen.
|
||||
//
|
||||
// p := screen.ScreenPlacement{ScreenID: "1", X: 100, Y: 200, Width: 800, Height: 600}
|
||||
// p.Apply(platformWindow)
|
||||
type ScreenPlacement struct {
|
||||
ScreenID string `json:"screenId"`
|
||||
X int `json:"x"`
|
||||
Y int `json:"y"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
}
|
||||
|
||||
// Placer is implemented by platform windows that can be repositioned.
|
||||
type Placer interface {
|
||||
SetPosition(x, y int)
|
||||
SetSize(width, height int)
|
||||
}
|
||||
|
||||
// Apply positions and sizes the given Placer according to the placement.
|
||||
//
|
||||
// placement.Apply(pw)
|
||||
func (p ScreenPlacement) Apply(target Placer) {
|
||||
if p.Width > 0 && p.Height > 0 {
|
||||
target.SetSize(p.Width, p.Height)
|
||||
}
|
||||
target.SetPosition(p.X, p.Y)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,19 +7,15 @@ import (
|
|||
"forge.lthn.ai/core/go/pkg/core"
|
||||
)
|
||||
|
||||
// Options holds configuration for the screen service.
|
||||
// Use: svc, err := screen.Register(platform)(core.New())
|
||||
type Options struct{}
|
||||
|
||||
// Service is a core.Service providing screen/display queries via IPC.
|
||||
// Use: svc, err := screen.Register(platform)(core.New())
|
||||
type Service struct {
|
||||
*core.ServiceRuntime[Options]
|
||||
platform Platform
|
||||
}
|
||||
|
||||
// Register creates a factory closure that captures the Platform adapter.
|
||||
// Use: core.WithService(screen.Register(platform))
|
||||
// Register(p) binds the screen service to a Core instance.
|
||||
// core.WithService(screen.Register(wailsScreen))
|
||||
func Register(p Platform) func(*core.Core) (any, error) {
|
||||
return func(c *core.Core) (any, error) {
|
||||
return &Service{
|
||||
|
|
@ -29,15 +25,11 @@ func Register(p Platform) func(*core.Core) (any, error) {
|
|||
}
|
||||
}
|
||||
|
||||
// OnStartup registers IPC handlers.
|
||||
// Use: _ = svc.OnStartup(context.Background())
|
||||
func (s *Service) OnStartup(ctx context.Context) error {
|
||||
s.Core().RegisterQuery(s.handleQuery)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleIPCEvents is auto-discovered by core.WithService.
|
||||
// Use: _ = svc.HandleIPCEvents(core, msg)
|
||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -48,6 +40,8 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
|
|||
return s.platform.GetAll(), true, nil
|
||||
case QueryPrimary:
|
||||
return s.platform.GetPrimary(), true, nil
|
||||
case QueryCurrent:
|
||||
return s.platform.GetCurrent(), true, nil
|
||||
case QueryByID:
|
||||
return s.queryByID(q.ID), true, nil
|
||||
case QueryAtPoint:
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
|
||||
type mockPlatform struct {
|
||||
screens []Screen
|
||||
current *Screen
|
||||
}
|
||||
|
||||
func (m *mockPlatform) GetAll() []Screen { return m.screens }
|
||||
|
|
@ -23,6 +24,7 @@ func (m *mockPlatform) GetPrimary() *Screen {
|
|||
}
|
||||
return nil
|
||||
}
|
||||
func (m *mockPlatform) GetCurrent() *Screen { return m.current }
|
||||
|
||||
func newTestService(t *testing.T) (*mockPlatform, *core.Core) {
|
||||
t.Helper()
|
||||
|
|
@ -130,3 +132,127 @@ func TestQueryWorkAreas_Good(t *testing.T) {
|
|||
assert.Len(t, areas, 2)
|
||||
assert.Equal(t, 38, areas[0].Y) // primary has menu bar offset
|
||||
}
|
||||
|
||||
// --- QueryCurrent ---
|
||||
|
||||
func TestQueryCurrent_Good(t *testing.T) {
|
||||
mock, c := newTestService(t)
|
||||
mock.current = &mock.screens[1] // set "External" as current
|
||||
|
||||
result, handled, err := c.QUERY(QueryCurrent{})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
scr := result.(*Screen)
|
||||
require.NotNil(t, scr)
|
||||
assert.Equal(t, "External", scr.Name)
|
||||
}
|
||||
|
||||
func TestQueryCurrent_Bad_NilWhenNoCurrentScreen(t *testing.T) {
|
||||
// current is nil by default
|
||||
_, c := newTestService(t)
|
||||
result, handled, err := c.QUERY(QueryCurrent{})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
|
||||
func TestQueryCurrent_Ugly_NoServiceRegistered(t *testing.T) {
|
||||
c, err := core.New(core.WithServiceLock())
|
||||
require.NoError(t, err)
|
||||
_, handled, _ := c.QUERY(QueryCurrent{})
|
||||
assert.False(t, handled)
|
||||
}
|
||||
|
||||
// --- Rect geometry helpers ---
|
||||
|
||||
func TestRect_Contains_Good(t *testing.T) {
|
||||
r := Rect{X: 100, Y: 100, Width: 200, Height: 150}
|
||||
assert.True(t, r.Contains(100, 100)) // top-left corner (inclusive)
|
||||
assert.True(t, r.Contains(200, 175)) // centre
|
||||
assert.True(t, r.Contains(299, 249)) // bottom-right corner (exclusive boundary - 1)
|
||||
}
|
||||
|
||||
func TestRect_Contains_Bad(t *testing.T) {
|
||||
r := Rect{X: 100, Y: 100, Width: 200, Height: 150}
|
||||
assert.False(t, r.Contains(99, 100)) // just left
|
||||
assert.False(t, r.Contains(100, 99)) // just above
|
||||
assert.False(t, r.Contains(300, 200)) // right boundary (exclusive)
|
||||
assert.False(t, r.Contains(200, 250)) // bottom boundary (exclusive)
|
||||
}
|
||||
|
||||
func TestRect_Center_Good(t *testing.T) {
|
||||
r := Rect{X: 0, Y: 0, Width: 200, Height: 100}
|
||||
cx, cy := r.Center()
|
||||
assert.Equal(t, 100, cx)
|
||||
assert.Equal(t, 50, cy)
|
||||
}
|
||||
|
||||
func TestRect_Center_Ugly_OddDimensions(t *testing.T) {
|
||||
r := Rect{X: 1, Y: 1, Width: 101, Height: 51}
|
||||
cx, cy := r.Center()
|
||||
assert.Equal(t, 51, cx) // integer division: 1 + 101/2 = 1 + 50 = 51
|
||||
assert.Equal(t, 26, cy) // 1 + 51/2 = 1 + 25 = 26
|
||||
}
|
||||
|
||||
func TestRect_Overlaps_Good(t *testing.T) {
|
||||
a := Rect{X: 0, Y: 0, Width: 200, Height: 200}
|
||||
b := Rect{X: 100, Y: 100, Width: 200, Height: 200}
|
||||
assert.True(t, a.Overlaps(b))
|
||||
assert.True(t, b.Overlaps(a))
|
||||
}
|
||||
|
||||
func TestRect_Overlaps_Bad(t *testing.T) {
|
||||
a := Rect{X: 0, Y: 0, Width: 100, Height: 100}
|
||||
b := Rect{X: 200, Y: 200, Width: 100, Height: 100}
|
||||
assert.False(t, a.Overlaps(b))
|
||||
}
|
||||
|
||||
func TestRect_Overlaps_Ugly_AdjacentEdge(t *testing.T) {
|
||||
// touching at edge — not overlapping (exclusive right/bottom boundary)
|
||||
a := Rect{X: 0, Y: 0, Width: 100, Height: 100}
|
||||
b := Rect{X: 100, Y: 0, Width: 100, Height: 100}
|
||||
assert.False(t, a.Overlaps(b))
|
||||
}
|
||||
|
||||
// --- ScreenPlacement ---
|
||||
|
||||
type mockPlacer struct {
|
||||
x, y int
|
||||
width, height int
|
||||
}
|
||||
|
||||
func (m *mockPlacer) SetPosition(x, y int) { m.x = x; m.y = y }
|
||||
func (m *mockPlacer) SetSize(width, height int) { m.width = width; m.height = height }
|
||||
|
||||
func TestScreenPlacement_Apply_Good(t *testing.T) {
|
||||
p := ScreenPlacement{ScreenID: "1", X: 50, Y: 75, Width: 800, Height: 600}
|
||||
placer := &mockPlacer{}
|
||||
p.Apply(placer)
|
||||
assert.Equal(t, 50, placer.x)
|
||||
assert.Equal(t, 75, placer.y)
|
||||
assert.Equal(t, 800, placer.width)
|
||||
assert.Equal(t, 600, placer.height)
|
||||
}
|
||||
|
||||
func TestScreenPlacement_Apply_Bad_ZeroDimensions(t *testing.T) {
|
||||
// Zero dimensions should skip SetSize but still call SetPosition
|
||||
p := ScreenPlacement{ScreenID: "1", X: 100, Y: 200, Width: 0, Height: 0}
|
||||
placer := &mockPlacer{width: 1280, height: 800}
|
||||
p.Apply(placer)
|
||||
assert.Equal(t, 100, placer.x)
|
||||
assert.Equal(t, 200, placer.y)
|
||||
// Size should remain unchanged when both dimensions are zero
|
||||
assert.Equal(t, 1280, placer.width)
|
||||
assert.Equal(t, 800, placer.height)
|
||||
}
|
||||
|
||||
func TestScreenPlacement_Apply_Ugly_NegativeCoords(t *testing.T) {
|
||||
// Negative coordinates are valid (multi-monitor setups with negative origin)
|
||||
p := ScreenPlacement{ScreenID: "2", X: -1920, Y: 0, Width: 1920, Height: 1080}
|
||||
placer := &mockPlacer{}
|
||||
p.Apply(placer)
|
||||
assert.Equal(t, -1920, placer.x)
|
||||
assert.Equal(t, 0, placer.y)
|
||||
assert.Equal(t, 1920, placer.width)
|
||||
assert.Equal(t, 1080, placer.height)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,28 +1,22 @@
|
|||
// pkg/systray/menu.go
|
||||
package systray
|
||||
|
||||
import "forge.lthn.ai/core/go/pkg/core"
|
||||
import coreerr "forge.lthn.ai/core/go-log"
|
||||
|
||||
// SetMenu sets a dynamic menu on the tray from TrayMenuItem descriptors.
|
||||
// Use: _ = m.SetMenu([]TrayMenuItem{{Label: "Quit", ActionID: "quit"}})
|
||||
func (m *Manager) SetMenu(items []TrayMenuItem) error {
|
||||
if m.tray == nil {
|
||||
return core.E("systray.SetMenu", "tray not initialised", nil)
|
||||
return coreerr.E("systray.SetMenu", "tray not initialised", nil)
|
||||
}
|
||||
m.menuItems = append([]TrayMenuItem(nil), items...)
|
||||
menu := m.buildMenu(items)
|
||||
menu := m.platform.NewMenu()
|
||||
m.buildMenu(menu, items)
|
||||
m.tray.SetMenu(menu)
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildMenu recursively builds a PlatformMenu from TrayMenuItem descriptors.
|
||||
func (m *Manager) buildMenu(items []TrayMenuItem) PlatformMenu {
|
||||
menu := m.platform.NewMenu()
|
||||
m.buildMenuInto(menu, items)
|
||||
return menu
|
||||
}
|
||||
|
||||
func (m *Manager) buildMenuInto(menu PlatformMenu, items []TrayMenuItem) {
|
||||
func (m *Manager) buildMenu(menu PlatformMenu, items []TrayMenuItem) {
|
||||
for _, item := range items {
|
||||
if item.Type == "separator" {
|
||||
menu.AddSeparator()
|
||||
|
|
@ -30,7 +24,7 @@ func (m *Manager) buildMenuInto(menu PlatformMenu, items []TrayMenuItem) {
|
|||
}
|
||||
if len(item.Submenu) > 0 {
|
||||
sub := menu.AddSubmenu(item.Label)
|
||||
m.buildMenuInto(sub, item.Submenu)
|
||||
m.buildMenu(sub, item.Submenu)
|
||||
continue
|
||||
}
|
||||
mi := menu.Add(item.Label)
|
||||
|
|
|
|||
|
|
@ -1,54 +1,27 @@
|
|||
// pkg/systray/messages.go
|
||||
package systray
|
||||
|
||||
// QueryConfig requests this service's config section from the display orchestrator.
|
||||
// Result: map[string]any
|
||||
// Use: result, _, err := c.QUERY(systray.QueryConfig{})
|
||||
type QueryConfig struct{}
|
||||
|
||||
// --- Tasks ---
|
||||
|
||||
// TaskSetTrayIcon sets the tray icon.
|
||||
// Use: _, _, err := c.PERFORM(systray.TaskSetTrayIcon{Data: iconBytes})
|
||||
type TaskSetTrayIcon struct{ Data []byte }
|
||||
|
||||
// TaskSetTooltip updates the tray tooltip text.
|
||||
// Use: _, _, err := c.PERFORM(systray.TaskSetTooltip{Tooltip: "Core is ready"})
|
||||
type TaskSetTooltip struct{ Tooltip string }
|
||||
type TaskSetTrayTooltip struct{ Tooltip string }
|
||||
|
||||
// TaskSetLabel updates the tray label text.
|
||||
// Use: _, _, err := c.PERFORM(systray.TaskSetLabel{Label: "Core"})
|
||||
type TaskSetLabel struct{ Label string }
|
||||
type TaskSetTrayLabel struct{ Label string }
|
||||
|
||||
// TaskSetTrayMenu sets the tray menu items.
|
||||
// Use: _, _, err := c.PERFORM(systray.TaskSetTrayMenu{Items: items})
|
||||
type TaskSetTrayMenu struct{ Items []TrayMenuItem }
|
||||
|
||||
// TaskShowPanel shows the tray panel window.
|
||||
// Use: _, _, err := c.PERFORM(systray.TaskShowPanel{})
|
||||
type TaskShowPanel struct{}
|
||||
|
||||
// TaskHidePanel hides the tray panel window.
|
||||
// Use: _, _, err := c.PERFORM(systray.TaskHidePanel{})
|
||||
type TaskHidePanel struct{}
|
||||
|
||||
// TaskShowMessage shows a tray message or notification.
|
||||
// Use: _, _, err := c.PERFORM(systray.TaskShowMessage{Title: "Core", Message: "Sync complete"})
|
||||
type TaskShowMessage struct {
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// TaskSaveConfig persists this service's config section via the display orchestrator.
|
||||
// Use: _, _, err := c.PERFORM(systray.TaskSaveConfig{Value: map[string]any{"tooltip": "Core"}})
|
||||
type TaskSaveConfig struct{ Value map[string]any }
|
||||
type TaskShowPanel struct{}
|
||||
|
||||
// --- Actions ---
|
||||
type TaskHidePanel struct{}
|
||||
|
||||
type TaskSaveConfig struct{ Config map[string]any }
|
||||
|
||||
// ActionTrayClicked is broadcast when the tray icon is clicked.
|
||||
// Use: _ = c.ACTION(systray.ActionTrayClicked{})
|
||||
type ActionTrayClicked struct{}
|
||||
|
||||
// ActionTrayMenuItemClicked is broadcast when a tray menu item is clicked.
|
||||
// Use: _ = c.ACTION(systray.ActionTrayMenuItemClicked{ActionID: "quit"})
|
||||
type ActionTrayMenuItemClicked struct{ ActionID string }
|
||||
|
|
|
|||
|
|
@ -22,16 +22,17 @@ type exportedMockTray struct {
|
|||
tooltip, label string
|
||||
}
|
||||
|
||||
func (t *exportedMockTray) SetIcon(data []byte) { t.icon = data }
|
||||
func (t *exportedMockTray) SetTemplateIcon(data []byte) { t.templateIcon = data }
|
||||
func (t *exportedMockTray) SetTooltip(text string) { t.tooltip = text }
|
||||
func (t *exportedMockTray) SetLabel(text string) { t.label = text }
|
||||
func (t *exportedMockTray) SetMenu(menu PlatformMenu) {}
|
||||
func (t *exportedMockTray) AttachWindow(w WindowHandle) {}
|
||||
func (t *exportedMockTray) SetIcon(data []byte) { t.icon = data }
|
||||
func (t *exportedMockTray) SetTemplateIcon(data []byte) { t.templateIcon = data }
|
||||
func (t *exportedMockTray) SetTooltip(text string) { t.tooltip = text }
|
||||
func (t *exportedMockTray) SetLabel(text string) { t.label = text }
|
||||
func (t *exportedMockTray) SetMenu(menu PlatformMenu) {}
|
||||
func (t *exportedMockTray) AttachWindow(w WindowHandle) {}
|
||||
func (t *exportedMockTray) ShowMessage(title, message string) error { return nil }
|
||||
|
||||
type exportedMockMenu struct {
|
||||
items []exportedMockMenuItem
|
||||
submenus []*exportedMockMenu
|
||||
items []exportedMockMenuItem
|
||||
subs []*exportedMockMenu
|
||||
}
|
||||
|
||||
func (m *exportedMockMenu) Add(label string) PlatformMenuItem {
|
||||
|
|
@ -41,9 +42,9 @@ func (m *exportedMockMenu) Add(label string) PlatformMenuItem {
|
|||
}
|
||||
func (m *exportedMockMenu) AddSeparator() {}
|
||||
func (m *exportedMockMenu) AddSubmenu(label string) PlatformMenu {
|
||||
sub := &exportedMockMenu{}
|
||||
m.items = append(m.items, exportedMockMenuItem{label: label})
|
||||
m.submenus = append(m.submenus, sub)
|
||||
sub := &exportedMockMenu{}
|
||||
m.subs = append(m.subs, sub)
|
||||
return sub
|
||||
}
|
||||
|
||||
|
|
@ -53,8 +54,7 @@ type exportedMockMenuItem struct {
|
|||
onClick func()
|
||||
}
|
||||
|
||||
func (mi *exportedMockMenuItem) SetTooltip(tip string) { mi.tooltip = tip }
|
||||
func (mi *exportedMockMenuItem) SetChecked(checked bool) { mi.checked = checked }
|
||||
func (mi *exportedMockMenuItem) SetEnabled(enabled bool) { mi.enabled = enabled }
|
||||
func (mi *exportedMockMenuItem) OnClick(fn func()) { mi.onClick = fn }
|
||||
func (mi *exportedMockMenuItem) AddSubmenu() PlatformMenu { return &exportedMockMenu{} }
|
||||
func (mi *exportedMockMenuItem) SetTooltip(tip string) { mi.tooltip = tip }
|
||||
func (mi *exportedMockMenuItem) SetChecked(checked bool) { mi.checked = checked }
|
||||
func (mi *exportedMockMenuItem) SetEnabled(enabled bool) { mi.enabled = enabled }
|
||||
func (mi *exportedMockMenuItem) OnClick(fn func()) { mi.onClick = fn }
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ func (p *mockPlatform) NewMenu() PlatformMenu {
|
|||
}
|
||||
|
||||
type mockTrayMenu struct {
|
||||
items []string
|
||||
submenus []*mockTrayMenu
|
||||
items []string
|
||||
subs []*mockTrayMenu
|
||||
}
|
||||
|
||||
func (m *mockTrayMenu) Add(label string) PlatformMenuItem {
|
||||
|
|
@ -33,23 +33,24 @@ func (m *mockTrayMenu) AddSeparator() { m.items = append(m.items, "---") }
|
|||
func (m *mockTrayMenu) AddSubmenu(label string) PlatformMenu {
|
||||
m.items = append(m.items, label)
|
||||
sub := &mockTrayMenu{}
|
||||
m.submenus = append(m.submenus, sub)
|
||||
m.subs = append(m.subs, sub)
|
||||
return sub
|
||||
}
|
||||
|
||||
type mockTrayMenuItem struct{}
|
||||
|
||||
func (mi *mockTrayMenuItem) SetTooltip(text string) {}
|
||||
func (mi *mockTrayMenuItem) SetChecked(checked bool) {}
|
||||
func (mi *mockTrayMenuItem) SetEnabled(enabled bool) {}
|
||||
func (mi *mockTrayMenuItem) OnClick(fn func()) {}
|
||||
func (mi *mockTrayMenuItem) AddSubmenu() PlatformMenu { return &mockTrayMenu{} }
|
||||
func (mi *mockTrayMenuItem) SetTooltip(text string) {}
|
||||
func (mi *mockTrayMenuItem) SetChecked(checked bool) {}
|
||||
func (mi *mockTrayMenuItem) SetEnabled(enabled bool) {}
|
||||
func (mi *mockTrayMenuItem) OnClick(fn func()) {}
|
||||
|
||||
type mockTray struct {
|
||||
icon, templateIcon []byte
|
||||
tooltip, label string
|
||||
menu PlatformMenu
|
||||
attachedWindow WindowHandle
|
||||
lastMessageTitle string
|
||||
lastMessageBody string
|
||||
}
|
||||
|
||||
func (t *mockTray) SetIcon(data []byte) { t.icon = data }
|
||||
|
|
@ -58,3 +59,8 @@ func (t *mockTray) SetTooltip(text string) { t.tooltip = text }
|
|||
func (t *mockTray) SetLabel(text string) { t.label = text }
|
||||
func (t *mockTray) SetMenu(menu PlatformMenu) { t.menu = menu }
|
||||
func (t *mockTray) AttachWindow(w WindowHandle) { t.attachedWindow = w }
|
||||
func (t *mockTray) ShowMessage(title, message string) error {
|
||||
t.lastMessageTitle = title
|
||||
t.lastMessageBody = message
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ type PlatformTray interface {
|
|||
SetLabel(text string)
|
||||
SetMenu(menu PlatformMenu)
|
||||
AttachWindow(w WindowHandle)
|
||||
ShowMessage(title, message string) error
|
||||
}
|
||||
|
||||
// PlatformMenu is a tray menu built by the backend.
|
||||
|
|
@ -34,7 +35,6 @@ type PlatformMenuItem interface {
|
|||
SetChecked(checked bool)
|
||||
SetEnabled(enabled bool)
|
||||
OnClick(fn func())
|
||||
AddSubmenu() PlatformMenu
|
||||
}
|
||||
|
||||
// WindowHandle is a cross-package interface for window operations.
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ package systray
|
|||
|
||||
import "forge.lthn.ai/core/go/pkg/core"
|
||||
|
||||
// Register creates a factory closure that captures the Platform adapter.
|
||||
// Register(p) binds the systray service to a Core instance.
|
||||
// core.WithService(systray.Register(wailsSystray))
|
||||
func Register(p Platform) func(*core.Core) (any, error) {
|
||||
return func(c *core.Core) (any, error) {
|
||||
return &Service{
|
||||
|
|
|
|||
|
|
@ -8,12 +8,8 @@ import (
|
|||
"dappco.re/go/core/gui/pkg/notification"
|
||||
)
|
||||
|
||||
// Options configures the systray service.
|
||||
// Use: core.WithService(systray.Register(platform))
|
||||
type Options struct{}
|
||||
|
||||
// Service manages system tray operations via Core tasks.
|
||||
// Use: svc := &systray.Service{}
|
||||
type Service struct {
|
||||
*core.ServiceRuntime[Options]
|
||||
manager *Manager
|
||||
|
|
@ -21,34 +17,31 @@ type Service struct {
|
|||
iconPath string
|
||||
}
|
||||
|
||||
// OnStartup loads tray config and registers task handlers.
|
||||
// Use: _ = svc.OnStartup(context.Background())
|
||||
func (s *Service) OnStartup(ctx context.Context) error {
|
||||
cfg, handled, _ := s.Core().QUERY(QueryConfig{})
|
||||
configValue, handled, _ := s.Core().QUERY(QueryConfig{})
|
||||
if handled {
|
||||
if tCfg, ok := cfg.(map[string]any); ok {
|
||||
s.applyConfig(tCfg)
|
||||
if trayConfig, ok := configValue.(map[string]any); ok {
|
||||
s.applyConfig(trayConfig)
|
||||
}
|
||||
}
|
||||
s.Core().RegisterTask(s.handleTask)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) applyConfig(cfg map[string]any) {
|
||||
tooltip, _ := cfg["tooltip"].(string)
|
||||
func (s *Service) applyConfig(configData map[string]any) {
|
||||
tooltip, _ := configData["tooltip"].(string)
|
||||
if tooltip == "" {
|
||||
tooltip = "Core"
|
||||
}
|
||||
_ = s.manager.Setup(tooltip, tooltip)
|
||||
|
||||
if iconPath, ok := cfg["icon"].(string); ok && iconPath != "" {
|
||||
if iconPath, ok := configData["icon"].(string); ok && iconPath != "" {
|
||||
// Icon loading is deferred to when assets are available.
|
||||
// Store the path for later use.
|
||||
s.iconPath = iconPath
|
||||
}
|
||||
}
|
||||
|
||||
// HandleIPCEvents satisfies Core's IPC hook.
|
||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -57,12 +50,14 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
|||
switch t := t.(type) {
|
||||
case TaskSetTrayIcon:
|
||||
return nil, true, s.manager.SetIcon(t.Data)
|
||||
case TaskSetTooltip:
|
||||
case TaskSetTrayTooltip:
|
||||
return nil, true, s.manager.SetTooltip(t.Tooltip)
|
||||
case TaskSetLabel:
|
||||
case TaskSetTrayLabel:
|
||||
return nil, true, s.manager.SetLabel(t.Label)
|
||||
case TaskSetTrayMenu:
|
||||
return nil, true, s.taskSetTrayMenu(t)
|
||||
case TaskShowMessage:
|
||||
return nil, true, s.manager.ShowMessage(t.Title, t.Message)
|
||||
case TaskShowPanel:
|
||||
return nil, true, s.manager.ShowPanel()
|
||||
case TaskHidePanel:
|
||||
|
|
@ -87,29 +82,6 @@ func (s *Service) taskSetTrayMenu(t TaskSetTrayMenu) error {
|
|||
return s.manager.SetMenu(t.Items)
|
||||
}
|
||||
|
||||
func (s *Service) showTrayMessage(title, message string) error {
|
||||
if s.manager == nil || !s.manager.IsActive() {
|
||||
_, _, err := s.Core().PERFORM(notification.TaskSend{
|
||||
Opts: notification.NotificationOptions{Title: title, Message: message},
|
||||
})
|
||||
return err
|
||||
}
|
||||
tray := s.manager.Tray()
|
||||
if tray == nil {
|
||||
return core.E("systray.showTrayMessage", "tray not initialised", nil)
|
||||
}
|
||||
if messenger, ok := tray.(interface{ ShowMessage(title, message string) }); ok {
|
||||
messenger.ShowMessage(title, message)
|
||||
return nil
|
||||
}
|
||||
_, _, err := s.Core().PERFORM(notification.TaskSend{
|
||||
Opts: notification.NotificationOptions{Title: title, Message: message},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Manager returns the underlying systray Manager.
|
||||
// Use: manager := svc.Manager()
|
||||
func (s *Service) Manager() *Manager {
|
||||
return s.manager
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,31 +84,36 @@ func TestTaskSetTrayMenu_Good(t *testing.T) {
|
|||
assert.True(t, handled)
|
||||
}
|
||||
|
||||
func TestTaskSetTrayMenu_Submenu_Good(t *testing.T) {
|
||||
p := newMockPlatform()
|
||||
c, err := core.New(
|
||||
core.WithService(Register(p)),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
||||
|
||||
svc := core.MustServiceFor[*Service](c, "systray")
|
||||
func TestTaskSetTrayTooltip_Good(t *testing.T) {
|
||||
svc, c := newTestSystrayService(t)
|
||||
require.NoError(t, svc.manager.Setup("Test", "Test"))
|
||||
|
||||
_, handled, err := c.PERFORM(TaskSetTrayMenu{Items: []TrayMenuItem{
|
||||
{
|
||||
Label: "File",
|
||||
Submenu: []TrayMenuItem{
|
||||
{Label: "Open", ActionID: "open"},
|
||||
},
|
||||
},
|
||||
}})
|
||||
_, handled, err := c.PERFORM(TaskSetTrayTooltip{Tooltip: "Updated"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
require.Len(t, p.trays, 1)
|
||||
require.NotEmpty(t, p.menus)
|
||||
require.Len(t, p.menus[0].submenus, 1)
|
||||
assert.Equal(t, "Updated", svc.manager.Tray().(*mockTray).tooltip)
|
||||
}
|
||||
|
||||
func TestTaskSetTrayLabel_Good(t *testing.T) {
|
||||
svc, c := newTestSystrayService(t)
|
||||
require.NoError(t, svc.manager.Setup("Test", "Test"))
|
||||
|
||||
_, handled, err := c.PERFORM(TaskSetTrayLabel{Label: "CoreGUI"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.Equal(t, "CoreGUI", svc.manager.Tray().(*mockTray).label)
|
||||
}
|
||||
|
||||
func TestTaskShowMessage_Good(t *testing.T) {
|
||||
svc, c := newTestSystrayService(t)
|
||||
require.NoError(t, svc.manager.Setup("Test", "Test"))
|
||||
|
||||
_, handled, err := c.PERFORM(TaskShowMessage{Title: "Heads up", Message: "Background work finished"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
tray := svc.manager.Tray().(*mockTray)
|
||||
assert.Equal(t, "Heads up", tray.lastMessageTitle)
|
||||
assert.Equal(t, "Background work finished", tray.lastMessageBody)
|
||||
}
|
||||
|
||||
func TestTaskSetTrayIcon_Bad(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import (
|
|||
_ "embed"
|
||||
"sync"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/core"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
)
|
||||
|
||||
//go:embed assets/apptray.png
|
||||
|
|
@ -27,7 +27,7 @@ type Manager struct {
|
|||
}
|
||||
|
||||
// NewManager creates a systray Manager.
|
||||
// Use: manager := systray.NewManager(platform)
|
||||
// systray.NewManager(systray.NewWailsPlatform(app)).Setup("Core", "Core")
|
||||
func NewManager(platform Platform) *Manager {
|
||||
return &Manager{
|
||||
platform: platform,
|
||||
|
|
@ -36,11 +36,11 @@ func NewManager(platform Platform) *Manager {
|
|||
}
|
||||
|
||||
// Setup creates the system tray with default icon and tooltip.
|
||||
// Use: _ = manager.Setup("Core", "Core")
|
||||
// systray.NewManager(systray.NewWailsPlatform(app)).Setup("Core", "Core")
|
||||
func (m *Manager) Setup(tooltip, label string) error {
|
||||
m.tray = m.platform.NewTray()
|
||||
if m.tray == nil {
|
||||
return core.E("systray.Setup", "platform returned nil tray", nil)
|
||||
return coreerr.E("systray.Setup", "platform returned nil tray", nil)
|
||||
}
|
||||
m.tray.SetTemplateIcon(defaultIcon)
|
||||
m.tray.SetTooltip(tooltip)
|
||||
|
|
@ -55,7 +55,7 @@ func (m *Manager) Setup(tooltip, label string) error {
|
|||
// Use: _ = manager.SetIcon(iconBytes)
|
||||
func (m *Manager) SetIcon(data []byte) error {
|
||||
if m.tray == nil {
|
||||
return core.E("systray.SetIcon", "tray not initialised", nil)
|
||||
return coreerr.E("systray.SetIcon", "tray not initialised", nil)
|
||||
}
|
||||
m.tray.SetIcon(data)
|
||||
m.hasIcon = len(data) > 0
|
||||
|
|
@ -66,7 +66,7 @@ func (m *Manager) SetIcon(data []byte) error {
|
|||
// Use: _ = manager.SetTemplateIcon(iconBytes)
|
||||
func (m *Manager) SetTemplateIcon(data []byte) error {
|
||||
if m.tray == nil {
|
||||
return core.E("systray.SetTemplateIcon", "tray not initialised", nil)
|
||||
return coreerr.E("systray.SetTemplateIcon", "tray not initialised", nil)
|
||||
}
|
||||
m.tray.SetTemplateIcon(data)
|
||||
m.hasTemplateIcon = len(data) > 0
|
||||
|
|
@ -77,7 +77,7 @@ func (m *Manager) SetTemplateIcon(data []byte) error {
|
|||
// Use: _ = manager.SetTooltip("Core is ready")
|
||||
func (m *Manager) SetTooltip(text string) error {
|
||||
if m.tray == nil {
|
||||
return core.E("systray.SetTooltip", "tray not initialised", nil)
|
||||
return coreerr.E("systray.SetTooltip", "tray not initialised", nil)
|
||||
}
|
||||
m.tray.SetTooltip(text)
|
||||
m.tooltip = text
|
||||
|
|
@ -88,7 +88,7 @@ func (m *Manager) SetTooltip(text string) error {
|
|||
// Use: _ = manager.SetLabel("Core")
|
||||
func (m *Manager) SetLabel(text string) error {
|
||||
if m.tray == nil {
|
||||
return core.E("systray.SetLabel", "tray not initialised", nil)
|
||||
return coreerr.E("systray.SetLabel", "tray not initialised", nil)
|
||||
}
|
||||
m.tray.SetLabel(text)
|
||||
m.label = text
|
||||
|
|
@ -99,7 +99,7 @@ func (m *Manager) SetLabel(text string) error {
|
|||
// Use: _ = manager.AttachWindow(windowHandle)
|
||||
func (m *Manager) AttachWindow(w WindowHandle) error {
|
||||
if m.tray == nil {
|
||||
return core.E("systray.AttachWindow", "tray not initialised", nil)
|
||||
return coreerr.E("systray.AttachWindow", "tray not initialised", nil)
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.panelWindow = w
|
||||
|
|
@ -108,30 +108,12 @@ func (m *Manager) AttachWindow(w WindowHandle) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// ShowPanel shows the attached tray panel window if one is configured.
|
||||
// Use: _ = manager.ShowPanel()
|
||||
func (m *Manager) ShowPanel() error {
|
||||
m.mu.RLock()
|
||||
w := m.panelWindow
|
||||
m.mu.RUnlock()
|
||||
if w == nil {
|
||||
return nil
|
||||
// ShowMessage displays a tray message if the backend supports it.
|
||||
func (m *Manager) ShowMessage(title, message string) error {
|
||||
if m.tray == nil {
|
||||
return coreerr.E("systray.ShowMessage", "tray not initialised", nil)
|
||||
}
|
||||
w.Show()
|
||||
return nil
|
||||
}
|
||||
|
||||
// HidePanel hides the attached tray panel window if one is configured.
|
||||
// Use: _ = manager.HidePanel()
|
||||
func (m *Manager) HidePanel() error {
|
||||
m.mu.RLock()
|
||||
w := m.panelWindow
|
||||
m.mu.RUnlock()
|
||||
if w == nil {
|
||||
return nil
|
||||
}
|
||||
w.Hide()
|
||||
return nil
|
||||
return m.tray.ShowMessage(title, message)
|
||||
}
|
||||
|
||||
// Tray returns the underlying platform tray for direct access.
|
||||
|
|
|
|||
|
|
@ -84,3 +84,29 @@ func TestManager_GetInfo_Good(t *testing.T) {
|
|||
info = m.GetInfo()
|
||||
assert.True(t, info["active"].(bool))
|
||||
}
|
||||
|
||||
func TestManager_Build_Submenu_Recursive_Good(t *testing.T) {
|
||||
m, p := newTestManager()
|
||||
require.NoError(t, m.Setup("Core", "Core"))
|
||||
|
||||
items := []TrayMenuItem{
|
||||
{
|
||||
Label: "Parent",
|
||||
Submenu: []TrayMenuItem{
|
||||
{Label: "Child 1"},
|
||||
{Label: "Child 2"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, m.SetMenu(items))
|
||||
require.Len(t, p.menus, 1)
|
||||
|
||||
menu := p.menus[0]
|
||||
require.Len(t, menu.items, 1)
|
||||
assert.Equal(t, "Parent", menu.items[0])
|
||||
require.Len(t, menu.subs, 1)
|
||||
require.Len(t, menu.subs[0].items, 2)
|
||||
assert.Equal(t, "Child 1", menu.subs[0].items[0])
|
||||
assert.Equal(t, "Child 2", menu.subs[0].items[1])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,12 @@ func (wt *wailsTray) AttachWindow(w WindowHandle) {
|
|||
wt.tray.AttachWindow(window)
|
||||
}
|
||||
|
||||
func (wt *wailsTray) ShowMessage(title, message string) error {
|
||||
_ = title
|
||||
_ = message
|
||||
return nil
|
||||
}
|
||||
|
||||
// wailsTrayMenu wraps *application.Menu for the PlatformMenu interface.
|
||||
type wailsTrayMenu struct {
|
||||
menu *application.Menu
|
||||
|
|
@ -88,7 +94,3 @@ func (mi *wailsTrayMenuItem) SetEnabled(enabled bool) { mi.item.SetEnabled(enabl
|
|||
func (mi *wailsTrayMenuItem) OnClick(fn func()) {
|
||||
mi.item.OnClick(func(ctx *application.Context) { fn() })
|
||||
}
|
||||
func (mi *wailsTrayMenuItem) AddSubmenu() PlatformMenu {
|
||||
// Wails doesn't have a direct AddSubmenu on MenuItem — use Menu.AddSubmenu instead
|
||||
return &wailsTrayMenu{menu: application.NewMenu()}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,19 +46,37 @@ type connector interface {
|
|||
Close() error
|
||||
}
|
||||
|
||||
// Options holds configuration for the webview service.
|
||||
// Use: svc, err := webview.Register(webview.Options{})(core.New())
|
||||
type Options struct {
|
||||
DebugURL string // Chrome debug endpoint (default: "http://localhost:9222")
|
||||
Timeout time.Duration // Operation timeout (default: 30s)
|
||||
ConsoleLimit int // Max console messages per window (default: 1000)
|
||||
}
|
||||
|
||||
// Service is a core.Service managing webview interactions via IPC.
|
||||
// Use: svc, err := webview.Register(webview.Options{})(core.New())
|
||||
func defaultOptions() Options {
|
||||
return Options{
|
||||
DebugURL: "http://localhost:9222",
|
||||
Timeout: 30 * time.Second,
|
||||
ConsoleLimit: 1000,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeOptions(options Options) Options {
|
||||
defaults := defaultOptions()
|
||||
if options.DebugURL == "" {
|
||||
options.DebugURL = defaults.DebugURL
|
||||
}
|
||||
if options.Timeout == 0 {
|
||||
options.Timeout = defaults.Timeout
|
||||
}
|
||||
if options.ConsoleLimit == 0 {
|
||||
options.ConsoleLimit = defaults.ConsoleLimit
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
*core.ServiceRuntime[Options]
|
||||
opts Options
|
||||
options Options
|
||||
connections map[string]connector
|
||||
exceptions map[string][]ExceptionInfo
|
||||
mu sync.RWMutex
|
||||
|
|
@ -66,27 +84,14 @@ type Service struct {
|
|||
watcherSetup func(conn connector, windowName string) // called after connection creation
|
||||
}
|
||||
|
||||
// Register creates a factory closure with declarative options.
|
||||
// Use: core.WithService(webview.Register(webview.Options{ConsoleLimit: 500}))
|
||||
func Register(options Options) func(*core.Core) (any, error) {
|
||||
o := Options{
|
||||
DebugURL: "http://localhost:9222",
|
||||
Timeout: 30 * time.Second,
|
||||
ConsoleLimit: 1000,
|
||||
}
|
||||
if options.DebugURL != "" {
|
||||
o.DebugURL = options.DebugURL
|
||||
}
|
||||
if options.Timeout != 0 {
|
||||
o.Timeout = options.Timeout
|
||||
}
|
||||
if options.ConsoleLimit != 0 {
|
||||
o.ConsoleLimit = options.ConsoleLimit
|
||||
}
|
||||
// RegisterWithOptions binds the webview service to a Core instance using a declarative Options literal.
|
||||
// core.WithService(webview.RegisterWithOptions(webview.Options{DebugURL: "http://localhost:9223", Timeout: 30 * time.Second, ConsoleLimit: 1000}))
|
||||
func RegisterWithOptions(options Options) func(*core.Core) (any, error) {
|
||||
o := normalizeOptions(options)
|
||||
return func(c *core.Core) (any, error) {
|
||||
svc := &Service{
|
||||
ServiceRuntime: core.NewServiceRuntime[Options](c, o),
|
||||
opts: o,
|
||||
options: o,
|
||||
connections: make(map[string]connector),
|
||||
exceptions: make(map[string][]ExceptionInfo),
|
||||
newConn: defaultNewConn(o),
|
||||
|
|
@ -96,8 +101,19 @@ func Register(options Options) func(*core.Core) (any, error) {
|
|||
}
|
||||
}
|
||||
|
||||
// Deprecated: use RegisterWithOptions(webview.Options{DebugURL: "http://localhost:9223", Timeout: 30 * time.Second, ConsoleLimit: 1000}).
|
||||
func Register(optionFns ...func(*Options)) func(*core.Core) (any, error) {
|
||||
options := defaultOptions()
|
||||
for _, fn := range optionFns {
|
||||
if fn != nil {
|
||||
fn(&options)
|
||||
}
|
||||
}
|
||||
return RegisterWithOptions(options)
|
||||
}
|
||||
|
||||
// defaultNewConn creates real go-webview connections.
|
||||
func defaultNewConn(opts Options) func(string, string) (connector, error) {
|
||||
func defaultNewConn(options Options) func(string, string) (connector, error) {
|
||||
return func(debugURL, windowName string) (connector, error) {
|
||||
// Enumerate targets, match by title/URL containing window name
|
||||
targets, err := gowebview.ListTargets(debugURL)
|
||||
|
|
@ -106,7 +122,7 @@ func defaultNewConn(opts Options) func(string, string) (connector, error) {
|
|||
}
|
||||
var wsURL string
|
||||
for _, t := range targets {
|
||||
if t.Type == "page" && (corego.Contains(t.Title, windowName) || corego.Contains(t.URL, windowName)) {
|
||||
if t.Type == "page" && (bytes.Contains([]byte(t.Title), []byte(windowName)) || bytes.Contains([]byte(t.URL), []byte(windowName))) {
|
||||
wsURL = t.WebSocketDebuggerURL
|
||||
break
|
||||
}
|
||||
|
|
@ -125,8 +141,8 @@ func defaultNewConn(opts Options) func(string, string) (connector, error) {
|
|||
}
|
||||
wv, err := gowebview.New(
|
||||
gowebview.WithDebugURL(debugURL),
|
||||
gowebview.WithTimeout(opts.Timeout),
|
||||
gowebview.WithConsoleLimit(opts.ConsoleLimit),
|
||||
gowebview.WithTimeout(options.Timeout),
|
||||
gowebview.WithConsoleLimit(options.ConsoleLimit),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -174,7 +190,6 @@ func (s *Service) defaultWatcherSetup(conn connector, windowName string) {
|
|||
})
|
||||
}
|
||||
|
||||
// OnStartup registers IPC handlers.
|
||||
func (s *Service) OnStartup(_ context.Context) error {
|
||||
s.Core().RegisterQuery(s.handleQuery)
|
||||
s.Core().RegisterTask(s.handleTask)
|
||||
|
|
@ -224,7 +239,7 @@ func (s *Service) getConn(windowName string) (connector, error) {
|
|||
if conn, ok := s.connections[windowName]; ok {
|
||||
return conn, nil
|
||||
}
|
||||
conn, err := s.newConn(s.opts.DebugURL, windowName)
|
||||
conn, err := s.newConn(s.options.DebugURL, windowName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -674,7 +689,6 @@ func (r *realConnector) GetURL() (string, error) { return r.wv.G
|
|||
func (r *realConnector) GetTitle() (string, error) { return r.wv.GetTitle() }
|
||||
func (r *realConnector) GetHTML(sel string) (string, error) { return r.wv.GetHTML(sel) }
|
||||
func (r *realConnector) ClearConsole() { r.wv.ClearConsole() }
|
||||
func (r *realConnector) Print() error { _, err := r.wv.Evaluate("window.print()"); return err }
|
||||
func (r *realConnector) Close() error { return r.wv.Close() }
|
||||
func (r *realConnector) SetViewport(w, h int) error { return r.wv.SetViewport(w, h) }
|
||||
func (r *realConnector) UploadFile(sel string, p []string) error { return r.wv.UploadFile(sel, p) }
|
||||
|
|
|
|||
|
|
@ -65,31 +65,18 @@ func (m *mockConnector) Check(sel string, c bool) error {
|
|||
m.lastCheckVal = c
|
||||
return nil
|
||||
}
|
||||
func (m *mockConnector) Evaluate(s string) (any, error) {
|
||||
m.lastEvalScript = s
|
||||
if m.evalFn != nil {
|
||||
return m.evalFn(s)
|
||||
}
|
||||
return m.evalResult, nil
|
||||
}
|
||||
func (m *mockConnector) Evaluate(s string) (any, error) { return m.evalResult, nil }
|
||||
func (m *mockConnector) Screenshot() ([]byte, error) { return m.screenshot, nil }
|
||||
func (m *mockConnector) GetURL() (string, error) { return m.url, nil }
|
||||
func (m *mockConnector) GetTitle() (string, error) { return m.title, nil }
|
||||
func (m *mockConnector) GetHTML(sel string) (string, error) { return m.html, nil }
|
||||
func (m *mockConnector) ClearConsole() { m.consoleClearCalled = true }
|
||||
func (m *mockConnector) Print() error { m.printCalled = true; return nil }
|
||||
func (m *mockConnector) Close() error { m.closed = true; return nil }
|
||||
func (m *mockConnector) SetViewport(w, h int) error {
|
||||
m.lastViewportW = w
|
||||
m.lastViewportH = h
|
||||
return nil
|
||||
}
|
||||
func (m *mockConnector) PrintToPDF() ([]byte, error) {
|
||||
if len(m.pdfBytes) == 0 {
|
||||
return []byte("%PDF-1.4\n"), nil
|
||||
}
|
||||
return m.pdfBytes, nil
|
||||
}
|
||||
func (m *mockConnector) UploadFile(sel string, p []string) error {
|
||||
m.lastUploadSel = sel
|
||||
m.lastUploadPaths = p
|
||||
|
|
@ -111,12 +98,8 @@ func (m *mockConnector) GetConsole() []ConsoleMessage { return m.console }
|
|||
|
||||
func newTestService(t *testing.T, mock *mockConnector) (*Service, *core.Core) {
|
||||
t.Helper()
|
||||
factory := Register(Options{})
|
||||
c, err := core.New(
|
||||
core.WithService(window.Register(window.NewMockPlatform())),
|
||||
core.WithService(factory),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
factory := RegisterWithOptions(Options{})
|
||||
c, err := core.New(core.WithService(factory), core.WithServiceLock())
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
||||
svc := core.MustServiceFor[*Service](c, "webview")
|
||||
|
|
@ -125,7 +108,7 @@ func newTestService(t *testing.T, mock *mockConnector) (*Service, *core.Core) {
|
|||
return svc, c
|
||||
}
|
||||
|
||||
func TestRegister_Good(t *testing.T) {
|
||||
func TestRegisterWithOptions_Good(t *testing.T) {
|
||||
svc, _ := newTestService(t, &mockConnector{})
|
||||
assert.NotNil(t, svc)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@
|
|||
package window
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
corego "dappco.re/go/core"
|
||||
coreio "forge.lthn.ai/core/go-io"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
)
|
||||
|
||||
// Layout is a named window arrangement.
|
||||
|
|
@ -69,13 +71,13 @@ func (lm *LayoutManager) loadLayouts() {
|
|||
if lm.configDir == "" {
|
||||
return
|
||||
}
|
||||
data, err := os.ReadFile(lm.layoutsFilePath())
|
||||
content, err := coreio.Local.Read(lm.filePath())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
lm.mu.Lock()
|
||||
defer lm.mu.Unlock()
|
||||
_ = corego.JSONUnmarshal(data, &lm.layouts)
|
||||
_ = json.Unmarshal([]byte(content), &lm.layouts)
|
||||
}
|
||||
|
||||
func (lm *LayoutManager) saveLayouts() {
|
||||
|
|
@ -88,15 +90,15 @@ func (lm *LayoutManager) saveLayouts() {
|
|||
if !r.OK {
|
||||
return
|
||||
}
|
||||
_ = os.MkdirAll(lm.configDir, 0o755)
|
||||
_ = os.WriteFile(lm.layoutsFilePath(), r.Value.([]byte), 0o644)
|
||||
_ = coreio.Local.EnsureDir(lm.configDir)
|
||||
_ = coreio.Local.Write(lm.filePath(), string(data))
|
||||
}
|
||||
|
||||
// SaveLayout creates or updates a named layout.
|
||||
// Use: _ = lm.SaveLayout("coding", windowStates)
|
||||
func (lm *LayoutManager) SaveLayout(name string, windowStates map[string]WindowState) error {
|
||||
if name == "" {
|
||||
return corego.E("layout.save", "layout name cannot be empty", nil)
|
||||
return coreerr.E("window.LayoutManager.SaveLayout", "layout name cannot be empty", nil)
|
||||
}
|
||||
now := time.Now().UnixMilli()
|
||||
lm.mu.Lock()
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
// pkg/window/messages.go
|
||||
package window
|
||||
|
||||
// WindowInfo contains information about a window.
|
||||
// Use: info := window.WindowInfo{Name: "editor", Title: "Core Editor"}
|
||||
type WindowInfo struct {
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title"`
|
||||
|
|
@ -16,113 +14,46 @@ type WindowInfo struct {
|
|||
Focused bool `json:"focused"`
|
||||
}
|
||||
|
||||
// Bounds describes the position and size of a window.
|
||||
// Use: bounds := window.Bounds{X: 10, Y: 10, Width: 1280, Height: 800}
|
||||
type Bounds struct {
|
||||
X int `json:"x"`
|
||||
Y int `json:"y"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
}
|
||||
|
||||
// --- Queries (read-only) ---
|
||||
|
||||
// QueryWindowList returns all tracked windows. Result: []WindowInfo
|
||||
// Use: result, _, err := c.QUERY(window.QueryWindowList{})
|
||||
type QueryWindowList struct{}
|
||||
|
||||
// QueryWindowByName returns a single window by name. Result: *WindowInfo (nil if not found)
|
||||
// Use: result, _, err := c.QUERY(window.QueryWindowByName{Name: "editor"})
|
||||
type QueryWindowByName struct{ Name string }
|
||||
|
||||
// QueryConfig requests this service's config section from the display orchestrator.
|
||||
// Result: map[string]any
|
||||
// Use: result, _, err := c.QUERY(window.QueryConfig{})
|
||||
type QueryConfig struct{}
|
||||
|
||||
// QueryWindowBounds returns the current bounds for a window.
|
||||
// Use: result, _, err := c.QUERY(window.QueryWindowBounds{Name: "editor"})
|
||||
type QueryWindowBounds struct{ Name string }
|
||||
// TaskOpenWindow opens a concrete Window descriptor.
|
||||
// window.TaskOpenWindow{Window: &window.Window{Name: "settings", URL: "/", Width: 800, Height: 600}}
|
||||
type TaskOpenWindow struct{ Window *Window }
|
||||
|
||||
// QueryFindSpace returns a suggested free placement for a new window.
|
||||
// Use: result, _, err := c.QUERY(window.QueryFindSpace{Width: 1280, Height: 800})
|
||||
type QueryFindSpace struct {
|
||||
Width int
|
||||
Height int
|
||||
ScreenWidth int
|
||||
ScreenHeight int
|
||||
}
|
||||
|
||||
// QueryLayoutSuggestion returns a layout recommendation for the current screen.
|
||||
// Use: result, _, err := c.QUERY(window.QueryLayoutSuggestion{WindowCount: 2})
|
||||
type QueryLayoutSuggestion struct {
|
||||
WindowCount int
|
||||
ScreenWidth int
|
||||
ScreenHeight int
|
||||
}
|
||||
|
||||
// --- Tasks (side-effects) ---
|
||||
|
||||
// TaskOpenWindow creates a new window. Result: WindowInfo
|
||||
// Use: _, _, err := c.PERFORM(window.TaskOpenWindow{Opts: []window.WindowOption{window.WithName("editor")}})
|
||||
type TaskOpenWindow struct {
|
||||
Window *Window
|
||||
Opts []WindowOption
|
||||
}
|
||||
|
||||
// TaskCloseWindow closes a window after persisting state.
|
||||
// Platform close events emit ActionWindowClosed through the tracked window handler.
|
||||
// Use: _, _, err := c.PERFORM(window.TaskCloseWindow{Name: "editor"})
|
||||
type TaskCloseWindow struct{ Name string }
|
||||
|
||||
// TaskSetPosition moves a window.
|
||||
// Use: _, _, err := c.PERFORM(window.TaskSetPosition{Name: "editor", X: 160, Y: 120})
|
||||
type TaskSetPosition struct {
|
||||
Name string
|
||||
X, Y int
|
||||
}
|
||||
|
||||
// TaskSetSize resizes a window.
|
||||
// Use: _, _, err := c.PERFORM(window.TaskSetSize{Name: "editor", Width: 1280, Height: 800})
|
||||
type TaskSetSize struct {
|
||||
Name string
|
||||
Width, Height int
|
||||
// W and H are compatibility aliases for older call sites.
|
||||
W, H int
|
||||
}
|
||||
|
||||
// TaskMaximise maximises a window.
|
||||
// Use: _, _, err := c.PERFORM(window.TaskMaximise{Name: "editor"})
|
||||
type TaskMaximise struct{ Name string }
|
||||
|
||||
// TaskMinimise minimises a window.
|
||||
// Use: _, _, err := c.PERFORM(window.TaskMinimise{Name: "editor"})
|
||||
type TaskMinimise struct{ Name string }
|
||||
|
||||
// TaskFocus brings a window to the front.
|
||||
// Use: _, _, err := c.PERFORM(window.TaskFocus{Name: "editor"})
|
||||
type TaskFocus struct{ Name string }
|
||||
|
||||
// TaskRestore restores a maximised or minimised window to its normal state.
|
||||
// Use: _, _, err := c.PERFORM(window.TaskRestore{Name: "editor"})
|
||||
type TaskRestore struct{ Name string }
|
||||
|
||||
// TaskSetTitle changes a window's title.
|
||||
// Use: _, _, err := c.PERFORM(window.TaskSetTitle{Name: "editor", Title: "Core Editor"})
|
||||
type TaskSetTitle struct {
|
||||
Name string
|
||||
Title string
|
||||
}
|
||||
|
||||
// TaskSetAlwaysOnTop pins a window above others.
|
||||
// Use: _, _, err := c.PERFORM(window.TaskSetAlwaysOnTop{Name: "editor", AlwaysOnTop: true})
|
||||
type TaskSetAlwaysOnTop struct {
|
||||
Name string
|
||||
AlwaysOnTop bool
|
||||
}
|
||||
|
||||
// TaskSetBackgroundColour updates the window background colour.
|
||||
// Use: _, _, err := c.PERFORM(window.TaskSetBackgroundColour{Name: "editor", Red: 0, Green: 0, Blue: 0, Alpha: 0})
|
||||
type TaskSetBackgroundColour struct {
|
||||
Name string
|
||||
Red uint8
|
||||
|
|
@ -131,99 +62,94 @@ type TaskSetBackgroundColour struct {
|
|||
Alpha uint8
|
||||
}
|
||||
|
||||
// TaskSetOpacity updates the window opacity as a value between 0 and 1.
|
||||
// Use: _, _, err := c.PERFORM(window.TaskSetOpacity{Name: "editor", Opacity: 0.85})
|
||||
type TaskSetOpacity struct {
|
||||
Name string
|
||||
Opacity float32
|
||||
}
|
||||
|
||||
// TaskSetVisibility shows or hides a window.
|
||||
// Use: _, _, err := c.PERFORM(window.TaskSetVisibility{Name: "editor", Visible: false})
|
||||
type TaskSetVisibility struct {
|
||||
Name string
|
||||
Visible bool
|
||||
}
|
||||
|
||||
// TaskFullscreen enters or exits fullscreen mode.
|
||||
// Use: _, _, err := c.PERFORM(window.TaskFullscreen{Name: "editor", Fullscreen: true})
|
||||
type TaskFullscreen struct {
|
||||
Name string
|
||||
Fullscreen bool
|
||||
}
|
||||
|
||||
// --- Layout Queries ---
|
||||
|
||||
// QueryLayoutList returns summaries of all saved layouts. Result: []LayoutInfo
|
||||
// Use: result, _, err := c.QUERY(window.QueryLayoutList{})
|
||||
type QueryLayoutList struct{}
|
||||
|
||||
// QueryLayoutGet returns a layout by name. Result: *Layout (nil if not found)
|
||||
// Use: result, _, err := c.QUERY(window.QueryLayoutGet{Name: "coding"})
|
||||
type QueryLayoutGet struct{ Name string }
|
||||
|
||||
// --- Layout Tasks ---
|
||||
|
||||
// TaskSaveLayout saves the current window arrangement as a named layout. Result: bool
|
||||
// Use: _, _, err := c.PERFORM(window.TaskSaveLayout{Name: "coding"})
|
||||
type TaskSaveLayout struct{ Name string }
|
||||
|
||||
// TaskRestoreLayout restores a saved layout by name.
|
||||
// Use: _, _, err := c.PERFORM(window.TaskRestoreLayout{Name: "coding"})
|
||||
type TaskRestoreLayout struct{ Name string }
|
||||
|
||||
// TaskDeleteLayout removes a saved layout by name.
|
||||
// Use: _, _, err := c.PERFORM(window.TaskDeleteLayout{Name: "coding"})
|
||||
type TaskDeleteLayout struct{ Name string }
|
||||
|
||||
// TaskTileWindows arranges windows in a tiling mode.
|
||||
// Use: _, _, err := c.PERFORM(window.TaskTileWindows{Mode: "grid"})
|
||||
type TaskTileWindows struct {
|
||||
Mode string // "left-right", "grid", "left-half", "right-half", etc.
|
||||
Windows []string // window names; empty = all
|
||||
}
|
||||
|
||||
// TaskSnapWindow snaps a window to a screen edge/corner.
|
||||
// Use: _, _, err := c.PERFORM(window.TaskSnapWindow{Name: "editor", Position: "left"})
|
||||
type TaskStackWindows struct {
|
||||
Windows []string // window names; empty = all
|
||||
OffsetX int
|
||||
OffsetY int
|
||||
}
|
||||
|
||||
type TaskSnapWindow struct {
|
||||
Name string // window name
|
||||
Position string // "left", "right", "top", "bottom", "top-left", "top-right", "bottom-left", "bottom-right", "center"
|
||||
}
|
||||
|
||||
// TaskArrangePair places two windows side-by-side in a balanced split.
|
||||
// Use: _, _, err := c.PERFORM(window.TaskArrangePair{First: "editor", Second: "terminal"})
|
||||
type TaskArrangePair struct {
|
||||
First string
|
||||
Second string
|
||||
}
|
||||
|
||||
// TaskBesideEditor places a target window beside an editor/IDE window.
|
||||
// Use: _, _, err := c.PERFORM(window.TaskBesideEditor{Editor: "editor", Window: "terminal"})
|
||||
type TaskBesideEditor struct {
|
||||
Editor string
|
||||
Window string
|
||||
}
|
||||
|
||||
// TaskStackWindows cascades windows with a shared offset.
|
||||
// Use: _, _, err := c.PERFORM(window.TaskStackWindows{Windows: []string{"editor", "terminal"}})
|
||||
type TaskStackWindows struct {
|
||||
Windows []string
|
||||
OffsetX int
|
||||
OffsetY int
|
||||
}
|
||||
|
||||
// TaskApplyWorkflow applies a predefined workflow layout to windows.
|
||||
// Use: _, _, err := c.PERFORM(window.TaskApplyWorkflow{Workflow: window.WorkflowCoding})
|
||||
type TaskApplyWorkflow struct {
|
||||
Workflow WorkflowLayout
|
||||
Windows []string
|
||||
Workflow string
|
||||
Windows []string // window names; empty = all
|
||||
}
|
||||
|
||||
// TaskSaveConfig persists this service's config section via the display orchestrator.
|
||||
// Use: _, _, err := c.PERFORM(window.TaskSaveConfig{Value: map[string]any{"default_width": 1280}})
|
||||
type TaskSaveConfig struct{ Value map[string]any }
|
||||
type TaskSaveConfig struct{ Config map[string]any }
|
||||
|
||||
// --- Actions (broadcasts) ---
|
||||
// QueryWindowZoom queries the current zoom factor for a named window. Result: float64
|
||||
type QueryWindowZoom struct{ Name string }
|
||||
|
||||
// QueryWindowBounds queries the current bounds for a named window. Result: *Bounds
|
||||
type QueryWindowBounds struct{ Name string }
|
||||
|
||||
// TaskSetZoom sets the zoom factor for a named window.
|
||||
// c.PERFORM(window.TaskSetZoom{Name: "main", Factor: 1.5})
|
||||
type TaskSetZoom struct {
|
||||
Name string
|
||||
Factor float64
|
||||
}
|
||||
|
||||
// TaskSetURL navigates a named window to a new URL.
|
||||
// c.PERFORM(window.TaskSetURL{Name: "main", URL: "/settings"})
|
||||
type TaskSetURL struct {
|
||||
Name string
|
||||
URL string
|
||||
}
|
||||
|
||||
// TaskSetHTML replaces the content of a named window with HTML.
|
||||
// c.PERFORM(window.TaskSetHTML{Name: "main", HTML: "<h1>Hello</h1>"})
|
||||
type TaskSetHTML struct {
|
||||
Name string
|
||||
HTML string
|
||||
}
|
||||
|
||||
// TaskExecJS evaluates JavaScript in a named window.
|
||||
// c.PERFORM(window.TaskExecJS{Name: "main", JS: "document.title = 'Updated'"})
|
||||
type TaskExecJS struct {
|
||||
Name string
|
||||
JS string
|
||||
}
|
||||
|
||||
// TaskToggleFullscreen toggles fullscreen on a named window.
|
||||
type TaskToggleFullscreen struct{ Name string }
|
||||
|
||||
// TaskPrint triggers the platform print dialog for a named window.
|
||||
type TaskPrint struct{ Name string }
|
||||
|
||||
// TaskFlash flashes (or stops flashing) the taskbar entry for a named window (Windows).
|
||||
type TaskFlash struct {
|
||||
Name string
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
// ActionWindowOpened is broadcast when a window is created.
|
||||
// Use: _ = c.ACTION(window.ActionWindowOpened{Name: "editor"})
|
||||
|
|
@ -245,8 +171,6 @@ type ActionWindowMoved struct {
|
|||
type ActionWindowResized struct {
|
||||
Name string
|
||||
Width, Height int
|
||||
// W and H are compatibility aliases for older listeners.
|
||||
W, H int
|
||||
}
|
||||
|
||||
// ActionWindowFocused is broadcast when a window gains focus.
|
||||
|
|
|
|||
|
|
@ -14,16 +14,11 @@ func NewMockPlatform() *MockPlatform {
|
|||
return &MockPlatform{}
|
||||
}
|
||||
|
||||
// CreateWindow creates an in-memory window for tests.
|
||||
// Use: w := platform.CreateWindow(window.PlatformWindowOptions{Name: "editor"})
|
||||
func (m *MockPlatform) CreateWindow(opts PlatformWindowOptions) PlatformWindow {
|
||||
func (m *MockPlatform) CreateWindow(options PlatformWindowOptions) PlatformWindow {
|
||||
w := &MockWindow{
|
||||
name: opts.Name, title: opts.Title, url: opts.URL,
|
||||
width: opts.Width, height: opts.Height,
|
||||
x: opts.X, y: opts.Y,
|
||||
alwaysOnTop: opts.AlwaysOnTop,
|
||||
backgroundColor: opts.BackgroundColour,
|
||||
visible: !opts.Hidden,
|
||||
name: options.Name, title: options.Title, url: options.URL,
|
||||
width: options.Width, height: options.Height,
|
||||
x: options.X, y: options.Y,
|
||||
}
|
||||
m.Windows = append(m.Windows, w)
|
||||
return w
|
||||
|
|
@ -42,47 +37,62 @@ func (m *MockPlatform) GetWindows() []PlatformWindow {
|
|||
// MockWindow is an in-memory window handle used by tests.
|
||||
// Use: w := &window.MockWindow{}
|
||||
type MockWindow struct {
|
||||
name, title, url string
|
||||
width, height, x, y int
|
||||
maximised, minimised bool
|
||||
focused bool
|
||||
visible, alwaysOnTop bool
|
||||
backgroundColor [4]uint8
|
||||
opacity float32
|
||||
closed bool
|
||||
eventHandlers []func(WindowEvent)
|
||||
fileDropHandlers []func(paths []string, targetID string)
|
||||
name, title, url string
|
||||
width, height, x, y int
|
||||
maximised, focused bool
|
||||
visible, alwaysOnTop bool
|
||||
backgroundColour [4]uint8
|
||||
closed bool
|
||||
zoom float64
|
||||
html string
|
||||
lastJS string
|
||||
flashing bool
|
||||
printCalled bool
|
||||
toggleFullscreenCount int
|
||||
eventHandlers []func(WindowEvent)
|
||||
fileDropHandlers []func(paths []string, targetID string)
|
||||
}
|
||||
|
||||
func (w *MockWindow) Name() string { return w.name }
|
||||
func (w *MockWindow) Title() string { return w.title }
|
||||
func (w *MockWindow) Position() (int, int) { return w.x, w.y }
|
||||
func (w *MockWindow) Size() (int, int) { return w.width, w.height }
|
||||
func (w *MockWindow) IsVisible() bool { return w.visible }
|
||||
func (w *MockWindow) IsMinimised() bool { return w.minimised }
|
||||
func (w *MockWindow) IsMaximised() bool { return w.maximised }
|
||||
func (w *MockWindow) IsFocused() bool { return w.focused }
|
||||
func (w *MockWindow) SetTitle(title string) { w.title = title }
|
||||
func (w *MockWindow) SetPosition(x, y int) { w.x = x; w.y = y }
|
||||
func (w *MockWindow) SetSize(width, height int) { w.width = width; w.height = height }
|
||||
func (w *MockWindow) SetBackgroundColour(r, g, b, a uint8) { w.backgroundColor = [4]uint8{r, g, b, a} }
|
||||
func (w *MockWindow) SetOpacity(opacity float32) { w.opacity = opacity }
|
||||
func (w *MockWindow) SetBackgroundColour(r, g, b, a uint8) { w.backgroundColour = [4]uint8{r, g, b, a} }
|
||||
func (w *MockWindow) SetVisibility(visible bool) { w.visible = visible }
|
||||
func (w *MockWindow) SetAlwaysOnTop(alwaysOnTop bool) { w.alwaysOnTop = alwaysOnTop }
|
||||
func (w *MockWindow) Maximise() { w.maximised = true; w.minimised = false; w.visible = true }
|
||||
func (w *MockWindow) Restore() { w.maximised = false; w.minimised = false; w.visible = true }
|
||||
func (w *MockWindow) Minimise() { w.minimised = true; w.maximised = false; w.visible = false }
|
||||
func (w *MockWindow) Maximise() { w.maximised = true }
|
||||
func (w *MockWindow) Restore() { w.maximised = false }
|
||||
func (w *MockWindow) Minimise() {}
|
||||
func (w *MockWindow) Focus() { w.focused = true }
|
||||
func (w *MockWindow) Close() {
|
||||
w.closed = true
|
||||
w.emit(WindowEvent{Type: "close", Name: w.name})
|
||||
func (w *MockWindow) Close() { w.closed = true }
|
||||
func (w *MockWindow) Show() { w.visible = true }
|
||||
func (w *MockWindow) Hide() { w.visible = false }
|
||||
func (w *MockWindow) Fullscreen() {}
|
||||
func (w *MockWindow) UnFullscreen() {}
|
||||
func (w *MockWindow) GetZoom() float64 { return w.zoom }
|
||||
func (w *MockWindow) SetZoom(factor float64) { w.zoom = factor }
|
||||
func (w *MockWindow) ZoomIn() { w.zoom += 0.1 }
|
||||
func (w *MockWindow) ZoomOut() { w.zoom -= 0.1 }
|
||||
func (w *MockWindow) SetURL(url string) { w.url = url }
|
||||
func (w *MockWindow) SetHTML(html string) { w.html = html }
|
||||
func (w *MockWindow) ExecJS(js string) { w.lastJS = js }
|
||||
func (w *MockWindow) GetBounds() Bounds {
|
||||
return Bounds{X: w.x, Y: w.y, Width: w.width, Height: w.height}
|
||||
}
|
||||
func (w *MockWindow) Show() { w.visible = true }
|
||||
func (w *MockWindow) Hide() { w.visible = false }
|
||||
func (w *MockWindow) Fullscreen() {}
|
||||
func (w *MockWindow) UnFullscreen() {}
|
||||
func (w *MockWindow) OpenDevTools() {}
|
||||
func (w *MockWindow) CloseDevTools() {}
|
||||
func (w *MockWindow) SetBounds(bounds Bounds) {
|
||||
w.x = bounds.X
|
||||
w.y = bounds.Y
|
||||
w.width = bounds.Width
|
||||
w.height = bounds.Height
|
||||
}
|
||||
func (w *MockWindow) ToggleFullscreen() { w.toggleFullscreenCount++ }
|
||||
func (w *MockWindow) Print() error { w.printCalled = true; return nil }
|
||||
func (w *MockWindow) Flash(enabled bool) { w.flashing = enabled }
|
||||
func (w *MockWindow) OnWindowEvent(handler func(WindowEvent)) {
|
||||
w.eventHandlers = append(w.eventHandlers, handler)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// pkg/window/mock_test.go
|
||||
package window
|
||||
|
||||
type mockPlatform struct {
|
||||
|
|
@ -9,14 +8,11 @@ func newMockPlatform() *mockPlatform {
|
|||
return &mockPlatform{}
|
||||
}
|
||||
|
||||
func (m *mockPlatform) CreateWindow(opts PlatformWindowOptions) PlatformWindow {
|
||||
func (m *mockPlatform) CreateWindow(options PlatformWindowOptions) PlatformWindow {
|
||||
w := &mockWindow{
|
||||
name: opts.Name, title: opts.Title, url: opts.URL,
|
||||
width: opts.Width, height: opts.Height,
|
||||
x: opts.X, y: opts.Y,
|
||||
alwaysOnTop: opts.AlwaysOnTop,
|
||||
backgroundColor: opts.BackgroundColour,
|
||||
visible: !opts.Hidden,
|
||||
name: options.Name, title: options.Title, url: options.URL,
|
||||
width: options.Width, height: options.Height,
|
||||
x: options.X, y: options.Y,
|
||||
}
|
||||
m.windows = append(m.windows, w)
|
||||
return w
|
||||
|
|
@ -31,48 +27,64 @@ func (m *mockPlatform) GetWindows() []PlatformWindow {
|
|||
}
|
||||
|
||||
type mockWindow struct {
|
||||
name, title, url string
|
||||
width, height, x, y int
|
||||
maximised, minimised bool
|
||||
focused bool
|
||||
visible, alwaysOnTop bool
|
||||
backgroundColor [4]uint8
|
||||
opacity float32
|
||||
devtoolsOpen bool
|
||||
closed bool
|
||||
eventHandlers []func(WindowEvent)
|
||||
fileDropHandlers []func(paths []string, targetID string)
|
||||
name, title, url string
|
||||
width, height, x, y int
|
||||
maximised, focused bool
|
||||
visible, alwaysOnTop bool
|
||||
backgroundColour [4]uint8
|
||||
closed bool
|
||||
minimised bool
|
||||
fullscreened bool
|
||||
zoom float64
|
||||
html string
|
||||
lastJS string
|
||||
flashing bool
|
||||
printCalled bool
|
||||
toggleFullscreenCount int
|
||||
eventHandlers []func(WindowEvent)
|
||||
fileDropHandlers []func(paths []string, targetID string)
|
||||
}
|
||||
|
||||
func (w *mockWindow) Name() string { return w.name }
|
||||
func (w *mockWindow) Title() string { return w.title }
|
||||
func (w *mockWindow) Position() (int, int) { return w.x, w.y }
|
||||
func (w *mockWindow) Size() (int, int) { return w.width, w.height }
|
||||
func (w *mockWindow) IsVisible() bool { return w.visible }
|
||||
func (w *mockWindow) IsMinimised() bool { return w.minimised }
|
||||
func (w *mockWindow) IsMaximised() bool { return w.maximised }
|
||||
func (w *mockWindow) IsFocused() bool { return w.focused }
|
||||
func (w *mockWindow) SetTitle(title string) { w.title = title }
|
||||
func (w *mockWindow) SetPosition(x, y int) { w.x = x; w.y = y }
|
||||
func (w *mockWindow) SetSize(width, height int) { w.width = width; w.height = height }
|
||||
func (w *mockWindow) SetBackgroundColour(r, g, b, a uint8) { w.backgroundColor = [4]uint8{r, g, b, a} }
|
||||
func (w *mockWindow) SetOpacity(opacity float32) { w.opacity = opacity }
|
||||
func (w *mockWindow) SetBackgroundColour(r, g, b, a uint8) { w.backgroundColour = [4]uint8{r, g, b, a} }
|
||||
func (w *mockWindow) SetVisibility(visible bool) { w.visible = visible }
|
||||
func (w *mockWindow) SetAlwaysOnTop(alwaysOnTop bool) { w.alwaysOnTop = alwaysOnTop }
|
||||
func (w *mockWindow) Maximise() { w.maximised = true; w.minimised = false; w.visible = true }
|
||||
func (w *mockWindow) Restore() { w.maximised = false; w.minimised = false; w.visible = true }
|
||||
func (w *mockWindow) Minimise() { w.minimised = true; w.maximised = false; w.visible = false }
|
||||
func (w *mockWindow) Maximise() { w.maximised = true }
|
||||
func (w *mockWindow) Restore() { w.maximised = false }
|
||||
func (w *mockWindow) Minimise() { w.minimised = true }
|
||||
func (w *mockWindow) Focus() { w.focused = true }
|
||||
func (w *mockWindow) Close() {
|
||||
w.closed = true
|
||||
w.emit(WindowEvent{Type: "close", Name: w.name})
|
||||
}
|
||||
func (w *mockWindow) Close() { w.closed = true }
|
||||
func (w *mockWindow) Show() { w.visible = true }
|
||||
func (w *mockWindow) Hide() { w.visible = false }
|
||||
func (w *mockWindow) Fullscreen() {}
|
||||
func (w *mockWindow) UnFullscreen() {}
|
||||
func (w *mockWindow) OpenDevTools() { w.devtoolsOpen = true }
|
||||
func (w *mockWindow) CloseDevTools() { w.devtoolsOpen = false }
|
||||
func (w *mockWindow) Fullscreen() { w.fullscreened = true }
|
||||
func (w *mockWindow) UnFullscreen() { w.fullscreened = false }
|
||||
func (w *mockWindow) GetZoom() float64 { return w.zoom }
|
||||
func (w *mockWindow) SetZoom(factor float64) { w.zoom = factor }
|
||||
func (w *mockWindow) ZoomIn() { w.zoom += 0.1 }
|
||||
func (w *mockWindow) ZoomOut() { w.zoom -= 0.1 }
|
||||
func (w *mockWindow) SetURL(url string) { w.url = url }
|
||||
func (w *mockWindow) SetHTML(html string) { w.html = html }
|
||||
func (w *mockWindow) ExecJS(js string) { w.lastJS = js }
|
||||
func (w *mockWindow) GetBounds() Bounds {
|
||||
return Bounds{X: w.x, Y: w.y, Width: w.width, Height: w.height}
|
||||
}
|
||||
func (w *mockWindow) SetBounds(bounds Bounds) {
|
||||
w.x = bounds.X
|
||||
w.y = bounds.Y
|
||||
w.width = bounds.Width
|
||||
w.height = bounds.Height
|
||||
}
|
||||
func (w *mockWindow) ToggleFullscreen() { w.toggleFullscreenCount++ }
|
||||
func (w *mockWindow) Print() error { w.printCalled = true; return nil }
|
||||
func (w *mockWindow) Flash(enabled bool) { w.flashing = enabled }
|
||||
func (w *mockWindow) OnWindowEvent(handler func(WindowEvent)) {
|
||||
w.eventHandlers = append(w.eventHandlers, handler)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,25 @@
|
|||
// pkg/window/options.go
|
||||
package window
|
||||
|
||||
// WindowOption is a functional option applied to a Window descriptor.
|
||||
// WindowOption is the compatibility layer for option-chain callers.
|
||||
// Prefer a Window literal with Manager.CreateWindow.
|
||||
type WindowOption func(*Window) error
|
||||
|
||||
// ApplyOptions creates a Window and applies all options in order.
|
||||
// Use: w, err := window.ApplyOptions(window.WithName("editor"), window.WithURL("/editor"))
|
||||
func ApplyOptions(opts ...WindowOption) (*Window, error) {
|
||||
// Deprecated: use Manager.CreateWindow(Window{Name: "settings", URL: "/", Width: 800, Height: 600}).
|
||||
func ApplyOptions(options ...WindowOption) (*Window, error) {
|
||||
w := &Window{}
|
||||
for _, opt := range opts {
|
||||
if opt == nil {
|
||||
for _, option := range options {
|
||||
if option == nil {
|
||||
continue
|
||||
}
|
||||
if err := opt(w); err != nil {
|
||||
if err := option(w); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// WithName sets the window name.
|
||||
// Use: window.WithName("editor")
|
||||
// Compatibility helpers for callers still using option chains.
|
||||
func WithName(name string) WindowOption {
|
||||
return func(w *Window) error { w.Name = name; return nil }
|
||||
}
|
||||
|
|
|
|||
334
pkg/window/persistence_test.go
Normal file
334
pkg/window/persistence_test.go
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
// pkg/window/persistence_test.go
|
||||
package window
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// --- StateManager Persistence Tests ---
|
||||
|
||||
func TestStateManager_SetAndGet_Good(t *testing.T) {
|
||||
sm := NewStateManagerWithDir(t.TempDir())
|
||||
state := WindowState{
|
||||
X: 150, Y: 250, Width: 1024, Height: 768,
|
||||
Maximized: true, Screen: "primary", URL: "/app",
|
||||
}
|
||||
sm.SetState("editor", state)
|
||||
|
||||
got, ok := sm.GetState("editor")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, 150, got.X)
|
||||
assert.Equal(t, 250, got.Y)
|
||||
assert.Equal(t, 1024, got.Width)
|
||||
assert.Equal(t, 768, got.Height)
|
||||
assert.True(t, got.Maximized)
|
||||
assert.Equal(t, "primary", got.Screen)
|
||||
assert.Equal(t, "/app", got.URL)
|
||||
assert.NotZero(t, got.UpdatedAt, "UpdatedAt should be set by SetState")
|
||||
}
|
||||
|
||||
func TestStateManager_UpdatePosition_Good(t *testing.T) {
|
||||
sm := NewStateManagerWithDir(t.TempDir())
|
||||
sm.SetState("win", WindowState{X: 0, Y: 0, Width: 800, Height: 600})
|
||||
|
||||
sm.UpdatePosition("win", 300, 400)
|
||||
|
||||
got, ok := sm.GetState("win")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, 300, got.X)
|
||||
assert.Equal(t, 400, got.Y)
|
||||
// Width/Height should remain unchanged
|
||||
assert.Equal(t, 800, got.Width)
|
||||
assert.Equal(t, 600, got.Height)
|
||||
}
|
||||
|
||||
func TestStateManager_UpdateSize_Good(t *testing.T) {
|
||||
sm := NewStateManagerWithDir(t.TempDir())
|
||||
sm.SetState("win", WindowState{X: 100, Y: 200, Width: 800, Height: 600})
|
||||
|
||||
sm.UpdateSize("win", 1920, 1080)
|
||||
|
||||
got, ok := sm.GetState("win")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, 1920, got.Width)
|
||||
assert.Equal(t, 1080, got.Height)
|
||||
// Position should remain unchanged
|
||||
assert.Equal(t, 100, got.X)
|
||||
assert.Equal(t, 200, got.Y)
|
||||
}
|
||||
|
||||
func TestStateManager_UpdateMaximized_Good(t *testing.T) {
|
||||
sm := NewStateManagerWithDir(t.TempDir())
|
||||
sm.SetState("win", WindowState{Width: 800, Height: 600, Maximized: false})
|
||||
|
||||
sm.UpdateMaximized("win", true)
|
||||
|
||||
got, ok := sm.GetState("win")
|
||||
require.True(t, ok)
|
||||
assert.True(t, got.Maximized)
|
||||
|
||||
sm.UpdateMaximized("win", false)
|
||||
|
||||
got, ok = sm.GetState("win")
|
||||
require.True(t, ok)
|
||||
assert.False(t, got.Maximized)
|
||||
}
|
||||
|
||||
func TestStateManager_CaptureState_Good(t *testing.T) {
|
||||
sm := NewStateManagerWithDir(t.TempDir())
|
||||
pw := &mockWindow{
|
||||
name: "captured", x: 75, y: 125,
|
||||
width: 1440, height: 900, maximised: true,
|
||||
}
|
||||
|
||||
sm.CaptureState(pw)
|
||||
|
||||
got, ok := sm.GetState("captured")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, 75, got.X)
|
||||
assert.Equal(t, 125, got.Y)
|
||||
assert.Equal(t, 1440, got.Width)
|
||||
assert.Equal(t, 900, got.Height)
|
||||
assert.True(t, got.Maximized)
|
||||
assert.NotZero(t, got.UpdatedAt)
|
||||
}
|
||||
|
||||
func TestStateManager_ApplyState_Good(t *testing.T) {
|
||||
sm := NewStateManagerWithDir(t.TempDir())
|
||||
sm.SetState("target", WindowState{X: 55, Y: 65, Width: 700, Height: 500})
|
||||
|
||||
w := &Window{Name: "target", Width: 1280, Height: 800, X: 0, Y: 0}
|
||||
sm.ApplyState(w)
|
||||
|
||||
assert.Equal(t, 55, w.X)
|
||||
assert.Equal(t, 65, w.Y)
|
||||
assert.Equal(t, 700, w.Width)
|
||||
assert.Equal(t, 500, w.Height)
|
||||
}
|
||||
|
||||
func TestStateManager_ApplyState_Good_NoState(t *testing.T) {
|
||||
sm := NewStateManagerWithDir(t.TempDir())
|
||||
|
||||
w := &Window{Name: "untouched", Width: 1280, Height: 800, X: 10, Y: 20}
|
||||
sm.ApplyState(w)
|
||||
|
||||
// Window should remain unchanged when no state is saved
|
||||
assert.Equal(t, 10, w.X)
|
||||
assert.Equal(t, 20, w.Y)
|
||||
assert.Equal(t, 1280, w.Width)
|
||||
assert.Equal(t, 800, w.Height)
|
||||
}
|
||||
|
||||
func TestStateManager_ListStates_Good(t *testing.T) {
|
||||
sm := NewStateManagerWithDir(t.TempDir())
|
||||
sm.SetState("alpha", WindowState{Width: 100})
|
||||
sm.SetState("beta", WindowState{Width: 200})
|
||||
sm.SetState("gamma", WindowState{Width: 300})
|
||||
|
||||
names := sm.ListStates()
|
||||
assert.Len(t, names, 3)
|
||||
assert.Contains(t, names, "alpha")
|
||||
assert.Contains(t, names, "beta")
|
||||
assert.Contains(t, names, "gamma")
|
||||
}
|
||||
|
||||
func TestStateManager_Clear_Good(t *testing.T) {
|
||||
sm := NewStateManagerWithDir(t.TempDir())
|
||||
sm.SetState("a", WindowState{Width: 100})
|
||||
sm.SetState("b", WindowState{Width: 200})
|
||||
sm.SetState("c", WindowState{Width: 300})
|
||||
|
||||
sm.Clear()
|
||||
|
||||
names := sm.ListStates()
|
||||
assert.Empty(t, names)
|
||||
|
||||
_, ok := sm.GetState("a")
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestStateManager_Persistence_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// First manager: write state and force sync to disk
|
||||
sm1 := NewStateManagerWithDir(dir)
|
||||
sm1.SetState("persist-win", WindowState{
|
||||
X: 42, Y: 84, Width: 500, Height: 300,
|
||||
Maximized: true, Screen: "secondary", URL: "/settings",
|
||||
})
|
||||
sm1.ForceSync()
|
||||
|
||||
// Second manager: load from the same directory
|
||||
sm2 := NewStateManagerWithDir(dir)
|
||||
|
||||
got, ok := sm2.GetState("persist-win")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, 42, got.X)
|
||||
assert.Equal(t, 84, got.Y)
|
||||
assert.Equal(t, 500, got.Width)
|
||||
assert.Equal(t, 300, got.Height)
|
||||
assert.True(t, got.Maximized)
|
||||
assert.Equal(t, "secondary", got.Screen)
|
||||
assert.Equal(t, "/settings", got.URL)
|
||||
assert.NotZero(t, got.UpdatedAt)
|
||||
}
|
||||
|
||||
func TestStateManager_SetPath_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "custom", "window-state.json")
|
||||
|
||||
sm := NewStateManagerWithDir(dir)
|
||||
sm.SetPath(path)
|
||||
sm.SetState("custom", WindowState{Width: 640, Height: 480})
|
||||
sm.ForceSync()
|
||||
|
||||
content, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(content), "custom")
|
||||
}
|
||||
|
||||
// --- LayoutManager Persistence Tests ---
|
||||
|
||||
func TestLayoutManager_SaveAndGet_Good(t *testing.T) {
|
||||
lm := NewLayoutManagerWithDir(t.TempDir())
|
||||
windows := map[string]WindowState{
|
||||
"editor": {X: 0, Y: 0, Width: 960, Height: 1080},
|
||||
"terminal": {X: 960, Y: 0, Width: 960, Height: 540},
|
||||
"browser": {X: 960, Y: 540, Width: 960, Height: 540},
|
||||
}
|
||||
|
||||
err := lm.SaveLayout("coding", windows)
|
||||
require.NoError(t, err)
|
||||
|
||||
layout, ok := lm.GetLayout("coding")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "coding", layout.Name)
|
||||
assert.Len(t, layout.Windows, 3)
|
||||
assert.Equal(t, 960, layout.Windows["editor"].Width)
|
||||
assert.Equal(t, 1080, layout.Windows["editor"].Height)
|
||||
assert.Equal(t, 960, layout.Windows["terminal"].X)
|
||||
assert.NotZero(t, layout.CreatedAt)
|
||||
assert.NotZero(t, layout.UpdatedAt)
|
||||
assert.Equal(t, layout.CreatedAt, layout.UpdatedAt, "CreatedAt and UpdatedAt should match on first save")
|
||||
}
|
||||
|
||||
func TestLayoutManager_SaveLayout_EmptyName_Bad(t *testing.T) {
|
||||
lm := NewLayoutManagerWithDir(t.TempDir())
|
||||
err := lm.SaveLayout("", map[string]WindowState{
|
||||
"win": {Width: 800},
|
||||
})
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestLayoutManager_SaveLayout_Update_Good(t *testing.T) {
|
||||
lm := NewLayoutManagerWithDir(t.TempDir())
|
||||
|
||||
// First save
|
||||
err := lm.SaveLayout("evolving", map[string]WindowState{
|
||||
"win1": {Width: 800, Height: 600},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
first, ok := lm.GetLayout("evolving")
|
||||
require.True(t, ok)
|
||||
originalCreatedAt := first.CreatedAt
|
||||
originalUpdatedAt := first.UpdatedAt
|
||||
|
||||
// Small delay to ensure UpdatedAt differs
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
|
||||
// Second save with same name but different windows
|
||||
err = lm.SaveLayout("evolving", map[string]WindowState{
|
||||
"win1": {Width: 1024, Height: 768},
|
||||
"win2": {Width: 640, Height: 480},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
updated, ok := lm.GetLayout("evolving")
|
||||
require.True(t, ok)
|
||||
|
||||
// CreatedAt should be preserved from the original save
|
||||
assert.Equal(t, originalCreatedAt, updated.CreatedAt, "CreatedAt should be preserved on update")
|
||||
// UpdatedAt should be newer
|
||||
assert.GreaterOrEqual(t, updated.UpdatedAt, originalUpdatedAt, "UpdatedAt should advance on update")
|
||||
// Windows should reflect the second save
|
||||
assert.Len(t, updated.Windows, 2)
|
||||
assert.Equal(t, 1024, updated.Windows["win1"].Width)
|
||||
}
|
||||
|
||||
func TestLayoutManager_ListLayouts_Good(t *testing.T) {
|
||||
lm := NewLayoutManagerWithDir(t.TempDir())
|
||||
require.NoError(t, lm.SaveLayout("coding", map[string]WindowState{
|
||||
"editor": {Width: 960}, "terminal": {Width: 960},
|
||||
}))
|
||||
require.NoError(t, lm.SaveLayout("presenting", map[string]WindowState{
|
||||
"slides": {Width: 1920},
|
||||
}))
|
||||
require.NoError(t, lm.SaveLayout("debugging", map[string]WindowState{
|
||||
"code": {Width: 640}, "debugger": {Width: 640}, "console": {Width: 640},
|
||||
}))
|
||||
|
||||
infos := lm.ListLayouts()
|
||||
assert.Len(t, infos, 3)
|
||||
|
||||
// Build a lookup map for assertions regardless of order
|
||||
byName := make(map[string]LayoutInfo)
|
||||
for _, info := range infos {
|
||||
byName[info.Name] = info
|
||||
}
|
||||
|
||||
assert.Equal(t, 2, byName["coding"].WindowCount)
|
||||
assert.Equal(t, 1, byName["presenting"].WindowCount)
|
||||
assert.Equal(t, 3, byName["debugging"].WindowCount)
|
||||
}
|
||||
|
||||
func TestLayoutManager_DeleteLayout_Good(t *testing.T) {
|
||||
lm := NewLayoutManagerWithDir(t.TempDir())
|
||||
require.NoError(t, lm.SaveLayout("temporary", map[string]WindowState{
|
||||
"win": {Width: 800},
|
||||
}))
|
||||
|
||||
// Verify it exists
|
||||
_, ok := lm.GetLayout("temporary")
|
||||
require.True(t, ok)
|
||||
|
||||
lm.DeleteLayout("temporary")
|
||||
|
||||
// Verify it is gone
|
||||
_, ok = lm.GetLayout("temporary")
|
||||
assert.False(t, ok)
|
||||
|
||||
// Verify list is empty
|
||||
assert.Empty(t, lm.ListLayouts())
|
||||
}
|
||||
|
||||
func TestLayoutManager_Persistence_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// First manager: save layout to disk
|
||||
lm1 := NewLayoutManagerWithDir(dir)
|
||||
err := lm1.SaveLayout("persisted", map[string]WindowState{
|
||||
"main": {X: 0, Y: 0, Width: 1280, Height: 800},
|
||||
"sidebar": {X: 1280, Y: 0, Width: 640, Height: 800},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Second manager: load from the same directory
|
||||
lm2 := NewLayoutManagerWithDir(dir)
|
||||
|
||||
layout, ok := lm2.GetLayout("persisted")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "persisted", layout.Name)
|
||||
assert.Len(t, layout.Windows, 2)
|
||||
assert.Equal(t, 1280, layout.Windows["main"].Width)
|
||||
assert.Equal(t, 800, layout.Windows["main"].Height)
|
||||
assert.Equal(t, 640, layout.Windows["sidebar"].Width)
|
||||
assert.NotZero(t, layout.CreatedAt)
|
||||
assert.NotZero(t, layout.UpdatedAt)
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ package window
|
|||
// Platform abstracts the windowing backend (Wails v3).
|
||||
// Use: var p window.Platform
|
||||
type Platform interface {
|
||||
CreateWindow(opts PlatformWindowOptions) PlatformWindow
|
||||
CreateWindow(options PlatformWindowOptions) PlatformWindow
|
||||
GetWindows() []PlatformWindow
|
||||
}
|
||||
|
||||
|
|
@ -63,6 +63,26 @@ type PlatformWindow interface {
|
|||
OpenDevTools()
|
||||
CloseDevTools()
|
||||
|
||||
// Zoom
|
||||
GetZoom() float64
|
||||
SetZoom(factor float64)
|
||||
ZoomIn()
|
||||
ZoomOut()
|
||||
|
||||
// Content
|
||||
SetURL(url string)
|
||||
SetHTML(html string)
|
||||
ExecJS(js string)
|
||||
|
||||
// Bounds
|
||||
GetBounds() Bounds
|
||||
SetBounds(bounds Bounds)
|
||||
|
||||
// Extras
|
||||
ToggleFullscreen()
|
||||
Print() error
|
||||
Flash(enabled bool)
|
||||
|
||||
// Events
|
||||
OnWindowEvent(handler func(event WindowEvent))
|
||||
|
||||
|
|
@ -70,6 +90,14 @@ type PlatformWindow interface {
|
|||
OnFileDrop(handler func(paths []string, targetID string))
|
||||
}
|
||||
|
||||
// Bounds holds the position and dimensions of a window.
|
||||
type Bounds struct {
|
||||
X int `json:"x"`
|
||||
Y int `json:"y"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
}
|
||||
|
||||
// WindowEvent is emitted by the backend for window state changes.
|
||||
// Use: evt := window.WindowEvent{Type: "focus", Name: "editor"}
|
||||
type WindowEvent struct {
|
||||
|
|
|
|||
|
|
@ -3,9 +3,8 @@ package window
|
|||
|
||||
import "forge.lthn.ai/core/go/pkg/core"
|
||||
|
||||
// Register creates a factory closure that captures the Platform adapter.
|
||||
// The returned function has the signature WithService requires: func(*Core) (any, error).
|
||||
// Use: core.WithService(window.Register(platform))
|
||||
// Register(p) binds the window service to a Core instance.
|
||||
// core.WithService(window.Register(window.NewWailsPlatform(app)))
|
||||
func Register(p Platform) func(*core.Core) (any, error) {
|
||||
return func(c *core.Core) (any, error) {
|
||||
return &Service{
|
||||
|
|
|
|||
|
|
@ -4,82 +4,66 @@ package window
|
|||
import (
|
||||
"context"
|
||||
|
||||
corego "dappco.re/go/core"
|
||||
"dappco.re/go/core/gui/pkg/screen"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"forge.lthn.ai/core/go/pkg/core"
|
||||
"forge.lthn.ai/core/gui/pkg/screen"
|
||||
)
|
||||
|
||||
// Options holds configuration for the window service.
|
||||
// Use: svc, err := window.Register(platform)(core.New())
|
||||
type Options struct{}
|
||||
|
||||
// Service is a core.Service managing window lifecycle via IPC.
|
||||
// Use: core.WithService(window.Register(window.NewMockPlatform()))
|
||||
// It embeds ServiceRuntime for Core access and composes Manager for platform operations.
|
||||
// Use: svc, err := window.Register(platform)(core.New())
|
||||
type Service struct {
|
||||
*core.ServiceRuntime[Options]
|
||||
manager *Manager
|
||||
platform Platform
|
||||
}
|
||||
|
||||
// OnStartup queries config from the display orchestrator and registers IPC handlers.
|
||||
// Use: _ = svc.OnStartup(context.Background())
|
||||
func (s *Service) OnStartup(ctx context.Context) error {
|
||||
// Query config — display registers its handler before us (registration order guarantee).
|
||||
// If display is not registered, handled=false and we skip config.
|
||||
cfg, handled, _ := s.Core().QUERY(QueryConfig{})
|
||||
configValue, handled, _ := s.Core().QUERY(QueryConfig{})
|
||||
if handled {
|
||||
if wCfg, ok := cfg.(map[string]any); ok {
|
||||
s.applyConfig(wCfg)
|
||||
if windowConfig, ok := configValue.(map[string]any); ok {
|
||||
s.applyConfig(windowConfig)
|
||||
}
|
||||
}
|
||||
|
||||
// Register QUERY and TASK handlers manually.
|
||||
// ACTION handler (HandleIPCEvents) is auto-registered by WithService —
|
||||
// do NOT call RegisterAction here or actions will double-fire.
|
||||
s.Core().RegisterQuery(s.handleQuery)
|
||||
s.Core().RegisterTask(s.handleTask)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) applyConfig(cfg map[string]any) {
|
||||
if width, ok := cfg["default_width"]; ok {
|
||||
func (s *Service) applyConfig(configData map[string]any) {
|
||||
if width, ok := configData["default_width"]; ok {
|
||||
if width, ok := width.(int); ok {
|
||||
s.manager.SetDefaultWidth(width)
|
||||
}
|
||||
}
|
||||
if height, ok := cfg["default_height"]; ok {
|
||||
if height, ok := configData["default_height"]; ok {
|
||||
if height, ok := height.(int); ok {
|
||||
s.manager.SetDefaultHeight(height)
|
||||
}
|
||||
}
|
||||
if stateFile, ok := cfg["state_file"]; ok {
|
||||
if stateFile, ok := configData["state_file"]; ok {
|
||||
if stateFile, ok := stateFile.(string); ok {
|
||||
s.manager.State().SetPath(stateFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HandleIPCEvents is auto-discovered and registered by core.WithService.
|
||||
// Use: _ = svc.HandleIPCEvents(core, msg)
|
||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Query Handlers ---
|
||||
|
||||
func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
|
||||
switch q := q.(type) {
|
||||
case QueryWindowList:
|
||||
return s.queryWindowList(), true, nil
|
||||
case QueryWindowByName:
|
||||
return s.queryWindowByName(q.Name), true, nil
|
||||
case QueryWindowZoom:
|
||||
return s.queryWindowZoom(q.Name)
|
||||
case QueryWindowBounds:
|
||||
if info := s.queryWindowByName(q.Name); info != nil {
|
||||
return &Bounds{X: info.X, Y: info.Y, Width: info.Width, Height: info.Height}, true, nil
|
||||
}
|
||||
return (*Bounds)(nil), true, nil
|
||||
return s.queryWindowBounds(q.Name)
|
||||
case QueryLayoutList:
|
||||
return s.manager.Layout().ListLayouts(), true, nil
|
||||
case QueryLayoutGet:
|
||||
|
|
@ -129,6 +113,23 @@ func (s *Service) queryWindowList() []WindowInfo {
|
|||
return result
|
||||
}
|
||||
|
||||
func (s *Service) queryWindowZoom(name string) (any, bool, error) {
|
||||
pw, ok := s.manager.Get(name)
|
||||
if !ok {
|
||||
return nil, true, coreerr.E("window.queryWindowZoom", "window not found: "+name, nil)
|
||||
}
|
||||
return pw.GetZoom(), true, nil
|
||||
}
|
||||
|
||||
func (s *Service) queryWindowBounds(name string) (any, bool, error) {
|
||||
pw, ok := s.manager.Get(name)
|
||||
if !ok {
|
||||
return nil, true, coreerr.E("window.queryWindowBounds", "window not found: "+name, nil)
|
||||
}
|
||||
bounds := pw.GetBounds()
|
||||
return &bounds, true, nil
|
||||
}
|
||||
|
||||
func (s *Service) queryWindowByName(name string) *WindowInfo {
|
||||
pw, ok := s.manager.Get(name)
|
||||
if !ok {
|
||||
|
|
@ -161,7 +162,7 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
|||
case TaskSetPosition:
|
||||
return nil, true, s.taskSetPosition(t.Name, t.X, t.Y)
|
||||
case TaskSetSize:
|
||||
return nil, true, s.taskSetSize(t.Name, t.Width, t.Height, t.W, t.H)
|
||||
return nil, true, s.taskSetSize(t.Name, t.Width, t.Height)
|
||||
case TaskMaximise:
|
||||
return nil, true, s.taskMaximise(t.Name)
|
||||
case TaskMinimise:
|
||||
|
|
@ -176,12 +177,24 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
|||
return nil, true, s.taskSetAlwaysOnTop(t.Name, t.AlwaysOnTop)
|
||||
case TaskSetBackgroundColour:
|
||||
return nil, true, s.taskSetBackgroundColour(t.Name, t.Red, t.Green, t.Blue, t.Alpha)
|
||||
case TaskSetOpacity:
|
||||
return nil, true, s.taskSetOpacity(t.Name, t.Opacity)
|
||||
case TaskSetVisibility:
|
||||
return nil, true, s.taskSetVisibility(t.Name, t.Visible)
|
||||
case TaskFullscreen:
|
||||
return nil, true, s.taskFullscreen(t.Name, t.Fullscreen)
|
||||
case TaskSetZoom:
|
||||
return nil, true, s.taskSetZoom(t.Name, t.Factor)
|
||||
case TaskSetURL:
|
||||
return nil, true, s.taskSetURL(t.Name, t.URL)
|
||||
case TaskSetHTML:
|
||||
return nil, true, s.taskSetHTML(t.Name, t.HTML)
|
||||
case TaskExecJS:
|
||||
return nil, true, s.taskExecJS(t.Name, t.JS)
|
||||
case TaskToggleFullscreen:
|
||||
return nil, true, s.taskToggleFullscreen(t.Name)
|
||||
case TaskPrint:
|
||||
return nil, true, s.taskPrint(t.Name)
|
||||
case TaskFlash:
|
||||
return nil, true, s.taskFlash(t.Name, t.Enabled)
|
||||
case TaskSaveLayout:
|
||||
return nil, true, s.taskSaveLayout(t.Name)
|
||||
case TaskRestoreLayout:
|
||||
|
|
@ -191,14 +204,10 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
|||
return nil, true, nil
|
||||
case TaskTileWindows:
|
||||
return nil, true, s.taskTileWindows(t.Mode, t.Windows)
|
||||
case TaskSnapWindow:
|
||||
return nil, true, s.taskSnapWindow(t.Name, t.Position)
|
||||
case TaskArrangePair:
|
||||
return nil, true, s.taskArrangePair(t.First, t.Second)
|
||||
case TaskBesideEditor:
|
||||
return nil, true, s.taskBesideEditor(t.Editor, t.Window)
|
||||
case TaskStackWindows:
|
||||
return nil, true, s.taskStackWindows(t.Windows, t.OffsetX, t.OffsetY)
|
||||
case TaskSnapWindow:
|
||||
return nil, true, s.taskSnapWindow(t.Name, t.Position)
|
||||
case TaskApplyWorkflow:
|
||||
return nil, true, s.taskApplyWorkflow(t.Workflow, t.Windows)
|
||||
default:
|
||||
|
|
@ -206,17 +215,44 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
|||
}
|
||||
}
|
||||
|
||||
func (s *Service) taskOpenWindow(t TaskOpenWindow) (any, bool, error) {
|
||||
var (
|
||||
pw PlatformWindow
|
||||
err error
|
||||
)
|
||||
if t.Window != nil {
|
||||
spec := *t.Window
|
||||
pw, err = s.manager.Create(&spec)
|
||||
} else {
|
||||
pw, err = s.manager.Open(t.Opts...)
|
||||
func (s *Service) primaryScreenArea() (int, int, int, int) {
|
||||
const fallbackX = 0
|
||||
const fallbackY = 0
|
||||
const fallbackWidth = 1920
|
||||
const fallbackHeight = 1080
|
||||
|
||||
result, handled, err := s.Core().QUERY(screen.QueryPrimary{})
|
||||
if err != nil || !handled {
|
||||
return fallbackX, fallbackY, fallbackWidth, fallbackHeight
|
||||
}
|
||||
|
||||
primary, ok := result.(*screen.Screen)
|
||||
if !ok || primary == nil {
|
||||
return fallbackX, fallbackY, fallbackWidth, fallbackHeight
|
||||
}
|
||||
|
||||
x := primary.WorkArea.X
|
||||
y := primary.WorkArea.Y
|
||||
width := primary.WorkArea.Width
|
||||
height := primary.WorkArea.Height
|
||||
if width <= 0 || height <= 0 {
|
||||
x = primary.Bounds.X
|
||||
y = primary.Bounds.Y
|
||||
width = primary.Bounds.Width
|
||||
height = primary.Bounds.Height
|
||||
}
|
||||
if width <= 0 || height <= 0 {
|
||||
return fallbackX, fallbackY, fallbackWidth, fallbackHeight
|
||||
}
|
||||
|
||||
return x, y, width, height
|
||||
}
|
||||
|
||||
func (s *Service) taskOpenWindow(t TaskOpenWindow) (any, bool, error) {
|
||||
if t.Window == nil {
|
||||
return nil, true, coreerr.E("window.taskOpenWindow", "window descriptor is required", nil)
|
||||
}
|
||||
pw, err := s.manager.CreateWindow(*t.Window)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
|
|
@ -261,7 +297,7 @@ func (s *Service) trackWindow(pw PlatformWindow) {
|
|||
if data := e.Data; data != nil {
|
||||
w, _ := data["w"].(int)
|
||||
h, _ := data["h"].(int)
|
||||
_ = s.Core().ACTION(ActionWindowResized{Name: e.Name, Width: w, Height: h, W: w, H: h})
|
||||
_ = s.Core().ACTION(ActionWindowResized{Name: e.Name, Width: w, Height: h})
|
||||
}
|
||||
case "close":
|
||||
_ = s.Core().ACTION(ActionWindowClosed{Name: e.Name})
|
||||
|
|
@ -279,7 +315,7 @@ func (s *Service) trackWindow(pw PlatformWindow) {
|
|||
func (s *Service) taskCloseWindow(name string) error {
|
||||
pw, ok := s.manager.Get(name)
|
||||
if !ok {
|
||||
return corego.E("window.service", corego.Sprintf("window not found: %s", name), nil)
|
||||
return coreerr.E("window.taskClose", "window not found: "+name, nil)
|
||||
}
|
||||
// Persist state BEFORE closing (spec requirement)
|
||||
s.manager.State().CaptureState(pw)
|
||||
|
|
@ -291,27 +327,17 @@ func (s *Service) taskCloseWindow(name string) error {
|
|||
func (s *Service) taskSetPosition(name string, x, y int) error {
|
||||
pw, ok := s.manager.Get(name)
|
||||
if !ok {
|
||||
return corego.E("window.service", corego.Sprintf("window not found: %s", name), nil)
|
||||
return coreerr.E("window.taskSetPosition", "window not found: "+name, nil)
|
||||
}
|
||||
pw.SetPosition(x, y)
|
||||
s.manager.State().UpdatePosition(name, x, y)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) taskSetSize(name string, width, height, fallbackWidth, fallbackHeight int) error {
|
||||
func (s *Service) taskSetSize(name string, width, height int) error {
|
||||
pw, ok := s.manager.Get(name)
|
||||
if !ok {
|
||||
return corego.E("window.service", corego.Sprintf("window not found: %s", name), nil)
|
||||
}
|
||||
if width == 0 && height == 0 {
|
||||
width, height = fallbackWidth, fallbackHeight
|
||||
} else {
|
||||
if width == 0 {
|
||||
width = fallbackWidth
|
||||
}
|
||||
if height == 0 {
|
||||
height = fallbackHeight
|
||||
}
|
||||
return coreerr.E("window.taskSetSize", "window not found: "+name, nil)
|
||||
}
|
||||
pw.SetSize(width, height)
|
||||
s.manager.State().UpdateSize(name, width, height)
|
||||
|
|
@ -321,7 +347,7 @@ func (s *Service) taskSetSize(name string, width, height, fallbackWidth, fallbac
|
|||
func (s *Service) taskMaximise(name string) error {
|
||||
pw, ok := s.manager.Get(name)
|
||||
if !ok {
|
||||
return corego.E("window.service", corego.Sprintf("window not found: %s", name), nil)
|
||||
return coreerr.E("window.taskMaximise", "window not found: "+name, nil)
|
||||
}
|
||||
pw.Maximise()
|
||||
s.manager.State().UpdateMaximized(name, true)
|
||||
|
|
@ -331,7 +357,7 @@ func (s *Service) taskMaximise(name string) error {
|
|||
func (s *Service) taskMinimise(name string) error {
|
||||
pw, ok := s.manager.Get(name)
|
||||
if !ok {
|
||||
return corego.E("window.service", corego.Sprintf("window not found: %s", name), nil)
|
||||
return coreerr.E("window.taskMinimise", "window not found: "+name, nil)
|
||||
}
|
||||
pw.Minimise()
|
||||
return nil
|
||||
|
|
@ -340,7 +366,7 @@ func (s *Service) taskMinimise(name string) error {
|
|||
func (s *Service) taskFocus(name string) error {
|
||||
pw, ok := s.manager.Get(name)
|
||||
if !ok {
|
||||
return corego.E("window.service", corego.Sprintf("window not found: %s", name), nil)
|
||||
return coreerr.E("window.taskFocus", "window not found: "+name, nil)
|
||||
}
|
||||
pw.Focus()
|
||||
return nil
|
||||
|
|
@ -349,7 +375,7 @@ func (s *Service) taskFocus(name string) error {
|
|||
func (s *Service) taskRestore(name string) error {
|
||||
pw, ok := s.manager.Get(name)
|
||||
if !ok {
|
||||
return corego.E("window.service", corego.Sprintf("window not found: %s", name), nil)
|
||||
return coreerr.E("window.taskRestore", "window not found: "+name, nil)
|
||||
}
|
||||
pw.Restore()
|
||||
s.manager.State().UpdateMaximized(name, false)
|
||||
|
|
@ -359,7 +385,7 @@ func (s *Service) taskRestore(name string) error {
|
|||
func (s *Service) taskSetTitle(name, title string) error {
|
||||
pw, ok := s.manager.Get(name)
|
||||
if !ok {
|
||||
return corego.E("window.service", corego.Sprintf("window not found: %s", name), nil)
|
||||
return coreerr.E("window.taskSetTitle", "window not found: "+name, nil)
|
||||
}
|
||||
pw.SetTitle(title)
|
||||
return nil
|
||||
|
|
@ -368,7 +394,7 @@ func (s *Service) taskSetTitle(name, title string) error {
|
|||
func (s *Service) taskSetAlwaysOnTop(name string, alwaysOnTop bool) error {
|
||||
pw, ok := s.manager.Get(name)
|
||||
if !ok {
|
||||
return corego.E("window.service", corego.Sprintf("window not found: %s", name), nil)
|
||||
return coreerr.E("window.taskSetAlwaysOnTop", "window not found: "+name, nil)
|
||||
}
|
||||
pw.SetAlwaysOnTop(alwaysOnTop)
|
||||
return nil
|
||||
|
|
@ -377,28 +403,16 @@ func (s *Service) taskSetAlwaysOnTop(name string, alwaysOnTop bool) error {
|
|||
func (s *Service) taskSetBackgroundColour(name string, red, green, blue, alpha uint8) error {
|
||||
pw, ok := s.manager.Get(name)
|
||||
if !ok {
|
||||
return corego.E("window.service", corego.Sprintf("window not found: %s", name), nil)
|
||||
return coreerr.E("window.taskSetBackgroundColour", "window not found: "+name, nil)
|
||||
}
|
||||
pw.SetBackgroundColour(red, green, blue, alpha)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) taskSetOpacity(name string, opacity float32) error {
|
||||
if opacity < 0 || opacity > 1 {
|
||||
return corego.E("window.setOpacity", "opacity must be between 0 and 1", nil)
|
||||
}
|
||||
pw, ok := s.manager.Get(name)
|
||||
if !ok {
|
||||
return corego.E("window.service", corego.Sprintf("window not found: %s", name), nil)
|
||||
}
|
||||
pw.SetOpacity(opacity)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) taskSetVisibility(name string, visible bool) error {
|
||||
pw, ok := s.manager.Get(name)
|
||||
if !ok {
|
||||
return corego.E("window.service", corego.Sprintf("window not found: %s", name), nil)
|
||||
return coreerr.E("window.taskSetVisibility", "window not found: "+name, nil)
|
||||
}
|
||||
pw.SetVisibility(visible)
|
||||
return nil
|
||||
|
|
@ -407,7 +421,7 @@ func (s *Service) taskSetVisibility(name string, visible bool) error {
|
|||
func (s *Service) taskFullscreen(name string, fullscreen bool) error {
|
||||
pw, ok := s.manager.Get(name)
|
||||
if !ok {
|
||||
return corego.E("window.service", corego.Sprintf("window not found: %s", name), nil)
|
||||
return coreerr.E("window.taskFullscreen", "window not found: "+name, nil)
|
||||
}
|
||||
if fullscreen {
|
||||
pw.Fullscreen()
|
||||
|
|
@ -417,6 +431,68 @@ func (s *Service) taskFullscreen(name string, fullscreen bool) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) taskSetZoom(name string, factor float64) error {
|
||||
pw, ok := s.manager.Get(name)
|
||||
if !ok {
|
||||
return coreerr.E("window.taskSetZoom", "window not found: "+name, nil)
|
||||
}
|
||||
pw.SetZoom(factor)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) taskSetURL(name, url string) error {
|
||||
pw, ok := s.manager.Get(name)
|
||||
if !ok {
|
||||
return coreerr.E("window.taskSetURL", "window not found: "+name, nil)
|
||||
}
|
||||
pw.SetURL(url)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) taskSetHTML(name, html string) error {
|
||||
pw, ok := s.manager.Get(name)
|
||||
if !ok {
|
||||
return coreerr.E("window.taskSetHTML", "window not found: "+name, nil)
|
||||
}
|
||||
pw.SetHTML(html)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) taskExecJS(name, js string) error {
|
||||
pw, ok := s.manager.Get(name)
|
||||
if !ok {
|
||||
return coreerr.E("window.taskExecJS", "window not found: "+name, nil)
|
||||
}
|
||||
pw.ExecJS(js)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) taskToggleFullscreen(name string) error {
|
||||
pw, ok := s.manager.Get(name)
|
||||
if !ok {
|
||||
return coreerr.E("window.taskToggleFullscreen", "window not found: "+name, nil)
|
||||
}
|
||||
pw.ToggleFullscreen()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) taskPrint(name string) error {
|
||||
pw, ok := s.manager.Get(name)
|
||||
if !ok {
|
||||
return coreerr.E("window.taskPrint", "window not found: "+name, nil)
|
||||
}
|
||||
return pw.Print()
|
||||
}
|
||||
|
||||
func (s *Service) taskFlash(name string, enabled bool) error {
|
||||
pw, ok := s.manager.Get(name)
|
||||
if !ok {
|
||||
return coreerr.E("window.taskFlash", "window not found: "+name, nil)
|
||||
}
|
||||
pw.Flash(enabled)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) taskSaveLayout(name string) error {
|
||||
windows := s.queryWindowList()
|
||||
states := make(map[string]WindowState, len(windows))
|
||||
|
|
@ -432,7 +508,7 @@ func (s *Service) taskSaveLayout(name string) error {
|
|||
func (s *Service) taskRestoreLayout(name string) error {
|
||||
layout, ok := s.manager.Layout().GetLayout(name)
|
||||
if !ok {
|
||||
return corego.E("window.restoreLayout", corego.Sprintf("layout not found: %s", name), nil)
|
||||
return coreerr.E("window.taskRestoreLayout", "layout not found: "+name, nil)
|
||||
}
|
||||
for winName, state := range layout.Windows {
|
||||
pw, found := s.manager.Get(winName)
|
||||
|
|
@ -449,6 +525,7 @@ func (s *Service) taskRestoreLayout(name string) error {
|
|||
} else {
|
||||
pw.Restore()
|
||||
}
|
||||
s.manager.State().CaptureState(pw)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -464,13 +541,21 @@ var tileModeMap = map[string]TileMode{
|
|||
func (s *Service) taskTileWindows(mode string, names []string) error {
|
||||
tm, ok := tileModeMap[mode]
|
||||
if !ok {
|
||||
return corego.E("window.tileWindows", corego.Sprintf("unknown tile mode: %s", mode), nil)
|
||||
return coreerr.E("window.taskTileWindows", "unknown tile mode: "+mode, nil)
|
||||
}
|
||||
if len(names) == 0 {
|
||||
names = s.manager.List()
|
||||
}
|
||||
screenW, screenH := s.primaryScreenSize()
|
||||
return s.manager.TileWindows(tm, names, screenW, screenH)
|
||||
originX, originY, screenWidth, screenHeight := s.primaryScreenArea()
|
||||
return s.manager.TileWindows(tm, names, screenWidth, screenHeight, originX, originY)
|
||||
}
|
||||
|
||||
func (s *Service) taskStackWindows(names []string, offsetX, offsetY int) error {
|
||||
if len(names) == 0 {
|
||||
names = s.manager.List()
|
||||
}
|
||||
originX, originY, _, _ := s.primaryScreenArea()
|
||||
return s.manager.StackWindows(names, offsetX, offsetY, originX, originY)
|
||||
}
|
||||
|
||||
var snapPosMap = map[string]SnapPosition{
|
||||
|
|
@ -484,100 +569,29 @@ var snapPosMap = map[string]SnapPosition{
|
|||
func (s *Service) taskSnapWindow(name, position string) error {
|
||||
pos, ok := snapPosMap[position]
|
||||
if !ok {
|
||||
return corego.E("window.snapWindow", corego.Sprintf("unknown snap position: %s", position), nil)
|
||||
return coreerr.E("window.taskSnapWindow", "unknown snap position: "+position, nil)
|
||||
}
|
||||
screenW, screenH := s.primaryScreenSize()
|
||||
return s.manager.SnapWindow(name, pos, screenW, screenH)
|
||||
originX, originY, screenWidth, screenHeight := s.primaryScreenArea()
|
||||
return s.manager.SnapWindow(name, pos, screenWidth, screenHeight, originX, originY)
|
||||
}
|
||||
|
||||
func (s *Service) taskArrangePair(first, second string) error {
|
||||
screenW, screenH := s.primaryScreenSize()
|
||||
return s.manager.ArrangePair(first, second, screenW, screenH)
|
||||
var workflowLayoutMap = map[string]WorkflowLayout{
|
||||
"coding": WorkflowCoding,
|
||||
"debugging": WorkflowDebugging,
|
||||
"presenting": WorkflowPresenting,
|
||||
"side-by-side": WorkflowSideBySide,
|
||||
}
|
||||
|
||||
func (s *Service) taskBesideEditor(editorName, windowName string) error {
|
||||
screenW, screenH := s.primaryScreenSize()
|
||||
if editorName == "" {
|
||||
editorName = s.detectEditorWindow()
|
||||
func (s *Service) taskApplyWorkflow(workflow string, names []string) error {
|
||||
layout, ok := workflowLayoutMap[workflow]
|
||||
if !ok {
|
||||
return coreerr.E("window.taskApplyWorkflow", "unknown workflow layout: "+workflow, nil)
|
||||
}
|
||||
if editorName == "" {
|
||||
return corego.E("window.besideEditor", "editor window not found", nil)
|
||||
}
|
||||
if windowName == "" {
|
||||
windowName = s.detectCompanionWindow(editorName)
|
||||
}
|
||||
if windowName == "" {
|
||||
return corego.E("window.besideEditor", "companion window not found", nil)
|
||||
}
|
||||
return s.manager.BesideEditor(editorName, windowName, screenW, screenH)
|
||||
}
|
||||
|
||||
func (s *Service) taskStackWindows(names []string, offsetX, offsetY int) error {
|
||||
if len(names) == 0 {
|
||||
names = s.manager.List()
|
||||
}
|
||||
return s.manager.StackWindows(names, offsetX, offsetY)
|
||||
}
|
||||
|
||||
func (s *Service) taskApplyWorkflow(workflow WorkflowLayout, names []string) error {
|
||||
screenW, screenH := s.primaryScreenSize()
|
||||
if len(names) == 0 {
|
||||
names = s.manager.List()
|
||||
}
|
||||
return s.manager.ApplyWorkflow(workflow, names, screenW, screenH)
|
||||
}
|
||||
|
||||
func (s *Service) detectEditorWindow() string {
|
||||
for _, info := range s.queryWindowList() {
|
||||
if looksLikeEditor(info.Name, info.Title) {
|
||||
return info.Name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Service) detectCompanionWindow(editorName string) string {
|
||||
for _, info := range s.queryWindowList() {
|
||||
if info.Name == editorName {
|
||||
continue
|
||||
}
|
||||
if !looksLikeEditor(info.Name, info.Title) {
|
||||
return info.Name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func looksLikeEditor(name, title string) bool {
|
||||
return containsAny(name, "editor", "ide", "code", "workspace") || containsAny(title, "editor", "ide", "code")
|
||||
}
|
||||
|
||||
func containsAny(value string, needles ...string) bool {
|
||||
lower := corego.Lower(value)
|
||||
for _, needle := range needles {
|
||||
if corego.Contains(lower, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Service) primaryScreenSize() (int, int) {
|
||||
result, handled, err := s.Core().QUERY(screen.QueryPrimary{})
|
||||
if err == nil && handled {
|
||||
if scr, ok := result.(*screen.Screen); ok && scr != nil {
|
||||
if scr.WorkArea.Width > 0 && scr.WorkArea.Height > 0 {
|
||||
return scr.WorkArea.Width, scr.WorkArea.Height
|
||||
}
|
||||
if scr.Bounds.Width > 0 && scr.Bounds.Height > 0 {
|
||||
return scr.Bounds.Width, scr.Bounds.Height
|
||||
}
|
||||
if scr.Size.Width > 0 && scr.Size.Height > 0 {
|
||||
return scr.Size.Width, scr.Size.Height
|
||||
}
|
||||
}
|
||||
}
|
||||
return 1920, 1080
|
||||
originX, originY, screenWidth, screenHeight := s.primaryScreenArea()
|
||||
return s.manager.ApplyWorkflow(layout, names, screenWidth, screenHeight, originX, originY)
|
||||
}
|
||||
|
||||
// Manager returns the underlying window Manager for direct access.
|
||||
|
|
|
|||
133
pkg/window/service_screen_test.go
Normal file
133
pkg/window/service_screen_test.go
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
package window
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/core"
|
||||
"forge.lthn.ai/core/gui/pkg/screen"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type mockScreenPlatform struct {
|
||||
screens []screen.Screen
|
||||
}
|
||||
|
||||
func (m *mockScreenPlatform) GetAll() []screen.Screen { return m.screens }
|
||||
|
||||
func (m *mockScreenPlatform) GetPrimary() *screen.Screen {
|
||||
for i := range m.screens {
|
||||
if m.screens[i].IsPrimary {
|
||||
return &m.screens[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockScreenPlatform) GetCurrent() *screen.Screen {
|
||||
return m.GetPrimary()
|
||||
}
|
||||
|
||||
func newTestWindowServiceWithScreen(t *testing.T, screens []screen.Screen) (*Service, *core.Core) {
|
||||
t.Helper()
|
||||
|
||||
c, err := core.New(
|
||||
core.WithService(screen.Register(&mockScreenPlatform{screens: screens})),
|
||||
core.WithService(Register(newMockPlatform())),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
||||
|
||||
svc := core.MustServiceFor[*Service](c, "window")
|
||||
return svc, c
|
||||
}
|
||||
|
||||
func TestTaskTileWindows_Good_UsesPrimaryScreenSize(t *testing.T) {
|
||||
_, c := newTestWindowServiceWithScreen(t, []screen.Screen{
|
||||
{
|
||||
ID: "1", Name: "Primary", IsPrimary: true,
|
||||
Bounds: screen.Rect{X: 0, Y: 0, Width: 2000, Height: 1000},
|
||||
WorkArea: screen.Rect{X: 0, Y: 0, Width: 2000, Height: 1000},
|
||||
},
|
||||
})
|
||||
|
||||
_ = requireOpenWindow(t, c, Window{Name: "left", Width: 400, Height: 400})
|
||||
_ = requireOpenWindow(t, c, Window{Name: "right", Width: 400, Height: 400})
|
||||
|
||||
_, handled, err := c.PERFORM(TaskTileWindows{Mode: "left-right", Windows: []string{"left", "right"}})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
result, _, err := c.QUERY(QueryWindowByName{Name: "left"})
|
||||
require.NoError(t, err)
|
||||
left := result.(*WindowInfo)
|
||||
assert.Equal(t, 0, left.X)
|
||||
assert.Equal(t, 1000, left.Width)
|
||||
assert.Equal(t, 1000, left.Height)
|
||||
|
||||
result, _, err = c.QUERY(QueryWindowByName{Name: "right"})
|
||||
require.NoError(t, err)
|
||||
right := result.(*WindowInfo)
|
||||
assert.Equal(t, 1000, right.X)
|
||||
assert.Equal(t, 1000, right.Width)
|
||||
assert.Equal(t, 1000, right.Height)
|
||||
}
|
||||
|
||||
func TestTaskSnapWindow_Good_UsesPrimaryScreenSize(t *testing.T) {
|
||||
_, c := newTestWindowServiceWithScreen(t, []screen.Screen{
|
||||
{
|
||||
ID: "1", Name: "Primary", IsPrimary: true,
|
||||
Bounds: screen.Rect{X: 0, Y: 0, Width: 2000, Height: 1000},
|
||||
WorkArea: screen.Rect{X: 0, Y: 0, Width: 2000, Height: 1000},
|
||||
},
|
||||
})
|
||||
|
||||
_ = requireOpenWindow(t, c, Window{Name: "snap", Width: 400, Height: 300})
|
||||
|
||||
_, handled, err := c.PERFORM(TaskSnapWindow{Name: "snap", Position: "left"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
result, _, err := c.QUERY(QueryWindowByName{Name: "snap"})
|
||||
require.NoError(t, err)
|
||||
info := result.(*WindowInfo)
|
||||
assert.Equal(t, 0, info.X)
|
||||
assert.Equal(t, 0, info.Y)
|
||||
assert.Equal(t, 1000, info.Width)
|
||||
assert.Equal(t, 1000, info.Height)
|
||||
}
|
||||
|
||||
func TestTaskTileWindows_Good_UsesPrimaryWorkAreaOrigin(t *testing.T) {
|
||||
_, c := newTestWindowServiceWithScreen(t, []screen.Screen{
|
||||
{
|
||||
ID: "1", Name: "Primary", IsPrimary: true,
|
||||
Bounds: screen.Rect{X: 0, Y: 0, Width: 2000, Height: 1000},
|
||||
WorkArea: screen.Rect{X: 100, Y: 50, Width: 2000, Height: 1000},
|
||||
},
|
||||
})
|
||||
|
||||
_ = requireOpenWindow(t, c, Window{Name: "left", Width: 400, Height: 400})
|
||||
_ = requireOpenWindow(t, c, Window{Name: "right", Width: 400, Height: 400})
|
||||
|
||||
_, handled, err := c.PERFORM(TaskTileWindows{Mode: "left-right", Windows: []string{"left", "right"}})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
result, _, err := c.QUERY(QueryWindowByName{Name: "left"})
|
||||
require.NoError(t, err)
|
||||
left := result.(*WindowInfo)
|
||||
assert.Equal(t, 100, left.X)
|
||||
assert.Equal(t, 50, left.Y)
|
||||
assert.Equal(t, 1000, left.Width)
|
||||
assert.Equal(t, 1000, left.Height)
|
||||
|
||||
result, _, err = c.QUERY(QueryWindowByName{Name: "right"})
|
||||
require.NoError(t, err)
|
||||
right := result.(*WindowInfo)
|
||||
assert.Equal(t, 1100, right.X)
|
||||
assert.Equal(t, 50, right.Y)
|
||||
assert.Equal(t, 1000, right.Width)
|
||||
assert.Equal(t, 1000, right.Height)
|
||||
}
|
||||
|
|
@ -23,39 +23,12 @@ func newTestWindowService(t *testing.T) (*Service, *core.Core) {
|
|||
return svc, c
|
||||
}
|
||||
|
||||
type testScreenPlatform struct {
|
||||
screens []screen.Screen
|
||||
}
|
||||
|
||||
func (p *testScreenPlatform) GetAll() []screen.Screen { return p.screens }
|
||||
|
||||
func (p *testScreenPlatform) GetPrimary() *screen.Screen {
|
||||
for i := range p.screens {
|
||||
if p.screens[i].IsPrimary {
|
||||
return &p.screens[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newTestWindowServiceWithScreen(t *testing.T) (*Service, *core.Core) {
|
||||
func requireOpenWindow(t *testing.T, c *core.Core, window Window) WindowInfo {
|
||||
t.Helper()
|
||||
c, err := core.New(
|
||||
core.WithService(Register(newMockPlatform())),
|
||||
core.WithService(screen.Register(&testScreenPlatform{
|
||||
screens: []screen.Screen{{
|
||||
ID: "primary", Name: "Primary", IsPrimary: true,
|
||||
Size: screen.Size{Width: 2560, Height: 1440},
|
||||
Bounds: screen.Rect{X: 0, Y: 0, Width: 2560, Height: 1440},
|
||||
WorkArea: screen.Rect{X: 0, Y: 0, Width: 2560, Height: 1440},
|
||||
}},
|
||||
})),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
result, handled, err := c.PERFORM(TaskOpenWindow{Window: &window})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
||||
svc := core.MustServiceFor[*Service](c, "window")
|
||||
return svc, c
|
||||
require.True(t, handled)
|
||||
return result.(WindowInfo)
|
||||
}
|
||||
|
||||
func TestRegister_Good(t *testing.T) {
|
||||
|
|
@ -82,7 +55,7 @@ func TestApplyConfig_Good(t *testing.T) {
|
|||
func TestTaskOpenWindow_Good(t *testing.T) {
|
||||
_, c := newTestWindowService(t)
|
||||
result, handled, err := c.PERFORM(TaskOpenWindow{
|
||||
Opts: []WindowOption{WithName("test"), WithURL("/")},
|
||||
Window: &Window{Name: "test", URL: "/"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
|
@ -90,16 +63,12 @@ func TestTaskOpenWindow_Good(t *testing.T) {
|
|||
assert.Equal(t, "test", info.Name)
|
||||
}
|
||||
|
||||
func TestTaskOpenWindowDescriptor_Good(t *testing.T) {
|
||||
func TestTaskOpenWindow_Bad_MissingWindow(t *testing.T) {
|
||||
_, c := newTestWindowService(t)
|
||||
result, handled, err := c.PERFORM(TaskOpenWindow{
|
||||
Window: &Window{Name: "descriptor", Title: "Descriptor", Width: 640, Height: 480},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result, handled, err := c.PERFORM(TaskOpenWindow{})
|
||||
assert.True(t, handled)
|
||||
info := result.(WindowInfo)
|
||||
assert.Equal(t, "descriptor", info.Name)
|
||||
assert.Equal(t, "Descriptor", info.Title)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
|
||||
func TestTaskOpenWindow_Bad(t *testing.T) {
|
||||
|
|
@ -112,9 +81,8 @@ func TestTaskOpenWindow_Bad(t *testing.T) {
|
|||
|
||||
func TestQueryWindowList_Good(t *testing.T) {
|
||||
_, c := newTestWindowService(t)
|
||||
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("a")}})
|
||||
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("b")}})
|
||||
_, _, _ = c.PERFORM(TaskMinimise{Name: "b"})
|
||||
_ = requireOpenWindow(t, c, Window{Name: "a"})
|
||||
_ = requireOpenWindow(t, c, Window{Name: "b"})
|
||||
|
||||
result, handled, err := c.QUERY(QueryWindowList{})
|
||||
require.NoError(t, err)
|
||||
|
|
@ -135,7 +103,7 @@ func TestQueryWindowList_Good(t *testing.T) {
|
|||
|
||||
func TestQueryWindowByName_Good(t *testing.T) {
|
||||
_, c := newTestWindowService(t)
|
||||
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
|
||||
_ = requireOpenWindow(t, c, Window{Name: "test"})
|
||||
|
||||
result, handled, err := c.QUERY(QueryWindowByName{Name: "test"})
|
||||
require.NoError(t, err)
|
||||
|
|
@ -156,7 +124,7 @@ func TestQueryWindowByName_Bad(t *testing.T) {
|
|||
|
||||
func TestTaskCloseWindow_Good(t *testing.T) {
|
||||
_, c := newTestWindowService(t)
|
||||
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
|
||||
_ = requireOpenWindow(t, c, Window{Name: "test"})
|
||||
|
||||
_, handled, err := c.PERFORM(TaskCloseWindow{Name: "test"})
|
||||
require.NoError(t, err)
|
||||
|
|
@ -176,7 +144,7 @@ func TestTaskCloseWindow_Bad(t *testing.T) {
|
|||
|
||||
func TestTaskSetPosition_Good(t *testing.T) {
|
||||
_, c := newTestWindowService(t)
|
||||
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
|
||||
_ = requireOpenWindow(t, c, Window{Name: "test"})
|
||||
|
||||
_, handled, err := c.PERFORM(TaskSetPosition{Name: "test", X: 100, Y: 200})
|
||||
require.NoError(t, err)
|
||||
|
|
@ -190,9 +158,9 @@ func TestTaskSetPosition_Good(t *testing.T) {
|
|||
|
||||
func TestTaskSetSize_Good(t *testing.T) {
|
||||
_, c := newTestWindowService(t)
|
||||
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
|
||||
_ = requireOpenWindow(t, c, Window{Name: "test"})
|
||||
|
||||
_, handled, err := c.PERFORM(TaskSetSize{Name: "test", W: 800, H: 600})
|
||||
_, handled, err := c.PERFORM(TaskSetSize{Name: "test", Width: 800, Height: 600})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
|
|
@ -376,7 +344,7 @@ func TestTaskRestoreLayout_ClearsMaximizedState(t *testing.T) {
|
|||
|
||||
func TestTaskMaximise_Good(t *testing.T) {
|
||||
_, c := newTestWindowService(t)
|
||||
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
|
||||
_ = requireOpenWindow(t, c, Window{Name: "test"})
|
||||
|
||||
_, handled, err := c.PERFORM(TaskMaximise{Name: "test"})
|
||||
require.NoError(t, err)
|
||||
|
|
@ -391,10 +359,7 @@ func TestFileDrop_Good(t *testing.T) {
|
|||
_, c := newTestWindowService(t)
|
||||
|
||||
// Open a window
|
||||
result, _, _ := c.PERFORM(TaskOpenWindow{
|
||||
Opts: []WindowOption{WithName("drop-test")},
|
||||
})
|
||||
info := result.(WindowInfo)
|
||||
info := requireOpenWindow(t, c, Window{Name: "drop-test"})
|
||||
assert.Equal(t, "drop-test", info.Name)
|
||||
|
||||
// Capture broadcast actions
|
||||
|
|
@ -422,3 +387,538 @@ func TestFileDrop_Good(t *testing.T) {
|
|||
assert.Equal(t, "upload-zone", dropped.TargetID)
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
// --- TaskMinimise ---
|
||||
|
||||
func TestTaskMinimise_Good(t *testing.T) {
|
||||
svc, c := newTestWindowService(t)
|
||||
_ = requireOpenWindow(t, c, Window{Name: "test"})
|
||||
|
||||
_, handled, err := c.PERFORM(TaskMinimise{Name: "test"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
pw, ok := svc.Manager().Get("test")
|
||||
require.True(t, ok)
|
||||
mw := pw.(*mockWindow)
|
||||
assert.True(t, mw.minimised)
|
||||
}
|
||||
|
||||
func TestTaskMinimise_Bad(t *testing.T) {
|
||||
_, c := newTestWindowService(t)
|
||||
_, handled, err := c.PERFORM(TaskMinimise{Name: "nonexistent"})
|
||||
assert.True(t, handled)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// --- TaskFocus ---
|
||||
|
||||
func TestTaskFocus_Good(t *testing.T) {
|
||||
svc, c := newTestWindowService(t)
|
||||
_ = requireOpenWindow(t, c, Window{Name: "test"})
|
||||
|
||||
_, handled, err := c.PERFORM(TaskFocus{Name: "test"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
pw, ok := svc.Manager().Get("test")
|
||||
require.True(t, ok)
|
||||
mw := pw.(*mockWindow)
|
||||
assert.True(t, mw.focused)
|
||||
}
|
||||
|
||||
func TestTaskFocus_Bad(t *testing.T) {
|
||||
_, c := newTestWindowService(t)
|
||||
_, handled, err := c.PERFORM(TaskFocus{Name: "nonexistent"})
|
||||
assert.True(t, handled)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// --- TaskRestore ---
|
||||
|
||||
func TestTaskRestore_Good(t *testing.T) {
|
||||
svc, c := newTestWindowService(t)
|
||||
_ = requireOpenWindow(t, c, Window{Name: "test"})
|
||||
|
||||
// First maximise, then restore
|
||||
_, _, _ = c.PERFORM(TaskMaximise{Name: "test"})
|
||||
|
||||
_, handled, err := c.PERFORM(TaskRestore{Name: "test"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
pw, ok := svc.Manager().Get("test")
|
||||
require.True(t, ok)
|
||||
mw := pw.(*mockWindow)
|
||||
assert.False(t, mw.maximised)
|
||||
|
||||
// Verify state was updated
|
||||
state, ok := svc.Manager().State().GetState("test")
|
||||
assert.True(t, ok)
|
||||
assert.False(t, state.Maximized)
|
||||
}
|
||||
|
||||
func TestTaskRestore_Bad(t *testing.T) {
|
||||
_, c := newTestWindowService(t)
|
||||
_, handled, err := c.PERFORM(TaskRestore{Name: "nonexistent"})
|
||||
assert.True(t, handled)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// --- TaskSetTitle ---
|
||||
|
||||
func TestTaskSetTitle_Good(t *testing.T) {
|
||||
svc, c := newTestWindowService(t)
|
||||
_ = requireOpenWindow(t, c, Window{Name: "test"})
|
||||
|
||||
_, handled, err := c.PERFORM(TaskSetTitle{Name: "test", Title: "New Title"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
pw, ok := svc.Manager().Get("test")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "New Title", pw.Title())
|
||||
}
|
||||
|
||||
func TestTaskSetTitle_Bad(t *testing.T) {
|
||||
_, c := newTestWindowService(t)
|
||||
_, handled, err := c.PERFORM(TaskSetTitle{Name: "nonexistent", Title: "Nope"})
|
||||
assert.True(t, handled)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// --- TaskSetAlwaysOnTop ---
|
||||
|
||||
func TestTaskSetAlwaysOnTop_Good(t *testing.T) {
|
||||
svc, c := newTestWindowService(t)
|
||||
_ = requireOpenWindow(t, c, Window{Name: "test"})
|
||||
|
||||
_, handled, err := c.PERFORM(TaskSetAlwaysOnTop{Name: "test", AlwaysOnTop: true})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
pw, ok := svc.Manager().Get("test")
|
||||
require.True(t, ok)
|
||||
mw := pw.(*mockWindow)
|
||||
assert.True(t, mw.alwaysOnTop)
|
||||
}
|
||||
|
||||
func TestTaskSetAlwaysOnTop_Bad(t *testing.T) {
|
||||
_, c := newTestWindowService(t)
|
||||
_, handled, err := c.PERFORM(TaskSetAlwaysOnTop{Name: "nonexistent", AlwaysOnTop: true})
|
||||
assert.True(t, handled)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// --- TaskSetBackgroundColour ---
|
||||
|
||||
func TestTaskSetBackgroundColour_Good(t *testing.T) {
|
||||
svc, c := newTestWindowService(t)
|
||||
_ = requireOpenWindow(t, c, Window{Name: "test"})
|
||||
|
||||
_, handled, err := c.PERFORM(TaskSetBackgroundColour{
|
||||
Name: "test", Red: 10, Green: 20, Blue: 30, Alpha: 40,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
pw, ok := svc.Manager().Get("test")
|
||||
require.True(t, ok)
|
||||
mw := pw.(*mockWindow)
|
||||
assert.Equal(t, [4]uint8{10, 20, 30, 40}, mw.backgroundColour)
|
||||
}
|
||||
|
||||
func TestTaskSetBackgroundColour_Bad(t *testing.T) {
|
||||
_, c := newTestWindowService(t)
|
||||
_, handled, err := c.PERFORM(TaskSetBackgroundColour{Name: "nonexistent", Red: 1, Green: 2, Blue: 3, Alpha: 4})
|
||||
assert.True(t, handled)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// --- TaskSetVisibility ---
|
||||
|
||||
func TestTaskSetVisibility_Good(t *testing.T) {
|
||||
svc, c := newTestWindowService(t)
|
||||
_ = requireOpenWindow(t, c, Window{Name: "test"})
|
||||
|
||||
_, handled, err := c.PERFORM(TaskSetVisibility{Name: "test", Visible: true})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
pw, ok := svc.Manager().Get("test")
|
||||
require.True(t, ok)
|
||||
mw := pw.(*mockWindow)
|
||||
assert.True(t, mw.visible)
|
||||
|
||||
// Now hide it
|
||||
_, handled, err = c.PERFORM(TaskSetVisibility{Name: "test", Visible: false})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.False(t, mw.visible)
|
||||
}
|
||||
|
||||
func TestTaskSetVisibility_Bad(t *testing.T) {
|
||||
_, c := newTestWindowService(t)
|
||||
_, handled, err := c.PERFORM(TaskSetVisibility{Name: "nonexistent", Visible: true})
|
||||
assert.True(t, handled)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// --- TaskFullscreen ---
|
||||
|
||||
func TestTaskFullscreen_Good(t *testing.T) {
|
||||
svc, c := newTestWindowService(t)
|
||||
_ = requireOpenWindow(t, c, Window{Name: "test"})
|
||||
|
||||
// Enter fullscreen
|
||||
_, handled, err := c.PERFORM(TaskFullscreen{Name: "test", Fullscreen: true})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
pw, ok := svc.Manager().Get("test")
|
||||
require.True(t, ok)
|
||||
mw := pw.(*mockWindow)
|
||||
assert.True(t, mw.fullscreened)
|
||||
|
||||
// Exit fullscreen
|
||||
_, handled, err = c.PERFORM(TaskFullscreen{Name: "test", Fullscreen: false})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.False(t, mw.fullscreened)
|
||||
}
|
||||
|
||||
func TestTaskFullscreen_Bad(t *testing.T) {
|
||||
_, c := newTestWindowService(t)
|
||||
_, handled, err := c.PERFORM(TaskFullscreen{Name: "nonexistent", Fullscreen: true})
|
||||
assert.True(t, handled)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// --- TaskSaveLayout ---
|
||||
|
||||
func TestTaskSaveLayout_Good(t *testing.T) {
|
||||
svc, c := newTestWindowService(t)
|
||||
_ = requireOpenWindow(t, c, Window{Name: "editor", Width: 960, Height: 1080, X: 0, Y: 0})
|
||||
_ = requireOpenWindow(t, c, Window{Name: "terminal", Width: 960, Height: 1080, X: 960, Y: 0})
|
||||
|
||||
_, handled, err := c.PERFORM(TaskSaveLayout{Name: "coding"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
// Verify layout was saved with correct window states
|
||||
layout, ok := svc.Manager().Layout().GetLayout("coding")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "coding", layout.Name)
|
||||
assert.Len(t, layout.Windows, 2)
|
||||
|
||||
editorState, ok := layout.Windows["editor"]
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, 0, editorState.X)
|
||||
assert.Equal(t, 960, editorState.Width)
|
||||
|
||||
termState, ok := layout.Windows["terminal"]
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, 960, termState.X)
|
||||
assert.Equal(t, 960, termState.Width)
|
||||
}
|
||||
|
||||
func TestTaskSaveLayout_Bad(t *testing.T) {
|
||||
_, c := newTestWindowService(t)
|
||||
// Saving an empty layout with empty name returns an error from LayoutManager
|
||||
_, handled, err := c.PERFORM(TaskSaveLayout{Name: ""})
|
||||
assert.True(t, handled)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// --- TaskRestoreLayout ---
|
||||
|
||||
func TestTaskRestoreLayout_Good(t *testing.T) {
|
||||
svc, c := newTestWindowService(t)
|
||||
// Open windows
|
||||
_ = requireOpenWindow(t, c, Window{Name: "editor", Width: 800, Height: 600, X: 0, Y: 0})
|
||||
_ = requireOpenWindow(t, c, Window{Name: "terminal", Width: 800, Height: 600, X: 0, Y: 0})
|
||||
|
||||
// Save a layout with specific positions
|
||||
_, _, _ = c.PERFORM(TaskSaveLayout{Name: "coding"})
|
||||
|
||||
// Move the windows to different positions
|
||||
_, _, _ = c.PERFORM(TaskSetPosition{Name: "editor", X: 500, Y: 500})
|
||||
_, _, _ = c.PERFORM(TaskSetPosition{Name: "terminal", X: 600, Y: 600})
|
||||
|
||||
// Restore the layout
|
||||
_, handled, err := c.PERFORM(TaskRestoreLayout{Name: "coding"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
// Verify windows were moved back to saved positions
|
||||
pw, ok := svc.Manager().Get("editor")
|
||||
require.True(t, ok)
|
||||
x, y := pw.Position()
|
||||
assert.Equal(t, 0, x)
|
||||
assert.Equal(t, 0, y)
|
||||
|
||||
pw2, ok := svc.Manager().Get("terminal")
|
||||
require.True(t, ok)
|
||||
x2, y2 := pw2.Position()
|
||||
assert.Equal(t, 0, x2)
|
||||
assert.Equal(t, 0, y2)
|
||||
|
||||
editorState, ok := svc.Manager().State().GetState("editor")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, 0, editorState.X)
|
||||
assert.Equal(t, 0, editorState.Y)
|
||||
|
||||
terminalState, ok := svc.Manager().State().GetState("terminal")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, 0, terminalState.X)
|
||||
assert.Equal(t, 0, terminalState.Y)
|
||||
}
|
||||
|
||||
func TestTaskRestoreLayout_Bad(t *testing.T) {
|
||||
_, c := newTestWindowService(t)
|
||||
_, handled, err := c.PERFORM(TaskRestoreLayout{Name: "nonexistent"})
|
||||
assert.True(t, handled)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// --- TaskStackWindows ---
|
||||
|
||||
func TestTaskStackWindows_Good(t *testing.T) {
|
||||
svc, c := newTestWindowService(t)
|
||||
_ = requireOpenWindow(t, c, Window{Name: "s1", Width: 800, Height: 600})
|
||||
_ = requireOpenWindow(t, c, Window{Name: "s2", Width: 800, Height: 600})
|
||||
|
||||
_, handled, err := c.PERFORM(TaskStackWindows{Windows: []string{"s1", "s2"}, OffsetX: 25, OffsetY: 35})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
pw, ok := svc.Manager().Get("s2")
|
||||
require.True(t, ok)
|
||||
x, y := pw.Position()
|
||||
assert.Equal(t, 25, x)
|
||||
assert.Equal(t, 35, y)
|
||||
}
|
||||
|
||||
// --- TaskApplyWorkflow ---
|
||||
|
||||
func TestTaskApplyWorkflow_Good(t *testing.T) {
|
||||
svc, c := newTestWindowService(t)
|
||||
_ = requireOpenWindow(t, c, Window{Name: "editor", Width: 800, Height: 600})
|
||||
_ = requireOpenWindow(t, c, Window{Name: "terminal", Width: 800, Height: 600})
|
||||
|
||||
_, handled, err := c.PERFORM(TaskApplyWorkflow{Workflow: "side-by-side"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
editor, ok := svc.Manager().Get("editor")
|
||||
require.True(t, ok)
|
||||
x, y := editor.Position()
|
||||
assert.Equal(t, 0, x)
|
||||
assert.Equal(t, 0, y)
|
||||
|
||||
terminal, ok := svc.Manager().Get("terminal")
|
||||
require.True(t, ok)
|
||||
x, y = terminal.Position()
|
||||
assert.Equal(t, 960, x)
|
||||
assert.Equal(t, 0, y)
|
||||
}
|
||||
|
||||
// --- TaskSetZoom / QueryWindowZoom ---
|
||||
|
||||
func TestTaskSetZoom_Good(t *testing.T) {
|
||||
svc, c := newTestWindowService(t)
|
||||
_ = requireOpenWindow(t, c, Window{Name: "test"})
|
||||
|
||||
_, handled, err := c.PERFORM(TaskSetZoom{Name: "test", Factor: 1.5})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
result, handled, err := c.QUERY(QueryWindowZoom{Name: "test"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.InDelta(t, 1.5, result.(float64), 0.001)
|
||||
|
||||
pw, ok := svc.Manager().Get("test")
|
||||
require.True(t, ok)
|
||||
mw := pw.(*mockWindow)
|
||||
assert.InDelta(t, 1.5, mw.zoom, 0.001)
|
||||
}
|
||||
|
||||
func TestTaskSetZoom_Bad(t *testing.T) {
|
||||
_, c := newTestWindowService(t)
|
||||
_, handled, err := c.PERFORM(TaskSetZoom{Name: "nonexistent", Factor: 1.2})
|
||||
assert.True(t, handled)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestQueryWindowZoom_Bad(t *testing.T) {
|
||||
_, c := newTestWindowService(t)
|
||||
_, handled, err := c.QUERY(QueryWindowZoom{Name: "nonexistent"})
|
||||
assert.True(t, handled)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// --- QueryWindowBounds ---
|
||||
|
||||
func TestQueryWindowBounds_Good(t *testing.T) {
|
||||
_, c := newTestWindowService(t)
|
||||
_ = requireOpenWindow(t, c, Window{Name: "bounds-test", Width: 800, Height: 600, X: 50, Y: 75})
|
||||
|
||||
result, handled, err := c.QUERY(QueryWindowBounds{Name: "bounds-test"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
bounds := result.(*Bounds)
|
||||
require.NotNil(t, bounds)
|
||||
assert.Equal(t, 50, bounds.X)
|
||||
assert.Equal(t, 75, bounds.Y)
|
||||
assert.Equal(t, 800, bounds.Width)
|
||||
assert.Equal(t, 600, bounds.Height)
|
||||
}
|
||||
|
||||
func TestQueryWindowBounds_Bad(t *testing.T) {
|
||||
_, c := newTestWindowService(t)
|
||||
_, handled, err := c.QUERY(QueryWindowBounds{Name: "nonexistent"})
|
||||
assert.True(t, handled)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// --- TaskSetURL ---
|
||||
|
||||
func TestTaskSetURL_Good(t *testing.T) {
|
||||
svc, c := newTestWindowService(t)
|
||||
_ = requireOpenWindow(t, c, Window{Name: "test"})
|
||||
|
||||
_, handled, err := c.PERFORM(TaskSetURL{Name: "test", URL: "/settings"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
pw, ok := svc.Manager().Get("test")
|
||||
require.True(t, ok)
|
||||
mw := pw.(*mockWindow)
|
||||
assert.Equal(t, "/settings", mw.url)
|
||||
}
|
||||
|
||||
func TestTaskSetURL_Bad(t *testing.T) {
|
||||
_, c := newTestWindowService(t)
|
||||
_, handled, err := c.PERFORM(TaskSetURL{Name: "nonexistent", URL: "/nope"})
|
||||
assert.True(t, handled)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// --- TaskSetHTML ---
|
||||
|
||||
func TestTaskSetHTML_Good(t *testing.T) {
|
||||
svc, c := newTestWindowService(t)
|
||||
_ = requireOpenWindow(t, c, Window{Name: "test"})
|
||||
|
||||
_, handled, err := c.PERFORM(TaskSetHTML{Name: "test", HTML: "<h1>Hello</h1>"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
pw, ok := svc.Manager().Get("test")
|
||||
require.True(t, ok)
|
||||
mw := pw.(*mockWindow)
|
||||
assert.Equal(t, "<h1>Hello</h1>", mw.html)
|
||||
}
|
||||
|
||||
func TestTaskSetHTML_Bad(t *testing.T) {
|
||||
_, c := newTestWindowService(t)
|
||||
_, handled, err := c.PERFORM(TaskSetHTML{Name: "nonexistent", HTML: "<p>nope</p>"})
|
||||
assert.True(t, handled)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// --- TaskExecJS ---
|
||||
|
||||
func TestTaskExecJS_Good(t *testing.T) {
|
||||
svc, c := newTestWindowService(t)
|
||||
_ = requireOpenWindow(t, c, Window{Name: "test"})
|
||||
|
||||
_, handled, err := c.PERFORM(TaskExecJS{Name: "test", JS: "document.title = 'Updated'"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
pw, ok := svc.Manager().Get("test")
|
||||
require.True(t, ok)
|
||||
mw := pw.(*mockWindow)
|
||||
assert.Equal(t, "document.title = 'Updated'", mw.lastJS)
|
||||
}
|
||||
|
||||
func TestTaskExecJS_Bad(t *testing.T) {
|
||||
_, c := newTestWindowService(t)
|
||||
_, handled, err := c.PERFORM(TaskExecJS{Name: "nonexistent", JS: "alert(1)"})
|
||||
assert.True(t, handled)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// --- TaskToggleFullscreen ---
|
||||
|
||||
func TestTaskToggleFullscreen_Good(t *testing.T) {
|
||||
svc, c := newTestWindowService(t)
|
||||
_ = requireOpenWindow(t, c, Window{Name: "test"})
|
||||
|
||||
_, handled, err := c.PERFORM(TaskToggleFullscreen{Name: "test"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
pw, ok := svc.Manager().Get("test")
|
||||
require.True(t, ok)
|
||||
mw := pw.(*mockWindow)
|
||||
assert.Equal(t, 1, mw.toggleFullscreenCount)
|
||||
}
|
||||
|
||||
func TestTaskToggleFullscreen_Bad(t *testing.T) {
|
||||
_, c := newTestWindowService(t)
|
||||
_, handled, err := c.PERFORM(TaskToggleFullscreen{Name: "nonexistent"})
|
||||
assert.True(t, handled)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// --- TaskPrint ---
|
||||
|
||||
func TestTaskPrint_Good(t *testing.T) {
|
||||
svc, c := newTestWindowService(t)
|
||||
_ = requireOpenWindow(t, c, Window{Name: "test"})
|
||||
|
||||
_, handled, err := c.PERFORM(TaskPrint{Name: "test"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
pw, ok := svc.Manager().Get("test")
|
||||
require.True(t, ok)
|
||||
mw := pw.(*mockWindow)
|
||||
assert.True(t, mw.printCalled)
|
||||
}
|
||||
|
||||
func TestTaskPrint_Bad(t *testing.T) {
|
||||
_, c := newTestWindowService(t)
|
||||
_, handled, err := c.PERFORM(TaskPrint{Name: "nonexistent"})
|
||||
assert.True(t, handled)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// --- TaskFlash ---
|
||||
|
||||
func TestTaskFlash_Good(t *testing.T) {
|
||||
svc, c := newTestWindowService(t)
|
||||
_ = requireOpenWindow(t, c, Window{Name: "test"})
|
||||
|
||||
_, handled, err := c.PERFORM(TaskFlash{Name: "test", Enabled: true})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
pw, ok := svc.Manager().Get("test")
|
||||
require.True(t, ok)
|
||||
mw := pw.(*mockWindow)
|
||||
assert.True(t, mw.flashing)
|
||||
|
||||
_, _, _ = c.PERFORM(TaskFlash{Name: "test", Enabled: false})
|
||||
assert.False(t, mw.flashing)
|
||||
}
|
||||
|
||||
func TestTaskFlash_Bad(t *testing.T) {
|
||||
_, c := newTestWindowService(t)
|
||||
_, handled, err := c.PERFORM(TaskFlash{Name: "nonexistent", Enabled: true})
|
||||
assert.True(t, handled)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import (
|
|||
"sync"
|
||||
"time"
|
||||
|
||||
corego "dappco.re/go/core"
|
||||
coreio "forge.lthn.ai/core/go-io"
|
||||
)
|
||||
|
||||
// WindowState holds the persisted position/size of a window.
|
||||
|
|
@ -63,18 +63,16 @@ func (sm *StateManager) filePath() string {
|
|||
if sm.statePath != "" {
|
||||
return sm.statePath
|
||||
}
|
||||
return corego.JoinPath(sm.configDir, "window_state.json")
|
||||
return filepath.Join(sm.configDir, "window_state.json")
|
||||
}
|
||||
|
||||
func (sm *StateManager) dataDir() string {
|
||||
if sm.statePath != "" {
|
||||
return corego.PathDir(sm.statePath)
|
||||
return filepath.Dir(sm.statePath)
|
||||
}
|
||||
return sm.configDir
|
||||
}
|
||||
|
||||
// SetPath overrides the persisted state file path.
|
||||
// Use: sm.SetPath(filepath.Join(t.TempDir(), "window_state.json"))
|
||||
func (sm *StateManager) SetPath(path string) {
|
||||
if path == "" {
|
||||
return
|
||||
|
|
@ -94,13 +92,13 @@ func (sm *StateManager) load() {
|
|||
if sm.configDir == "" && sm.statePath == "" {
|
||||
return
|
||||
}
|
||||
data, err := os.ReadFile(sm.filePath())
|
||||
content, err := coreio.Local.Read(sm.filePath())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
_ = corego.JSONUnmarshal(data, &sm.states)
|
||||
_ = json.Unmarshal([]byte(content), &sm.states)
|
||||
}
|
||||
|
||||
func (sm *StateManager) save() {
|
||||
|
|
@ -113,9 +111,10 @@ func (sm *StateManager) save() {
|
|||
if !r.OK {
|
||||
return
|
||||
}
|
||||
data := r.Value.([]byte)
|
||||
_ = os.MkdirAll(sm.dataDir(), 0o755)
|
||||
_ = os.WriteFile(sm.filePath(), data, 0o644)
|
||||
if dir := sm.dataDir(); dir != "" {
|
||||
_ = coreio.Local.EnsureDir(dir)
|
||||
}
|
||||
_ = coreio.Local.Write(sm.filePath(), string(data))
|
||||
}
|
||||
|
||||
func (sm *StateManager) scheduleSave() {
|
||||
|
|
|
|||
|
|
@ -1,19 +1,7 @@
|
|||
// pkg/window/tiling.go
|
||||
package window
|
||||
|
||||
import corego "dappco.re/go/core"
|
||||
|
||||
// normalizeWindowForLayout clears transient maximise/minimise state before
|
||||
// applying a new geometry. This keeps layout helpers effective even when a
|
||||
// window was previously maximised.
|
||||
func normalizeWindowForLayout(pw PlatformWindow) {
|
||||
if pw == nil {
|
||||
return
|
||||
}
|
||||
if pw.IsMaximised() || pw.IsMinimised() {
|
||||
pw.Restore()
|
||||
}
|
||||
}
|
||||
import coreerr "forge.lthn.ai/core/go-log"
|
||||
|
||||
// TileMode defines how windows are arranged.
|
||||
// Use: mode := window.TileModeLeftRight
|
||||
|
|
@ -60,6 +48,16 @@ const (
|
|||
SnapCenter
|
||||
)
|
||||
|
||||
var snapPositionNames = map[SnapPosition]string{
|
||||
SnapLeft: "left", SnapRight: "right",
|
||||
SnapTop: "top", SnapBottom: "bottom",
|
||||
SnapTopLeft: "top-left", SnapTopRight: "top-right",
|
||||
SnapBottomLeft: "bottom-left", SnapBottomRight: "bottom-right",
|
||||
SnapCenter: "center",
|
||||
}
|
||||
|
||||
func (p SnapPosition) String() string { return snapPositionNames[p] }
|
||||
|
||||
// WorkflowLayout is a predefined arrangement for common tasks.
|
||||
// Use: workflow := window.WorkflowCoding
|
||||
type WorkflowLayout int
|
||||
|
|
@ -80,29 +78,36 @@ var workflowNames = map[WorkflowLayout]string{
|
|||
// Use: label := window.WorkflowCoding.String()
|
||||
func (w WorkflowLayout) String() string { return workflowNames[w] }
|
||||
|
||||
// ParseWorkflowLayout converts a workflow name into its enum value.
|
||||
// Use: workflow, ok := window.ParseWorkflowLayout("coding")
|
||||
func ParseWorkflowLayout(name string) (WorkflowLayout, bool) {
|
||||
for workflow, workflowName := range workflowNames {
|
||||
if workflowName == name {
|
||||
return workflow, true
|
||||
}
|
||||
func layoutOrigin(origin []int) (int, int) {
|
||||
if len(origin) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
return WorkflowCoding, false
|
||||
if len(origin) == 1 {
|
||||
return origin[0], 0
|
||||
}
|
||||
return origin[0], origin[1]
|
||||
}
|
||||
|
||||
func (m *Manager) captureState(pw PlatformWindow) {
|
||||
if m.state == nil || pw == nil {
|
||||
return
|
||||
}
|
||||
m.state.CaptureState(pw)
|
||||
}
|
||||
|
||||
// TileWindows arranges the named windows in the given mode across the screen area.
|
||||
func (m *Manager) TileWindows(mode TileMode, names []string, screenW, screenH int) error {
|
||||
func (m *Manager) TileWindows(mode TileMode, names []string, screenW, screenH int, origin ...int) error {
|
||||
originX, originY := layoutOrigin(origin)
|
||||
windows := make([]PlatformWindow, 0, len(names))
|
||||
for _, name := range names {
|
||||
pw, ok := m.Get(name)
|
||||
if !ok {
|
||||
return corego.E("window.tiling", corego.Sprintf("window %q not found", name), nil)
|
||||
return coreerr.E("window.Manager.TileWindows", "window not found: "+name, nil)
|
||||
}
|
||||
windows = append(windows, pw)
|
||||
}
|
||||
if len(windows) == 0 {
|
||||
return corego.E("window.tiling", "no windows to tile", nil)
|
||||
return coreerr.E("window.Manager.TileWindows", "no windows to tile", nil)
|
||||
}
|
||||
|
||||
halfW, halfH := screenW/2, screenH/2
|
||||
|
|
@ -111,9 +116,9 @@ func (m *Manager) TileWindows(mode TileMode, names []string, screenW, screenH in
|
|||
case TileModeLeftRight:
|
||||
w := screenW / len(windows)
|
||||
for i, pw := range windows {
|
||||
normalizeWindowForLayout(pw)
|
||||
pw.SetPosition(i*w, 0)
|
||||
pw.SetPosition(originX+i*w, originY)
|
||||
pw.SetSize(w, screenH)
|
||||
m.captureState(pw)
|
||||
}
|
||||
case TileModeGrid:
|
||||
cols := 2
|
||||
|
|
@ -127,128 +132,125 @@ func (m *Manager) TileWindows(mode TileMode, names []string, screenW, screenH in
|
|||
col := i % cols
|
||||
rows := (len(windows) + cols - 1) / cols
|
||||
cellH := screenH / rows
|
||||
pw.SetPosition(col*cellW, row*cellH)
|
||||
pw.SetPosition(originX+col*cellW, originY+row*cellH)
|
||||
pw.SetSize(cellW, cellH)
|
||||
m.captureState(pw)
|
||||
}
|
||||
case TileModeLeftHalf:
|
||||
for _, pw := range windows {
|
||||
normalizeWindowForLayout(pw)
|
||||
pw.SetPosition(0, 0)
|
||||
pw.SetPosition(originX, originY)
|
||||
pw.SetSize(halfW, screenH)
|
||||
m.captureState(pw)
|
||||
}
|
||||
case TileModeRightHalf:
|
||||
for _, pw := range windows {
|
||||
normalizeWindowForLayout(pw)
|
||||
pw.SetPosition(halfW, 0)
|
||||
pw.SetPosition(originX+halfW, originY)
|
||||
pw.SetSize(halfW, screenH)
|
||||
m.captureState(pw)
|
||||
}
|
||||
case TileModeTopHalf:
|
||||
for _, pw := range windows {
|
||||
normalizeWindowForLayout(pw)
|
||||
pw.SetPosition(0, 0)
|
||||
pw.SetPosition(originX, originY)
|
||||
pw.SetSize(screenW, halfH)
|
||||
m.captureState(pw)
|
||||
}
|
||||
case TileModeBottomHalf:
|
||||
for _, pw := range windows {
|
||||
normalizeWindowForLayout(pw)
|
||||
pw.SetPosition(0, halfH)
|
||||
pw.SetPosition(originX, originY+halfH)
|
||||
pw.SetSize(screenW, halfH)
|
||||
m.captureState(pw)
|
||||
}
|
||||
case TileModeTopLeft:
|
||||
for _, pw := range windows {
|
||||
normalizeWindowForLayout(pw)
|
||||
pw.SetPosition(0, 0)
|
||||
pw.SetPosition(originX, originY)
|
||||
pw.SetSize(halfW, halfH)
|
||||
m.captureState(pw)
|
||||
}
|
||||
case TileModeTopRight:
|
||||
for _, pw := range windows {
|
||||
normalizeWindowForLayout(pw)
|
||||
pw.SetPosition(halfW, 0)
|
||||
pw.SetPosition(originX+halfW, originY)
|
||||
pw.SetSize(halfW, halfH)
|
||||
m.captureState(pw)
|
||||
}
|
||||
case TileModeBottomLeft:
|
||||
for _, pw := range windows {
|
||||
normalizeWindowForLayout(pw)
|
||||
pw.SetPosition(0, halfH)
|
||||
pw.SetPosition(originX, originY+halfH)
|
||||
pw.SetSize(halfW, halfH)
|
||||
m.captureState(pw)
|
||||
}
|
||||
case TileModeBottomRight:
|
||||
for _, pw := range windows {
|
||||
normalizeWindowForLayout(pw)
|
||||
pw.SetPosition(halfW, halfH)
|
||||
pw.SetPosition(originX+halfW, originY+halfH)
|
||||
pw.SetSize(halfW, halfH)
|
||||
m.captureState(pw)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SnapWindow snaps a window to a screen edge/corner/centre.
|
||||
func (m *Manager) SnapWindow(name string, pos SnapPosition, screenW, screenH int) error {
|
||||
func (m *Manager) SnapWindow(name string, pos SnapPosition, screenW, screenH int, origin ...int) error {
|
||||
originX, originY := layoutOrigin(origin)
|
||||
pw, ok := m.Get(name)
|
||||
if !ok {
|
||||
return corego.E("window.tiling", corego.Sprintf("window %q not found", name), nil)
|
||||
return coreerr.E("window.Manager.SnapWindow", "window not found: "+name, nil)
|
||||
}
|
||||
|
||||
halfW, halfH := screenW/2, screenH/2
|
||||
|
||||
switch pos {
|
||||
case SnapLeft:
|
||||
normalizeWindowForLayout(pw)
|
||||
pw.SetPosition(0, 0)
|
||||
pw.SetPosition(originX, originY)
|
||||
pw.SetSize(halfW, screenH)
|
||||
case SnapRight:
|
||||
normalizeWindowForLayout(pw)
|
||||
pw.SetPosition(halfW, 0)
|
||||
pw.SetPosition(originX+halfW, originY)
|
||||
pw.SetSize(halfW, screenH)
|
||||
case SnapTop:
|
||||
normalizeWindowForLayout(pw)
|
||||
pw.SetPosition(0, 0)
|
||||
pw.SetPosition(originX, originY)
|
||||
pw.SetSize(screenW, halfH)
|
||||
case SnapBottom:
|
||||
normalizeWindowForLayout(pw)
|
||||
pw.SetPosition(0, halfH)
|
||||
pw.SetPosition(originX, originY+halfH)
|
||||
pw.SetSize(screenW, halfH)
|
||||
case SnapTopLeft:
|
||||
normalizeWindowForLayout(pw)
|
||||
pw.SetPosition(0, 0)
|
||||
pw.SetPosition(originX, originY)
|
||||
pw.SetSize(halfW, halfH)
|
||||
case SnapTopRight:
|
||||
normalizeWindowForLayout(pw)
|
||||
pw.SetPosition(halfW, 0)
|
||||
pw.SetPosition(originX+halfW, originY)
|
||||
pw.SetSize(halfW, halfH)
|
||||
case SnapBottomLeft:
|
||||
normalizeWindowForLayout(pw)
|
||||
pw.SetPosition(0, halfH)
|
||||
pw.SetPosition(originX, originY+halfH)
|
||||
pw.SetSize(halfW, halfH)
|
||||
case SnapBottomRight:
|
||||
normalizeWindowForLayout(pw)
|
||||
pw.SetPosition(halfW, halfH)
|
||||
pw.SetPosition(originX+halfW, originY+halfH)
|
||||
pw.SetSize(halfW, halfH)
|
||||
case SnapCenter:
|
||||
normalizeWindowForLayout(pw)
|
||||
cw, ch := pw.Size()
|
||||
pw.SetPosition((screenW-cw)/2, (screenH-ch)/2)
|
||||
pw.SetPosition(originX+(screenW-cw)/2, originY+(screenH-ch)/2)
|
||||
}
|
||||
m.captureState(pw)
|
||||
return nil
|
||||
}
|
||||
|
||||
// StackWindows cascades windows with an offset.
|
||||
func (m *Manager) StackWindows(names []string, offsetX, offsetY int) error {
|
||||
func (m *Manager) StackWindows(names []string, offsetX, offsetY int, origin ...int) error {
|
||||
originX, originY := layoutOrigin(origin)
|
||||
for i, name := range names {
|
||||
pw, ok := m.Get(name)
|
||||
if !ok {
|
||||
return corego.E("window.tiling", corego.Sprintf("window %q not found", name), nil)
|
||||
return coreerr.E("window.Manager.StackWindows", "window not found: "+name, nil)
|
||||
}
|
||||
normalizeWindowForLayout(pw)
|
||||
pw.SetPosition(i*offsetX, i*offsetY)
|
||||
pw.SetPosition(originX+i*offsetX, originY+i*offsetY)
|
||||
m.captureState(pw)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplyWorkflow arranges windows in a predefined workflow layout.
|
||||
func (m *Manager) ApplyWorkflow(workflow WorkflowLayout, names []string, screenW, screenH int) error {
|
||||
func (m *Manager) ApplyWorkflow(workflow WorkflowLayout, names []string, screenW, screenH int, origin ...int) error {
|
||||
originX, originY := layoutOrigin(origin)
|
||||
if len(names) == 0 {
|
||||
return corego.E("window.tiling", "no windows for workflow", nil)
|
||||
return coreerr.E("window.Manager.ApplyWorkflow", "no windows for workflow", nil)
|
||||
}
|
||||
|
||||
switch workflow {
|
||||
|
|
@ -256,41 +258,41 @@ func (m *Manager) ApplyWorkflow(workflow WorkflowLayout, names []string, screenW
|
|||
// 70/30 split — main editor + terminal
|
||||
mainW := screenW * 70 / 100
|
||||
if pw, ok := m.Get(names[0]); ok {
|
||||
normalizeWindowForLayout(pw)
|
||||
pw.SetPosition(0, 0)
|
||||
pw.SetPosition(originX, originY)
|
||||
pw.SetSize(mainW, screenH)
|
||||
m.captureState(pw)
|
||||
}
|
||||
if len(names) > 1 {
|
||||
if pw, ok := m.Get(names[1]); ok {
|
||||
normalizeWindowForLayout(pw)
|
||||
pw.SetPosition(mainW, 0)
|
||||
pw.SetPosition(originX+mainW, originY)
|
||||
pw.SetSize(screenW-mainW, screenH)
|
||||
m.captureState(pw)
|
||||
}
|
||||
}
|
||||
case WorkflowDebugging:
|
||||
// 60/40 split
|
||||
mainW := screenW * 60 / 100
|
||||
if pw, ok := m.Get(names[0]); ok {
|
||||
normalizeWindowForLayout(pw)
|
||||
pw.SetPosition(0, 0)
|
||||
pw.SetPosition(originX, originY)
|
||||
pw.SetSize(mainW, screenH)
|
||||
m.captureState(pw)
|
||||
}
|
||||
if len(names) > 1 {
|
||||
if pw, ok := m.Get(names[1]); ok {
|
||||
normalizeWindowForLayout(pw)
|
||||
pw.SetPosition(mainW, 0)
|
||||
pw.SetPosition(originX+mainW, originY)
|
||||
pw.SetSize(screenW-mainW, screenH)
|
||||
m.captureState(pw)
|
||||
}
|
||||
}
|
||||
case WorkflowPresenting:
|
||||
// Maximise first window
|
||||
if pw, ok := m.Get(names[0]); ok {
|
||||
normalizeWindowForLayout(pw)
|
||||
pw.SetPosition(0, 0)
|
||||
pw.SetPosition(originX, originY)
|
||||
pw.SetSize(screenW, screenH)
|
||||
m.captureState(pw)
|
||||
}
|
||||
case WorkflowSideBySide:
|
||||
return m.TileWindows(TileModeLeftRight, names, screenW, screenH)
|
||||
return m.TileWindows(TileModeLeftRight, names, screenW, screenH, originX, originY)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,30 +18,28 @@ func NewWailsPlatform(app *application.App) *WailsPlatform {
|
|||
return &WailsPlatform{app: app}
|
||||
}
|
||||
|
||||
// CreateWindow opens a new Wails window from platform options.
|
||||
// Use: w := wp.CreateWindow(window.PlatformWindowOptions{Name: "editor", URL: "/editor"})
|
||||
func (wp *WailsPlatform) CreateWindow(opts PlatformWindowOptions) PlatformWindow {
|
||||
func (wp *WailsPlatform) CreateWindow(options PlatformWindowOptions) PlatformWindow {
|
||||
wOpts := application.WebviewWindowOptions{
|
||||
Name: opts.Name,
|
||||
Title: opts.Title,
|
||||
URL: opts.URL,
|
||||
Width: opts.Width,
|
||||
Height: opts.Height,
|
||||
X: opts.X,
|
||||
Y: opts.Y,
|
||||
MinWidth: opts.MinWidth,
|
||||
MinHeight: opts.MinHeight,
|
||||
MaxWidth: opts.MaxWidth,
|
||||
MaxHeight: opts.MaxHeight,
|
||||
Frameless: opts.Frameless,
|
||||
Hidden: opts.Hidden,
|
||||
AlwaysOnTop: opts.AlwaysOnTop,
|
||||
DisableResize: opts.DisableResize,
|
||||
EnableFileDrop: opts.EnableFileDrop,
|
||||
BackgroundColour: application.NewRGBA(opts.BackgroundColour[0], opts.BackgroundColour[1], opts.BackgroundColour[2], opts.BackgroundColour[3]),
|
||||
Name: options.Name,
|
||||
Title: options.Title,
|
||||
URL: options.URL,
|
||||
Width: options.Width,
|
||||
Height: options.Height,
|
||||
X: options.X,
|
||||
Y: options.Y,
|
||||
MinWidth: options.MinWidth,
|
||||
MinHeight: options.MinHeight,
|
||||
MaxWidth: options.MaxWidth,
|
||||
MaxHeight: options.MaxHeight,
|
||||
Frameless: options.Frameless,
|
||||
Hidden: options.Hidden,
|
||||
AlwaysOnTop: options.AlwaysOnTop,
|
||||
DisableResize: options.DisableResize,
|
||||
EnableFileDrop: options.EnableFileDrop,
|
||||
BackgroundColour: application.NewRGBA(options.BackgroundColour[0], options.BackgroundColour[1], options.BackgroundColour[2], options.BackgroundColour[3]),
|
||||
}
|
||||
w := wp.app.Window.NewWithOptions(wOpts)
|
||||
return &wailsWindow{w: w, title: opts.Title}
|
||||
return &wailsWindow{w: w, title: options.Title}
|
||||
}
|
||||
|
||||
// GetWindows returns the live Wails windows.
|
||||
|
|
@ -64,20 +62,10 @@ type wailsWindow struct {
|
|||
title string
|
||||
}
|
||||
|
||||
func (ww *wailsWindow) Name() string { return ww.w.Name() }
|
||||
func (ww *wailsWindow) Title() string {
|
||||
if ww.title != "" {
|
||||
return ww.title
|
||||
}
|
||||
if ww.w != nil {
|
||||
return ww.w.Name()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
func (ww *wailsWindow) Name() string { return ww.w.Name() }
|
||||
func (ww *wailsWindow) Title() string { return ww.title }
|
||||
func (ww *wailsWindow) Position() (int, int) { return ww.w.Position() }
|
||||
func (ww *wailsWindow) Size() (int, int) { return ww.w.Size() }
|
||||
func (ww *wailsWindow) IsVisible() bool { return ww.w.IsVisible() }
|
||||
func (ww *wailsWindow) IsMinimised() bool { return ww.w.IsMinimised() }
|
||||
func (ww *wailsWindow) IsMaximised() bool { return ww.w.IsMaximised() }
|
||||
func (ww *wailsWindow) IsFocused() bool { return ww.w.IsFocused() }
|
||||
func (ww *wailsWindow) SetTitle(title string) { ww.title = title; ww.w.SetTitle(title) }
|
||||
|
|
@ -102,10 +90,25 @@ func (ww *wailsWindow) Focus() { ww.w.Focus() }
|
|||
func (ww *wailsWindow) Close() { ww.w.Close() }
|
||||
func (ww *wailsWindow) Show() { ww.w.Show() }
|
||||
func (ww *wailsWindow) Hide() { ww.w.Hide() }
|
||||
func (ww *wailsWindow) Fullscreen() { ww.w.Fullscreen() }
|
||||
func (ww *wailsWindow) UnFullscreen() { ww.w.UnFullscreen() }
|
||||
func (ww *wailsWindow) OpenDevTools() { ww.w.OpenDevTools() }
|
||||
func (ww *wailsWindow) CloseDevTools() { ww.w.CloseDevTools() }
|
||||
func (ww *wailsWindow) Fullscreen() { ww.w.Fullscreen() }
|
||||
func (ww *wailsWindow) UnFullscreen() { ww.w.UnFullscreen() }
|
||||
func (ww *wailsWindow) GetZoom() float64 { return ww.w.GetZoom() }
|
||||
func (ww *wailsWindow) SetZoom(factor float64) { ww.w.SetZoom(factor) }
|
||||
func (ww *wailsWindow) ZoomIn() { ww.w.ZoomIn() }
|
||||
func (ww *wailsWindow) ZoomOut() { ww.w.ZoomOut() }
|
||||
func (ww *wailsWindow) SetURL(url string) { ww.w.SetURL(url) }
|
||||
func (ww *wailsWindow) SetHTML(html string) { ww.w.SetHTML(html) }
|
||||
func (ww *wailsWindow) ExecJS(js string) { ww.w.ExecJS(js) }
|
||||
func (ww *wailsWindow) GetBounds() Bounds {
|
||||
r := ww.w.Bounds()
|
||||
return Bounds{X: r.X, Y: r.Y, Width: r.Width, Height: r.Height}
|
||||
}
|
||||
func (ww *wailsWindow) SetBounds(b Bounds) {
|
||||
ww.w.SetBounds(application.Rect{X: b.X, Y: b.Y, Width: b.Width, Height: b.Height})
|
||||
}
|
||||
func (ww *wailsWindow) ToggleFullscreen() { ww.w.ToggleFullscreen() }
|
||||
func (ww *wailsWindow) Print() error { return ww.w.Print() }
|
||||
func (ww *wailsWindow) Flash(enabled bool) { ww.w.Flash(enabled) }
|
||||
|
||||
func (ww *wailsWindow) OnWindowEvent(handler func(event WindowEvent)) {
|
||||
name := ww.w.Name()
|
||||
|
|
|
|||
|
|
@ -2,10 +2,9 @@
|
|||
package window
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sync"
|
||||
|
||||
corego "dappco.re/go/core"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
)
|
||||
|
||||
// Window is CoreGUI's own window descriptor — NOT a Wails type alias.
|
||||
|
|
@ -53,7 +52,7 @@ type Manager struct {
|
|||
}
|
||||
|
||||
// NewManager creates a window Manager with the given platform backend.
|
||||
// Use: mgr := window.NewManager(platform)
|
||||
// window.NewManager(window.NewWailsPlatform(app))
|
||||
func NewManager(platform Platform) *Manager {
|
||||
return &Manager{
|
||||
platform: platform,
|
||||
|
|
@ -64,7 +63,7 @@ func NewManager(platform Platform) *Manager {
|
|||
}
|
||||
|
||||
// NewManagerWithDir creates a window Manager with a custom config directory for state/layout persistence.
|
||||
// Use: mgr := window.NewManagerWithDir(platform, t.TempDir())
|
||||
// window.NewManagerWithDir(window.NewMockPlatform(), t.TempDir())
|
||||
func NewManagerWithDir(platform Platform, configDir string) *Manager {
|
||||
return &Manager{
|
||||
platform: platform,
|
||||
|
|
@ -74,69 +73,82 @@ func NewManagerWithDir(platform Platform, configDir string) *Manager {
|
|||
}
|
||||
}
|
||||
|
||||
// SetDefaultWidth overrides the fallback width used when a window is created without one.
|
||||
// Use: mgr.SetDefaultWidth(1280)
|
||||
func (m *Manager) SetDefaultWidth(width int) {
|
||||
if width > 0 {
|
||||
m.defaultWidth = width
|
||||
}
|
||||
}
|
||||
|
||||
// SetDefaultHeight overrides the fallback height used when a window is created without one.
|
||||
// Use: mgr.SetDefaultHeight(800)
|
||||
func (m *Manager) SetDefaultHeight(height int) {
|
||||
if height > 0 {
|
||||
m.defaultHeight = height
|
||||
}
|
||||
}
|
||||
|
||||
// Open creates a window using functional options, applies saved state, and tracks it.
|
||||
// Use: _, err := mgr.Open(window.WithName("editor"), window.WithURL("/editor"))
|
||||
func (m *Manager) Open(opts ...WindowOption) (PlatformWindow, error) {
|
||||
w, err := ApplyOptions(opts...)
|
||||
// Deprecated: use CreateWindow(Window{Name: "settings", URL: "/settings", Width: 800, Height: 600}).
|
||||
func (m *Manager) Open(options ...WindowOption) (PlatformWindow, error) {
|
||||
w, err := ApplyOptions(options...)
|
||||
if err != nil {
|
||||
return nil, corego.Wrap(err, "window.Manager.Open", "failed to apply options")
|
||||
return nil, coreerr.E("window.Manager.Open", "failed to apply options", err)
|
||||
}
|
||||
return m.Create(w)
|
||||
return m.CreateWindow(*w)
|
||||
}
|
||||
|
||||
// Create creates a window from a Window descriptor.
|
||||
// Use: _, err := mgr.Create(&window.Window{Name: "editor", URL: "/editor"})
|
||||
// CreateWindow creates a window from a Window descriptor.
|
||||
// window.NewManager(window.NewWailsPlatform(app)).CreateWindow(window.Window{Name: "settings", URL: "/settings", Width: 800, Height: 600})
|
||||
func (m *Manager) CreateWindow(spec Window) (PlatformWindow, error) {
|
||||
_, pw, err := m.createWindow(spec)
|
||||
return pw, err
|
||||
}
|
||||
|
||||
// Deprecated: use CreateWindow(Window{Name: "settings", URL: "/settings", Width: 800, Height: 600}).
|
||||
func (m *Manager) Create(w *Window) (PlatformWindow, error) {
|
||||
if w.Name == "" {
|
||||
w.Name = "main"
|
||||
if w == nil {
|
||||
return nil, coreerr.E("window.Manager.Create", "window descriptor is required", nil)
|
||||
}
|
||||
if w.Title == "" {
|
||||
w.Title = "Core"
|
||||
spec, pw, err := m.createWindow(*w)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if w.Width == 0 {
|
||||
*w = spec
|
||||
return pw, nil
|
||||
}
|
||||
|
||||
func (m *Manager) createWindow(spec Window) (Window, PlatformWindow, error) {
|
||||
if spec.Name == "" {
|
||||
spec.Name = "main"
|
||||
}
|
||||
if spec.Title == "" {
|
||||
spec.Title = "Core"
|
||||
}
|
||||
if spec.Width == 0 {
|
||||
if m.defaultWidth > 0 {
|
||||
w.Width = m.defaultWidth
|
||||
spec.Width = m.defaultWidth
|
||||
} else {
|
||||
w.Width = 1280
|
||||
spec.Width = 1280
|
||||
}
|
||||
}
|
||||
if w.Height == 0 {
|
||||
if spec.Height == 0 {
|
||||
if m.defaultHeight > 0 {
|
||||
w.Height = m.defaultHeight
|
||||
spec.Height = m.defaultHeight
|
||||
} else {
|
||||
w.Height = 800
|
||||
spec.Height = 800
|
||||
}
|
||||
}
|
||||
if w.URL == "" {
|
||||
w.URL = "/"
|
||||
if spec.URL == "" {
|
||||
spec.URL = "/"
|
||||
}
|
||||
|
||||
// Apply saved state if available
|
||||
m.state.ApplyState(w)
|
||||
// Apply saved state if available.
|
||||
m.state.ApplyState(&spec)
|
||||
|
||||
pw := m.platform.CreateWindow(w.ToPlatformOptions())
|
||||
pw := m.platform.CreateWindow(spec.ToPlatformOptions())
|
||||
|
||||
m.mu.Lock()
|
||||
m.windows[w.Name] = pw
|
||||
m.windows[spec.Name] = pw
|
||||
m.mu.Unlock()
|
||||
|
||||
return pw, nil
|
||||
return spec, pw, nil
|
||||
}
|
||||
|
||||
// Get returns a tracked window by name.
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue