diff --git a/.github/workflows/split-upm.yml b/.github/workflows/split-upm.yml index d5fcc74..794c593 100644 --- a/.github/workflows/split-upm.yml +++ b/.github/workflows/split-upm.yml @@ -24,13 +24,13 @@ jobs: contents: read steps: - name: Check out source - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Install pinned Rust toolchain run: rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal - name: Cache Cargo inputs - uses: Swatinem/rust-cache@aa7c1c80a07a27a84c0aa76d0cef0aad3830e330 # v2.7.8 + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: workspaces: native @@ -83,7 +83,7 @@ jobs: git diff --exit-code -- "Packages/dev.tnayuki.unterm/Third Party Notices.md" - name: Upload SBOM and notices - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: unterm-supply-chain path: | @@ -94,12 +94,12 @@ jobs: retention-days: 30 build-macos: - runs-on: macos-latest + runs-on: macos-15 permissions: contents: read steps: - name: Check out source - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Install pinned Rust toolchain run: | @@ -107,7 +107,7 @@ jobs: rustup default "$RUST_TOOLCHAIN" - name: Cache Cargo inputs - uses: Swatinem/rust-cache@aa7c1c80a07a27a84c0aa76d0cef0aad3830e330 # v2.7.8 + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: workspaces: native @@ -117,7 +117,7 @@ jobs: cargo test -p unterm --lib --locked # Deny new warnings while preserving the upstream crate's documented # legacy lint debt until those broad refactors can be reviewed separately. - cargo clippy -p unterm --lib --tests --locked -- \ + cargo clippy -p unterm --lib --bins --tests --locked -- \ -D warnings \ -A clippy::too_many_arguments \ -A clippy::type_complexity \ @@ -135,23 +135,32 @@ jobs: - name: Write checksum and build provenance run: | - BINARY="Packages/dev.tnayuki.unterm/Editor/Plugins/macOS/unterm.dylib" - shasum -a 256 "$BINARY" > unterm-macos.sha256 + LIBRARY="Packages/dev.tnayuki.unterm/Editor/Plugins/macOS/unterm.dylib" + DEBUGGER="Packages/dev.tnayuki.unterm/Editor/Plugins/macOS/unterm-debugger" + test -x "$DEBUGGER" + library_sha256="$(shasum -a 256 "$LIBRARY" | awk '{print $1}')" + debugger_sha256="$(shasum -a 256 "$DEBUGGER" | awk '{print $1}')" + printf '%s %s\n%s %s\n' \ + "$library_sha256" "$(basename "$LIBRARY")" \ + "$debugger_sha256" "$(basename "$DEBUGGER")" \ + > unterm-macos.sha256 jq -n \ --arg commit "$GITHUB_SHA" \ - --arg runner "macos-latest" \ + --arg runner "macos-15" \ --arg toolchain "$RUST_TOOLCHAIN" \ --arg sourceDateEpoch "$(git log -1 --pretty=%ct)" \ - --arg sha256 "$(awk '{print $1}' unterm-macos.sha256)" \ - '{schemaVersion:1, commit:$commit, runner:$runner, rustToolchain:$toolchain, sourceDateEpoch:$sourceDateEpoch, artifact:"unterm.dylib", sha256:$sha256}' \ + --arg sha256 "$library_sha256" \ + --arg debuggerSha256 "$debugger_sha256" \ + '{schemaVersion:1, commit:$commit, runner:$runner, rustToolchain:$toolchain, sourceDateEpoch:$sourceDateEpoch, artifact:"unterm.dylib", sha256:$sha256, debuggerArtifact:"unterm-debugger", debuggerSha256:$debuggerSha256, debuggerSignature:"ad-hoc"}' \ > build-provenance-macos.json - name: Upload macOS build - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: unterm-macos path: | Packages/dev.tnayuki.unterm/Editor/Plugins/macOS/unterm.dylib + Packages/dev.tnayuki.unterm/Editor/Plugins/macOS/unterm-debugger unterm-macos.sha256 build-provenance-macos.json if-no-files-found: error @@ -163,7 +172,7 @@ jobs: contents: read steps: - name: Check out source - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Install pinned Rust toolchain shell: pwsh @@ -172,7 +181,7 @@ jobs: rustup default $env:RUST_TOOLCHAIN - name: Cache Cargo inputs - uses: Swatinem/rust-cache@aa7c1c80a07a27a84c0aa76d0cef0aad3830e330 # v2.7.8 + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: workspaces: native @@ -184,8 +193,13 @@ jobs: shell: pwsh run: | $binary = 'Packages/dev.tnayuki.unterm/Editor/Plugins/Windows/x86_64/unterm.dll' + $debugger = 'Packages/dev.tnayuki.unterm/Editor/Plugins/Windows/x86_64/unterm-debugger.exe' $hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $binary).Hash.ToLowerInvariant() - "$hash unterm.dll" | Set-Content -Encoding ascii unterm-windows.sha256 + $debuggerHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $debugger).Hash.ToLowerInvariant() + @( + "$hash unterm.dll" + "$debuggerHash unterm-debugger.exe" + ) | Set-Content -Encoding ascii unterm-windows.sha256 $sourceDateEpoch = (git log -1 --pretty=%ct) [ordered]@{ schemaVersion = 1 @@ -195,14 +209,17 @@ jobs: sourceDateEpoch = $sourceDateEpoch artifact = 'unterm.dll' sha256 = $hash + debuggerArtifact = 'unterm-debugger.exe' + debuggerSha256 = $debuggerHash } | ConvertTo-Json | Set-Content -Encoding utf8 build-provenance-windows.json - name: Upload Windows build - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: unterm-windows path: | Packages/dev.tnayuki.unterm/Editor/Plugins/Windows/x86_64/unterm.dll + Packages/dev.tnayuki.unterm/Editor/Plugins/Windows/x86_64/unterm-debugger.exe unterm-windows.sha256 build-provenance-windows.json if-no-files-found: error @@ -218,24 +235,24 @@ jobs: attestations: write steps: - name: Download macOS build - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: unterm-macos path: artifacts/macos - name: Download Windows build - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: unterm-windows path: artifacts/windows - name: Download supply-chain evidence - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: unterm-supply-chain path: artifacts/supply-chain - - name: Attest native binaries and evidence - uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0 + - name: Attest native libraries, debugger executables, and evidence + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: subject-path: artifacts/**/* diff --git a/.gitignore b/.gitignore index a1e061e..ebaa189 100644 --- a/.gitignore +++ b/.gitignore @@ -117,4 +117,6 @@ InitTestScene*.unity* # with `native/build-macos.sh` and `cargo about` (see native/about.toml). Their # `.meta` files stay tracked so GUIDs are stable. /Packages/dev.tnayuki.unterm/Editor/Plugins/macOS/unterm.dylib +/Packages/dev.tnayuki.unterm/Editor/Plugins/macOS/unterm-debugger /Packages/dev.tnayuki.unterm/Editor/Plugins/Windows/x86_64/unterm.dll +/Packages/dev.tnayuki.unterm/Editor/Plugins/Windows/x86_64/unterm-debugger.exe diff --git a/Packages/dev.tnayuki.unterm/CHANGELOG.md b/Packages/dev.tnayuki.unterm/CHANGELOG.md index 05c569e..d0828b0 100644 --- a/Packages/dev.tnayuki.unterm/CHANGELOG.md +++ b/Packages/dev.tnayuki.unterm/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [Unreleased] + +### Added + +- Unterm now ships a full Mono debugger for play mode. Enable it under Preferences ▸ Unterm ▸ Debugger and click in the code editor's gutter to set breakpoints (a dedicated dot column appears next to the line numbers); pressing Play then just debugs — the debugger attaches to the editor's Mono runtime over the soft-debugger protocol, arms your breakpoints as their types load (so a breakpoint in `Awake`/`Start` hits from the very first frame), and pops to the front when one hits. It runs as a standalone process with its own window (`Window ▸ Unterm ▸ Debugger (Standalone Process)`, ⇧⌘D), so it stays fully interactive while the editor is frozen at a breakpoint, and it persists across Play/Stop cycles. +- The debugger window gives you the essentials of an IDE debugger, rendered with the same native code editor as Unterm's own: a project file tree, a read-only source view with the executing line highlighted, continue / step over / into / out (also `c`/`n`/`i`/`o`), pause ("break all", `p`), a call stack you can click to inspect any frame, a thread list when a stop involves multiple managed threads, and a variables panel showing locals, `this`, and inherited fields — private members included — with lazy expansion of objects and arrays (reference cycles like `transform ↔ gameObject` are handled). Hovering an identifier in the source shows its current value, and the breakpoint list jumps to source on click. +- Breakpoints live in `Library/Unterm/breakpoints.json`, shared between the editor and the debugger and synced live in both directions: toggling in the code editor arms/disarms in a running debug session immediately (even mid-play), and toggling in the debugger — including while the editor is frozen at a breakpoint — updates the editor's gutter dots as soon as it runs again. +- The debugger can attach to more than the editor: development builds with script debugging enabled are discovered on the network via Unity's player multicast and offered in a target picker (labelled with their project and host), and source locations reported by a player built on another machine are mapped back to this project's files for display and breakpoints. + +- The code editor now syntax-highlights Markdown while editing and can render it (toggle Preview with ⇧⌘V, or the tab ⋮ / right-click menu); a `.md` opened from a transcript or preview link opens rendered by default, while a Project double-click opens for editing. + +- The set of file extensions the code editor opens is now its own preference (Preferences ▸ Unterm ▸ Code Editor, semicolon-separated, with a reset button) instead of borrowing Unity's C# project-generation extension settings — Unterm never generates `.csproj` files, so tying "what opens" to a generation setting that never runs was misleading. The default covers Unity's code and text formats plus the docs, configs, and native-plugin sources an agent transcript typically links (`md`, `yml`, `toml`, `rs`, `c`/`cpp`, `java`/`kt`, `swift`, shell scripts, …), so those open in-editor out of the box. + +### Fixed + +- The Unterm code editor stays selected as the External Script Editor across package updates — it's now recognized by package identity rather than the exact cached path (which changes each update), so script and link opens keep landing in Unterm instead of silently falling back to another editor. +- File-path links in the Claude Code transcript now underline every existing file instead of only a hard-coded list of code extensions, and a click always opens the file somewhere: the configured script editor first, then Unity's own asset pipeline on decline (a scene link loads the scene, a prefab opens in prefab mode, an image opens in its associated app), and the OS default app for files outside the asset database — so paths like `native/**/*.rs` or `docs/*.md` no longer show an underline that does nothing when clicked. + ## [0.6.0] - 2026-07-08 ### Added diff --git a/Packages/dev.tnayuki.unterm/Editor/Plugins/Windows/x86_64/unterm-debugger.exe.meta b/Packages/dev.tnayuki.unterm/Editor/Plugins/Windows/x86_64/unterm-debugger.exe.meta new file mode 100644 index 0000000..778ea17 --- /dev/null +++ b/Packages/dev.tnayuki.unterm/Editor/Plugins/Windows/x86_64/unterm-debugger.exe.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: cb9a07383c2428e7f826a00406c94bae +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/dev.tnayuki.unterm/Editor/Plugins/macOS/unterm-debugger.meta b/Packages/dev.tnayuki.unterm/Editor/Plugins/macOS/unterm-debugger.meta new file mode 100644 index 0000000..813609b --- /dev/null +++ b/Packages/dev.tnayuki.unterm/Editor/Plugins/macOS/unterm-debugger.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 863b76ae04ad815c6731490daa3f7fed +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/dev.tnayuki.unterm/Editor/UntermAgentWindow.cs b/Packages/dev.tnayuki.unterm/Editor/UntermAgentWindow.cs index da2a8d2..27abe2c 100644 --- a/Packages/dev.tnayuki.unterm/Editor/UntermAgentWindow.cs +++ b/Packages/dev.tnayuki.unterm/Editor/UntermAgentWindow.cs @@ -923,9 +923,10 @@ private void HandlePanelMouse(Rect rect) case EventType.MouseUp when _selecting: _selecting = false; - // A plain click (no drag-selection) on a file path opens it through - // the configured script editor. OpenFromAgent no-ops for non-file / - // non-editable tokens (the underline only marks files that exist). + // A plain click (no drag-selection) on a file path opens it: the + // configured script editor first, then Unity's asset pipeline or + // the OS default app on decline. OpenFromAgent no-ops for tokens + // that aren't existing files (matching the underline). if (!_native.AgentviewPanelHasSelection(Vid)) { string tok = _native.AgentviewPanelTokenAt(Vid, lx, ly); diff --git a/Packages/dev.tnayuki.unterm/Editor/UntermBreakpoints.cs b/Packages/dev.tnayuki.unterm/Editor/UntermBreakpoints.cs new file mode 100644 index 0000000..f3566b2 --- /dev/null +++ b/Packages/dev.tnayuki.unterm/Editor/UntermBreakpoints.cs @@ -0,0 +1,148 @@ +using System.Collections.Generic; +using System.IO; +using UnityEditor; +using UnityEngine; + +namespace Unterm.Editor +{ + /// + /// Project-wide debugger breakpoint store, persisted to + /// Library/Unterm/breakpoints.json (the same convention as the rest of + /// Unterm's editor state). It is the single source of truth shared by BOTH the + /// editor and the standalone debugger process — the debugger writes the same file + /// directly when you toggle a breakpoint while stopped (when the editor is frozen + /// and can't run), so edits from either side persist. + /// + /// Lines are 0-based, matching the native code editor's gutter line indices + /// (converted to 1-based when handed to the SDB debugger, whose sequence points + /// are 1-based source lines). + /// + [InitializeOnLoad] + internal static class UntermBreakpoints + { + [System.Serializable] + private class Entry { public string path; public int[] lines; } + [System.Serializable] + private class Store { public Entry[] files; } + + private static Dictionary> _map; + + private static string Dir => + Path.Combine(Path.GetDirectoryName(Application.dataPath), "Library", "Unterm"); + private static string StorePath => Path.Combine(Dir, "breakpoints.json"); + + /// Raised when the store file changes on disk — i.e. the debugger toggled a + /// breakpoint while we run — so open code editors can refresh their gutter dots. + public static event System.Action Changed; + + // Last write time we've accounted for; poll for anything newer (the debugger's + // edits), and stamp it after our own writes so we don't fire on those. + private static long _seenTicks; + + static UntermBreakpoints() + { + _seenTicks = FileTicks(); + EditorApplication.update += Poll; + } + + private static long FileTicks() + { + try { return File.Exists(StorePath) ? File.GetLastWriteTimeUtc(StorePath).Ticks : 0; } + catch { return 0; } + } + + private static void Poll() + { + long ticks = FileTicks(); + if (ticks == _seenTicks) return; + _seenTicks = ticks; + _map = null; // drop the cache; next read reloads from disk + Changed?.Invoke(); + } + + private static Dictionary> Map + { + get { if (_map == null) Load(); return _map; } + } + + /// Re-read from disk (e.g. to pick up edits the debugger made while stopped). + public static void Reload() => _map = null; + + /// 0-based breakpoint lines for a file (ascending), as uint[] for the FFI. + public static uint[] For(string path) + { + if (!string.IsNullOrEmpty(path) && Map.TryGetValue(Norm(path), out var s)) + { + var a = new uint[s.Count]; + int i = 0; + foreach (var l in s) a[i++] = (uint)l; + return a; + } + return System.Array.Empty(); + } + + /// Toggle a 0-based breakpoint line; returns the file's new full set. + public static uint[] Toggle(string path, int line) + { + if (string.IsNullOrEmpty(path)) return System.Array.Empty(); + var key = Norm(path); + if (!Map.TryGetValue(key, out var s)) { s = new SortedSet(); Map[key] = s; } + if (!s.Remove(line)) s.Add(line); + if (s.Count == 0) Map.Remove(key); + Save(); + return For(path); + } + + /// All (absolute file path, 0-based line) breakpoints across the project. + public static List> All() + { + var r = new List>(); + foreach (var kv in Map) + foreach (var l in kv.Value) + r.Add(new KeyValuePair(kv.Key, l)); + return r; + } + + private static string Norm(string p) + { + try { return Path.GetFullPath(p); } catch { return p; } + } + + private static void Load() + { + _map = new Dictionary>(); + try + { + if (!File.Exists(StorePath)) return; + var store = JsonUtility.FromJson(File.ReadAllText(StorePath)); + if (store?.files == null) return; + foreach (var e in store.files) + { + if (string.IsNullOrEmpty(e.path) || e.lines == null) continue; + var set = new SortedSet(); + foreach (var l in e.lines) set.Add(l); + if (set.Count > 0) _map[Norm(e.path)] = set; + } + } + catch { /* corrupt store: start empty */ } + } + + private static void Save() + { + try + { + Directory.CreateDirectory(Dir); + var entries = new List(_map.Count); + foreach (var kv in _map) + { + var arr = new int[kv.Value.Count]; + kv.Value.CopyTo(arr); + entries.Add(new Entry { path = kv.Key, lines = arr }); + } + File.WriteAllText(StorePath, JsonUtility.ToJson(new Store { files = entries.ToArray() })); + _seenTicks = FileTicks(); // our own write; don't re-fire Changed for it + } + catch { /* best effort */ } + } + } +} diff --git a/Packages/dev.tnayuki.unterm/Editor/UntermBreakpoints.cs.meta b/Packages/dev.tnayuki.unterm/Editor/UntermBreakpoints.cs.meta new file mode 100644 index 0000000..163aa7a --- /dev/null +++ b/Packages/dev.tnayuki.unterm/Editor/UntermBreakpoints.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3637239f1719f4940a32dc3f182f1fc8 \ No newline at end of file diff --git a/Packages/dev.tnayuki.unterm/Editor/UntermCodeEditorWindow.cs b/Packages/dev.tnayuki.unterm/Editor/UntermCodeEditorWindow.cs index ea82f65..694ee14 100644 --- a/Packages/dev.tnayuki.unterm/Editor/UntermCodeEditorWindow.cs +++ b/Packages/dev.tnayuki.unterm/Editor/UntermCodeEditorWindow.cs @@ -97,6 +97,16 @@ public sealed class UntermCodeEditorWindow : EditorWindow, IHasCustomMenu private GUIStyle _imeHidden; private bool _mouseDragging; + // Markdown preview: a read-only rendered view of the LIVE buffer (unsaved + // edits included), served by the native EditorView's document-mode panel. + // Only offered for Markdown files. Serialized so a domain reload keeps the + // mode; re-applied to the native view in LoadNative. + [SerializeField] private bool _preview; + // Preview click-to-open: the file-path token under the press, opened on a + // release that didn't drag (a drag is a text selection instead). + private bool _pvMoved; + private string _pvDownToken = ""; + // Find / replace / goto bar overlay. private enum BarMode { None, Find, Replace, Goto } private BarMode _bar = BarMode.None; @@ -192,22 +202,45 @@ private static bool OpenSelectedValidate() // Open a file path clicked in the Claude Code transcript. `root` resolves a // project-relative path (the agent often reports paths relative to its - // working directory). Routes through the configured script editor: when Unterm - // is selected in External Tools the file opens here; otherwise it opens in - // whatever editor is configured. No-op for missing or non-editable files. + // working directory). The transcript underlines every existing file, so any + // click must land somewhere: the configured script editor first (when Unterm + // is selected in External Tools, code/text files open here), then Unity's + // asset pipeline for anything the editor declined (a scene loads, a prefab + // opens in prefab mode, an image in its associated app), and the OS default + // app for files outside the asset database. No-op only for missing files. public static void OpenFromAgent(string path, string root) { if (string.IsNullOrEmpty(path)) return; if (!Path.IsPathRooted(path) && !string.IsNullOrEmpty(root)) path = Path.Combine(root, path); - if (Directory.Exists(path) || !File.Exists(path) || - !UntermExternalCodeEditor.HandlesExtension(path)) return; - CodeEditor.Editor.CurrentCodeEditor?.OpenProject(Path.GetFullPath(path), -1, -1); + if (Directory.Exists(path) || !File.Exists(path)) return; + string full = Path.GetFullPath(path); + // A link clicked in a rendered view (the agent transcript or a Markdown + // preview) is a reading action, so a linked Markdown file opens in + // preview by default. The hint is honored only by Unterm's OpenProject + // (a different selected external editor ignores it and opens normally); + // clear it afterwards so it can't leak into a later, unrelated open. + UntermExternalCodeEditor.NextOpenPrefersPreview = IsMarkdownPath(full); + try + { + if (CodeEditor.Editor.CurrentCodeEditor?.OpenProject(full, -1, -1) == true) + return; + } + finally + { + UntermExternalCodeEditor.NextOpenPrefersPreview = false; + } + string rel = FileUtil.GetProjectRelativePath(full.Replace('\\', '/')); + var asset = string.IsNullOrEmpty(rel) ? null : AssetDatabase.LoadMainAssetAtPath(rel); + if (asset != null) AssetDatabase.OpenAsset(asset); + else EditorUtility.OpenWithDefaultApp(full); } // Reuse an already-open window for the same file; otherwise a new one. `line` // is 1-based (-1 = none) and jumps the caret once the editor is ready. - internal static void OpenPath(string path, int line = -1) + // `preview` opens a new Markdown window rendered rather than in edit mode; an + // already-open window keeps whatever mode it's in (it may be mid-edit). + internal static void OpenPath(string path, int line = -1, bool preview = false) { string full = Path.GetFullPath(path); foreach (var w in Resources.FindObjectsOfTypeAll()) @@ -226,6 +259,9 @@ internal static void OpenPath(string path, int line = -1) var win = CreateInstance(); win.minSize = new Vector2(320, 200); DockIntoCenter(win); + // Set the preview intent before LoadFile: LoadFile applies it (gated to + // Markdown via CanPreview) once the native view is ready. + win._preview = preview; win.LoadFile(path); win._pendingLine = line; // applied once the native editor is ready win.Focus(); @@ -309,17 +345,64 @@ private static Rect RectUnion(Rect a, Rect b) return new Rect(x, y, xMax - x, yMax - y); } - // Extension -> tree-sitter language token (null = no grammar / plain). The - // first milestone only bundles C#; others fall through to plain. + // Extension -> tree-sitter language token (null = no grammar / plain). We + // bundle C# and Markdown grammars; others fall through to plain. private static string LangTokenFor(string path) { switch (Path.GetExtension(path).ToLowerInvariant()) { case ".cs": return "cs"; + case ".md": + case ".markdown": return "md"; default: return null; } } + // Whether `path` is a Markdown file (the preview toggle is offered only then). + internal static bool IsMarkdownPath(string path) + { + switch (Path.GetExtension(path ?? "").ToLowerInvariant()) + { + case ".md": + case ".markdown": return true; + default: return false; + } + } + + private bool CanPreview => + _native != null && _native.EditorPreviewSupported && IsMarkdownPath(_filePath); + + // Flip Markdown preview on/off. Preview is read-only, so it also drops the + // completion popup / signature hint and closes the find bar. + private void TogglePreview() + { + if (!CanPreview && !_preview) return; + SetPreview(!_preview); + } + + private void SetPreview(bool on) + { + if (_native == null || _editorId == 0) return; + _preview = on && CanPreview; + _native.EditorSetPreview(Eid, _preview); + if (_preview) + { + CloseCompletion(); + CloseSignatureHelp(); + if (_bar != BarMode.None) CloseBar(); + } + else + { + _refocus = true; // hand focus back to the editing surface + // Diff polling paused while previewing: re-read HEAD + index now so + // the gutter reflects any commits / staging done in the meantime. + _native.EditorRefreshDiff(Eid); + } + UpdateTitle(); + RenderView(); + Repaint(); + } + // Decide the line ending to preserve: keep the file's if it has one // (CRLF if any \r\n present, else LF), otherwise the OS default. private static bool DetectCrlf(string text) @@ -349,8 +432,14 @@ private void LoadFile(string path) { _native.EditorSetLanguage(Eid, _langToken); _native.EditorSetText(Eid, text); + if (UntermDebuggerPrefs.Enabled) + _native.EditorSetBreakpoints(Eid, UntermBreakpoints.For(_filePath)); // restore dots _savedSerial = _native.EditorEditSerial(Eid); // baseline: just-loaded = clean _native.EditorSetPath(Eid, _filePath); // fetch git texts for diff gutter markers + // A non-Markdown file can't be previewed: drop the mode carried over + // from a previously-open Markdown file in this window. + if (_preview && !CanPreview) _preview = false; + _native.EditorSetPreview(Eid, _preview); RenderView(); Repaint(); } @@ -382,6 +471,32 @@ private void OnEnable() // serialized flag is just the pre-reload display value). MarkDirty(); UpdateTitle(); + UntermDebuggerPrefs.Changed += OnDebuggerPrefChanged; + // The debugger toggling a breakpoint (while we run) writes the store; refresh + // this file's dots when that happens. + UntermBreakpoints.Changed += OnBreakpointsChanged; + } + + // Debugging toggled in Preferences: re-apply the gutter mode (dot column) and + // show/clear this file's breakpoint dots immediately. + private void OnDebuggerPrefChanged() + { + if (_native == null || _editorId == 0) return; + _native.EditorSetBpGutter(Eid, UntermDebuggerPrefs.Enabled); + OnBreakpointsChanged(); + } + + // The shared store changed on disk (debugger-side toggle): re-apply this file's + // dots. No-op'd cheaply when the file/line set is unchanged. + private void OnBreakpointsChanged() + { + if (_native == null || _editorId == 0) return; + _native.EditorSetBreakpoints(Eid, + UntermDebuggerPrefs.Enabled && !string.IsNullOrEmpty(_filePath) + ? UntermBreakpoints.For(_filePath) + : Array.Empty()); + RenderView(); + Repaint(); } private void OnBeforeReload() @@ -396,6 +511,8 @@ private void OnBeforeReload() private void OnDisable() { + UntermDebuggerPrefs.Changed -= OnDebuggerPrefChanged; + UntermBreakpoints.Changed -= OnBreakpointsChanged; AssemblyReloadEvents.beforeAssemblyReload -= OnBeforeReload; EditorApplication.update -= OnEditorUpdate; _native?.PopupHide(); @@ -476,6 +593,18 @@ private void LoadNative() _native.EditorRefreshDiff(Eid); } + // Sync the breakpoint gutter to the current pref for BOTH fresh and + // re-adopted views (a re-adopted native view keeps its old flag, which + // would otherwise leave the gutter/dots out of sync after a reload). + _native.EditorSetBpGutter(Eid, UntermDebuggerPrefs.Enabled); + if (UntermDebuggerPrefs.Enabled && !string.IsNullOrEmpty(_filePath)) + _native.EditorSetBreakpoints(Eid, UntermBreakpoints.For(_filePath)); + + // Re-apply preview mode after a reload / re-adopt (the serialized flag + // is the intent; a re-adopted native view keeps its own, which may drift). + if (_preview && !CanPreview) _preview = false; + _native.EditorSetPreview(Eid, _preview); + _refocus = true; RenderView(); _status = "ready"; @@ -666,13 +795,18 @@ private void OnGUI() DrawBar(); - DrawImeField(rect); - SyncIme(); - // Don't steal focus from the find bar's field while it's open. - if (_bar == BarMode.None && _refocus && Event.current.type == EventType.Repaint) + // Preview is read-only: no hidden IME field (its commit would mutate the + // buffer) and no focus grab. + if (!_preview) { - EditorGUI.FocusTextInControl(InputControl); - _refocus = false; + DrawImeField(rect); + SyncIme(); + // Don't steal focus from the find bar's field while it's open. + if (_bar == BarMode.None && _refocus && Event.current.type == EventType.Repaint) + { + EditorGUI.FocusTextInControl(InputControl); + _refocus = false; + } } } @@ -688,9 +822,30 @@ private void HandleMouse(Rect rect) float lx = (e.mousePosition.x - rect.x) * ppp; float ly = (e.mousePosition.y - rect.y) * ppp; + // Preview: read-only text selection + click-to-open file paths; none of + // the editing / gutter / breakpoint / hover-tooltip handling applies. + if (_preview) { HandlePreviewMouse(rect, e, lx, ly); return; } + switch (e.type) { case EventType.MouseDown when e.button == 0 && rect.Contains(e.mousePosition): + // Gutter clicks with debugging enabled: the thin left diff-marker + // lane (~8px) keeps its Stage/Revert menu; the rest of the gutter + // (breakpoint column + line numbers) toggles a breakpoint. + if (UntermDebuggerPrefs.Enabled + && lx < _native.EditorGutterWidth(Eid) && !string.IsNullOrEmpty(_filePath)) + { + if (lx < 8f * ppp) + { + int laneHunk = _native.EditorHunkAt(Eid, lx, ly); + if (laneHunk >= 0) { ShowHunkMenu(laneHunk); e.Use(); break; } + } + uint bpLine = _native.EditorLineAtY(Eid, ly); + var set = UntermBreakpoints.Toggle(_filePath, (int)bpLine); + _native.EditorSetBreakpoints(Eid, set); + RenderView(); Repaint(); e.Use(); + break; + } CloseCompletion(); // a click dismisses the popup // A click on a gutter diff marker opens its Stage/Revert menu // instead of moving the caret (VS Code-style). @@ -726,6 +881,56 @@ private void HandleMouse(Rect rect) } } + // Mouse in Markdown preview: drag selects text; a click (no drag) on an + // underlined file-path token opens it (the same targets the transcript + // opens); a context click offers copy / select-all / exit. + private void HandlePreviewMouse(Rect rect, Event e, float lx, float ly) + { + switch (e.type) + { + case EventType.MouseDown when e.button == 0 && rect.Contains(e.mousePosition): + _pvDownToken = _native.EditorPreviewTokenAt(Eid, lx, ly) ?? ""; + _pvMoved = false; + _native.EditorMouse(Eid, lx, ly, 0); // begin selection + _mouseDragging = true; + RenderView(); Repaint(); e.Use(); + break; + case EventType.MouseDrag when _mouseDragging: + _pvMoved = true; + _native.EditorMouse(Eid, lx, ly, 1); // extend selection + RenderView(); Repaint(); e.Use(); + break; + case EventType.MouseUp when _mouseDragging: + _mouseDragging = false; + if (!_pvMoved && !string.IsNullOrEmpty(_pvDownToken)) + OpenFromAgent(_pvDownToken, Path.GetDirectoryName(_filePath) ?? ""); + _pvDownToken = ""; + e.Use(); + break; + case EventType.ContextClick when rect.Contains(e.mousePosition): + ShowPreviewContextMenu(); + e.Use(); + break; + } + } + + private void ShowPreviewContextMenu() + { + var menu = new GenericMenu(); + menu.AddItem(new GUIContent("Copy"), false, () => + { + string s = _native.EditorCopy(Eid); + if (!string.IsNullOrEmpty(s)) EditorGUIUtility.systemCopyBuffer = s; + }); + menu.AddItem(new GUIContent("Select All"), false, () => + { + _native.EditorSelectAll(Eid); RenderView(); Repaint(); + }); + menu.AddSeparator(""); + menu.AddItem(new GUIContent($"Edit (exit preview) {KCmd}{KSep}{KShift}{KSep}V"), false, () => SetPreview(false)); + menu.ShowAsContext(); + } + private Vector2 _lastHoverPos = new Vector2(float.NaN, float.NaN); // Show/hide the diff-peek tooltip for the pointer at `pos`. Deduped by @@ -814,6 +1019,11 @@ private void ShowContextMenu() { _native.EditorToggleComment(Eid); MarkDirty(); RenderView(); Repaint(); }); + if (CanPreview) + { + menu.AddSeparator(""); + menu.AddItem(new GUIContent($"Preview Markdown {KCmd}{KSep}{KShift}{KSep}V"), false, TogglePreview); + } menu.AddSeparator(""); menu.AddItem(new GUIContent($"Save {KCmd}{KSep}S"), false, Save); menu.ShowAsContext(); @@ -824,6 +1034,11 @@ private void ShowContextMenu() public void AddItemsToMenu(GenericMenu menu) { if (_native == null || _editorId == 0) return; + if (CanPreview || _preview) + { + menu.AddItem(new GUIContent($"Preview Markdown {KCmd}{KSep}{KShift}{KSep}V"), _preview, TogglePreview); + menu.AddSeparator(""); + } menu.AddItem(new GUIContent($"Find… {KCmd}{KSep}F"), false, () => OpenBar(BarMode.Find)); menu.AddItem(new GUIContent($"Replace… {KCmd}{KSep}{KAlt}{KSep}F"), false, () => OpenBar(BarMode.Replace)); menu.AddItem(new GUIContent($"Go to Line… {KCmd}{KSep}L"), false, () => OpenBar(BarMode.Goto)); @@ -859,6 +1074,29 @@ private void HandleKeys() return; } + // Markdown preview is read-only: allow copy / select-all / toggle / exit, + // and swallow every editing key so nothing reaches the buffer. + if (_preview) + { + if ((e.command || e.control) && e.shift && e.keyCode == KeyCode.V) { TogglePreview(); e.Use(); return; } + if (e.keyCode == KeyCode.Escape) { SetPreview(false); e.Use(); return; } + if (e.command || e.control) + { + switch (e.keyCode) + { + case KeyCode.C: + { + string s = _native.EditorCopy(Eid); + if (!string.IsNullOrEmpty(s)) EditorGUIUtility.systemCopyBuffer = s; + e.Use(); return; + } + case KeyCode.A: + _native.EditorSelectAll(Eid); RenderView(); Repaint(); e.Use(); return; + } + } + return; + } + // While composing, let every key reach the IME field. if (_composing) return; @@ -962,6 +1200,8 @@ private void HandleKeys() { case KeyCode.S: Save(); e.Use(); return; + case KeyCode.V when e.shift && CanPreview: + TogglePreview(); e.Use(); return; case KeyCode.V: _native.EditorInsert(Eid, EditorGUIUtility.systemCopyBuffer); MarkDirty(); RenderView(); Repaint(); e.Use(); return; @@ -1469,13 +1709,16 @@ private void OnEditorUpdate() if (EditorApplication.timeSinceStartup - _lastExtCheck > 1.0) { _lastExtCheck = EditorApplication.timeSinceStartup; - CheckExternalChange(); - _native.EditorRefreshDiff(Eid); // background re-read of HEAD + index + CheckExternalChange(); // still needed in preview (file may change on disk) + // The diff gutter is an edit-mode surface: don't re-read git while + // previewing (SetPreview refreshes it again on exit). + if (!_preview) _native.EditorRefreshDiff(Eid); // background HEAD + index } // A background git fetch may have finished: poll every tick (cheap bool); // native applies it and returns true only when the git texts actually - // changed, so the steady-state 1s refresh doesn't cause re-renders. - if (_native.EditorPollDiff(Eid)) { RenderView(); Repaint(); } + // changed, so the steady-state 1s refresh doesn't cause re-renders. Skipped + // in preview (the gutter isn't shown, so there's nothing to apply). + if (!_preview && _native.EditorPollDiff(Eid)) { RenderView(); Repaint(); } PollSignatureTask(); // While a hint is shown, re-evaluate whenever the caret moved by ANY means // (arrows, click, edits) so it tracks the active parameter and closes once @@ -2029,7 +2272,8 @@ private void UpdateTitle() { // No manual dirty marker — Unity overlays the unsaved indicator from // hasUnsavedChanges. - titleContent = new GUIContent(string.IsNullOrEmpty(_filePath) ? "Untitled" : Path.GetFileName(_filePath)); + string name = string.IsNullOrEmpty(_filePath) ? "Untitled" : Path.GetFileName(_filePath); + titleContent = new GUIContent(_preview ? name + " (Preview)" : name); } // Cmd/Ctrl+S while this window is focused saves the file (a window-context diff --git a/Packages/dev.tnayuki.unterm/Editor/UntermDebuggerLauncher.cs b/Packages/dev.tnayuki.unterm/Editor/UntermDebuggerLauncher.cs new file mode 100644 index 0000000..f39c98b --- /dev/null +++ b/Packages/dev.tnayuki.unterm/Editor/UntermDebuggerLauncher.cs @@ -0,0 +1,190 @@ +using System.Diagnostics; +using System.IO; +using UnityEditor; +using UnityEngine; + +namespace Unterm.Editor +{ + /// + /// Whether debugging is enabled (Preferences > Unterm). Gates the debugger menu + /// item, the Play-mode auto-launch, and the code editor's breakpoint gutter (the + /// gutter only reserves the dot column and toggles breakpoints when enabled). + /// + internal static class UntermDebuggerPrefs + { + private const string EnabledKey = "Unterm.Debugger.Enabled"; + + /// Raised when changes, so open code editors can + /// re-apply their gutter mode immediately. + public static event System.Action Changed; + + public static bool Enabled + { + get => EditorPrefs.GetBool(EnabledKey, false); + set + { + if (value == Enabled) return; + EditorPrefs.SetBool(EnabledKey, value); + Changed?.Invoke(); + } + } + } + + /// + /// Manages the standalone Unterm debugger — a separate process with its own window + /// that attaches to the editor's Mono agent. It can be opened explicitly from the + /// Window/Unterm/Debugger (Standalone Process) menu, and is also opened automatically when you + /// enter Play mode with breakpoints set. The window is persistent: it stays alive + /// across Play/Stop cycles (surfacing itself when a breakpoint is hit) and closes + /// itself when the editor it is attached to goes away. + /// + [InitializeOnLoad] + internal static class UntermDebuggerLauncher + { + private const string ProcessName = "unterm-debugger"; +#if UNITY_EDITOR_WIN + private const string ExecutableName = ProcessName + ".exe"; +#else + private const string ExecutableName = ProcessName; +#endif + private static Process _proc; + + static UntermDebuggerLauncher() + { + EditorApplication.playModeStateChanged -= OnPlayModeChanged; + EditorApplication.playModeStateChanged += OnPlayModeChanged; + // Best-effort cleanup of the instance we launched when the editor quits. + EditorApplication.quitting -= Stop; + EditorApplication.quitting += Stop; + // (Following the editor to the foreground is handled natively by the debugger + // via an NSWorkspace observer, so it works even while suspended at a + // breakpoint — the editor's main thread is frozen then and can't signal.) + } + + private static string ProjectRoot => + Directory.GetParent(Application.dataPath)?.FullName ?? Application.dataPath; + + // The debugger and editor coordinate through this project's Library/Unterm dir, + // so single-instance and focus are scoped to THIS project (not by process name, + // which would clash with a debugger running for another Unity project). + private static string StateDir => Path.Combine(ProjectRoot, "Library", "Unterm"); + private static string PidFile => Path.Combine(StateDir, "debugger.pid"); + private static string FocusFile => Path.Combine(StateDir, "focus.request"); + + /// Open the debugger window from the menu. If this project's debugger is already + /// running, bring it forward; otherwise launch it (in Edit mode it attaches, + /// arms the current breakpoints and waits for Play — you can also use Pause). + /// Greyed out until debugging is enabled in Preferences > Unterm. + [MenuItem("Window/Unterm/Debugger (Standalone Process) %#d")] + private static void Open() => EnsureRunning(); + + [MenuItem("Window/Unterm/Debugger (Standalone Process) %#d", validate = true)] + private static bool OpenValidate() => UntermDebuggerPrefs.Enabled; + + private static void OnPlayModeChanged(PlayModeStateChange state) + { + // Auto-open on Play so a breakpoint has something to stop into. The window + // is left running when leaving Play; it re-syncs breakpoints each run. + // The debugger is a persistent, always-attached process (like an IDE that + // stays attached across Play/Stop), so once it's up there's no launch race + // to wait out — and the type-load watch suspends the VM to arm in time. + if (state == PlayModeStateChange.ExitingEditMode && UntermDebuggerPrefs.Enabled) + { + UntermBreakpoints.Reload(); + if (UntermBreakpoints.All().Count > 0) EnsureRunning(); + } + } + + // The PID of this project's running debugger (from its pid file), or 0 if none. + // A domain reload discards the managed `_proc` handle, so the pid file — written + // by the debugger into THIS project's Library/Unterm — is the source of truth. + private static int RunningPid() + { + try + { + if (!File.Exists(PidFile)) return 0; + if (!int.TryParse(File.ReadAllText(PidFile).Trim(), out var pid) || pid <= 0) return 0; + var p = Process.GetProcessById(pid); // throws if no such process + if (p.HasExited) return 0; + // Guard against PID reuse: confirm it's actually our debugger. + return p.ProcessName.Contains(ProcessName) ? pid : 0; + } + catch { return 0; } + } + + private static bool IsRunning() => RunningPid() != 0; + + // Touch the focus file; the running debugger polls it and comes to the front. + private static void RequestFocus() + { + try + { + Directory.CreateDirectory(StateDir); + File.WriteAllText(FocusFile, System.DateTime.UtcNow.Ticks.ToString()); + } + catch { /* best effort */ } + } + + // The debugger binary ships next to the native plugin; fall back to the cargo + // dev build during development. + private static string DebuggerPath() + { + try + { + var dir = Path.GetDirectoryName(UntermWindow.PluginPath); + if (!string.IsNullOrEmpty(dir)) + { + var p = Path.Combine(dir, ExecutableName); + if (File.Exists(p)) return p; + } + } + catch { /* fall through to dev path */ } + var dev = Path.Combine(ProjectRoot, "native", "target", "debug", ExecutableName); + return File.Exists(dev) ? dev : null; + } + + private static void EnsureRunning() + { + // Single instance per project: if it's already up, just bring it forward. + if (IsRunning()) { RequestFocus(); return; } + + var exe = DebuggerPath(); + if (string.IsNullOrEmpty(exe)) + { + UnityEngine.Debug.LogWarning("Unterm: unterm-debugger binary not found; cannot start debugger."); + return; + } + + // No breakpoint arguments: the debugger seeds itself from the shared store + // (Library/Unterm/breakpoints.json) — the same file it re-reads on every + // play-mode domain reload — so both sides share one source of truth. + try + { + _proc = Process.Start(new ProcessStartInfo + { + FileName = exe, + WorkingDirectory = ProjectRoot, + UseShellExecute = false, + }); + } + catch (System.Exception e) + { + UnityEngine.Debug.LogWarning("Unterm: failed to launch debugger: " + e.Message); + _proc = null; + } + } + + private static void Stop() + { + try { if (_proc != null && !_proc.HasExited) _proc.Kill(); } + catch { /* already gone */ } + _proc = null; + // Also stop the instance recorded in this project's pid file, in case our + // managed handle was lost to a domain reload. This is project-scoped (the + // pid file lives in this project's Library), so it won't touch another + // project's debugger. + try { var pid = RunningPid(); if (pid != 0) Process.GetProcessById(pid).Kill(); } + catch { /* already gone */ } + } + } +} diff --git a/Packages/dev.tnayuki.unterm/Editor/UntermDebuggerLauncher.cs.meta b/Packages/dev.tnayuki.unterm/Editor/UntermDebuggerLauncher.cs.meta new file mode 100644 index 0000000..1d4412d --- /dev/null +++ b/Packages/dev.tnayuki.unterm/Editor/UntermDebuggerLauncher.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9a81c29b68a4f41258d3be7ee3960599 \ No newline at end of file diff --git a/Packages/dev.tnayuki.unterm/Editor/UntermExternalCodeEditor.cs b/Packages/dev.tnayuki.unterm/Editor/UntermExternalCodeEditor.cs index 973bea6..fa84604 100644 --- a/Packages/dev.tnayuki.unterm/Editor/UntermExternalCodeEditor.cs +++ b/Packages/dev.tnayuki.unterm/Editor/UntermExternalCodeEditor.cs @@ -5,6 +5,44 @@ namespace Unterm.Editor { + /// + /// The file extensions the Unterm code editor claims, user-editable under + /// "Preferences > Unterm > Code Editor" (semicolon-separated; dots and + /// case don't matter). The VSCode/Rider packages gate opening on Unity's C# + /// project-generation extension settings because for them "openable" means + /// "part of the generated project" — Unterm generates no .csproj at all, so + /// borrowing a generation setting it never runs would be misleading; it keeps + /// its own list instead. The default covers Unity's code/text formats plus the + /// docs, configs and native-plugin sources an agent transcript typically links. + /// + internal static class UntermOpenExtensions + { + private const string Key = "Unterm.CodeEditor.OpenExtensions"; + + public const string Default = + "cs;uxml;uss;shader;compute;cginc;hlsl;glslinc;template;raytrace;" + + "asmdef;asmref;rsp;json;log;txt;xml;md;markdown;yml;yaml;toml;ini;cfg;csv;tsv;properties;" + + "js;ts;py;rs;lua;sh;bat;ps1;c;h;cc;cpp;hpp;mm;m;swift;java;kt;gradle;pro;plist;html;css;" + + "gitignore;gitattributes"; + + public static string Value + { + get => EditorPrefs.GetString(Key, Default); + set => EditorPrefs.SetString(Key, value); + } + + /// Whether `ext` (no dot) is in the configured list. Parses on each call — + /// this sits behind user clicks, never a per-frame path. + public static bool Contains(string ext) + { + foreach (var e in Value.Split(';')) + if (string.Equals(e.Trim().TrimStart('.'), ext, + System.StringComparison.OrdinalIgnoreCase)) + return true; + return false; + } + } + /// /// Registers the Unterm code editor as a selectable "External Script Editor" /// (Preferences > External Tools). When Unterm is chosen, every script open @@ -17,11 +55,25 @@ namespace Unterm.Editor [InitializeOnLoad] internal sealed class UntermExternalCodeEditor : IExternalCodeEditor { + // One-shot hint: the next OpenProject should open a Markdown file in preview + // (rendered) rather than edit mode. Set by the transcript / preview link + // flow just before it routes through the generic OpenProject, and consumed + // (cleared) here — so only a link click in a rendered view prefers preview, + // while a Project-window double-click (which also lands in OpenProject) opens + // in edit mode. A different selected external editor never reads it. + internal static bool NextOpenPrefersPreview; + + // Unity's package id, used to recognize our stored selection by identity + // rather than an exact path (see TryGetInstallationForPath). + private const string PackageId = "dev.tnayuki.unterm"; + // The "installation path" Unity stores as the selected editor. Unterm is // in-editor (no executable), but Unity's dropdown only lists installations // whose path exists on disk. The compiled assembly is real and stable within // a project, and unlike a package.json path it remains valid when Unterm is - // embedded under another package's Editor/ThirdParty subtree. + // embedded under another package's Editor/ThirdParty subtree. The identity + // fallback below still recognizes selections made by standalone upstream + // package versions that used package.json as their installation path. internal static readonly string EditorKey = Path.GetFullPath(typeof(UntermExternalCodeEditor).Assembly.Location); @@ -30,6 +82,15 @@ static UntermExternalCodeEditor() try { CodeEditor.Register(new UntermExternalCodeEditor()); + // After a package update the stored selection still points at the + // PREVIOUS cache path (a git/UPM install resolves to + // `Library/PackageCache/dev.tnayuki.unterm@/`, and `` + // changes each update). Opens still route here — the current-editor + // resolution matches by identity — but Unity's Preferences dropdown + // compares exact paths, so it would show Unterm as unselected. Refresh + // the stored path to the current one so the dropdown stays correct. + // Deferred so it runs once the editor is settled, not mid-registration. + EditorApplication.delayCall += RefreshSelectionIfOurs; } catch (System.Exception e) { @@ -37,6 +98,26 @@ static UntermExternalCodeEditor() } } + // If the selected external editor is a previous build of THIS package (its + // cache path changed on update), re-store the current path so the selection + // is recognized exactly, not just by identity. No-op when it's already current + // or when the user has chosen a different editor. + private static void RefreshSelectionIfOurs() + { + try + { + // Unity stores the selection under this pref (see CodeEditor's own + // CurrentEditorPath); read it directly to avoid an internal API. + string stored = EditorPrefs.GetString("kScriptsDefaultApp", string.Empty); + if (stored != EditorKey && IdentifiesPackage(stored)) + CodeEditor.SetExternalScriptEditor(EditorKey); + } + catch (System.Exception e) + { + Debug.LogWarning("[Unterm] External editor selection refresh failed: " + e); + } + } + public CodeEditor.Installation[] Installations { get; } = { new CodeEditor.Installation { Name = "Unterm Code Editor", Path = EditorKey }, @@ -44,7 +125,14 @@ static UntermExternalCodeEditor() public bool TryGetInstallationForPath(string editorPath, out CodeEditor.Installation installation) { - if (editorPath == EditorKey) + // Match the current path OR any package.json belonging to this package. + // Unity stores the selected editor as the path it had when chosen; after a + // package update relocates the package (the UPM cache folder's `@` + // changes), that stored path no longer equals `EditorKey`. Keying only off + // the exact string would then make Unity treat Unterm as unselected and + // silently route script / Markdown opens to another editor (or the OS). + // Recognizing it by identity keeps the selection across updates. + if (editorPath == EditorKey || IdentifiesPackage(editorPath)) { installation = Installations[0]; return true; @@ -53,6 +141,20 @@ public bool TryGetInstallationForPath(string editorPath, out CodeEditor.Installa return false; } + // Whether `editorPath` is this package's `package.json`, wherever it currently + // resolves — embedded (`.../dev.tnayuki.unterm/package.json`) or UPM-cached + // (`.../dev.tnayuki.unterm@/package.json`). + private static bool IdentifiesPackage(string editorPath) + { + if (string.IsNullOrEmpty(editorPath)) return false; + if (!string.Equals(Path.GetFileName(editorPath), "package.json", + System.StringComparison.OrdinalIgnoreCase)) + return false; + string dir = Path.GetFileName(Path.GetDirectoryName(editorPath) ?? ""); + return dir == PackageId + || dir.StartsWith(PackageId + "@", System.StringComparison.Ordinal); + } + public void Initialize(string editorInstallationPath) { } // Unterm highlights with tree-sitter and completes with in-process Roslyn, so @@ -79,39 +181,28 @@ public bool OpenProject(string filePath = "", int line = -1, int column = -1) return true; } if (!File.Exists(filePath)) return false; + // Consume the one-shot preview hint (only meaningful for Markdown). + bool preview = NextOpenPrefersPreview; + NextOpenPrefersPreview = false; // Only claim files we consider code/text. Scenes, prefabs, materials and // other binary/asset opens Unity routes through here must fall through // (return false) so Unity's own handler opens them — otherwise a scene // double-click would land in the text editor. if (!HandlesExtension(filePath)) return false; - UntermCodeEditorWindow.OpenPath(filePath, line); + UntermCodeEditorWindow.OpenPath(filePath, line, preview); return true; } - // Decides which double-clicked assets Unterm claims: the extensions Unity - // treats as project code — its C# project-generation set plus whatever the - // user added under Project Settings > Editor — together with the few text - // formats Unity's own VSCode/Rider packages force-add. Anything else — scenes, - // prefabs, materials, other assets — is declined so Unity opens it with its - // native handler. Shared with the transcript path-click flow in - // . + // Decides which double-clicked assets Unterm claims: the extension list + // configured under Preferences > Unterm (). + // Anything else — scenes, prefabs, materials, other assets — is declined so + // Unity opens it with its native handler. The transcript path-click flow + // () reaches this through + // and falls back to Unity's own open on decline. internal static bool HandlesExtension(string filePath) { - string ext = Path.GetExtension(filePath).TrimStart('.').ToLowerInvariant(); - if (ext.Length == 0) return false; - if (ext == "json" || ext == "asmdef" || ext == "asmref" || ext == "log") - return true; - return HasExtension(EditorSettings.projectGenerationBuiltinExtensions, ext) - || HasExtension(EditorSettings.projectGenerationUserExtensions, ext); - } - - private static bool HasExtension(string[] extensions, string ext) - { - if (extensions == null) return false; - foreach (var e in extensions) - if (string.Equals(e, ext, System.StringComparison.OrdinalIgnoreCase)) - return true; - return false; + string ext = Path.GetExtension(filePath).TrimStart('.'); + return ext.Length > 0 && UntermOpenExtensions.Contains(ext); } } } diff --git a/Packages/dev.tnayuki.unterm/Editor/UntermNative.cs b/Packages/dev.tnayuki.unterm/Editor/UntermNative.cs index cdcff8e..dcce183 100644 --- a/Packages/dev.tnayuki.unterm/Editor/UntermNative.cs +++ b/Packages/dev.tnayuki.unterm/Editor/UntermNative.cs @@ -134,6 +134,9 @@ private static string NativeError() [UnmanagedFunctionPointer(CallingConvention.Cdecl)] [return: MarshalAs(UnmanagedType.I1)] private delegate bool EdHoverFn(ulong id, float x, float y); [UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate int EdHunkAtFn(ulong id, float x, float y); [UnmanagedFunctionPointer(CallingConvention.Cdecl)] [return: MarshalAs(UnmanagedType.I1)] private delegate bool EdStageHunkFn(ulong id, uint hunk); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate uint EdLineAtYFn(ulong id, float y); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate void EdSetBpsFn(ulong id, IntPtr lines, UIntPtr count); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate void EdSetBoolFn(ulong id, [MarshalAs(UnmanagedType.I1)] bool on); [UnmanagedFunctionPointer(CallingConvention.Cdecl)] [return: MarshalAs(UnmanagedType.I1)] private delegate bool EdFindFn(ulong id, [MarshalAs(UnmanagedType.LPUTF8Str)] string query, [MarshalAs(UnmanagedType.I1)] bool forward, [MarshalAs(UnmanagedType.I1)] bool caseSensitive); [UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate uint EdReplaceAllFn(ulong id, [MarshalAs(UnmanagedType.LPUTF8Str)] string query, [MarshalAs(UnmanagedType.LPUTF8Str)] string repl, [MarshalAs(UnmanagedType.I1)] bool caseSensitive); [UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate void EdCompleteFn(ulong id, uint prefixLen, [MarshalAs(UnmanagedType.LPUTF8Str)] string text); @@ -192,6 +195,8 @@ private static string NativeError() private AvUintSetFn _edSetUndoLimit; // Git-diff gutter markers (optional: an older native bundle may lack them). private AvStrFn _edSetPath; private AvVoidFn _edRefreshDiff; private BoolFn _edPollDiff; + // Markdown preview (optional: an older native bundle may lack them). + private EdSetBoolFn _edSetPreview; private BoolFn _edPreviewActive; private AvTokenFn _edPreviewTokenAt; private AvVoidFn _edRender; private AvPtrFn _edRawTexture; private AvFloatFn _edContentHeight; private AvCaretFn _edCaret; private U64IdFn _edEditSerial; private AvInputKeyFn _edKey; private SetFocusFn _edSetFocus; private AvStrFn _edInsert; private AvStrFn _edSetPreedit; private AvStrFn _edSetText; private AvStrFn _edAddUsing; @@ -199,6 +204,7 @@ private static string NativeError() private AvBufFn _edCopy; private AvBufFn _edCut; private EdMouseFn _edMouse; private EdHoverFn _edHover; private AvF1Fn _edScroll; private EdHunkAtFn _edHunkAt; private EdStageHunkFn _edHunkStaged; private EdStageHunkFn _edHunkHasStaged; private EdStageHunkFn _edHunkStagedOnly; private EdStageHunkFn _edStageHunk; private EdStageHunkFn _edUnstageHunk; private AvUintSetFn _edRevertHunk; private AvF1Fn _edSetScroll; private AvFloatFn _edScrollOffset; private AvF1Fn _edScrollH; + private AvFloatFn _edGutterWidth; private EdLineAtYFn _edLineAtY; private AvUintSetFn _edToggleBp; private EdSetBpsFn _edSetBps; private EdSetBoolFn _edSetBpGutter; private AvVoidFn _edIndent, _edOutdent, _edToggleComment, _edMoveUp, _edMoveDown, _edDuplicate, _edDeleteLine; private AvUintSetFn _edGotoLine; private EdFindFn _edFind; private AvStrFn _edReplaceSel; private EdReplaceAllFn _edReplaceAll; private AvBufFn _edWordPrefix; private EdCompleteFn _edComplete; private EdSetComplFn _edSetCompletions; private AvUintGetFn _edCaretOffset; @@ -360,6 +366,9 @@ public void Load(string bundlePath) _edSetLanguage = Sym("unterm_editor_set_language"); _edSetPath = SymOpt("unterm_editor_set_path"); _edRefreshDiff = SymOpt("unterm_editor_refresh_diff"); + _edSetPreview = SymOpt("unterm_editor_set_preview"); + _edPreviewActive = SymOpt("unterm_editor_preview_active"); + _edPreviewTokenAt = SymOpt("unterm_editor_preview_token_at"); _edPollDiff = SymOpt("unterm_editor_poll_diff"); _edRender = Sym("unterm_editor_render"); _edRawTexture = Sym("unterm_editor_raw_texture"); @@ -391,6 +400,13 @@ public void Load(string bundlePath) _edSetScroll = Sym("unterm_editor_set_scroll"); _edScrollOffset = Sym("unterm_editor_scroll_offset"); _edScrollH = Sym("unterm_editor_scroll_h"); + // Optional: tolerate an older plugin .dylib that predates these symbols + // (features stay disabled rather than breaking the whole editor at load). + _edGutterWidth = SymOpt("unterm_editor_gutter_width"); + _edLineAtY = SymOpt("unterm_editor_line_at_y"); + _edToggleBp = SymOpt("unterm_editor_toggle_breakpoint"); + _edSetBps = SymOpt("unterm_editor_set_breakpoints"); + _edSetBpGutter = SymOpt("unterm_editor_set_bp_gutter"); _edIndent = Sym("unterm_editor_indent"); _edOutdent = Sym("unterm_editor_outdent"); _edToggleComment = Sym("unterm_editor_toggle_comment"); @@ -650,6 +666,19 @@ public void EditorSetTheme(ulong id, Color bg, Color32 fg, bool dark) => public void EditorRefreshDiff(ulong id) => _edRefreshDiff?.Invoke(id); /// Apply a finished background git fetch; true if new markers arrived (re-render). public bool EditorPollDiff(ulong id) => _edPollDiff?.Invoke(id) ?? false; + /// Whether the native bundle supports Markdown preview (older bundles don't). + public bool EditorPreviewSupported => _edSetPreview != null; + /// Toggle Markdown-preview mode (render/texture/scroll/mouse/copy route to it). + public void EditorSetPreview(ulong id, bool on) => _edSetPreview?.Invoke(id, on); + /// Whether Markdown-preview mode is on. + public bool EditorPreviewActive(ulong id) => _edPreviewActive?.Invoke(id) ?? false; + /// The existing-file path token under (x,y) in preview mode (empty if none). + public string EditorPreviewTokenAt(ulong id, float x, float y) + { + if (_edPreviewTokenAt == null) return string.Empty; + var p = _edPreviewTokenAt(id, x, y, out UIntPtr len); + return Utf8(p, len); + } public void EditorRender(ulong id) => _edRender(id); public IntPtr EditorRawTexture(ulong id) => _edRawTexture(id); public float EditorContentHeight(ulong id) => _edContentHeight(id); @@ -692,6 +721,18 @@ public void EditorKey(ulong id, string name, bool ctrl, bool alt, bool shift) => public void EditorScrollH(ulong id, float dx) => _edScrollH(id, dx); public void EditorSetScroll(ulong id, float px) => _edSetScroll(id, px); public float EditorScrollOffset(ulong id) => _edScrollOffset(id); + public float EditorGutterWidth(ulong id) => _edGutterWidth != null ? _edGutterWidth(id) : 0f; + public uint EditorLineAtY(ulong id, float y) => _edLineAtY != null ? _edLineAtY(id, y) : 0u; + public void EditorToggleBreakpoint(ulong id, uint line) => _edToggleBp?.Invoke(id, line); + public void EditorSetBpGutter(ulong id, bool on) => _edSetBpGutter?.Invoke(id, on); + public void EditorSetBreakpoints(ulong id, uint[] lines) + { + if (_edSetBps == null) return; + if (lines == null || lines.Length == 0) { _edSetBps(id, IntPtr.Zero, UIntPtr.Zero); return; } + var h = GCHandle.Alloc(lines, GCHandleType.Pinned); + try { _edSetBps(id, h.AddrOfPinnedObject(), (UIntPtr)lines.Length); } + finally { h.Free(); } + } public void EditorIndent(ulong id) => _edIndent(id); public void EditorOutdent(ulong id) => _edOutdent(id); public void EditorToggleComment(ulong id) => _edToggleComment(id); @@ -768,11 +809,13 @@ public void Dispose() _edCreate = null; _edExists = null; _edDestroy = null; _edResize = null; _edSetScale = null; _edSetUndoLimit = null; _edSetFont = null; _edSetTheme = null; _edSetLanguage = null; _edRender = null; _edRawTexture = null; _edSetPath = null; _edRefreshDiff = null; _edPollDiff = null; + _edSetPreview = null; _edPreviewActive = null; _edPreviewTokenAt = null; _edContentHeight = null; _edEditSerial = null; _edCaret = null; _edKey = null; _edSetFocus = null; _edInsert = null; _edSetPreedit = null; _edSetText = null; _edAddUsing = null; _edText = null; _edUndo = null; _edRedo = null; _edSelectAll = null; _edCopy = null; _edCut = null; _edMouse = null; _edHover = null; _edScroll = null; _edHunkAt = null; _edHunkStaged = null; _edHunkHasStaged = null; _edHunkStagedOnly = null; _edStageHunk = null; _edUnstageHunk = null; _edRevertHunk = null; _edSetScroll = null; _edScrollOffset = null; _edScrollH = null; _edIndent = null; _edOutdent = null; + _edGutterWidth = null; _edLineAtY = null; _edToggleBp = null; _edSetBps = null; _edToggleComment = null; _edMoveUp = null; _edMoveDown = null; _edDuplicate = null; _edDeleteLine = null; _edGotoLine = null; _edFind = null; _edReplaceSel = null; _edReplaceAll = null; _edWordPrefix = null; _edComplete = null; _edSetCompletions = null; _edCaretOffset = null; diff --git a/Packages/dev.tnayuki.unterm/Editor/UntermSettingsProvider.cs b/Packages/dev.tnayuki.unterm/Editor/UntermSettingsProvider.cs index 1d6079a..955750e 100644 --- a/Packages/dev.tnayuki.unterm/Editor/UntermSettingsProvider.cs +++ b/Packages/dev.tnayuki.unterm/Editor/UntermSettingsProvider.cs @@ -35,6 +35,8 @@ public static SettingsProvider Create() { "unterm", "claude", "claude code", "agent", "terminal", "download", "code editor", "undo", "history", "sound", "notify", "notification", "chime", + "debug", "debugger", "breakpoint", "extension", "extensions", "open", + "unity", "mcp", "tools", "security", "approval", }, }; } @@ -89,6 +91,20 @@ private static void OnGui() if (nextLimit != curLimit) UntermCodeEditorPrefs.UndoLimit = nextLimit; + string curExts = UntermOpenExtensions.Value; + string nextExts = EditorGUILayout.DelayedTextField( + new GUIContent("Openable extensions", + "Semicolon-separated file extensions the Unterm code editor claims: " + + "double-clicked assets (when Unterm is the External Script Editor) and " + + "file links clicked in the agent transcript. Anything else falls through " + + "to Unity's own handler or the OS default app."), + curExts); + if (nextExts != curExts) + UntermOpenExtensions.Value = nextExts; + if (nextExts != UntermOpenExtensions.Default && + GUILayout.Button("Reset extensions to default", GUILayout.ExpandWidth(false))) + UntermOpenExtensions.Value = UntermOpenExtensions.Default; + EditorGUILayout.Space(); EditorGUILayout.LabelField("Agent", EditorStyles.boldLabel); bool notify = UntermAgentPrefs.NotifySoundEnabled; @@ -101,6 +117,18 @@ private static void OnGui() if (nextNotify != notify) UntermAgentPrefs.NotifySoundEnabled = nextNotify; + EditorGUILayout.Space(); + EditorGUILayout.LabelField("Debugger", EditorStyles.boldLabel); + bool curDbg = UntermDebuggerPrefs.Enabled; + bool nextDbg = EditorGUILayout.Toggle( + new GUIContent("Enable debugging", + "Enables the Window/Unterm/Debugger (Standalone Process) menu and the code editor's breakpoint " + + "gutter (click left of the line numbers to set breakpoints; entering Play " + + "mode with breakpoints launches the debugger)."), + curDbg); + if (nextDbg != curDbg) + UntermDebuggerPrefs.Enabled = nextDbg; + EditorGUILayout.Space(); EditorGUILayout.LabelField("Unity MCP", EditorStyles.boldLabel); bool mcpEnabled = UntermMcpSecurity.Enabled; diff --git a/Packages/dev.tnayuki.unterm/Third Party Notices.md b/Packages/dev.tnayuki.unterm/Third Party Notices.md index ea57124..75e657d 100644 --- a/Packages/dev.tnayuki.unterm/Third Party Notices.md +++ b/Packages/dev.tnayuki.unterm/Third Party Notices.md @@ -7,17 +7,19 @@ require preserving the copyright notice and license text reproduced here. This file is generated with [`cargo about`](https://github.com/EmbarkStudios/cargo-about); run `native/generate-notices.sh` from the repository root to regenerate it. -- MIT License (244) -- Apache License 2.0 (12) +- MIT License (287) +- Apache License 2.0 (15) - zlib License (3) - Creative Commons Zero v1.0 Universal (2) - ISC License (1) +- SIL Open Font License 1.1 (1) +- Ubuntu Font Licence v1.0 (1) - Unicode License v3 (1) ## Apache License 2.0 - moxcms 0.8.1 — https://github.com/awxkee/moxcms.git -- pxfm 0.1.29 — https://github.com/awxkee/pxfm +- pxfm 0.1.30 — https://github.com/awxkee/pxfm ``` Apache License @@ -437,6 +439,215 @@ run `native/generate-notices.sh` from the repository root to regenerate it. ## Apache License 2.0 +- unicode-general-category 1.1.0 — https://github.com/yeslogic/unicode-general-category + +``` + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +``` + +## Apache License 2.0 + - alacritty_terminal 0.25.1 — https://github.com/alacritty/alacritty ``` @@ -778,84 +989,294 @@ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. -5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +``` + +## Apache License 2.0 + +- glutin_wgl_sys 0.6.1 — https://github.com/rust-windowing/glutin + +``` +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. -6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. -7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. -8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. -9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. -END OF TERMS AND CONDITIONS + END OF TERMS AND CONDITIONS -APPENDIX: How to apply the Apache License to your work. + APPENDIX: How to apply the Apache License to your work. - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. -Copyright [yyyy] [name of copyright owner] + Copyright 2022 Kirill Chibisov -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at - http://www.apache.org/licenses/LICENSE-2.0 + http://www.apache.org/licenses/LICENSE-2.0 -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. ``` ## Apache License 2.0 -- glutin_wgl_sys 0.6.1 — https://github.com/rust-windowing/glutin +- dpi 0.1.2 — https://github.com/rust-windowing/winit +- winit 0.30.13 — https://github.com/rust-windowing/winit ``` Apache License @@ -1046,7 +1467,7 @@ Apache License same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2022 Kirill Chibisov + Copyright {yyyy} {name of copyright owner} Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -1059,7 +1480,6 @@ Apache License WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. - ``` ## Apache License 2.0 @@ -1380,6 +1800,9 @@ DEALINGS IN THE SOFTWARE. - core-foundation-sys 0.8.7 — https://github.com/servo/core-foundation-rs - core-foundation 0.10.1 — https://github.com/servo/core-foundation-rs +- core-foundation 0.9.4 — https://github.com/servo/core-foundation-rs +- core-graphics-types 0.1.3 — https://github.com/servo/core-foundation-rs +- core-graphics 0.23.2 — https://github.com/servo/core-foundation-rs - euclid 0.22.14 — https://github.com/servo/euclid ``` @@ -1413,13 +1836,14 @@ DEALINGS IN THE SOFTWARE. ## MIT License -- cc 1.2.65 — https://github.com/rust-lang/cc-rs +- cc 1.2.67 — https://github.com/rust-lang/cc-rs - cfg-if 1.0.4 — https://github.com/rust-lang/cfg-if - filetime 0.2.29 — https://github.com/alexcrichton/filetime - find-msvc-tools 0.1.9 — https://github.com/rust-lang/cc-rs - git2 0.21.0 — https://github.com/rust-lang/git2-rs - jobserver 0.1.35 — https://github.com/rust-lang/jobserver-rs - pkg-config 0.3.33 — https://github.com/rust-lang/pkg-config-rs +- socket2 0.5.10 — https://github.com/rust-lang/socket2 ``` Copyright (c) 2014 Alex Crichton @@ -1554,14 +1978,48 @@ DEALINGS IN THE SOFTWARE. - bitflags 1.3.2 — https://github.com/bitflags/bitflags - bitflags 2.13.0 — https://github.com/bitflags/bitflags -- log 0.4.32 — https://github.com/rust-lang/log +- log 0.4.33 — https://github.com/rust-lang/log - num-traits 0.2.19 — https://github.com/rust-num/num-traits -- regex-automata 0.4.14 — https://github.com/rust-lang/regex +- regex-automata 0.4.15 — https://github.com/rust-lang/regex - regex-syntax 0.8.11 — https://github.com/rust-lang/regex -- regex 1.12.4 — https://github.com/rust-lang/regex +- regex 1.13.0 — https://github.com/rust-lang/regex + +``` +Copyright (c) 2014 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + +- uuid 1.23.5 — https://github.com/uuid-rs/uuid ``` Copyright (c) 2014 The Rust Project Developers +Copyright (c) 2018 Ashley Mannix, Christopher Armstrong, Dylan DPC, Hunar Roop Kahlon Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated @@ -1651,6 +2109,40 @@ THE SOFTWARE. ## MIT License +- either 1.16.0 — https://github.com/rayon-rs/either +- itertools 0.14.0 — https://github.com/rust-itertools/itertools + +``` +Copyright (c) 2015 + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + - ioctl-rs 0.1.6 — https://github.com/dcuddeback/ioctl-rs - serial-core 0.4.0 — https://github.com/dcuddeback/serial-rs - serial-unix 0.4.0 — https://github.com/dcuddeback/serial-rs @@ -2081,12 +2573,78 @@ SOFTWARE. ## MIT License -- lock_api 0.4.14 — https://github.com/Amanieu/parking_lot -- parking_lot 0.12.5 — https://github.com/Amanieu/parking_lot -- parking_lot_core 0.9.12 — https://github.com/Amanieu/parking_lot +- lock_api 0.4.14 — https://github.com/Amanieu/parking_lot +- parking_lot 0.12.5 — https://github.com/Amanieu/parking_lot +- parking_lot_core 0.9.12 — https://github.com/Amanieu/parking_lot + +``` +Copyright (c) 2016 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + +- shell-words 1.1.1 — https://github.com/tmiasko/shell-words + +``` +Copyright (c) 2016 Tomasz Miąsko + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + +- indexmap 2.14.0 — https://github.com/indexmap-rs/indexmap ``` -Copyright (c) 2016 The Rust Project Developers +Copyright (c) 2016--2017 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated @@ -2116,10 +2674,10 @@ DEALINGS IN THE SOFTWARE. ## MIT License -- shell-words 1.1.1 — https://github.com/tmiasko/shell-words +- equivalent 1.0.2 — https://github.com/indexmap-rs/equivalent ``` -Copyright (c) 2016 Tomasz Miąsko +Copyright (c) 2016--2023 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated @@ -2149,10 +2707,10 @@ DEALINGS IN THE SOFTWARE. ## MIT License -- indexmap 2.14.0 — https://github.com/indexmap-rs/indexmap +- scopeguard 1.2.0 — https://github.com/bluss/scopeguard ``` -Copyright (c) 2016--2017 +Copyright (c) 2016-2019 Ulrik Sverdrup "bluss" and scopeguard developers Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated @@ -2182,10 +2740,10 @@ DEALINGS IN THE SOFTWARE. ## MIT License -- equivalent 1.0.2 — https://github.com/indexmap-rs/equivalent +- fnv 1.0.7 — https://github.com/servo/rust-fnv ``` -Copyright (c) 2016--2023 +Copyright (c) 2017 Contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated @@ -2215,10 +2773,36 @@ DEALINGS IN THE SOFTWARE. ## MIT License -- scopeguard 1.2.0 — https://github.com/bluss/scopeguard +- memoffset 0.6.5 — https://github.com/Gilnaa/memoffset ``` -Copyright (c) 2016-2019 Ulrik Sverdrup "bluss" and scopeguard developers +Copyright (c) 2017 Gilad Naaman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +## MIT License + +- vcpkg 0.2.15 — https://github.com/mcgoo/vcpkg-rs + +``` +Copyright (c) 2017 Jim McGrath Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated @@ -2248,10 +2832,10 @@ DEALINGS IN THE SOFTWARE. ## MIT License -- fnv 1.0.7 — https://github.com/servo/rust-fnv +- shared_library 0.1.9 — https://github.com/tomaka/shared_library/ ``` -Copyright (c) 2017 Contributors +Copyright (c) 2017 Pierre Krieger Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated @@ -2276,15 +2860,16 @@ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - ``` ## MIT License -- memoffset 0.6.5 — https://github.com/Gilnaa/memoffset +- foreign-types-macros 0.2.3 — https://github.com/sfackler/foreign-types +- foreign-types-shared 0.3.1 — https://github.com/sfackler/foreign-types +- foreign-types 0.5.0 — https://github.com/sfackler/foreign-types ``` -Copyright (c) 2017 Gilad Naaman +Copyright (c) 2017 The foreign-types Developers Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -2303,14 +2888,16 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ``` ## MIT License -- vcpkg 0.2.15 — https://github.com/mcgoo/vcpkg-rs +- signal-hook-registry 1.4.8 — https://github.com/vorner/signal-hook +- signal-hook 0.3.18 — https://github.com/vorner/signal-hook ``` -Copyright (c) 2017 Jim McGrath +Copyright (c) 2017 tokio-jsonrpc developers Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated @@ -2340,10 +2927,10 @@ DEALINGS IN THE SOFTWARE. ## MIT License -- shared_library 0.1.9 — https://github.com/tomaka/shared_library/ +- autocfg 1.5.1 — https://github.com/cuviper/autocfg ``` -Copyright (c) 2017 Pierre Krieger +Copyright (c) 2018 Josh Stone Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated @@ -2368,15 +2955,18 @@ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ``` ## MIT License -- signal-hook-registry 1.4.8 — https://github.com/vorner/signal-hook -- signal-hook 0.3.18 — https://github.com/vorner/signal-hook +- fearless_simd 0.4.1 — https://github.com/linebender/fearless_simd +- kurbo 0.13.1 — https://github.com/linebender/kurbo +- peniko 0.6.1 — https://github.com/linebender/peniko +- polycool 0.4.0 — https://github.com/linebender/kurbo ``` -Copyright (c) 2017 tokio-jsonrpc developers +Copyright (c) 2018 Raph Levien Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated @@ -2406,10 +2996,10 @@ DEALINGS IN THE SOFTWARE. ## MIT License -- autocfg 1.5.1 — https://github.com/cuviper/autocfg +- smallvec 1.15.2 — https://github.com/servo/rust-smallvec ``` -Copyright (c) 2018 Josh Stone +Copyright (c) 2018 The Servo Project Developers Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated @@ -2439,10 +3029,10 @@ DEALINGS IN THE SOFTWARE. ## MIT License -- smallvec 1.15.2 — https://github.com/servo/rust-smallvec +- pin-utils 0.1.0 — https://github.com/rust-lang-nursery/pin-utils ``` -Copyright (c) 2018 The Servo Project Developers +Copyright (c) 2018 The pin-utils authors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated @@ -2472,10 +3062,10 @@ DEALINGS IN THE SOFTWARE. ## MIT License -- pin-utils 0.1.0 — https://github.com/rust-lang-nursery/pin-utils +- ahash 0.8.12 — https://github.com/tkaitchuck/ahash ``` -Copyright (c) 2018 The pin-utils authors +Copyright (c) 2018 Tom Kaitchuck Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated @@ -2636,6 +3226,40 @@ DEALINGS IN THE SOFTWARE. ## MIT License +- tracing-core 0.1.36 — https://github.com/tokio-rs/tracing +- tracing 0.1.44 — https://github.com/tokio-rs/tracing + +``` +Copyright (c) 2019 Tokio Contributors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + - downcast-rs 1.2.1 — https://github.com/marcianx/downcast-rs ``` @@ -3089,8 +3713,8 @@ DEALINGS IN THE SOFTWARE. - anstyle-wincon 3.0.11 — https://github.com/rust-cli/anstyle.git - anstyle 1.0.14 — https://github.com/rust-cli/anstyle.git - colorchoice 1.0.5 — https://github.com/rust-cli/anstyle.git -- env_filter 1.0.1 — https://github.com/rust-cli/env_logger -- env_logger 0.11.10 — https://github.com/rust-cli/env_logger +- env_filter 2.0.0 — https://github.com/rust-cli/env_logger +- env_logger 0.11.11 — https://github.com/rust-cli/env_logger - is_terminal_polyfill 1.70.2 — https://github.com/polyfill-rs/is_terminal_polyfill - once_cell_polyfill 1.70.2 — https://github.com/polyfill-rs/once_cell_polyfill @@ -3147,7 +3771,7 @@ SOFTWARE. ## MIT License - time-core 0.1.9 — https://github.com/time-rs/time -- time 0.3.49 — https://github.com/time-rs/time +- time 0.3.53 — https://github.com/time-rs/time ``` Copyright (c) Jacob Pratt et al. @@ -3207,7 +3831,7 @@ DEALINGS IN THE SOFTWARE. ## MIT License -- arrayvec 0.7.6 — https://github.com/bluss/arrayvec +- arrayvec 0.7.8 — https://github.com/bluss/arrayvec ``` Copyright (c) Ulrik Sverdrup "bluss" 2015-2023 @@ -3240,6 +3864,32 @@ DEALINGS IN THE SOFTWARE. ## MIT License +- nohash-hasher 0.2.0 — https://github.com/paritytech/nohash-hasher + +``` +Copyright 2018 Parity Technologies (UK) Ltd. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + - rangemap 1.7.1 — https://github.com/jeffparsons/rangemap ``` @@ -3255,8 +3905,42 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI ## MIT License -- zerocopy-derive 0.8.52 — https://github.com/google/zerocopy -- zerocopy 0.8.52 — https://github.com/google/zerocopy +- vello_common 0.0.9 — https://github.com/linebender/vello +- vello_cpu 0.0.9 — https://github.com/linebender/vello + +``` +Copyright 2020 the Vello Authors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + +- zerocopy-derive 0.8.54 — https://github.com/google/zerocopy +- zerocopy 0.8.54 — https://github.com/google/zerocopy ``` Copyright 2023 The Fuchsia Authors @@ -3289,6 +3973,23 @@ DEALINGS IN THE SOFTWARE. ## MIT License +- epaint_default_fonts 0.35.0 — https://github.com/emilk/egui/tree/main/crates/epaint_default_fonts + +``` +MIT License + +Copyright (c) 2014 John Slegers + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + - lru 0.16.4 — https://github.com/jeromefroe/lru-rs.git ``` @@ -3463,8 +4164,8 @@ SOFTWARE. ## MIT License -- bytemuck 1.25.0 — https://github.com/Lokathor/bytemuck -- bytemuck_derive 1.10.2 — https://github.com/Lokathor/bytemuck +- bytemuck 1.25.1 — https://github.com/Lokathor/bytemuck +- bytemuck_derive 1.11.0 — https://github.com/Lokathor/bytemuck ``` MIT License @@ -3614,6 +4315,35 @@ SOFTWARE. ## MIT License +- web-time 1.1.0 — https://github.com/daxpedda/web-time + +``` +MIT License + +Copyright (c) 2023 dAxpeDDa + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +``` + +## MIT License + - core_maths 0.1.1 — https://github.com/robertbastian/core_maths ``` @@ -3642,14 +4372,14 @@ SOFTWARE. ## MIT License -- naga 29.0.3 — https://github.com/gfx-rs/wgpu -- wgpu-core-deps-apple 29.0.3 — https://github.com/gfx-rs/wgpu -- wgpu-core-deps-windows-linux-android 29.0.3 — https://github.com/gfx-rs/wgpu -- wgpu-core 29.0.3 — https://github.com/gfx-rs/wgpu -- wgpu-hal 29.0.3 — https://github.com/gfx-rs/wgpu -- wgpu-naga-bridge 29.0.3 — https://github.com/gfx-rs/wgpu -- wgpu-types 29.0.3 — https://github.com/gfx-rs/wgpu -- wgpu 29.0.3 — https://github.com/gfx-rs/wgpu +- naga 29.0.4 — https://github.com/gfx-rs/wgpu +- wgpu-core-deps-apple 29.0.4 — https://github.com/gfx-rs/wgpu +- wgpu-core-deps-windows-linux-android 29.0.4 — https://github.com/gfx-rs/wgpu +- wgpu-core 29.0.4 — https://github.com/gfx-rs/wgpu +- wgpu-hal 29.0.4 — https://github.com/gfx-rs/wgpu +- wgpu-naga-bridge 29.0.4 — https://github.com/gfx-rs/wgpu +- wgpu-types 29.0.4 — https://github.com/gfx-rs/wgpu +- wgpu 29.0.4 — https://github.com/gfx-rs/wgpu ``` MIT License @@ -3679,12 +4409,25 @@ SOFTWARE. ## MIT License - unterm 0.1.0 +- accesskit 0.24.1 — https://github.com/AccessKit/accesskit +- block2 0.5.1 — https://github.com/madsmtm/objc2 - block2 0.6.2 — https://github.com/madsmtm/objc2 - dispatch2 0.3.1 — https://github.com/madsmtm/objc2 +- dispatch 0.2.0 — http://github.com/SSheldon/rust-dispatch +- dpi 0.1.2 — https://github.com/rust-windowing/winit +- ecolor 0.35.0 — https://github.com/emilk/egui +- egui-wgpu 0.35.0 — https://github.com/emilk/egui/tree/main/crates/egui-wgpu +- egui-winit 0.35.0 — https://github.com/emilk/egui/tree/main/crates/egui-winit +- egui 0.35.0 — https://github.com/emilk/egui +- emath 0.35.0 — https://github.com/emilk/egui/tree/main/crates/emath +- epaint 0.35.0 — https://github.com/emilk/egui/tree/main/crates/epaint - gpu-descriptor-types 0.2.0 — https://github.com/zakarumych/gpu-descriptor - gpu-descriptor 0.3.2 — https://github.com/zakarumych/gpu-descriptor - harfrust 0.5.2 — https://github.com/harfbuzz/harfrust +- harfrust 0.7.0 — https://github.com/harfbuzz/harfrust - libm 0.2.16 — https://github.com/rust-lang/compiler-builtins +- objc-sys 0.3.5 — https://github.com/madsmtm/objc2 +- objc2-app-kit 0.2.2 — https://github.com/madsmtm/objc2 - objc2-app-kit 0.3.2 — https://github.com/madsmtm/objc2 - objc2-cloud-kit 0.3.2 — https://github.com/madsmtm/objc2 - objc2-core-data 0.3.2 — https://github.com/madsmtm/objc2 @@ -3694,16 +4437,20 @@ SOFTWARE. - objc2-core-text 0.3.2 — https://github.com/madsmtm/objc2 - objc2-core-video 0.3.2 — https://github.com/madsmtm/objc2 - objc2-encode 4.1.0 — https://github.com/madsmtm/objc2 +- objc2-foundation 0.2.2 — https://github.com/madsmtm/objc2 - objc2-foundation 0.3.2 — https://github.com/madsmtm/objc2 - objc2-io-surface 0.3.2 — https://github.com/madsmtm/objc2 - objc2-metal 0.3.2 — https://github.com/madsmtm/objc2 - objc2-quartz-core 0.3.2 — https://github.com/madsmtm/objc2 +- objc2 0.5.2 — https://github.com/madsmtm/objc2 - objc2 0.6.4 — https://github.com/madsmtm/objc2 - profiling 1.0.18 — https://github.com/aclysma/profiling - serial-windows 0.4.0 — https://github.com/dcuddeback/serial-rs - serial 0.4.0 — https://github.com/dcuddeback/serial-rs - siphasher 1.0.3 — https://github.com/jedisct1/rust-siphash +- tree-sitter-md 0.5.3 — https://github.com/tree-sitter-grammars/tree-sitter-markdown - tree-sitter 0.25.10 — https://github.com/tree-sitter/tree-sitter +- type-map 0.5.1 — https://github.com/kardeiz/type-map - unity-native-plugin-sys 0.9.0 — https://github.com/aosoft/unity-native-plugin-rs - unity-native-plugin 0.9.0 — https://github.com/aosoft/unity-native-plugin-rs - windows-collections 0.3.2 — https://github.com/microsoft/windows-rs @@ -3716,6 +4463,7 @@ SOFTWARE. - windows-result 0.4.1 — https://github.com/microsoft/windows-rs - windows-strings 0.5.1 — https://github.com/microsoft/windows-rs - windows-sys 0.48.0 — https://github.com/microsoft/windows-rs +- windows-sys 0.52.0 — https://github.com/microsoft/windows-rs - windows-sys 0.59.0 — https://github.com/microsoft/windows-rs - windows-sys 0.61.2 — https://github.com/microsoft/windows-rs - windows-targets 0.48.5 — https://github.com/microsoft/windows-rs @@ -3844,6 +4592,7 @@ DEALINGS IN THE SOFTWARE. ## MIT License +- color 0.3.3 — https://github.com/linebender/color - half 2.7.1 — https://github.com/VoidStarKat/half-rs - linebender_resource_handle 0.1.1 — https://github.com/linebender/raw_resource_handle @@ -3907,7 +4656,7 @@ DEALINGS IN THE SOFTWARE. ## MIT License -- rustc-hash 2.1.2 — https://github.com/rust-lang/rustc-hash +- rustc-hash 2.1.3 — https://github.com/rust-lang/rustc-hash ``` Permission is hereby granted, free of charge, to any @@ -3950,7 +4699,7 @@ DEALINGS IN THE SOFTWARE. - piper 0.2.5 — https://github.com/smol-rs/piper - polling 3.11.0 — https://github.com/smol-rs/polling - proc-macro2 1.0.106 — https://github.com/dtolnay/proc-macro2 -- quote 1.0.45 — https://github.com/dtolnay/quote +- quote 1.0.46 — https://github.com/dtolnay/quote - rustc-hash 1.1.0 — https://github.com/rust-lang-nursery/rustc-hash - rustix-openpty 0.2.0 — https://github.com/sunfishcode/rustix-openpty - rustix 1.1.4 — https://github.com/bytecodealliance/rustix @@ -3958,6 +4707,7 @@ DEALINGS IN THE SOFTWARE. - serde_core 1.0.228 — https://github.com/serde-rs/serde - serde_derive 1.0.228 — https://github.com/serde-rs/serde - serde_json 1.0.150 — https://github.com/serde-rs/json +- smol_str 0.2.2 — https://github.com/rust-analyzer/smol_str - smol_str 0.3.6 — https://github.com/rust-lang/rust-analyzer/tree/master/lib/smol_str - syn 2.0.118 — https://github.com/dtolnay/syn - thiserror-impl 1.0.69 — https://github.com/dtolnay/thiserror @@ -3965,7 +4715,7 @@ DEALINGS IN THE SOFTWARE. - thiserror 1.0.69 — https://github.com/dtolnay/thiserror - thiserror 2.0.18 — https://github.com/dtolnay/thiserror - unicode-ident 1.0.24 — https://github.com/dtolnay/unicode-ident -- zmij 1.0.21 — https://github.com/dtolnay/zmij +- zmij 1.0.23 — https://github.com/dtolnay/zmij ``` Permission is hereby granted, free of charge, to any @@ -4083,7 +4833,7 @@ SOFTWARE. ## MIT License -- tinyvec 1.11.0 — https://github.com/Lokathor/tinyvec +- tinyvec 1.12.0 — https://github.com/Lokathor/tinyvec ``` Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: @@ -4272,8 +5022,8 @@ THE SOFTWARE. - aho-corasick 1.1.4 — https://github.com/BurntSushi/aho-corasick - byteorder-lite 0.1.0 — https://github.com/image-rs/byteorder-lite -- jiff 0.2.28 — https://github.com/BurntSushi/jiff -- memchr 2.8.2 — https://github.com/BurntSushi/memchr +- jiff 0.2.32 — https://github.com/BurntSushi/jiff +- memchr 2.8.3 — https://github.com/BurntSushi/memchr - walkdir 2.5.0 — https://github.com/BurntSushi/walkdir ``` @@ -4478,7 +5228,35 @@ SOFTWARE. ## MIT License -- crossbeam-utils 0.8.21 — https://github.com/crossbeam-rs/crossbeam +- guillotiere 0.7.0 — https://github.com/nical/guillotiere + +``` +The MIT License (MIT) + +Copyright (c) 2019 Nicolas Silva + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + +- crossbeam-utils 0.8.22 — https://github.com/crossbeam-rs/crossbeam ``` The MIT License (MIT) @@ -4712,6 +5490,210 @@ USE OR OTHER DEALINGS IN THE SOFTWARE. ``` +## SIL Open Font License 1.1 + +- epaint_default_fonts 0.35.0 — https://github.com/emilk/egui/tree/main/crates/epaint_default_fonts + +``` +This Font Software is licensed under the SIL Open Font License, +Version 1.1. + +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + +``` + +## Ubuntu Font Licence v1.0 + +- epaint_default_fonts 0.35.0 — https://github.com/emilk/egui/tree/main/crates/epaint_default_fonts + +``` +------------------------------- +UBUNTU FONT LICENCE Version 1.0 +------------------------------- + +PREAMBLE +This licence allows the licensed fonts to be used, studied, modified and +redistributed freely. The fonts, including any derivative works, can be +bundled, embedded, and redistributed provided the terms of this licence +are met. The fonts and derivatives, however, cannot be released under +any other licence. The requirement for fonts to remain under this +licence does not require any document created using the fonts or their +derivatives to be published under this licence, as long as the primary +purpose of the document is not to be a vehicle for the distribution of +the fonts. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this licence and clearly marked as such. This may +include source files, build scripts and documentation. + +"Original Version" refers to the collection of Font Software components +as received under this licence. + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to +a new environment. + +"Copyright Holder(s)" refers to all individuals and companies who have a +copyright ownership of the Font Software. + +"Substantially Changed" refers to Modified Versions which can be easily +identified as dissimilar to the Font Software by users of the Font +Software comparing the Original Version with the Modified Version. + +To "Propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification and with or without charging +a redistribution fee), making available to the public, and in some +countries other activities as well. + +PERMISSION & CONDITIONS +This licence does not grant any rights under trademark law and all such +rights are reserved. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of the Font Software, to propagate the Font Software, subject to +the below conditions: + +1) Each copy of the Font Software must contain the above copyright +notice and this licence. These can be included either as stand-alone +text files, human-readable headers or in the appropriate machine- +readable metadata fields within text or binary files as long as those +fields can be easily viewed by the user. + +2) The font name complies with the following: +(a) The Original Version must retain its name, unmodified. +(b) Modified Versions which are Substantially Changed must be renamed to +avoid use of the name of the Original Version or similar names entirely. +(c) Modified Versions which are not Substantially Changed must be +renamed to both (i) retain the name of the Original Version and (ii) add +additional naming elements to distinguish the Modified Version from the +Original Version. The name of such Modified Versions must be the name of +the Original Version, with "derivative X" where X represents the name of +the new work, appended to that name. + +3) The name(s) of the Copyright Holder(s) and any contributor to the +Font Software shall not be used to promote, endorse or advertise any +Modified Version, except (i) as required by this licence, (ii) to +acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with +their explicit written permission. + +4) The Font Software, modified or unmodified, in part or in whole, must +be distributed entirely under this licence, and must not be distributed +under any other licence. The requirement for fonts to remain under this +licence does not affect any document created using the Font Software, +except any version of the Font Software extracted from a document +created using the Font Software may only be distributed under this +licence. + +TERMINATION +This licence becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER +DEALINGS IN THE FONT SOFTWARE. + +``` + ## Unicode License v3 - unicode-ident 1.0.24 — https://github.com/dtolnay/unicode-ident diff --git a/native/Cargo.lock b/native/Cargo.lock index db38e59..b898464 100644 --- a/native/Cargo.lock +++ b/native/Cargo.lock @@ -2,12 +2,33 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "accesskit" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +dependencies = [ + "uuid", +] + [[package]] name = "adler2" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -33,7 +54,7 @@ dependencies = [ "piper", "polling", "regex-automata", - "rustix", + "rustix 1.1.4", "rustix-openpty", "serde", "signal-hook", @@ -48,6 +69,31 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.0", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.18", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -115,9 +161,9 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "ash" @@ -200,13 +246,22 @@ dependencies = [ "serde_core", ] +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + [[package]] name = "block2" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2", + "objc2 0.6.4", ] [[package]] @@ -217,18 +272,18 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424" dependencies = [ "bytemuck_derive", ] [[package]] name = "bytemuck_derive" -version = "1.10.2" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +checksum = "f65693059b6b9c588b9f62fed1cedbf0a8b805631457ea162d68f0de186f3de5" dependencies = [ "proc-macro2", "quote", @@ -241,11 +296,31 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.0", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + [[package]] name = "cc" -version = "1.2.65" +version = "1.2.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" dependencies = [ "find-msvc-tools", "jobserver", @@ -276,12 +351,31 @@ dependencies = [ "unicode-width 0.2.2", ] +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" +dependencies = [ + "bytemuck", +] + [[package]] name = "colorchoice" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -291,6 +385,16 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -307,6 +411,30 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + [[package]] name = "core_maths" version = "0.1.1" @@ -324,14 +452,14 @@ checksum = "bbe782a9e7520cc7de2232c957a47f99d3a35e855552677d07a557bc1a3b66ed" dependencies = [ "bitflags 2.13.0", "fontdb", - "harfrust", + "harfrust 0.5.2", "linebender_resource_handle", "log", "rangemap", - "rustc-hash 2.1.2", + "rustc-hash 2.1.3", "self_cell", "skrifa 0.40.0", - "smol_str", + "smol_str 0.3.6", "swash", "sys-locale", "unicode-bidi", @@ -351,9 +479,9 @@ dependencies = [ [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crunchy" @@ -367,12 +495,49 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + [[package]] name = "dispatch2" version = "0.3.1" @@ -380,7 +545,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags 2.13.0", - "objc2", + "objc2 0.6.4", ] [[package]] @@ -407,11 +572,96 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "ecolor" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6758be723a3f298bbfda4db75748bc2ba0abafe096b6383c7c32da264764fbc3" +dependencies = [ + "bytemuck", + "emath", +] + +[[package]] +name = "egui" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796c98d50b79631281d516343a6f6e93c0666462ca36e2c93b39f25d7793325" +dependencies = [ + "accesskit", + "ahash", + "bitflags 2.13.0", + "emath", + "epaint", + "itertools", + "log", + "nohash-hasher", + "profiling", + "smallvec", + "unicode-segmentation", +] + +[[package]] +name = "egui-wgpu" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e6cfac0725563555fa4f91e9f799b9d7c6c5dd831fca6abc8234afc64b7a34" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "epaint", + "log", + "profiling", + "thiserror 2.0.18", + "type-map", + "web-time", + "wgpu", +] + +[[package]] +name = "egui-winit" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea6bf3608db949588b95b8b341ee358d0c3f95cf4dc3f53d8d76717edee87db" +dependencies = [ + "egui", + "log", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "profiling", + "raw-window-handle", + "web-time", + "winit", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "emath" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd4ec073c9898516584d8c6cfdcee95b530b3d941cd5031ef4050aa36812308b" +dependencies = [ + "bytemuck", +] + [[package]] name = "env_filter" -version = "1.0.1" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" dependencies = [ "log", "regex", @@ -419,9 +669,9 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.10" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" dependencies = [ "anstream", "anstyle", @@ -430,6 +680,37 @@ dependencies = [ "log", ] +[[package]] +name = "epaint" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e60a8888b51da911df23918fd7301359b1d43a406a0ff3b8863af093dd7fc6c" +dependencies = [ + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "font-types", + "harfrust 0.7.0", + "log", + "nohash-hasher", + "parking_lot", + "profiling", + "self_cell", + "skrifa 0.42.1", + "smallvec", + "unicode-general-category", + "unicode-segmentation", + "vello_cpu", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13ee4e1f553a3584c301f3a56ff1a775f1384781396cea301c8d952e9b93f560" + [[package]] name = "equivalent" version = "1.0.2" @@ -490,6 +771,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fearless_simd" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" + [[package]] name = "filedescriptor" version = "0.8.3" @@ -577,6 +864,33 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -650,6 +964,22 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "glifo" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e" +dependencies = [ + "bytemuck", + "foldhash 0.2.0", + "hashbrown 0.17.1", + "log", + "peniko", + "skrifa 0.42.1", + "smallvec", + "vello_common", +] + [[package]] name = "glow" version = "0.17.0" @@ -680,7 +1010,7 @@ dependencies = [ "cosmic-text", "etagere", "lru", - "rustc-hash 2.1.2", + "rustc-hash 2.1.3", "wgpu", ] @@ -718,6 +1048,15 @@ dependencies = [ "bitflags 2.13.0", ] +[[package]] +name = "guillotiere" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +dependencies = [ + "euclid", +] + [[package]] name = "half" version = "2.7.1" @@ -743,6 +1082,18 @@ dependencies = [ "smallvec", ] +[[package]] +name = "harfrust" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0431e8e389aa0f1e72bb9d1c2db8957a1a7a3580e8ed97db819c14837aac9b3e" +dependencies = [ + "bitflags 2.13.0", + "bytemuck", + "read-fonts 0.39.2", + "smallvec", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -768,6 +1119,9 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] [[package]] name = "hermit-abi" @@ -826,9 +1180,9 @@ dependencies = [ [[package]] name = "inotify-sys" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ea94e891b3606826e9c998be69ddca42247dad8ad50b1649a5cb7e1c9ae06fd" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" dependencies = [ "libc", ] @@ -857,6 +1211,15 @@ dependencies = [ "phf", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -865,10 +1228,11 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.28" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102" +checksum = "961d16382652bfdd8c6f68b223b26a8c93e0d475c672f414411db31c6c5c900e" dependencies = [ + "defmt", "jiff-static", "log", "portable-atomic", @@ -878,12 +1242,42 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.28" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0879bd39df99c4c5e2c6615ccc026391a423dde10532c573e6086eb94a802cc" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" dependencies = [ "proc-macro2", "quote", + "rustc_version", + "simd_cesu8", "syn", ] @@ -927,9 +1321,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.102" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", "futures-util", @@ -973,6 +1367,18 @@ dependencies = [ "libc", ] +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1013,6 +1419,18 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "bitflags 2.13.0", + "libc", + "plain", + "redox_syscall 0.9.0", +] + [[package]] name = "libz-sys" version = "1.1.29" @@ -1037,6 +1455,12 @@ version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1060,9 +1484,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.32" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "lru" @@ -1072,9 +1496,9 @@ checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmap2" @@ -1137,9 +1561,9 @@ dependencies = [ [[package]] name = "naga" -version = "29.0.3" +version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dd91265cc2454558f659b3b4b9640f0ddb8cc6521277f166b8a8c181c898079" +checksum = "b2bf919621e7975acb27d881bae2fb993e0d45c8e0446e85e6272971e00dc8df" dependencies = [ "arrayvec", "bit-set 0.9.1", @@ -1161,6 +1585,27 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.0", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -1184,6 +1629,12 @@ dependencies = [ "pin-utils", ] +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + [[package]] name = "notify" version = "6.1.1" @@ -1218,6 +1669,44 @@ dependencies = [ "libm", ] +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + [[package]] name = "objc2" version = "0.6.4" @@ -1227,6 +1716,22 @@ dependencies = [ "objc2-encode", ] +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.0", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + [[package]] name = "objc2-app-kit" version = "0.3.2" @@ -1234,18 +1739,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.0", - "block2", + "block2 0.6.2", "libc", - "objc2", - "objc2-cloud-kit", - "objc2-core-data", + "objc2 0.6.4", + "objc2-cloud-kit 0.3.2", + "objc2-core-data 0.3.2", "objc2-core-foundation", "objc2-core-graphics", - "objc2-core-image", + "objc2-core-image 0.3.2", "objc2-core-text", "objc2-core-video", - "objc2-foundation", - "objc2-quartz-core", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", ] [[package]] @@ -1255,8 +1773,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ "bitflags 2.13.0", - "objc2", - "objc2-foundation", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", ] [[package]] @@ -1266,8 +1807,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ "bitflags 2.13.0", - "objc2", - "objc2-foundation", + "objc2 0.6.4", + "objc2-foundation 0.3.2", ] [[package]] @@ -1278,7 +1819,7 @@ checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.13.0", "dispatch2", - "objc2", + "objc2 0.6.4", ] [[package]] @@ -1289,19 +1830,43 @@ checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ "bitflags 2.13.0", "dispatch2", - "objc2", + "objc2 0.6.4", "objc2-core-foundation", "objc2-io-surface", ] +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + [[package]] name = "objc2-core-image" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" dependencies = [ - "objc2", - "objc2-foundation", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", ] [[package]] @@ -1311,7 +1876,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ "bitflags 2.13.0", - "objc2", + "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", ] @@ -1323,7 +1888,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" dependencies = [ "bitflags 2.13.0", - "objc2", + "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", "objc2-io-surface", @@ -1337,60 +1902,177 @@ checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" [[package]] name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.0", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.0", + "block2 0.6.2", + "libc", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.0", + "libc", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.0", + "block2 0.6.2", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-io-surface", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.0", + "block2 0.6.2", + "libc", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-video", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" dependencies = [ - "bitflags 2.13.0", - "block2", - "libc", - "objc2", - "objc2-core-foundation", + "objc2 0.5.2", + "objc2-foundation 0.2.2", ] [[package]] -name = "objc2-io-surface" +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-cloud-kit 0.2.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core 0.2.2", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-ui-kit" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.13.0", - "libc", - "objc2", + "objc2 0.6.4", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", ] [[package]] -name = "objc2-metal" -version = "0.3.2" +name = "objc2-uniform-type-identifiers" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" dependencies = [ - "bitflags 2.13.0", - "block2", - "dispatch2", - "objc2", - "objc2-core-foundation", - "objc2-foundation", - "objc2-io-surface", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", ] [[package]] -name = "objc2-quartz-core" -version = "0.3.2" +name = "objc2-user-notifications" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ "bitflags 2.13.0", - "block2", - "libc", - "objc2", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-core-video", - "objc2-foundation", - "objc2-metal", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", ] [[package]] @@ -1405,6 +2087,16 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + [[package]] name = "ordered-float" version = "5.3.0" @@ -1432,11 +2124,24 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] +[[package]] +name = "peniko" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "bytemuck", + "color", + "kurbo", + "linebender_resource_handle", + "smallvec", +] + [[package]] name = "phf" version = "0.11.3" @@ -1455,6 +2160,26 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1484,6 +2209,12 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "plist" version = "1.10.0" @@ -1520,7 +2251,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -1530,6 +2261,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22686f4785f02a4fcc856d3b3bb19bf6c8160d103f7a99cc258bddd0251dc7f2" +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -1578,6 +2318,15 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1606,9 +2355,9 @@ dependencies = [ [[package]] name = "pxfm" -version = "0.1.29" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" [[package]] name = "quick-xml" @@ -1621,9 +2370,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.45" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -1658,10 +2407,10 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" dependencies = [ - "objc2", + "objc2 0.6.4", "objc2-core-foundation", - "objc2-foundation", - "objc2-quartz-core", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", ] [[package]] @@ -1685,6 +2434,15 @@ dependencies = [ "font-types", ] +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1694,11 +2452,20 @@ dependencies = [ "bitflags 2.13.0", ] +[[package]] +name = "redox_syscall" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759" +dependencies = [ + "bitflags 2.13.0", +] + [[package]] name = "regex" -version = "1.12.4" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" dependencies = [ "aho-corasick", "memchr", @@ -1708,9 +2475,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" dependencies = [ "aho-corasick", "memchr", @@ -1743,9 +2510,31 @@ checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] [[package]] name = "rustix" @@ -1756,7 +2545,7 @@ dependencies = [ "bitflags 2.13.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -1768,14 +2557,14 @@ checksum = "1de16c7c59892b870a6336f185dc10943517f1327447096bbb7bb32cd85e2393" dependencies = [ "errno", "libc", - "rustix", + "rustix 1.1.4", ] [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "same-file" @@ -1798,6 +2587,12 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -1932,6 +2727,22 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "similar" version = "2.7.0" @@ -1985,12 +2796,31 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + [[package]] name = "smol_str" version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "spirv" version = "0.4.0+sdk-1.4.341.0" @@ -2124,9 +2954,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.49" +version = "0.3.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" dependencies = [ "deranged", "num-conv", @@ -2144,9 +2974,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.29" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" dependencies = [ "num-conv", "time-core", @@ -2163,9 +2993,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -2176,6 +3006,52 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" + [[package]] name = "tree-sitter" version = "0.25.10" @@ -2206,6 +3082,16 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" +[[package]] +name = "tree-sitter-md" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2efd398be546456c814598ee56c0f51769a77241511b4a58077815d120afa882" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "ttf-parser" version = "0.25.1" @@ -2215,6 +3101,15 @@ dependencies = [ "core_maths", ] +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.3", +] + [[package]] name = "unicase" version = "2.9.0" @@ -2227,6 +3122,12 @@ version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2269,9 +3170,9 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b87595f1063af99a3bd106e3ab6d99c38ccf68d9ead7ef61f7f2388d07a357e" dependencies = [ - "objc2", - "objc2-foundation", - "objc2-metal", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", "unity-native-plugin-sys", ] @@ -2286,9 +3187,13 @@ name = "unterm" version = "0.1.0" dependencies = [ "alacritty_terminal", + "block2 0.6.2", "bytemuck", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", + "egui", + "egui-wgpu", + "egui-winit", "env_logger", "git2", "glyphon", @@ -2297,29 +3202,32 @@ dependencies = [ "libc", "log", "notify", - "objc2", - "objc2-app-kit", - "objc2-foundation", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "objc2-io-surface", - "objc2-metal", - "objc2-quartz-core", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", "pollster", "portable-pty", "pulldown-cmark", "raw-window-handle", "serde_json", "similar", + "socket2", "streaming-iterator", "syntect", "sys-locale", "timeago", "tree-sitter", "tree-sitter-c-sharp", + "tree-sitter-md", "unicode-script", "unicode-width 0.1.14", "unity-native-plugin", "wgpu", "windows", + "winit", ] [[package]] @@ -2328,12 +3236,46 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5fab0d6c3c01ae70085a09cb03d4c7a1d6314e2b3e075392783396d724ca0a" + [[package]] name = "vcpkg" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "vello_common" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d672facaa2d697285a786cd9d44d614cd2ce54cdc022504bf339f8fff3b750" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "hashbrown 0.17.1", + "log", + "peniko", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "vello_cpu" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "588691169aed86b5c8fb487266afee01323234e6fd0a3f2aaec0eaa8e4007f23" +dependencies = [ + "bytemuck", + "glifo", + "hashbrown 0.17.1", + "vello_common", +] + [[package]] name = "version_check" version = "0.9.5" @@ -2372,9 +3314,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasm-bindgen" -version = "0.2.125" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -2385,9 +3327,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.75" +version = "0.4.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" dependencies = [ "js-sys", "wasm-bindgen", @@ -2395,9 +3337,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.125" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2405,9 +3347,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.125" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", @@ -2418,9 +3360,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.125" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] @@ -2439,9 +3381,19 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.102" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ "js-sys", "wasm-bindgen", @@ -2449,9 +3401,9 @@ dependencies = [ [[package]] name = "wgpu" -version = "29.0.3" +version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb3feacc458f7bee8bc1737149b42b6c731aa461039a4264a67bb6681646b250" +checksum = "76e8840e1ba2881d4cbb18d2147627a56af426ff064c0401eb0c8410c6325d07" dependencies = [ "arrayvec", "bitflags 2.13.0", @@ -2479,9 +3431,9 @@ dependencies = [ [[package]] name = "wgpu-core" -version = "29.0.3" +version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02da3ad1b568337f25513b317870960ef87073ea0945502e44b864b67a8c77b7" +checksum = "2f519832254e56965a9940c4af57dcb75f702b6f6fa4a0b172f685395843a4d7" dependencies = [ "arrayvec", "bit-set 0.9.1", @@ -2504,6 +3456,7 @@ dependencies = [ "thiserror 2.0.18", "wgpu-core-deps-apple", "wgpu-core-deps-emscripten", + "wgpu-core-deps-wasm", "wgpu-core-deps-windows-linux-android", "wgpu-hal", "wgpu-naga-bridge", @@ -2512,43 +3465,52 @@ dependencies = [ [[package]] name = "wgpu-core-deps-apple" -version = "29.0.3" +version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62e51b5447e144b3dbba4feb01f80f4fa21696fa0cd99afb2c3df1affd6fdb28" +checksum = "f5e39e26c4c0e07589e67d18546cf79ff45383659fc72fca4dd293358a0347f3" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-emscripten" -version = "29.0.3" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01e09be551dc939498bdd5f6b2c66e55ab275dad25825267a08605a80fc9f0af" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-wasm" +version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3487cd6293a963bc5c0c0396f6a2192043c50003c07f4efdccbad3d90ec9d819" +checksum = "af1fb1798be2a912497d4c224f72d39bb0cb34af50e8bcc29865bc339c943059" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-windows-linux-android" -version = "29.0.3" +version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb01076d0aa08b0ba9bd741e178b5cc440f5abe99d9581323a4c8b5d1a1916" +checksum = "4e592c1bbef6ad047647ae6e666ebd8cee7a32bb4544d9700ec96cbf73230257" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-hal" -version = "29.0.3" +version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31f8e1a9e7a8512f276f7c62e018c7fa8d60954303fed2e5750114332049193f" +checksum = "97ace1c17727311c22a46e4e3faf56ea6de81af99dcc839bdfb54857b94d448d" dependencies = [ "android_system_properties", "arrayvec", "ash", "bit-set 0.9.1", "bitflags 2.13.0", - "block2", + "block2 0.6.2", "bytemuck", "cfg-if", "cfg_aliases", @@ -2564,11 +3526,11 @@ dependencies = [ "log", "naga", "ndk-sys", - "objc2", + "objc2 0.6.4", "objc2-core-foundation", - "objc2-foundation", - "objc2-metal", - "objc2-quartz-core", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", "once_cell", "ordered-float", "parking_lot", @@ -2593,9 +3555,9 @@ dependencies = [ [[package]] name = "wgpu-naga-bridge" -version = "29.0.3" +version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59c654c483f058800972c3645e95388a7eca31bf9fe1933bc20e036588a0be02" +checksum = "95226013f547544b223281cd16a4fb549aa9dcb562adbda0faae4c73ffbbc161" dependencies = [ "naga", "wgpu-types", @@ -2603,9 +3565,9 @@ dependencies = [ [[package]] name = "wgpu-types" -version = "29.0.3" +version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9bcc31518a0e9735aefebedb5f7a9ef3ed1c42549c9f4c882fa9060ceaac639" +checksum = "84bf84cd9ca8ca45e2b223a3868f1adf9bfc0c66aeac212e76ee7e40fdadf8f5" dependencies = [ "bitflags 2.13.0", "bytemuck", @@ -2756,6 +3718,15 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -2904,6 +3875,55 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "android-activity", + "atomic-waker", + "bitflags 2.13.0", + "block2 0.5.1", + "calloop", + "cfg_aliases", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "ndk", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", + "orbclient", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "smol_str 0.2.2", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.10.1" @@ -2913,6 +3933,25 @@ dependencies = [ "winapi", ] +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.0", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + [[package]] name = "xml-rs" version = "0.8.28" @@ -2942,18 +3981,18 @@ checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" [[package]] name = "zerocopy" -version = "0.8.52" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.52" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", @@ -2962,6 +4001,6 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/native/about.toml b/native/about.toml index b07b871..16bcfaa 100644 --- a/native/about.toml +++ b/native/about.toml @@ -15,6 +15,13 @@ accepted = [ "Unlicense", "0BSD", "MPL-2.0", + # The debugger's egui window: clipboard-win/error-code (BSL-1.0) via + # egui-winit's clipboard, and egui's bundled fonts (OFL-1.1 + + # Ubuntu-font-1.0 via epaint_default_fonts). All permissive; the font + # licenses are satisfied by shipping their notices, which this generates. + "BSL-1.0", + "OFL-1.1", + "Ubuntu-font-1.0", ] # The shipped targets: macOS aarch64/x86_64 and Windows x86_64. This prunes diff --git a/native/build-macos.sh b/native/build-macos.sh index 34c8b05..a4841b0 100755 --- a/native/build-macos.sh +++ b/native/build-macos.sh @@ -30,19 +30,36 @@ echo "==> building unterm ($PROFILE)" for arch in "${ARCHS[@]}"; do # Bash 3.2 (macOS) treats "${CARGO_FLAGS[@]}" as unbound under `set -u` when the # array is empty (the debug profile), so expand it guardedly. - cargo build -p unterm --locked ${CARGO_FLAGS[@]+"${CARGO_FLAGS[@]}"} --target "$arch" + cargo build -p unterm --locked --lib --bin unterm-debugger \ + ${CARGO_FLAGS[@]+"${CARGO_FLAGS[@]}"} --target "$arch" done -DEST="../Packages/dev.tnayuki.unterm/Editor/Plugins/macOS/unterm.dylib" -mkdir -p "$(dirname "$DEST")" +PLUGIN_DIR="../Packages/dev.tnayuki.unterm/Editor/Plugins/macOS" +LIB_DEST="$PLUGIN_DIR/unterm.dylib" +DEBUGGER_DEST="$PLUGIN_DIR/unterm-debugger" +mkdir -p "$PLUGIN_DIR" LIBS=() +DEBUGGERS=() for arch in "${ARCHS[@]}"; do LIBS+=("target/$arch/${TARGET_DIR}/libunterm.dylib") + DEBUGGERS+=("target/$arch/${TARGET_DIR}/unterm-debugger") done -echo "==> lipo -> $DEST" -lipo -create "${LIBS[@]}" -output "$DEST" +echo "==> lipo library -> $LIB_DEST" +lipo -create "${LIBS[@]}" -output "$LIB_DEST" -echo "==> done: $DEST" -lipo -info "$DEST" +echo "==> lipo debugger -> $DEBUGGER_DEST" +lipo -create "${DEBUGGERS[@]}" -output "$DEBUGGER_DEST" +chmod 0755 "$DEBUGGER_DEST" +# Lipo invalidates per-architecture Mach-O signatures. Re-sign the universal +# executable deterministically (ad-hoc, no timestamp) before publishing it. +codesign --force --sign - --timestamp=none \ + --identifier dev.tnayuki.unterm.debugger "$DEBUGGER_DEST" +codesign --verify --strict --verbose=2 "$DEBUGGER_DEST" + +echo "==> done: $LIB_DEST" +lipo -info "$LIB_DEST" +echo "==> done: $DEBUGGER_DEST" +lipo -info "$DEBUGGER_DEST" +test -x "$DEBUGGER_DEST" diff --git a/native/build-windows.ps1 b/native/build-windows.ps1 index bf186fd..a410493 100644 --- a/native/build-windows.ps1 +++ b/native/build-windows.ps1 @@ -39,17 +39,23 @@ if ($Configuration -eq 'release') { rustup target add $Target | Out-Null Write-Host "==> building unterm ($Configuration, $Target)" -cargo build -p unterm --locked @cargoFlags --target $Target +cargo build -p unterm --locked --lib --bin unterm-debugger @cargoFlags --target $Target if ($LASTEXITCODE -ne 0) { throw "cargo build failed (exit $LASTEXITCODE)" } -$dest = Join-Path $PSScriptRoot '..\Packages\dev.tnayuki.unterm\Editor\Plugins\Windows\x86_64\unterm.dll' -$destDir = Split-Path -Parent $dest -New-Item -ItemType Directory -Force -Path $destDir | Out-Null +$pluginDir = Join-Path $PSScriptRoot '..\Packages\dev.tnayuki.unterm\Editor\Plugins\Windows\x86_64' +$libDest = Join-Path $pluginDir 'unterm.dll' +$debuggerDest = Join-Path $pluginDir 'unterm-debugger.exe' +New-Item -ItemType Directory -Force -Path $pluginDir | Out-Null $lib = Join-Path $PSScriptRoot "target\$Target\$targetDir\unterm.dll" if (-not (Test-Path -LiteralPath $lib)) { throw "built dll not found: $lib" } +$debugger = Join-Path $PSScriptRoot "target\$Target\$targetDir\unterm-debugger.exe" +if (-not (Test-Path -LiteralPath $debugger)) { throw "built debugger not found: $debugger" } -Write-Host "==> copy -> $dest" -Copy-Item -LiteralPath $lib -Destination $dest -Force +Write-Host "==> copy -> $libDest" +Copy-Item -LiteralPath $lib -Destination $libDest -Force +Write-Host "==> copy -> $debuggerDest" +Copy-Item -LiteralPath $debugger -Destination $debuggerDest -Force -Write-Host "==> done: $dest" +Write-Host "==> done: $libDest" +Write-Host "==> done: $debuggerDest" diff --git a/native/deny.toml b/native/deny.toml index e231214..9fa6235 100644 --- a/native/deny.toml +++ b/native/deny.toml @@ -23,6 +23,10 @@ allow = [ "ISC", "MIT", "MPL-2.0", + # egui's bundled debugger fonts. Redistribution requirements are satisfied + # by the generated Third Party Notices shipped with the Unity package. + "OFL-1.1", + "Ubuntu-font-1.0", "Unicode-3.0", "Unlicense", "Zlib", diff --git a/native/unterm/Cargo.toml b/native/unterm/Cargo.toml index 09cc1f9..f4e0783 100644 --- a/native/unterm/Cargo.toml +++ b/native/unterm/Cargo.toml @@ -65,8 +65,25 @@ notify = { version = "6", default-features = false, features = ["macos_fsevent"] timeago = { version = "0.6.1", default-features = false, features = ["isolang", "translations"] } isolang = { version = "2.4.0", default-features = false } +# Standalone debugger window (`unterm-debugger` bin / `debugger` module): its own +# winit event loop + wgpu surface, so it stays live while the editor is frozen at a +# breakpoint. Only entered by the bin; the Unity-loaded cdylib never calls into it. +# The debugger ships only for macOS and Windows. Disable winit/egui-winit's +# default Linux X11/Wayland backends so unused platform crates do not enter the +# audited lockfile (and the shipped dependency surface stays target-accurate). +winit = { version = "0.30", default-features = false, features = ["rwh_06"] } +egui-wgpu = "0.35.0" +egui = "0.35" +egui-winit = { version = "0.35", default-features = false } +# Player discovery: bind the Unity multicast group with SO_REUSEPORT (shared with the +# editor's own listener) — std's UdpSocket can't set the sockopt before bind. +socket2 = { version = "0.5", features = ["all"] } +tree-sitter-md = "0.5.3" + # Graceful child shutdown: SIGTERM the `claude` process before falling back to # SIGKILL, so it can finalize its transcript (std's Child::kill is SIGKILL-only). +# Also player discovery: getifaddrs enumerates interfaces so the multicast group is +# joined on each (macOS won't receive from a secondary interface via INADDR_ANY alone). [target.'cfg(unix)'.dependencies] libc = "0.2" @@ -89,9 +106,16 @@ objc2-io-surface = "0.3" # we render to with wgpu (so the list can overflow the editor window). objc2-app-kit = { version = "0.3", features = [ "NSResponder", "NSWindow", "NSPanel", "NSView", "NSScreen", + # Debugger: observe the editor coming to the foreground (NSWorkspace notice) to + # tuck the debugger window just behind it. + "NSWorkspace", ] } objc2-quartz-core = { version = "0.3", features = ["CALayer", "CAMetalLayer"] } objc2-foundation = { version = "0.3", features = ["NSGeometry"] } +# Debugger foreground-follow: the NSWorkspace observer block + the winit window's +# NSWindow handle (to orderWindow:relativeTo: it below the editor's window). +block2 = "0.6" +raw-window-handle = "0.6" # IOSurface creation stays on the plain CoreFoundation FFI; the raw pointer is # bridged into objc2's `&IOSurfaceRef` for the Metal texture call. core-foundation = "0.10" @@ -122,6 +146,9 @@ windows = { version = "0.62", features = [ # Probe whether a `claude` pid from the session registry is still alive. "Win32_System_Threading", "Win32_UI_WindowsAndMessaging", + # Debugger foreground-follow: SetWinEventHook(EVENT_SYSTEM_FOREGROUND) to detect the + # editor coming forward, then SetWindowPos to slide the window just behind it. + "Win32_UI_Accessibility", ] } # Same crate the macOS side uses; the d3d features pull in the # IUnityGraphicsD3D11/D3D12 interfaces for the device capture in unity.rs. diff --git a/native/unterm/examples/discover_test.rs b/native/unterm/examples/discover_test.rs new file mode 100644 index 0000000..87c754e --- /dev/null +++ b/native/unterm/examples/discover_test.rs @@ -0,0 +1,35 @@ +//! Ad-hoc: verify player discovery receives + parses live announcements. +//! cargo run -p unterm --example discover_test +use std::time::{Duration, Instant}; + +fn main() { + let sock = match unterm::sdb::discovery_socket() { + Ok(s) => s, + Err(e) => { + eprintln!("discovery_socket failed: {e}"); + return; + } + }; + eprintln!("bound + joined; listening 10s…"); + let mut buf = [0u8; 2048]; + let mut seen = std::collections::HashSet::new(); + let end = Instant::now() + Duration::from_secs(10); + while Instant::now() < end { + match sock.recv_from(&mut buf) { + Ok((n, from)) => { + if let Some(p) = unterm::sdb::parse_player(&buf[..n]) { + if seen.insert(p.guid) { + eprintln!( + "PLAYER from {from}: guid={} ip={} port={} project={:?} label={:?}", + p.guid, p.ip, p.port, p.project, p.label + ); + } + } else { + eprintln!("(non-debuggable or unparsable {n}B from {from})"); + } + } + Err(_) => {} + } + } + eprintln!("done: {} unique player(s)", seen.len()); +} diff --git a/native/unterm/examples/dump_debug.rs b/native/unterm/examples/dump_debug.rs new file mode 100644 index 0000000..8c9800c --- /dev/null +++ b/native/unterm/examples/dump_debug.rs @@ -0,0 +1,225 @@ +//! Headless SDB debug session against a running Unity editor. +//! +//! Attaches to the editor's Mono agent, arms a source breakpoint, and — once you +//! press Play with the target script on a GameObject — resolves it via sequence +//! points, stops on the hit, and dumps the call stack + locals. +//! +//! cargo run -p unterm --example dump_debug # Rotator.cs:37 +//! cargo run -p unterm --example dump_debug -- Rotator.cs 43 +//! +//! The project root is discovered from the cwd (walks up to Library/EditorInstance.json). + +use std::time::{Duration, Instant}; + +use unterm::sdb::{self, kind_name, value::Value, wire, DebugInfo}; + +fn main() { + env_logger::try_init().ok(); + + let mut args = std::env::args().skip(1); + let file = args.next().unwrap_or_else(|| "Rotator.cs".to_string()); + let line: i32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(37); + + let root = std::env::current_dir().unwrap(); + let mut conn = match sdb::connect_editor(&root) { + Ok(c) => c, + Err(e) => { + eprintln!("connect failed: {e} (is the Unity editor open under {}?)", root.display()); + std::process::exit(1); + } + }; + let v = conn.version().clone(); + println!("attached: {} (protocol {}.{})", v.vm, v.major, v.minor); + while conn.take_queued().is_some() {} // drain VM_START + + validate_debug_info_framing(&mut conn); + + // Arm: subscribe to TYPE_LOAD for the target file. Types already loaded resolve + // immediately; everything else resolves when the play-mode domain loads them. + let watch = conn.watch_source_files(&[file.clone()]).expect("watch source"); + println!("watching TYPE_LOAD for {file} (request {watch})"); + let mut armed = false; + if let Ok(types) = conn.types_for_source_file(&file, true) { + if !types.is_empty() { + println!("{} type(s) already loaded for {file}", types.len()); + armed |= try_arm(&mut conn, &types, &file, line); + } + } + + println!("\n>>> Press Play in Unity (attach `{file}` to a GameObject). Waiting for the breakpoint...\n"); + conn.set_read_timeout(Some(Duration::from_millis(500))).ok(); + let deadline = Instant::now() + Duration::from_secs(300); + + while Instant::now() < deadline { + let composite = match conn.next_event() { + Ok(c) => c, + Err(wire::SdbError::Io(ref e)) + if matches!( + e.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) => + { + continue; + } + Err(wire::SdbError::Eof) => { + println!("editor disconnected"); + return; + } + Err(e) => { + println!("event error: {e}"); + continue; + } + }; + + for ev in &composite.events { + match ev.kind { + wire::kind::TYPE_LOAD => { + if !armed { + armed = try_arm(&mut conn, &[ev.type_id], &file, line); + } + } + wire::kind::BREAKPOINT => { + println!("\n=== BREAKPOINT hit on thread {} ===", ev.thread); + dump_stop(&mut conn, ev.thread); + println!("\nresuming..."); + conn.resume().ok(); + return; + } + other => { + println!("event: {} ({})", kind_name(other), other); + } + } + } + } + println!("timed out waiting for the breakpoint"); +} + +/// Get type methods, read their sequence points, resolve the line, and set the BP. +fn try_arm(conn: &mut sdb::Connection, types: &[u32], file: &str, line: i32) -> bool { + let mut candidates = Vec::new(); + for &t in types { + let methods = match conn.type_methods(t) { + Ok(m) => m, + Err(_) => continue, + }; + for m in methods { + if let Ok(info) = conn.debug_info(m) { + candidates.push((m, info)); + } + } + } + match sdb::resolve_line(&candidates, file, line) { + Some((method, il)) => { + let name = conn.method_name(method).unwrap_or_default(); + match conn.set_breakpoint(method, il) { + Ok(req) => { + println!("armed breakpoint at {file}:{line} -> {name}+0x{il:x} (request {req})"); + true + } + Err(e) => { + println!("set_breakpoint failed: {e}"); + false + } + } + } + None => { + println!("could not resolve {file}:{line} in {} method(s) yet", candidates.len()); + false + } + } +} + +/// Dump the call stack and the top frame's `this` + in-scope locals. +fn dump_stop(conn: &mut sdb::Connection, thread: u32) { + let frames = match conn.frames(thread) { + Ok(f) => f, + Err(e) => { + println!("frames error: {e}"); + return; + } + }; + println!("call stack ({} frames):", frames.len()); + for (i, f) in frames.iter().enumerate().take(12) { + let name = conn.method_name(f.method).unwrap_or_default(); + let loc = conn + .debug_info(f.method) + .ok() + .and_then(|info| il_to_source(&info, f.il_offset)) + .unwrap_or_default(); + println!(" #{i:<2} {name} (il 0x{:x}) {loc}", f.il_offset); + } + + let Some(top) = frames.first() else { return }; + + match conn.frame_this(thread, top.id) { + Ok(Value::Object { id, .. }) if id != 0 => println!("\nthis = obj#{id}"), + Ok(v) => println!("\nthis = {}", v.summary()), + Err(e) => println!("\nthis: "), + } + + let locals = match conn.locals_info(top.method) { + Ok(l) => l, + Err(e) => { + println!("locals_info error: {e}"); + return; + } + }; + // Only request locals in scope at the current IL offset (others may be invalid). + let in_scope: Vec<(usize, &sdb::Local)> = locals + .iter() + .enumerate() + .filter(|(_, l)| top.il_offset >= l.live_start && top.il_offset < l.live_end) + .collect(); + println!("locals ({}/{} in scope at il 0x{:x}):", in_scope.len(), locals.len(), top.il_offset); + if in_scope.is_empty() { + return; + } + let positions: Vec = in_scope.iter().map(|(i, _)| *i as i32).collect(); + match conn.frame_values(thread, top.id, &positions) { + Ok(values) => { + for ((_, l), v) in in_scope.iter().zip(values) { + println!(" {} = {}", l.name, v.summary()); + } + } + Err(e) => println!(" "), + } +} + +/// Map an IL offset back to a source line: the nearest non-hidden seq point at or +/// before it. +fn il_to_source(info: &DebugInfo, il: i32) -> Option { + let sp = info + .seq_points + .iter() + .filter(|s| !s.is_hidden() && s.il_offset <= il) + .max_by_key(|s| s.il_offset)?; + let src = info.sources.get(sp.source_idx as usize).cloned().unwrap_or_default(); + Some(format!("{}:{}", src.rsplit(['/', '\\']).next().unwrap_or(&src), sp.line)) +} + +/// Sanity-check the GET_DEBUG_INFO decoder against a loaded method (mscorlib is +/// always present) so a layout error surfaces before we depend on it for breakpoints. +fn validate_debug_info_framing(conn: &mut sdb::Connection) { + let probe = (|| -> Option<()> { + let dom = conn.root_domain().ok()?; + let asm = *conn.domain_assemblies(dom).ok()?.first()?; + // ASSEMBLY.GET_TYPE for a ubiquitous type. + let type_id = conn.assembly_type(asm, "System.Object").ok()?; + if type_id == 0 { + return None; + } + let methods = conn.type_methods(type_id).ok()?; + let m = *methods.first()?; + let info = conn.debug_info(m).ok()?; + println!( + "debug_info framing ok: mscorlib method maxil={} sources={} seq_points={}", + info.max_il, + info.sources.len(), + info.seq_points.len() + ); + Some(()) + })(); + if probe.is_none() { + println!("debug_info framing probe skipped (no symbols on mscorlib, expected)"); + } +} diff --git a/native/unterm/src/bin/unterm-debugger.rs b/native/unterm/src/bin/unterm-debugger.rs new file mode 100644 index 0000000..7ca0c4d --- /dev/null +++ b/native/unterm/src/bin/unterm-debugger.rs @@ -0,0 +1,24 @@ +//! The standalone Unterm debugger window. +//! +//! Thin launcher: all logic lives in `unterm::debugger`. Usage: +//! unterm-debugger [file.cs:line ...] +//! Each argument is a breakpoint as `SourceFile.cs:line`. The working directory must +//! be the Unity project root (so `Library/EditorInstance.json` is found). Defaults to +//! Rotator.cs:45 when no breakpoints are given. + +fn main() { + let mut bps: Vec<(String, i32)> = Vec::new(); + for arg in std::env::args().skip(1) { + if let Some((file, line)) = arg.rsplit_once(':') { + if let Ok(n) = line.parse::() { + bps.push((file.to_string(), n)); + continue; + } + } + eprintln!("ignoring malformed breakpoint arg: {arg:?} (expected file.cs:line)"); + } + if bps.is_empty() { + bps.push(("Rotator.cs".to_string(), 45)); + } + unterm::debugger::run(bps); +} diff --git a/native/unterm/src/debugger.rs b/native/unterm/src/debugger.rs new file mode 100644 index 0000000..6a7d97d --- /dev/null +++ b/native/unterm/src/debugger.rs @@ -0,0 +1,2751 @@ +//! Standalone debugger window, built with egui. +//! +//! Runs its own winit event loop + wgpu surface in a separate process, so it stays +//! alive and interactive while the Unity editor is frozen at a breakpoint. The SDB +//! session runs on a background thread that owns the socket (never blocked by the +//! managed suspend) and communicates with the egui UI via a shared `UiState` plus a +//! command channel. +//! +//! The UI is themed to match Unity's editor (Inter/RobotoMono fonts, dark palette, +//! 18px rows). Launched by the `unterm-debugger` bin. + +use std::collections::BTreeMap; +use std::sync::mpsc::{Receiver, Sender}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use egui::{Color32, FontId, RichText}; +use winit::application::ApplicationHandler; +use winit::event::WindowEvent; +use winit::event_loop::{ActiveEventLoop, EventLoop, EventLoopProxy}; +use winit::window::{Window, WindowId}; + +use crate::editorview::EditorView; +use crate::gpu; +use crate::sdb::{self, value, wire}; + +// --------------------------------------------------------------------------- +// Messages + shared state +// --------------------------------------------------------------------------- + +/// A command from the UI thread to the SDB session thread. +#[derive(Debug, Clone)] +enum Cmd { + Resume, + StepOver, + StepInto, + StepOut, + /// Toggle a breakpoint at a 1-based source line in the currently-viewed file. + ToggleBp(i32), + /// The UI switched the displayed file (so the session reports its breakpoints). + View(String), + /// Break into a running program (VM suspend) and inspect the current state. + Pause, + /// Inspect a different (managed) thread's stack and variables. + SelectThread(u32), + /// Inspect a different call-stack frame (0 = innermost). + SelectFrame(usize), + /// Lazily fetch the children (fields or elements) of an expandable value. + ExpandVar { id: u32, array: bool }, + /// Attach to a different target (the editor, or a discovered player). + SelectTarget(Target), +} + +/// An attachable SDB endpoint: this project's editor, or a discovered Unity player. +#[derive(Clone, Debug)] +struct Target { + ip: String, + port: u16, + label: String, +} + +/// Wakeups posted from the SDB thread to the winit loop. +#[derive(Debug, Clone, Copy)] +enum Wake { + Redraw, + /// Surface the window (un-minimize + focus), e.g. when a breakpoint is hit. + Focus, + /// Tuck the window just behind the editor's window (the editor came forward), so it + /// rides above other apps without covering the editor or stealing focus. + Raise, + /// Close the window (the editor we were attached to went away). + Quit, +} + +/// Input the source pane collected this frame, applied to the embedded editor after +/// the egui pass (the editor lives on `App`, out of reach inside the UI closure). +struct SourcePaneOut { + /// The pane's rect in egui points (for sizing the editor to it). + rect: egui::Rect, + acts: Vec, + /// Pointer position (egui points) while hovering the source, for the value tooltip. + hover: Option, +} + +impl Default for SourcePaneOut { + fn default() -> Self { + Self { rect: egui::Rect::NOTHING, acts: Vec::new(), hover: None } + } +} + +/// A forwarded editor input; coordinates are physical px relative to the pane. +enum EditorAct { + Mouse { x: f32, y: f32, kind: u8 }, + Scroll { dx: f32, dy: f32 }, + Key(String, bool, bool, bool), +} + +/// Which pane has keyboard focus (drives ↑/↓ navigation). Focus follows the last click. +#[derive(Clone, Copy, PartialEq, Default)] +enum Focus { + #[default] + None, + Tree, + Source, +} + +/// Shared, rendered debugger state (snapshotted by the UI each frame). +#[derive(Default, Clone)] +struct UiState { + /// An error / special message (shown only when not normally attached). + status: String, + /// Whether the SDB connection is established. + attached: bool, + /// Runtime/protocol version detail (shown on hover). + version: String, + stopped: bool, + /// Absolute path of the source file the top frame is in. + cur_file: String, + /// 1-based current line, or 0 if unknown. + cur_line: i32, + /// Managed threads at the current stop (only populated while stopped). + threads: Vec, + /// The thread whose stack/variables are shown (SDB thread id). + cur_thread: u32, + stack: Vec, + /// Which call-stack frame's variables are shown (0 = innermost). + cur_frame: usize, + locals: Vec, + members: Vec, + this_label: String, + /// Lazily-fetched children of expandable values, keyed by object id. + children: std::collections::HashMap>, + /// Bumps on every new stop / frame switch so the UI can drop stale expand state. + gen: u64, + /// 1-based breakpoint lines in the currently-displayed file (for gutter dots). + bp_lines: Vec, + /// All breakpoints across files (display file, 1-based line) for the list panel. + all_bps: Vec<(String, i32)>, + log: Vec, + /// Debuggable Unity players found via multicast discovery (for the target picker). + players: Vec, + /// Label of the target we're currently attached to (for the target picker). + cur_target: String, +} + +/// A variable row in the inspector. `expand` is set when the value is a reference +/// object or array whose fields/elements can be drilled into on demand. +#[derive(Clone)] +struct Var { + name: String, + value: String, + expand: Option, +} + +#[derive(Clone, Copy)] +struct Expand { + id: u32, + array: bool, +} + +/// A managed thread shown in the Threads strip (name + where it is stopped). +#[derive(Clone)] +struct ThreadInfo { + id: u32, + name: String, + location: String, +} + +impl UiState { + fn push_log(&mut self, msg: impl Into) { + self.log.push(msg.into()); + let n = self.log.len(); + if n > 8 { + self.log.drain(0..n - 8); + } + } +} + +/// Entry point. `breakpoints` is the (file, line) set to arm (file may be a basename). +pub fn run(breakpoints: Vec<(String, i32)>) { + env_logger::try_init().ok(); + + // Advertise our PID so the editor launcher can tell this project's debugger is + // already running (project-scoped single instance) and signal it to come forward. + write_pid_file(); + + let mut builder = EventLoop::::with_user_event(); + #[cfg(target_os = "macos")] + { + use winit::platform::macos::{ActivationPolicy, EventLoopBuilderExtMacOS}; + // Accessory: no Dock icon / menu-bar ownership (it's a helper for the editor); + // its window can still become key. It follows the editor forward via the + // activation observer below, so it needn't be in the Dock / Cmd+Tab. + builder.with_activation_policy(ActivationPolicy::Accessory); + builder.with_activate_ignoring_other_apps(true); + } + let event_loop = builder.build().expect("create event loop"); + let proxy = event_loop.create_proxy(); + + // Follow the editor to the foreground: when it activates, slide this window up to + // just behind it (above other apps, without covering the editor or taking focus). + install_activation_observer(proxy.clone()); + + let state = Arc::new(Mutex::new(UiState::default())); + let (tx, rx) = std::sync::mpsc::channel::(); + { + let state = state.clone(); + let proxy = proxy.clone(); + std::thread::spawn(move || session_thread(breakpoints, state, rx, proxy)); + } + // Discover debuggable Unity players on the network (for the target picker). + spawn_discovery(state.clone(), proxy.clone()); + + let files = scan_cs_files(); + let tree = build_tree(&files); + let mut app = App { + state, + tx, + gfx: None, + view_file: String::new(), + editor: None, + editor_tex: None, + editor_file: String::new(), + editor_dims: (0, 0), + files, + tree, + scroll_to_cur: false, + goto: None, + focus: Focus::None, + expanded: ["Assets".to_string(), "Packages".to_string()] + .into_iter() + .collect(), + tree_sel: 0, + var_open: std::collections::HashSet::new(), + last_gen: 0, + hover_tip: None, + }; + event_loop.run_app(&mut app).expect("run app"); +} + +// --------------------------------------------------------------------------- +// egui + winit + wgpu integration +// --------------------------------------------------------------------------- + +struct Gfx { + window: Arc, + surface: wgpu::Surface<'static>, + config: wgpu::SurfaceConfiguration, + egui_ctx: egui::Context, + egui_state: egui_winit::State, + egui_renderer: egui_wgpu::Renderer, +} + +struct App { + state: Arc>, + tx: Sender, + gfx: Option, + // Which file the source pane shows (may differ from the stopped file). + view_file: String, + // Embedded read-only code editor (shared with the Unity code editor) rendered + // into a texture and shown in the source pane; None until the window exists. + editor: Option, + /// egui handle for the editor's texture, and the file/size it was last set to. + editor_tex: Option, + editor_file: String, + editor_dims: (u32, u32), + // Project .cs files (absolute path, display name) + the prebuilt folder tree. + files: Vec<(String, String)>, + tree: Tree, + scroll_to_cur: bool, + /// A line to scroll the source to next frame (e.g. from a breakpoint-list click). + goto: Option, + /// Which pane has keyboard focus (for ↑/↓ navigation). + focus: Focus, + /// Expanded folder keys in the file tree, and the selected (cursor) row index. + expanded: std::collections::HashSet, + tree_sel: usize, + /// Object ids the user has expanded in the variables inspector. + var_open: std::collections::HashSet, + /// Last-seen inspector generation; when it changes, `var_open` is dropped. + last_gen: u64, + /// Hover-watch tooltip (screen pos + text), computed one frame behind the pointer. + hover_tip: Option<(egui::Pos2, String)>, +} + +impl ApplicationHandler for App { + fn resumed(&mut self, el: &ActiveEventLoop) { + if self.gfx.is_some() { + return; + } + #[allow(unused_mut)] + let mut attrs = Window::default_attributes() + .with_title("Unterm Debugger") + .with_inner_size(winit::dpi::LogicalSize::new(1100.0, 680.0)) + // Keep the tree / source / inspector columns and the three stacked + // inspector panes usable — below this they'd collapse into each other. + .with_min_inner_size(winit::dpi::LogicalSize::new(720.0, 420.0)); + // Windows: a tool window (no taskbar button) — the counterpart to the macOS + // Accessory policy. It follows the editor forward via the activation hook. + #[cfg(target_os = "windows")] + { + use winit::platform::windows::WindowAttributesExtWindows; + attrs = attrs.with_skip_taskbar(true); + } + let window = Arc::new(el.create_window(attrs).expect("create window")); + window.focus_window(); + + let g = gpu::gpu(); + let size = window.inner_size(); + let surface = g.instance.create_surface(window.clone()).expect("surface"); + let caps = surface.get_capabilities(&g.adapter); + // egui expects a linear (non-sRGB) framebuffer; prefer one to get correct colors. + let format = caps + .formats + .iter() + .copied() + .find(|f| !f.is_srgb()) + .unwrap_or(caps.formats[0]); + let config = wgpu::SurfaceConfiguration { + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + format, + width: size.width.max(1), + height: size.height.max(1), + present_mode: wgpu::PresentMode::Fifo, + alpha_mode: caps.alpha_modes.first().copied().unwrap_or(wgpu::CompositeAlphaMode::Auto), + view_formats: vec![], + desired_maximum_frame_latency: 2, + }; + surface.configure(&g.device, &config); + + let egui_ctx = egui::Context::default(); + setup_theme(&egui_ctx); + let egui_state = egui_winit::State::new( + egui_ctx.clone(), + egui::ViewportId::ROOT, + window.as_ref(), + Some(window.scale_factor() as f32), + None, + None, + ); + let egui_renderer = + egui_wgpu::Renderer::new(&g.device, format, egui_wgpu::RendererOptions::default()); + + let gfx = Gfx { + window, + surface, + config, + egui_ctx, + egui_state, + egui_renderer, + }; + gfx.window.request_redraw(); // paint the first frame + self.gfx = Some(gfx); + } + + fn user_event(&mut self, el: &ActiveEventLoop, ev: Wake) { + if matches!(ev, Wake::Quit) { + el.exit(); + return; + } + if let Some(g) = &self.gfx { + match ev { + Wake::Focus => { + // Bring a hidden/behind/minimized window forward on a breakpoint hit. + g.window.set_visible(true); + g.window.set_minimized(false); + g.window.focus_window(); + } + Wake::Raise => order_behind_editor(&g.window), + _ => {} + } + g.window.request_redraw(); + } + } + + fn window_event(&mut self, el: &ActiveEventLoop, _id: WindowId, event: WindowEvent) { + let Some(gfx) = self.gfx.as_mut() else { + return; + }; + let resp = gfx.egui_state.on_window_event(&gfx.window, &event); + if resp.repaint { + gfx.window.request_redraw(); + } + match event { + WindowEvent::CloseRequested => el.exit(), + WindowEvent::Resized(size) => { + gfx.config.width = size.width.max(1); + gfx.config.height = size.height.max(1); + gfx.surface.configure(&gpu::gpu().device, &gfx.config); + gfx.window.request_redraw(); + } + WindowEvent::RedrawRequested => self.paint(), + _ => {} + } + } +} + +impl App { + fn paint(&mut self) { + let snap = self.state.lock().map(|s| s.clone()).unwrap_or_default(); + + // `gen` bumps on every stop, call-stack frame select, and thread select — the + // exact moments the source should jump. Follow the (resolved) current location + // and drop stale object-expansion state. Keying on `gen` (not the line number) + // means selecting a frame in another file at the same line still jumps, and a + // stop at the same line after browsing away still returns to it. + if snap.gen != self.last_gen { + self.last_gen = snap.gen; + self.var_open.clear(); + if snap.stopped { + self.view_file = self.resolve_local(&snap.cur_file); + self.scroll_to_cur = true; + } + } + if self.view_file.is_empty() { + self.view_file = self.resolve_local(&snap.cur_file); + } + + // What source line to scroll the editor to this frame (a fresh stop, or a + // breakpoint-list jump). + let scroll_line = if self.scroll_to_cur && snap.stopped { + self.scroll_to_cur = false; + Some(snap.cur_line) + } else { + self.goto.take() + }; + + let g = gpu::gpu(); + let mut cmds: Vec = Vec::new(); + let mut new_view: Option = None; + let mut goto: Option = None; + let mut focus = self.focus; + let mut src_out = SourcePaneOut::default(); + // Taken out (not borrowed) so build_ui can mutate them without conflicting + // with ui_ctx's immutable borrows of other App fields. + let mut expanded = std::mem::take(&mut self.expanded); + let mut tree_sel = self.tree_sel; + let mut var_open = std::mem::take(&mut self.var_open); + + let gfx = self.gfx.as_mut().unwrap(); + let raw = gfx.egui_state.take_egui_input(&gfx.window); + let ctx = gfx.egui_ctx.clone(); + let ui_ctx = UiCtx { + s: &snap, + tree: &self.tree, + files: &self.files, + editor_tex: self.editor_tex, + // Shown a frame behind the pointer (it needs the editor, updated below). + hover_tip: self.hover_tip.clone(), + }; + let full = ctx.run_ui(raw, |ui| { + build_ui( + ui, + &ui_ctx, + &mut cmds, + &mut new_view, + &mut goto, + &mut focus, + &mut expanded, + &mut tree_sel, + &mut var_open, + &mut src_out, + ); + }); + drop(ui_ctx); + self.focus = focus; + self.expanded = expanded; + self.tree_sel = tree_sel; + self.var_open = var_open; + + for cmd in cmds { + self.tx.send(cmd).ok(); + } + if let Some(v) = new_view { + if v != self.view_file { + self.view_file = v.clone(); + self.scroll_to_cur = false; + self.tx.send(Cmd::View(v)).ok(); + } + // A jump from the breakpoint list also scrolls the source next frame. + self.goto = goto; + } + + // Drive the embedded editor (size, content, input, breakpoints, render). + let needs_reg = self.sync_editor(&snap, &src_out, full.pixels_per_point, scroll_line); + // Recompute the hover-watch tooltip for next frame (needs the live editor). + self.hover_tip = self.compute_hover(&snap, &src_out, full.pixels_per_point); + + let gfx = self.gfx.as_mut().unwrap(); + // (Re)bind the editor's texture so the source pane's image samples it. + if let Some(ed) = &self.editor { + let view = ed.texture_view(); + match self.editor_tex { + Some(_) if !needs_reg => {} + Some(id) => gfx.egui_renderer.update_egui_texture_from_wgpu_texture( + &g.device, + view, + wgpu::FilterMode::Nearest, + id, + ), + None => { + self.editor_tex = Some(gfx.egui_renderer.register_native_texture( + &g.device, + view, + wgpu::FilterMode::Nearest, + )) + } + } + } + gfx.egui_state.handle_platform_output(&gfx.window, full.platform_output); + let tris = gfx.egui_ctx.tessellate(full.shapes, full.pixels_per_point); + let screen = egui_wgpu::ScreenDescriptor { + size_in_pixels: [gfx.config.width, gfx.config.height], + pixels_per_point: full.pixels_per_point, + }; + for (id, delta) in &full.textures_delta.set { + gfx.egui_renderer.update_texture(&g.device, &g.queue, *id, delta); + } + let mut encoder = g + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("egui") }); + let user_cmds = + gfx.egui_renderer.update_buffers(&g.device, &g.queue, &mut encoder, &tris, &screen); + + let frame = match gfx.surface.get_current_texture() { + wgpu::CurrentSurfaceTexture::Success(t) | wgpu::CurrentSurfaceTexture::Suboptimal(t) => t, + _ => { + gfx.surface.configure(&g.device, &gfx.config); + return; + } + }; + let view = frame.texture.create_view(&wgpu::TextureViewDescriptor::default()); + { + let mut pass = encoder + .begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("egui-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &view, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { r: 0.13, g: 0.13, b: 0.13, a: 1.0 }), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }) + .forget_lifetime(); + gfx.egui_renderer.render(&mut pass, &tris, &screen); + } + g.queue.submit(user_cmds.into_iter().chain([encoder.finish()])); + frame.present(); + for id in &full.textures_delta.free { + gfx.egui_renderer.free_texture(id); + } + // Keep animating while egui wants it (panel drag, hover, etc.). + if gfx.egui_ctx.has_requested_repaint() { + gfx.window.request_redraw(); + } + } + + /// Map a source path from the target's debug info to a local project file. Debug + /// info records the path as compiled — the local editor's absolute path (which + /// exists as-is), or a remote build machine's path (which does not). We match by + /// basename, disambiguating same-named files by the longest common path suffix, so + /// the developer's local checkout of the same project resolves regardless of where + /// it ran. Falls back to the original path when nothing matches. + fn resolve_local(&self, dbg_path: &str) -> String { + if dbg_path.is_empty() { + return String::new(); + } + // Fast path: the recorded path exists here (local editor debugging). + if std::path::Path::new(dbg_path).is_file() { + return dbg_path.to_string(); + } + let base = basename(dbg_path); + let want: Vec<&str> = dbg_path.rsplit(['/', '\\']).collect(); // trailing-first + let mut best: Option<(usize, &str)> = None; + for (abs, name) in &self.files { + if name != base { + continue; + } + let have = abs.rsplit(['/', '\\']); + let score = want.iter().zip(have).take_while(|(a, b)| *a == b).count(); + if best.map(|(s, _)| score > s).unwrap_or(true) { + best = Some((score, abs)); + } + } + best.map(|(_, abs)| abs.to_string()) + .unwrap_or_else(|| dbg_path.to_string()) + } + + /// Hover-watch: resolve the identifier under the pointer to its current value + /// (in-scope locals / `this` members). Returns the screen pos + `name = value`. + fn compute_hover( + &mut self, + snap: &UiState, + src_out: &SourcePaneOut, + ppp: f32, + ) -> Option<(egui::Pos2, String)> { + if !snap.stopped { + return None; + } + let screen = src_out.hover?; + let ed = self.editor.as_mut()?; + let x = (screen.x - src_out.rect.min.x) * ppp; + let y = (screen.y - src_out.rect.min.y) * ppp; + let (line, col) = ed.pos_at_pixel(x, y)?; + let word = word_at(&line, col)?; + let val = lookup_var(snap, &word)?; + Some((screen, format!("{word} = {val}"))) + } + + /// Create/size the embedded editor, load the viewed file, apply forwarded input, + /// push breakpoints + the current execution line, and render it. Returns whether + /// its texture needs (re)registering with egui (first use or after a resize). + fn sync_editor( + &mut self, + snap: &UiState, + src_out: &SourcePaneOut, + ppp: f32, + scroll_line: Option, + ) -> bool { + let rect = src_out.rect; + if !rect.is_finite() || rect.width() < 1.0 || rect.height() < 1.0 { + return false; + } + let w = (rect.width() * ppp).round().max(1.0) as u32; + let h = (rect.height() * ppp).round().max(1.0) as u32; + + let mut needs_register = false; + if self.editor.is_none() { + let mut ed = EditorView::new(w, h, ppp); + ed.set_read_only(true); + ed.set_bp_gutter(true); // the debugger always shows/toggles breakpoints + ed.set_language("cs"); + // Match the Unity code editor's look (dark theme + RobotoMono). + ed.set_theme(0.156, 0.156, 0.156, 1.0, 0xd2, 0xd2, 0xd2, true); + if let Some(p) = unity_font_path("RobotoMono-Regular.ttf") { + ed.set_font(&p); + } + self.editor = Some(ed); + self.editor_dims = (w, h); + needs_register = true; + } else if self.editor_dims != (w, h) { + if let Some(editor) = self.editor.as_mut() { + editor.resize(w, h, ppp); + } + self.editor_dims = (w, h); + needs_register = true; // the shared surface (and its view) was recreated + } + + // Load the viewed file when it changes. + if self.view_file != self.editor_file { + self.editor_file = self.view_file.clone(); + let text = std::fs::read_to_string(&self.view_file).unwrap_or_default(); + self.editor.as_mut().unwrap().set_text(&text); + } + + let ed = self.editor.as_mut().unwrap(); + + // Forward collected input. A press in the gutter toggles a breakpoint (routed + // through the session, the source of truth) instead of moving the caret. + let gutter = ed.gutter_width(); + for act in &src_out.acts { + match act { + EditorAct::Mouse { x, y, kind } => { + if *kind == 0 && *x < gutter { + let line = ed.line_at_y(*y); + self.tx.send(Cmd::ToggleBp(line as i32 + 1)).ok(); + } else { + ed.mouse(*x, *y, *kind); + } + } + EditorAct::Scroll { dx, dy } => { + ed.scroll(*dy); + ed.scroll_h(*dx); + } + EditorAct::Key(name, ctrl, alt, shift) => ed.key(name, *ctrl, *alt, *shift), + } + } + + // Breakpoints (1-based store → 0-based editor) + the execution line. + let bp0: Vec = snap + .bp_lines + .iter() + .filter(|&&l| l > 0) + .map(|&l| (l - 1) as u32) + .collect(); + ed.set_breakpoints(&bp0); + let viewing_stopped = + snap.stopped && basename(&self.view_file) == basename(&snap.cur_file); + ed.set_exec_line(if viewing_stopped && snap.cur_line > 0 { + (snap.cur_line - 1) as usize + } else { + usize::MAX + }); + + if let Some(line) = scroll_line { + if line > 0 { + ed.goto_line((line - 1) as usize); + } + } + + ed.render(); + needs_register + } +} + +/// Absolute path to a Unity-bundled font (`Contents/Resources/Fonts/`), if present. +fn unity_font_path(name: &str) -> Option { + let inst = sdb::find_editor_instance(&std::env::current_dir().ok()?)?; + let json: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(inst).ok()?).ok()?; + let contents = json.get("app_contents_path")?.as_str()?; + let p = std::path::Path::new(contents) + .join("Resources") + .join("Fonts") + .join(name); + p.exists().then(|| p.to_string_lossy().into_owned()) +} + +// --------------------------------------------------------------------------- +// Theme (Unity dark editor) +// --------------------------------------------------------------------------- + +fn c(r: u8, g: u8, b: u8) -> Color32 { + Color32::from_rgb(r, g, b) +} + +fn setup_theme(ctx: &egui::Context) { + // Fonts: Unity's Inter (UI) + RobotoMono (code), if available. + let mut fonts = egui::FontDefinitions::default(); + if let Some(b) = unity_font_bytes("Inter-Regular.ttf") { + fonts.font_data.insert("inter".into(), Arc::new(egui::FontData::from_owned(b))); + fonts + .families + .entry(egui::FontFamily::Proportional) + .or_default() + .insert(0, "inter".into()); + } + if let Some(b) = unity_font_bytes("RobotoMono-Regular.ttf") { + fonts.font_data.insert("robotomono".into(), Arc::new(egui::FontData::from_owned(b))); + fonts + .families + .entry(egui::FontFamily::Monospace) + .or_default() + .insert(0, "robotomono".into()); + } + // Japanese fallback (Inter/RobotoMono have no CJK; Unity bundles no CJK font), + // appended last so kanji/kana render instead of tofu. Try each platform's system + // fonts; non-existent paths are simply skipped. + for jp in [ + // macOS + "/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc", + "/System/Library/Fonts/Hiragino Sans GB.ttc", + // Windows + "C:\\Windows\\Fonts\\YuGothM.ttc", + "C:\\Windows\\Fonts\\meiryo.ttc", + "C:\\Windows\\Fonts\\msgothic.ttc", + // Linux (Noto CJK, common install paths) + "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", + ] { + if let Ok(b) = std::fs::read(jp) { + fonts.font_data.insert("jp".into(), Arc::new(egui::FontData::from_owned(b))); + for fam in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] { + fonts.families.entry(fam).or_default().push("jp".into()); + } + break; + } + } + ctx.set_fonts(fonts); + + use egui::{FontFamily, TextStyle}; + let text_styles: std::collections::BTreeMap = [ + (TextStyle::Small, FontId::new(10.0, FontFamily::Proportional)), + (TextStyle::Body, FontId::new(12.0, FontFamily::Proportional)), + (TextStyle::Button, FontId::new(12.0, FontFamily::Proportional)), + (TextStyle::Heading, FontId::new(13.0, FontFamily::Proportional)), + (TextStyle::Monospace, FontId::new(12.0, FontFamily::Monospace)), + ] + .into(); + + let mut v = egui::Visuals::dark(); + v.window_fill = c(0x38, 0x38, 0x38); + v.panel_fill = c(0x38, 0x38, 0x38); + v.extreme_bg_color = c(0x28, 0x28, 0x28); + v.faint_bg_color = c(0x3f, 0x3f, 0x3f); + v.code_bg_color = c(0x28, 0x28, 0x28); + v.override_text_color = Some(c(0xc4, 0xc4, 0xc4)); + v.hyperlink_color = c(0x4c, 0x7e, 0xff); + v.selection.bg_fill = c(0x2c, 0x5d, 0x87); + v.selection.stroke = egui::Stroke::new(1.0, c(0x4c, 0x7e, 0xff)); + v.widgets.noninteractive.bg_fill = c(0x38, 0x38, 0x38); + v.widgets.noninteractive.fg_stroke.color = c(0xc4, 0xc4, 0xc4); + v.widgets.inactive.bg_fill = c(0x58, 0x58, 0x58); + v.widgets.inactive.weak_bg_fill = c(0x58, 0x58, 0x58); + v.widgets.inactive.fg_stroke.color = c(0xc4, 0xc4, 0xc4); + v.widgets.hovered.bg_fill = c(0x67, 0x67, 0x67); + v.widgets.hovered.weak_bg_fill = c(0x67, 0x67, 0x67); + v.widgets.hovered.fg_stroke.color = c(0xe0, 0xe0, 0xe0); + v.widgets.active.bg_fill = c(0x4c, 0x7e, 0xff); + v.widgets.active.weak_bg_fill = c(0x4c, 0x7e, 0xff); + v.widgets.open.bg_fill = c(0x45, 0x45, 0x45); + // Visible panel separators / borders (#232323), brighter on hover for resizing. + v.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, c(0x20, 0x20, 0x20)); + v.widgets.hovered.bg_stroke = egui::Stroke::new(2.0, c(0x4c, 0x7e, 0xff)); + v.widgets.active.bg_stroke = egui::Stroke::new(2.0, c(0x4c, 0x7e, 0xff)); + ctx.all_styles_mut(move |style| { + style.text_styles = text_styles.clone(); + style.visuals = v.clone(); + style.spacing.item_spacing = egui::vec2(6.0, 3.0); + style.interaction.resize_grab_radius_side = 8.0; + // Only the source code + variable values opt into selection (below); keep + // titles, the file tree, the stack and the breakpoint list non-selectable. + style.interaction.selectable_labels = false; + }); +} + +fn unity_font_bytes(name: &str) -> Option> { + let inst = sdb::find_editor_instance(&std::env::current_dir().ok()?)?; + let json: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(inst).ok()?).ok()?; + let contents = json.get("app_contents_path")?.as_str()?; + let p = std::path::Path::new(contents) + .join("Resources") + .join("Fonts") + .join(name); + std::fs::read(p).ok() +} + +// --------------------------------------------------------------------------- +// UI +// --------------------------------------------------------------------------- + +/// Borrowed data the UI reads each frame (avoids capturing `&mut App` in nested +/// egui closures, which would be a borrow conflict). +struct UiCtx<'a> { + s: &'a UiState, + tree: &'a Tree, + files: &'a [(String, String)], + /// egui texture handle for the embedded editor (None until its first render). + editor_tex: Option, + /// Hover-watch tooltip to draw over the source (screen pos + text). + hover_tip: Option<(egui::Pos2, String)>, +} + +fn build_ui( + ui: &mut egui::Ui, + ctx: &UiCtx, + cmds: &mut Vec, + new_view: &mut Option, + goto: &mut Option, + focus: &mut Focus, + expanded: &mut std::collections::HashSet, + tree_sel: &mut usize, + var_open: &mut std::collections::HashSet, + src_out: &mut SourcePaneOut, +) { + let s = ctx.s; + let accent = c(0x4c, 0x7e, 0xff); + let amber = c(0xe6, 0xb4, 0x40); + + // --- toolbar --- + egui::Panel::top("toolbar").show(ui, |ui| { + ui.horizontal(|ui| { + let enabled = s.stopped; + ui.add_enabled_ui(enabled, |ui| { + if ui.button("▶").on_hover_text("Continue (c)").clicked() { + cmds.push(Cmd::Resume); + } + if ui.button("⤼").on_hover_text("Step Over (n)").clicked() { + cmds.push(Cmd::StepOver); + } + if ui.button("↓").on_hover_text("Step Into (i)").clicked() { + cmds.push(Cmd::StepInto); + } + if ui.button("↑").on_hover_text("Step Out (o)").clicked() { + cmds.push(Cmd::StepOut); + } + }); + ui.add_enabled_ui(s.attached && !s.stopped, |ui| { + if ui.button("⏸").on_hover_text("Pause / Break All (p)").clicked() { + cmds.push(Cmd::Pause); + } + }); + ui.separator(); + let (word, col) = if !s.attached { + if s.status.is_empty() { + ("Connecting…", c(0x9a, 0x9a, 0x9a)) + } else { + (s.status.as_str(), c(0xd6, 0x5a, 0x5a)) + } + } else if s.stopped { + ("Paused", amber) + } else { + ("Running", c(0x88, 0xc0, 0x70)) + }; + ui.label(RichText::new("●").color(col)); + let resp = ui.label(RichText::new(word).color(col)); + if s.attached { + let tip = if s.version.is_empty() { + "attached".to_string() + } else { + format!("attached — {}", s.version) + }; + resp.on_hover_text(tip); + } + if s.stopped && !s.cur_file.is_empty() { + ui.label(RichText::new(format!("{}:{}", basename(&s.cur_file), s.cur_line)).weak()); + } + + // Target picker: the editor, plus any debuggable players found on the network. + ui.separator(); + let cur = if s.cur_target.is_empty() { "Editor" } else { s.cur_target.as_str() }; + egui::ComboBox::from_id_salt("target") + .selected_text(cur) + .show_ui(ui, |ui| { + if ui.selectable_label(cur == "Editor", "Editor (this project)").clicked() { + cmds.push(Cmd::SelectTarget(Target { + ip: String::new(), + port: 0, + label: "Editor".to_string(), + })); + } + for p in &s.players { + let label = format!("{} ({})", p.label, p.ip); + let text = if p.project.is_empty() { + label.clone() + } else { + format!("{} · {}", p.project, label) + }; + if ui.selectable_label(cur == label, text).clicked() { + cmds.push(Cmd::SelectTarget(Target { + ip: p.ip.clone(), + port: p.port, + label, + })); + } + } + if s.players.is_empty() { + ui.label(RichText::new("(no players found)").weak()); + } + }); + }); + }); + + // keyboard shortcuts + ui.input(|i| { + if s.stopped { + if i.key_pressed(egui::Key::C) { + cmds.push(Cmd::Resume); + } + if i.key_pressed(egui::Key::N) { + cmds.push(Cmd::StepOver); + } + if i.key_pressed(egui::Key::I) { + cmds.push(Cmd::StepInto); + } + if i.key_pressed(egui::Key::O) { + cmds.push(Cmd::StepOut); + } + } else if s.attached && i.key_pressed(egui::Key::P) { + cmds.push(Cmd::Pause); + } + }); + + // --- left: file tree (no title; full rows; scrollable) --- + let files_resp = egui::Panel::left("files") + .resizable(true) + .default_size(210.0) + .show(ui, |ui| { + egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| { + tree_panel(ui, ctx.tree, *focus == Focus::Tree, expanded, tree_sel, new_view); + }); + }); + + // --- right: three vertically-stacked, resizable panes --- + egui::Panel::right("inspector") + .resizable(true) + .default_size(330.0) + .show(ui, |ui| { + // Cap each pane's max height so the next pane's title (>= ~46px) always + // stays visible — egui doesn't auto-negotiate sizes between stacked panels. + let min = 46.0_f32; + let total = ui.available_height(); + let stack_max = (total - 2.0 * min).max(min); + egui::Panel::top("p_stack").resizable(true).default_size(150.0).size_range(egui::Rangef::new(min, stack_max)).show(ui, |ui| { + // Threads strip: only when the stop involves more than one managed thread. + if s.threads.len() > 1 { + ui.label(RichText::new("Threads").strong()); + egui::ScrollArea::both().id_salt("sa_threads").max_height(72.0).auto_shrink([false, false]).show(ui, |ui| { + for t in &s.threads { + let selected = t.id == s.cur_thread; + let col = if selected { accent } else { c(0xc4, 0xc4, 0xc4) }; + let resp = ui.add( + egui::Label::new( + RichText::new(format!("{} · {}", t.name, t.location)).monospace().color(col), + ) + .selectable(false) + .truncate() + .sense(egui::Sense::click()), + ); + if selected { + ui.painter().rect_filled( + resp.rect.expand2(egui::vec2(4.0, 1.0)), + 2.0, + accent.gamma_multiply(0.18), + ); + } + if resp.clicked() && !selected { + cmds.push(Cmd::SelectThread(t.id)); + } + } + }); + ui.separator(); + } + ui.label(RichText::new("Call Stack").strong()); + egui::ScrollArea::both().id_salt("sa_stack").auto_shrink([false, false]).show(ui, |ui| { + for (i, f) in s.stack.iter().enumerate() { + let selected = i == s.cur_frame; + let col = if selected { accent } else { c(0xc4, 0xc4, 0xc4) }; + let resp = ui.add( + egui::Label::new(RichText::new(f).monospace().color(col)) + .selectable(false) + .sense(egui::Sense::click()), + ); + if selected { + ui.painter().rect_filled( + resp.rect.expand2(egui::vec2(4.0, 1.0)), + 2.0, + accent.gamma_multiply(0.18), + ); + } + if resp.clicked() && i != s.cur_frame { + cmds.push(Cmd::SelectFrame(i)); + } + } + }); + }); + let vars_max = (ui.available_height() - min).max(min); + egui::Panel::top("p_vars").resizable(true).default_size(240.0).size_range(egui::Rangef::new(min, vars_max)).show(ui, |ui| { + ui.label(RichText::new("Variables").strong()); + egui::ScrollArea::both().id_salt("sa_vars").auto_shrink([false, false]).show(ui, |ui| { + egui::CollapsingHeader::new("Local").default_open(true).show(ui, |ui| { + if s.locals.is_empty() { + ui.label(RichText::new("(none)").weak()); + } + var_tree(ui, &s.locals, &s.children, var_open, cmds, 0, &mut Vec::new()); + }); + if !s.this_label.is_empty() { + egui::CollapsingHeader::new(format!("this : {}", s.this_label)) + .default_open(true) + .show(ui, |ui| { + var_tree(ui, &s.members, &s.children, var_open, cmds, 0, &mut Vec::new()); + }); + } + }); + }); + egui::CentralPanel::default().show(ui, |ui| { + ui.label(RichText::new("Breakpoints").strong()); + egui::ScrollArea::both().id_salt("sa_bps").auto_shrink([false, false]).show(ui, |ui| { + if s.all_bps.is_empty() { + ui.label(RichText::new("(none)").weak()); + } + for (file, line) in &s.all_bps { + let resp = ui + .horizontal(|ui| { + ui.label(RichText::new("●").color(c(0xc8, 0x3a, 0x3a))); + ui.add( + egui::Label::new(RichText::new(format!("{file}:{line}")).monospace()) + .sense(egui::Sense::click()), + ) + }) + .inner; + if resp.clicked() { + if let Some((abs, _)) = ctx.files.iter().find(|(_, n)| n == file) { + *new_view = Some(abs.clone()); + *goto = Some(*line); + } + } + } + }); + }); + }); + + // --- center: source --- + let src_resp = egui::CentralPanel::default().show(ui, |ui| { + source_view(ui, ctx, *focus == Focus::Source, src_out); + }); + + // Focus follows the last click (updates next frame's arrow-key target). + if ui.ctx().input(|i| i.pointer.any_pressed()) { + if let Some(p) = ui.ctx().input(|i| i.pointer.interact_pos()) { + *focus = if files_resp.response.rect.contains(p) { + Focus::Tree + } else if src_resp.response.rect.contains(p) { + Focus::Source + } else { + Focus::None + }; + } + } +} + +/// Render variable rows, recursing into expanded objects/arrays (children fetched +/// lazily via `Cmd::ExpandVar` the first time a node is opened). +/// +/// `path` is the chain of object ids currently descended through. Because open state +/// is keyed by object id, a cyclic graph (e.g. Unity's `transform ↔ gameObject`) would +/// otherwise recurse forever — expanding both ends makes every occurrence auto-open — +/// exhausting memory. We refuse to descend into an id already on the path. +fn var_tree( + ui: &mut egui::Ui, + vars: &[Var], + children: &std::collections::HashMap>, + var_open: &mut std::collections::HashSet, + cmds: &mut Vec, + depth: usize, + path: &mut Vec, +) { + for v in vars { + let open = v.expand.map(|e| var_open.contains(&e.id)).unwrap_or(false); + ui.horizontal(|ui| { + ui.add_space(depth as f32 * 14.0); + // Expand triangle (or a spacer, to keep names aligned). + if let Some(e) = v.expand { + let tri = if open { "▾" } else { "▸" }; + let hit = ui.add( + egui::Label::new(RichText::new(tri).monospace().color(c(0x9a, 0x9a, 0x9a))) + .sense(egui::Sense::click()), + ); + let name = ui.add( + egui::Label::new(RichText::new(&v.name).monospace().color(c(0xa0, 0xa0, 0xa0))) + .selectable(false) + .sense(egui::Sense::click()), + ); + if hit.clicked() || name.clicked() { + if open { + var_open.remove(&e.id); + } else { + var_open.insert(e.id); + if !children.contains_key(&e.id) { + cmds.push(Cmd::ExpandVar { id: e.id, array: e.array }); + } + } + } + } else { + ui.add_space(12.0); + ui.label(RichText::new(&v.name).monospace().color(c(0xa0, 0xa0, 0xa0))); + } + if !v.value.is_empty() { + ui.label(RichText::new("=").weak()); + // The value is selectable (copy it); names/triangles are not. + ui.add( + egui::Label::new(RichText::new(&v.value).monospace().color(c(0xd2, 0xd2, 0xd2))) + .selectable(true), + ); + } + }); + if open { + if let Some(e) = v.expand { + if path.contains(&e.id) || depth >= 64 { + // Cycle (or pathologically deep): stop descending to bound memory. + leaf_note(ui, depth + 1, "…"); + } else { + match children.get(&e.id) { + Some(kids) if !kids.is_empty() => { + path.push(e.id); + var_tree(ui, kids, children, var_open, cmds, depth + 1, path); + path.pop(); + } + Some(_) => leaf_note(ui, depth + 1, "(empty)"), + None => leaf_note(ui, depth + 1, "loading…"), + } + } + } + } + } +} + +fn leaf_note(ui: &mut egui::Ui, depth: usize, text: &str) { + ui.horizontal(|ui| { + ui.add_space(depth as f32 * 14.0 + 12.0); + ui.label(RichText::new(text).weak()); + }); +} + +/// The source pane: draws the embedded editor's texture and forwards pointer/keys +/// to it (applied to the editor on `App` after the egui pass). The editor renders +/// the code, gutter, breakpoints, caret and current line — this is just its host. +fn source_view(ui: &mut egui::Ui, ctx: &UiCtx, focused: bool, out: &mut SourcePaneOut) { + let (rect, resp) = ui.allocate_exact_size(ui.available_size(), egui::Sense::click_and_drag()); + out.rect = rect; + + if let Some(tex) = ctx.editor_tex { + ui.painter().image( + tex, + rect, + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), + Color32::WHITE, + ); + } else { + ui.painter().rect_filled(rect, 0.0, c(0x28, 0x28, 0x28)); + } + + // egui points → physical px relative to the pane's top-left (matching the code + // editor window's `(mouse - rect) * ppp` convention). + let ppp = ui.ctx().pixels_per_point(); + let phys = |p: egui::Pos2| ((p.x - rect.min.x) * ppp, (p.y - rect.min.y) * ppp); + + // Mouse (kinds: 0 click, 1 drag, 2 double-click). + if resp.double_clicked() { + if let Some(p) = resp.interact_pointer_pos() { + let (x, y) = phys(p); + out.acts.push(EditorAct::Mouse { x, y, kind: 2 }); + } + } else if resp.drag_started() || resp.clicked() { + if let Some(p) = resp.interact_pointer_pos() { + let (x, y) = phys(p); + out.acts.push(EditorAct::Mouse { x, y, kind: 0 }); + } + } + if resp.dragged() { + if let Some(p) = resp.interact_pointer_pos() { + let (x, y) = phys(p); + out.acts.push(EditorAct::Mouse { x, y, kind: 1 }); + } + } + + // Wheel scroll + hover position (for the value tooltip) while over the pane. + if resp.hovered() { + let d = ui.input(|i| i.smooth_scroll_delta); + if d.x != 0.0 || d.y != 0.0 { + out.acts.push(EditorAct::Scroll { dx: -d.x * ppp, dy: -d.y * ppp }); + } + out.hover = resp.hover_pos(); + } + + // Hover-watch tooltip (computed a frame behind, so it needs the live editor). + if let Some((pos, text)) = &ctx.hover_tip { + egui::Area::new(egui::Id::new("unterm_hover_watch")) + .fixed_pos(*pos + egui::vec2(12.0, 16.0)) + .order(egui::Order::Tooltip) + .interactable(false) + .show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.label(RichText::new(text).monospace()); + }); + }); + } + + // Keyboard navigation when the pane has focus (read-only ⇒ only motions apply). + if focused { + let events = ui.input(|i| i.events.clone()); + for ev in events { + if let egui::Event::Key { key, pressed: true, modifiers, .. } = ev { + if let Some(name) = map_editor_key(key, &modifiers) { + out.acts + .push(EditorAct::Key(name, modifiers.ctrl, modifiers.alt, modifiers.shift)); + } + } + } + } +} + +/// Translate an egui key press into the editor's key-name convention (navigation +/// only; shift extends the selection, alt/ctrl = word, cmd = line/document ends). +fn map_editor_key(key: egui::Key, m: &egui::Modifiers) -> Option { + use egui::Key; + let cmd = m.command || m.mac_cmd; + let word = m.alt || m.ctrl; + let name = match key { + Key::ArrowLeft => { + if word { + "WordLeft" + } else if cmd { + "Home" + } else { + "LeftArrow" + } + } + Key::ArrowRight => { + if word { + "WordRight" + } else if cmd { + "End" + } else { + "RightArrow" + } + } + Key::ArrowUp => { + if cmd { + "DocStart" + } else { + "UpArrow" + } + } + Key::ArrowDown => { + if cmd { + "DocEnd" + } else { + "DownArrow" + } + } + Key::Home => "Home", + Key::End => "End", + Key::PageUp => "PageUp", + Key::PageDown => "PageDown", + _ => return None, + }; + Some(name.to_string()) +} + +// --- file tree --- + +#[derive(Default)] +struct Tree { + dirs: BTreeMap, + files: Vec<(String, String)>, // (name, abspath) +} + +fn build_tree(files: &[(String, String)]) -> Tree { + let root = project_root(); + let mut tree = Tree::default(); + for (abs, name) in files { + let rel = std::path::Path::new(abs) + .strip_prefix(&root) + .ok() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| std::path::PathBuf::from(name)); + let mut comps: Vec = rel + .components() + .map(|c| c.as_os_str().to_string_lossy().into_owned()) + .collect(); + // Show cached packages (Library/PackageCache/…) under the "Packages" node. + if comps.len() >= 2 && comps[0] == "Library" && comps[1] == "PackageCache" { + comps.splice(0..2, ["Packages".to_string()]); + } + let mut node = &mut tree; + for (k, comp) in comps.iter().enumerate() { + if k + 1 == comps.len() { + node.files.push((comp.clone(), abs.clone())); + } else { + node = node.dirs.entry(comp.clone()).or_default(); + } + } + } + tree +} + +/// One visible row of the file tree, in display order (respecting `expanded`). +struct TreeRow { + depth: usize, + is_dir: bool, + label: String, + /// Folder path (tracked in `expanded`) for dirs, or the file abspath for files. + key: String, +} + +/// Flatten the tree into ordered visible rows, descending into open folders only. +fn flatten_tree( + tree: &Tree, + prefix: &str, + depth: usize, + expanded: &std::collections::HashSet, + out: &mut Vec, +) { + for (dname, child) in &tree.dirs { + let key = if prefix.is_empty() { + dname.clone() + } else { + format!("{prefix}/{dname}") + }; + out.push(TreeRow { + depth, + is_dir: true, + label: dname.clone(), + key: key.clone(), + }); + if expanded.contains(&key) { + flatten_tree(child, &key, depth + 1, expanded, out); + } + } + for (name, abs) in &tree.files { + out.push(TreeRow { + depth, + is_dir: false, + label: name.clone(), + key: abs.clone(), + }); + } +} + +/// Custom file tree. Keyboard (when the pane is focused): ↑/↓ move the cursor over +/// the visible rows (folders included), → expands a folder (or steps into its first +/// child), ← collapses it (or steps out to the parent), and ↑/↓/Enter open a file +/// row. Clicking toggles a folder or opens a file. Full-width Unity-style selection. +fn tree_panel( + ui: &mut egui::Ui, + tree: &Tree, + focused: bool, + expanded: &mut std::collections::HashSet, + tree_sel: &mut usize, + new_view: &mut Option, +) { + let mut rows = Vec::new(); + flatten_tree(tree, "", 0, expanded, &mut rows); + if rows.is_empty() { + return; + } + *tree_sel = (*tree_sel).min(rows.len() - 1); + + let mut scroll_sel = false; + if focused { + let (up, down, left, right, enter) = ui.input(|i| { + ( + i.key_pressed(egui::Key::ArrowUp), + i.key_pressed(egui::Key::ArrowDown), + i.key_pressed(egui::Key::ArrowLeft), + i.key_pressed(egui::Key::ArrowRight), + i.key_pressed(egui::Key::Enter), + ) + }); + scroll_sel = up || down || left || right || enter; + if down { + *tree_sel = (*tree_sel + 1).min(rows.len() - 1); + } + if up { + *tree_sel = tree_sel.saturating_sub(1); + } + if right { + let r = &rows[*tree_sel]; + if r.is_dir { + if !expanded.contains(&r.key) { + expanded.insert(r.key.clone()); + } else if *tree_sel + 1 < rows.len() && rows[*tree_sel + 1].depth > r.depth { + *tree_sel += 1; // already open: step into first child + } + } + } + if left { + let (is_dir, key, depth) = ( + rows[*tree_sel].is_dir, + rows[*tree_sel].key.clone(), + rows[*tree_sel].depth, + ); + if is_dir && expanded.contains(&key) { + expanded.remove(&key); // collapse + } else if depth > 0 { + for k in (0..*tree_sel).rev() { + if rows[k].depth < depth { + *tree_sel = k; // step out to parent + break; + } + } + } + } + if up || down || enter { + let r = &rows[*tree_sel]; + if !r.is_dir { + *new_view = Some(r.key.clone()); + } else if enter { + if expanded.contains(&r.key) { + expanded.remove(&r.key); + } else { + expanded.insert(r.key.clone()); + } + } + } + } + + let row_h = 18.0; + let font = FontId::proportional(12.0); + for (idx, r) in rows.iter().enumerate() { + let selected = idx == *tree_sel; + let text_col = if selected { + c(0xf0, 0xf0, 0xf2) + } else { + c(0xc4, 0xc4, 0xc4) + }; + let galley = ui + .painter() + .layout_no_wrap(r.label.clone(), font.clone(), text_col); + let indent = 6.0 + r.depth as f32 * 14.0; + let row_w = (indent + 16.0 + galley.size().x).max(ui.available_width()); + let (rect, resp) = ui.allocate_exact_size(egui::vec2(row_w, row_h), egui::Sense::click()); + if selected { + ui.painter().rect_filled(rect, 0.0, c(0x2c, 0x5d, 0x87)); + if scroll_sel { + ui.scroll_to_rect(rect, None); + } + } else if resp.hovered() { + ui.painter().rect_filled(rect, 0.0, c(0x45, 0x45, 0x45)); + } + let x = rect.left() + indent; + let cy = rect.center().y; + if r.is_dir { + // Draw the disclosure triangle as a vector shape: the loaded UI font has no + // reliable ▸/▾ glyph, so a painted triangle renders crisply everywhere. + let col = c(0x9a, 0x9a, 0x9a); + let pts = if expanded.contains(&r.key) { + vec![ + egui::pos2(x + 1.0, cy - 2.0), + egui::pos2(x + 9.0, cy - 2.0), + egui::pos2(x + 5.0, cy + 3.0), + ] + } else { + vec![ + egui::pos2(x + 2.0, cy - 4.0), + egui::pos2(x + 2.0, cy + 4.0), + egui::pos2(x + 7.0, cy), + ] + }; + ui.painter() + .add(egui::Shape::convex_polygon(pts, col, egui::Stroke::NONE)); + } + ui.painter().galley( + egui::pos2(x + 16.0, cy - galley.size().y / 2.0), + galley, + text_col, + ); + if resp.clicked() { + *tree_sel = idx; + if r.is_dir { + if expanded.contains(&r.key) { + expanded.remove(&r.key); + } else { + expanded.insert(r.key.clone()); + } + } else { + *new_view = Some(r.key.clone()); + } + } + } +} + +// --------------------------------------------------------------------------- +// SDB session thread (unchanged from the glyphon version) +// --------------------------------------------------------------------------- + +struct Stop { + stack: Vec, + frames: Vec, + locals: Vec, + members: Vec, + this_label: String, + file: String, + line: i32, +} + +struct Bp { + file: String, + line: i32, + armed: bool, + req: Option, +} + +/// Listen for Unity player-discovery multicast and keep `s.players` in sync (deduped +/// by Guid, expiring players not heard from for a few seconds). Runs for the process's +/// lifetime; a bind failure (locked-down env) just leaves the player list empty. +fn spawn_discovery(state: Arc>, proxy: EventLoopProxy) { + std::thread::spawn(move || { + let Ok(sock) = sdb::discovery_socket() else { + return; + }; + let mut seen: std::collections::HashMap = + std::collections::HashMap::new(); + let mut last_sig: Vec = Vec::new(); + let mut buf = [0u8; 2048]; + loop { + match sock.recv_from(&mut buf) { + Ok((n, _)) => { + if let Some(p) = sdb::parse_player(&buf[..n]) { + seen.insert(p.guid, (p, std::time::Instant::now())); + } + } + Err(ref e) + if matches!( + e.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) => {} + Err(_) => {} + } + seen.retain(|_, (_, t)| t.elapsed() < std::time::Duration::from_secs(4)); + // Only publish (and wake the UI) when the set of players actually changes. + let mut sig: Vec = seen.keys().copied().collect(); + sig.sort_unstable(); + if sig != last_sig { + last_sig = sig; + let mut players: Vec = seen.values().map(|(p, _)| p.clone()).collect(); + players.sort_by(|a, b| a.project.cmp(&b.project).then(a.label.cmp(&b.label))); + set(&state, &proxy, |s| s.players = players); + } + } + }); +} + +fn session_thread( + breakpoints: Vec<(String, i32)>, + state: Arc>, + rx: Receiver, + proxy: EventLoopProxy, +) { + let root = std::env::current_dir().unwrap_or_default(); + // Breakpoints seeded on every (re)connect — the store is the source of truth; CLI + // pairs, when given, override it for a manual run. + let seed = if breakpoints.is_empty() { + load_breakpoints().unwrap_or_default() + } else { + breakpoints + }; + // Default target: this project's editor; the user can switch to a discovered player. + // An empty ip is the "Editor" sentinel — resolved fresh (its pid can change) below. + let mut target = Target { ip: String::new(), port: 0, label: "Editor".to_string() }; + loop { + if target.ip.is_empty() { + match sdb::editor_endpoint(&root) { + Ok((ip, port)) => { + target.ip = ip; + target.port = port; + } + Err(_) => { + target.ip = "127.0.0.1".to_string(); + } + } + } + set(&state, &proxy, |s| { + s.attached = false; + s.stopped = false; + s.status = format!("Connecting to {}…", target.label); + }); + let conn = match sdb::connect(&target.ip, target.port) { + Ok(c) => c, + Err(e) => { + set(&state, &proxy, |s| s.status = format!("{}: connect failed: {e}", target.label)); + // Nothing to do until the user picks a (different) target. + match wait_for_target(&rx) { + Some(t) => { + target = t; + continue; + } + None => return, // UI channel closed → shutting down + } + } + }; + match run_connection(conn, &seed, state.clone(), &rx, proxy.clone(), &target.label) { + Some(t) => target = t, // switch targets and reconnect + None => return, // disconnected / died / channel closed + } + } +} + +/// Block until the UI asks to attach to a target (or the channel closes → `None`). +fn wait_for_target(rx: &Receiver) -> Option { + loop { + match rx.recv() { + Ok(Cmd::SelectTarget(t)) => return Some(t), + Ok(_) => {} + Err(_) => return None, + } + } +} + +/// One attached session. Returns `Some(next)` to switch targets, or `None` to exit. +fn run_connection( + mut conn: sdb::Connection, + seed: &[(String, i32)], + state: Arc>, + rx: &Receiver, + proxy: EventLoopProxy, + target_label: &str, +) -> Option { + let v = conn.version().clone(); + while conn.take_queued().is_some() {} + + let mut bps: Vec = seed + .iter() + .cloned() + .map(|(file, line)| Bp { file, line, armed: false, req: None }) + .collect(); + set(&state, &proxy, |s| { + s.attached = true; + s.cur_target = target_label.to_string(); + s.status.clear(); + s.version = format!("{} (protocol {}.{})", v.vm, v.major, v.minor); + s.push_log(format!("attached: {target_label} ({} bp)", bps.len())); + }); + + // Watch for a new play-mode AppDomain: the Play domain reload invalidates any + // breakpoint armed on an edit-mode method, so we re-sync + re-arm on the fresh types. + conn.set_event(wire::kind::APPDOMAIN_CREATE, wire::suspend::NONE, &[]).ok(); + let mut watch_req: Option = None; + rewatch(&mut conn, &bps, &mut watch_req); + arm_loaded(&mut conn, &mut bps, &state, &proxy); + sync_bps(&state, &proxy, &bps, ""); + + conn.set_read_timeout(Some(Duration::from_millis(50))).ok(); + let mut cur_thread: u32 = 0; + let mut cur_frames: Vec = Vec::new(); + let mut step_req: Option = None; + let mut ui_view = String::new(); + + // The editor menu, re-opened while this debugger is already running, touches + // `focus.request` to ask us to come forward instead of spawning a second window. + let focus_path = unterm_state_path("focus.request"); + let mut last_focus = std::fs::metadata(&focus_path).ok().and_then(|m| m.modified().ok()); + + // Live breakpoint sync: watch the shared store for edits the editor makes while we + // run, and reconcile. `last_bp` is bumped after our own writes so we don't re-read + // them back. + let bp_path = unterm_state_path("breakpoints.json"); + let bp_mtime = || std::fs::metadata(&bp_path).ok().and_then(|m| m.modified().ok()); + let mut last_bp = bp_mtime(); + + // Retry arming: when we attach mid-play the agent may not answer type queries yet, + // so the initial `arm_loaded` finds nothing; keep retrying un-armed breakpoints + // (against already-loaded types) so they take without a manual toggle. + let mut last_arm = std::time::Instant::now(); + + loop { + let f = std::fs::metadata(&focus_path).ok().and_then(|m| m.modified().ok()); + if f != last_focus { + last_focus = f; + proxy.send_event(Wake::Focus).ok(); + } + + // Editor edited the breakpoint store while we run → reconcile live. + let cur_bp = bp_mtime(); + if cur_bp != last_bp { + last_bp = cur_bp; + reconcile_breakpoints(&mut conn, &mut bps, &state, &proxy, &ui_view); + } + + if bps.iter().any(|b| !b.armed) + && last_arm.elapsed() >= Duration::from_millis(500) + { + last_arm = std::time::Instant::now(); + arm_loaded(&mut conn, &mut bps, &state, &proxy); + sync_bps(&state, &proxy, &bps, &ui_view); + } + + while let Ok(cmd) = rx.try_recv() { + match &cmd { + // Attach elsewhere: drop this connection (the agent detaches/resumes on + // disconnect) and let the outer loop reconnect to the chosen target. + Cmd::SelectTarget(t) => return Some(t.clone()), + // View + breakpoint edits work any time (even while running). + Cmd::View(file) => { + ui_view = file.clone(); + sync_bps(&state, &proxy, &bps, &ui_view); + } + Cmd::ToggleBp(line) => { + let line = *line; + let base = basename(&ui_view).to_string(); + if base.is_empty() { + continue; + } + if let Some(pos) = bps.iter().position(|b| basename(&b.file) == base && b.line == line) { + let req = bps[pos].req; + req.map(|q| conn.clear_event(wire::kind::BREAKPOINT, q)); + bps.remove(pos); + } else { + let mut bp = Bp { file: base.clone(), line, armed: false, req: None }; + if let Ok(types) = conn.types_for_source_file(&bp.file, true) { + if !types.is_empty() { + if let Some(req) = arm(&mut conn, &types, &bp.file, bp.line, &state, &proxy) { + bp.armed = true; + bp.req = Some(req); + } + } + } + bps.push(bp); + } + sync_bps(&state, &proxy, &bps, &ui_view); + persist_breakpoints(&ui_view, &bps); + last_bp = bp_mtime(); // our own write; don't reconcile it back + } + // Break into a running program: suspend, then inspect a thread that + // has a resolvable managed stack (Unity spawns many native threads). + Cmd::Pause if cur_thread == 0 => { + if conn.suspend().is_ok() { + let threads = conn.all_threads().unwrap_or_default(); + let chosen = threads + .iter() + .copied() + .find(|&t| { + conn.frames(t) + .ok() + .and_then(|f| f.into_iter().next()) + .and_then(|f| conn.debug_info(f.method).ok().map(|i| (i, f.il_offset))) + .and_then(|(i, il)| il_to_source(&i, il)) + .map(|(_, ln)| ln > 0) + .unwrap_or(false) + }) + .or_else(|| threads.first().copied()) + .unwrap_or(0); + if chosen != 0 { + cur_thread = chosen; + let stop = dump(&mut conn, chosen); + cur_frames = stop.frames; + let threads = collect_threads(&mut conn); + ui_view = stop.file.clone(); + set(&state, &proxy, |s| { + s.stopped = true; + s.threads = threads; + s.cur_thread = chosen; + s.cur_frame = 0; + s.cur_file = stop.file; + s.cur_line = stop.line; + s.stack = stop.stack; + s.locals = stop.locals; + s.members = stop.members; + s.this_label = stop.this_label; + s.children.clear(); + s.gen = s.gen.wrapping_add(1); + }); + sync_bps(&state, &proxy, &bps, &ui_view); + proxy.send_event(Wake::Focus).ok(); + } else { + conn.resume().ok(); + } + } + } + // Inspect a different thread: switch the active thread and dump its + // innermost frame. Subsequent steps then act on this thread. + Cmd::SelectThread(id) if cur_thread != 0 => { + let id = *id; + cur_thread = id; + let stop = dump(&mut conn, id); + cur_frames = stop.frames; + ui_view = stop.file.clone(); + set(&state, &proxy, |s| { + s.cur_thread = id; + s.cur_frame = 0; + s.cur_file = stop.file; + s.cur_line = stop.line; + s.stack = stop.stack; + s.locals = stop.locals; + s.members = stop.members; + s.this_label = stop.this_label; + s.children.clear(); + s.gen = s.gen.wrapping_add(1); + }); + sync_bps(&state, &proxy, &bps, &ui_view); + } + // Inspect a different call-stack frame: recompute its variables and + // jump the source view to its location. + Cmd::SelectFrame(idx) if cur_thread != 0 => { + if let Some(frame) = cur_frames.get(*idx).cloned() { + let (locals, members, this_label) = frame_vars(&mut conn, cur_thread, &frame); + let (file, line) = conn + .debug_info(frame.method) + .ok() + .and_then(|info| il_to_source(&info, frame.il_offset)) + .unwrap_or_default(); + ui_view = file.clone(); + let idx = *idx; + set(&state, &proxy, |s| { + s.cur_frame = idx; + s.cur_file = file; + s.cur_line = line; + s.locals = locals; + s.members = members; + s.this_label = this_label; + s.children.clear(); + s.gen = s.gen.wrapping_add(1); + }); + sync_bps(&state, &proxy, &bps, &ui_view); + } + } + // Lazily fetch an expandable value's children. + Cmd::ExpandVar { id, array } if cur_thread != 0 => { + let (id, array) = (*id, *array); + let children = if array { + fetch_array(&mut conn, id) + } else { + fetch_object_fields(&mut conn, id) + }; + set(&state, &proxy, |s| { + s.children.insert(id, children); + }); + } + // Resume/step only make sense while stopped. + Cmd::Resume if cur_thread != 0 => { + running(&state, &proxy); + conn.resume().ok(); + cur_thread = 0; + cur_frames.clear(); + } + step @ (Cmd::StepOver | Cmd::StepInto | Cmd::StepOut) if cur_thread != 0 => { + let depth = match step { + Cmd::StepInto => wire::step::DEPTH_INTO, + Cmd::StepOut => wire::step::DEPTH_OUT, + _ => wire::step::DEPTH_OVER, + }; + if let Ok(req) = conn.step(cur_thread, wire::step::SIZE_LINE, depth) { + step_req = Some(req); + } + running(&state, &proxy); + conn.resume().ok(); + cur_thread = 0; + cur_frames.clear(); + } + _ => {} + } + } + + match conn.next_event() { + Ok(comp) => { + // Whether a breakpoint/step actually stopped us in this batch — if so we + // stay suspended; otherwise a suspended batch (e.g. the type-load watch) + // must be resumed once we've handled it. + let mut stopped_here = false; + for ev in &comp.events { + match ev.kind { + wire::kind::APPDOMAIN_CREATE => { + // Fresh (play-mode) domain: clear the old event requests (so + // they can't linger as duplicates), re-sync breakpoints from + // the shared store (so editor-side edits apply on this run), + // then re-watch + re-arm on TYPE_LOAD. + for bp in &mut bps { + if let Some(req) = bp.req.take() { + conn.clear_event(wire::kind::BREAKPOINT, req).ok(); + } + bp.armed = false; + } + if let Some(fresh) = load_breakpoints() { + bps = fresh + .into_iter() + .map(|(file, line)| Bp { file, line, armed: false, req: None }) + .collect(); + } + rewatch(&mut conn, &bps, &mut watch_req); + sync_bps(&state, &proxy, &bps, &ui_view); + } + wire::kind::TYPE_LOAD => { + for bp in &mut bps { + if !bp.armed { + if let Some(req) = + arm(&mut conn, &[ev.type_id], &bp.file, bp.line, &state, &proxy) + { + bp.armed = true; + bp.req = Some(req); + } + } + } + } + wire::kind::BREAKPOINT | wire::kind::STEP => { + // A landed step keeps focus where it is; a real breakpoint + // hit surfaces the window (the user is over in the editor). + let was_step = step_req.is_some(); + if let Some(req) = step_req.take() { + conn.clear_event(wire::kind::STEP, req).ok(); + } + stopped_here = true; + cur_thread = ev.thread; + let stop = dump(&mut conn, ev.thread); + cur_frames = stop.frames; + let threads = collect_threads(&mut conn); + ui_view = stop.file.clone(); + set(&state, &proxy, |s| { + s.stopped = true; + s.threads = threads; + s.cur_thread = ev.thread; + s.cur_frame = 0; + s.cur_file = stop.file; + s.cur_line = stop.line; + s.stack = stop.stack; + s.locals = stop.locals; + s.members = stop.members; + s.this_label = stop.this_label; + s.children.clear(); + s.gen = s.gen.wrapping_add(1); + }); + sync_bps(&state, &proxy, &bps, &ui_view); + if !was_step { + proxy.send_event(Wake::Focus).ok(); + } + } + wire::kind::VM_DEATH => { + set(&state, &proxy, |s| { + s.attached = false; + s.status = "target exited".into(); + }); + proxy.send_event(Wake::Quit).ok(); + return None; + } + _ => {} + } + } + // The type-load watch suspends the VM so we can arm a breakpoint on the + // freshly-loaded type BEFORE its methods (Awake/Start) run — winning the + // launch-vs-run race. Resume once armed (unless we actually stopped). + if comp.suspend_policy != wire::suspend::NONE && !stopped_here { + conn.resume().ok(); + } + } + Err(wire::SdbError::Io(ref e)) + if matches!( + e.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) => {} + Err(wire::SdbError::Eof) => { + set(&state, &proxy, |s| { + s.attached = false; + s.status = format!("{target_label} disconnected"); + }); + proxy.send_event(Wake::Quit).ok(); + return None; + } + Err(e) => set(&state, &proxy, |s| s.push_log(format!("event error: {e}"))), + } + } +} + +/// Publish bp_lines (current file) + all_bps (everything) for the UI. +fn sync_bps(state: &Arc>, proxy: &EventLoopProxy, bps: &[Bp], cur_file: &str) { + let base = basename(cur_file); + let lines: Vec = bps + .iter() + .filter(|b| basename(&b.file) == base) + .map(|b| b.line) + .collect(); + let all: Vec<(String, i32)> = bps + .iter() + .map(|b| (basename(&b.file).to_string(), b.line)) + .collect(); + set(state, proxy, |s| { + s.bp_lines = lines; + s.all_bps = all; + }); +} + +fn persist_breakpoints(ui_view: &str, bps: &[Bp]) { + if ui_view.is_empty() { + return; + } + let dir = project_root().join("Library").join("Unterm"); + let path = dir.join("breakpoints.json"); + let mut files: Vec = std::fs::read_to_string(&path) + .ok() + .and_then(|t| serde_json::from_str::(&t).ok()) + .and_then(|v| v.get("files").and_then(|f| f.as_array()).cloned()) + .unwrap_or_default(); + let base = basename(ui_view); + let lines: Vec = bps + .iter() + .filter(|b| basename(&b.file) == base) + .map(|b| (b.line - 1) as i64) + .collect(); + files.retain(|e| e.get("path").and_then(|p| p.as_str()) != Some(ui_view)); + if !lines.is_empty() { + files.push(serde_json::json!({ "path": ui_view, "lines": lines })); + } + let out = serde_json::json!({ "files": files }); + let _ = std::fs::create_dir_all(&dir); + let _ = std::fs::write(&path, serde_json::to_string(&out).unwrap_or_default()); +} + +fn running(state: &Arc>, proxy: &EventLoopProxy) { + set(state, proxy, |s| { + s.stopped = false; + s.threads.clear(); + s.cur_thread = 0; + s.cur_frame = 0; + s.stack.clear(); + s.locals.clear(); + s.members.clear(); + s.this_label.clear(); + s.children.clear(); + }); +} + +/// (Re)subscribe to TYPE_LOAD for the files that currently hold breakpoints, clearing +/// any previous subscription so a newly-added breakpoint's file gets watched too. +fn rewatch(conn: &mut sdb::Connection, bps: &[Bp], watch_req: &mut Option) { + if let Some(req) = watch_req.take() { + conn.clear_event(wire::kind::TYPE_LOAD, req).ok(); + } + let mut files: Vec = bps.iter().map(|b| b.file.clone()).collect(); + files.sort(); + files.dedup(); + if !files.is_empty() { + *watch_req = conn.watch_source_files(&files).ok(); + } +} + +/// Arm every not-yet-armed breakpoint against the types already loaded for its file. +fn arm_loaded( + conn: &mut sdb::Connection, + bps: &mut [Bp], + state: &Arc>, + proxy: &EventLoopProxy, +) { + for bp in bps.iter_mut() { + if bp.armed { + continue; + } + if let Ok(types) = conn.types_for_source_file(&bp.file, true) { + if !types.is_empty() { + if let Some(req) = arm(conn, &types, &bp.file, bp.line, state, proxy) { + bp.armed = true; + bp.req = Some(req); + } + } + } + } +} + +/// Reconcile our live breakpoints with the shared store (edited by the editor while +/// we run): clear breakpoints dropped from the store and arm ones newly added, leaving +/// unchanged ones (and their armed state) intact. +fn reconcile_breakpoints( + conn: &mut sdb::Connection, + bps: &mut Vec, + state: &Arc>, + proxy: &EventLoopProxy, + ui_view: &str, +) { + let Some(fresh) = load_breakpoints() else { + return; // unreadable store: keep what we have rather than wiping + }; + // Drop breakpoints no longer in the store (clearing their agent request). + let mut i = 0; + while i < bps.len() { + if fresh.iter().any(|(f, l)| *f == bps[i].file && *l == bps[i].line) { + i += 1; + } else { + if let Some(req) = bps[i].req.take() { + conn.clear_event(wire::kind::BREAKPOINT, req).ok(); + } + bps.remove(i); + } + } + // Arm breakpoints newly added to the store (against already-loaded types; ones + // whose type isn't loaded yet are picked up by the arm-retry / TYPE_LOAD watch). + for (file, line) in fresh { + if bps.iter().any(|b| b.file == file && b.line == line) { + continue; + } + let mut bp = Bp { file: file.clone(), line, armed: false, req: None }; + if let Ok(types) = conn.types_for_source_file(&bp.file, true) { + if !types.is_empty() { + if let Some(req) = arm(conn, &types, &bp.file, bp.line, state, proxy) { + bp.armed = true; + bp.req = Some(req); + } + } + } + bps.push(bp); + } + sync_bps(state, proxy, bps, ui_view); +} + +/// Read the shared breakpoint store as (basename, 1-based line) pairs. Returns `None` +/// on a missing/unreadable file so a transient error can't wipe live breakpoints. +fn load_breakpoints() -> Option> { + let path = project_root().join("Library").join("Unterm").join("breakpoints.json"); + let text = std::fs::read_to_string(&path).ok()?; + let v: serde_json::Value = serde_json::from_str(&text).ok()?; + let mut out = Vec::new(); + if let Some(files) = v.get("files").and_then(|f| f.as_array()) { + for e in files { + let Some(p) = e.get("path").and_then(|p| p.as_str()) else { + continue; + }; + let base = basename(p).to_string(); + if let Some(lines) = e.get("lines").and_then(|l| l.as_array()) { + for ln in lines.iter().filter_map(|l| l.as_i64()) { + out.push((base.clone(), ln as i32 + 1)); // store is 0-based + } + } + } + } + Some(out) +} + +fn arm( + conn: &mut sdb::Connection, + types: &[u32], + file: &str, + line: i32, + state: &Arc>, + proxy: &EventLoopProxy, +) -> Option { + let mut candidates = Vec::new(); + for &t in types { + let Ok(methods) = conn.type_methods(t) else { + continue; + }; + for m in methods { + if let Ok(info) = conn.debug_info(m) { + candidates.push((m, info)); + } + } + } + let (method, il) = sdb::resolve_line(&candidates, file, line)?; + match conn.set_breakpoint(method, il) { + Ok(req) => { + let name = conn.method_name(method).unwrap_or_default(); + set(state, proxy, |s| s.push_log(format!("breakpoint: {name}+0x{il:x}"))); + Some(req) + } + Err(_) => None, + } +} + +/// Enumerate the managed threads (those with a resolvable source frame) for the +/// Threads strip, labelling each with its name and topmost source location. +fn collect_threads(conn: &mut sdb::Connection) -> Vec { + let mut out = Vec::new(); + for id in conn.all_threads().unwrap_or_default() { + let frames = conn.frames(id).unwrap_or_default(); + let top = frames.iter().find_map(|f| { + conn.debug_info(f.method) + .ok() + .and_then(|info| il_to_source(&info, f.il_offset)) + .filter(|(_, ln)| *ln > 0) + .map(|(src, ln)| (f.method, src, ln)) + }); + let Some((method, src, ln)) = top else { + continue; // native/threadpool thread with no managed frame: skip + }; + let raw = conn.thread_name(id).unwrap_or_default(); + let name = if raw.is_empty() { format!("Thread {id}") } else { raw }; + let mname = conn.method_name(method).unwrap_or_default(); + out.push(ThreadInfo { + id, + name, + location: format!("{mname} {}:{ln}", basename(&src)), + }); + } + out +} + +fn dump(conn: &mut sdb::Connection, thread: u32) -> Stop { + let frames = conn.frames(thread).unwrap_or_default(); + let mut stack = Vec::new(); + let mut file = String::new(); + let mut line = 0; + for (i, f) in frames.iter().enumerate().take(20) { + let name = conn.method_name(f.method).unwrap_or_default(); + let (src, ln) = conn + .debug_info(f.method) + .ok() + .and_then(|info| il_to_source(&info, f.il_offset)) + .unwrap_or_default(); + if i == 0 { + file = src.clone(); + line = ln; + } + let loc = if ln > 0 { format!("{}:{ln}", basename(&src)) } else { String::new() }; + stack.push(format!("#{i} {name} {loc}")); + } + + let (locals, members, this_label) = match frames.first() { + Some(top) => frame_vars(conn, thread, top), + None => (Vec::new(), Vec::new(), String::new()), + }; + Stop { stack, frames, locals, members, this_label, file, line } +} + +/// Locals, `this` members, and the `this` type label for a single call-stack frame. +fn frame_vars( + conn: &mut sdb::Connection, + thread: u32, + frame: &sdb::Frame, +) -> (Vec, Vec, String) { + let mut locals = Vec::new(); + if let Ok(infos) = conn.locals_info(frame.method) { + let in_scope: Vec<(usize, sdb::Local)> = infos + .into_iter() + .enumerate() + .filter(|(_, l)| frame.il_offset >= l.live_start && frame.il_offset < l.live_end) + .collect(); + let positions: Vec = in_scope.iter().map(|(i, _)| *i as i32).collect(); + if let Ok(values) = conn.frame_values(thread, frame.id, &positions) { + for ((_, l), val) in in_scope.iter().zip(values) { + locals.push(make_var(conn, l.name.clone(), &val)); + } + } + } + + let mut members = Vec::new(); + let mut this_label = String::new(); + if let Ok(value::Value::Object { id, .. }) = conn.frame_this(thread, frame.id) { + if id != 0 { + if let Ok(ty) = conn.object_type(id) { + this_label = conn.type_name(ty).unwrap_or_default(); + } + members = fetch_object_fields(conn, id); + } + } + (locals, members, this_label) +} + +/// Build a variable row, tagging it as expandable when it's a non-null object/array. +fn make_var(conn: &mut sdb::Connection, name: String, val: &value::Value) -> Var { + let expand = match val { + value::Value::Object { tag, id } if *id != 0 => Some(Expand { + id: *id, + array: *tag == value::tag::ARRAY || *tag == value::tag::SZARRAY, + }), + _ => None, + }; + Var { + name, + value: render_value(conn, val), + expand, + } +} + +/// The instance fields of an object as variable rows. +fn fetch_object_fields(conn: &mut sdb::Connection, id: u32) -> Vec { + let mut out = Vec::new(); + let Ok(mut ty) = conn.object_type(id) else { + return out; + }; + // Walk the type hierarchy so inherited (base-class) instance fields show too — + // `TYPE.GET_FIELDS` returns only the fields declared on each type. Derived fields + // come first, then each base class up to (but not including) System.Object. + for _ in 0..32 { + if let Ok(fields) = conn.type_fields(ty) { + let inst: Vec = fields.into_iter().filter(|f| !f.is_static()).collect(); + let ids: Vec = inst.iter().map(|f| f.id).collect(); + if !ids.is_empty() { + if let Ok(vals) = conn.object_values(id, &ids) { + for (f, val) in inst.iter().zip(vals) { + out.push(make_var(conn, f.name.clone(), &val)); + } + } + } + } + match conn.type_base(ty) { + Ok(base) if base != 0 => ty = base, + _ => break, + } + } + out +} + +/// The elements of an array as variable rows (capped, with an ellipsis overflow row). +fn fetch_array(conn: &mut sdb::Connection, id: u32) -> Vec { + const CAP: i32 = 500; + let len = conn.array_length(id).unwrap_or(0).max(0); + let n = len.min(CAP); + let mut out = Vec::new(); + if n > 0 { + if let Ok(vals) = conn.array_values(id, 0, n) { + for (i, val) in vals.iter().enumerate() { + out.push(make_var(conn, format!("[{i}]"), val)); + } + } + } + if len > n { + out.push(Var { + name: format!("… {} more", len - n), + value: String::new(), + expand: None, + }); + } + out +} + +fn render_value(conn: &mut sdb::Connection, v: &value::Value) -> String { + use value::Value; + match v { + Value::String(0) => "null".into(), + Value::String(id) => format!("\"{}\"", conn.string_value(*id).unwrap_or_default()), + Value::Object { id, .. } if *id == 0 => "null".into(), + Value::Object { id, .. } => conn + .object_type(*id) + .ok() + .and_then(|t| conn.type_name(t).ok()) + .map(|n| format!("{{{n}}}")) + .unwrap_or_else(|| format!("obj#{id}")), + Value::ValueType { fields, .. } => format!( + "({})", + fields.iter().map(|f| render_value(conn, f)).collect::>().join(", ") + ), + other => other.summary(), + } +} + +fn il_to_source(info: &sdb::DebugInfo, il: i32) -> Option<(String, i32)> { + let sp = info + .seq_points + .iter() + .filter(|s| !s.is_hidden() && s.il_offset <= il) + .max_by_key(|s| s.il_offset)?; + let src = info.sources.first().cloned().unwrap_or_default(); + Some((src, sp.line)) +} + +fn basename(path: &str) -> &str { + path.rsplit(['/', '\\']).next().unwrap_or(path) +} + +/// The C# identifier covering character column `col` in `line`, if any. +fn word_at(line: &str, col: usize) -> Option { + let chars: Vec = line.chars().collect(); + if col >= chars.len() { + return None; + } + let is_ident = |c: char| c.is_alphanumeric() || c == '_'; + if !is_ident(chars[col]) { + return None; + } + let mut start = col; + while start > 0 && is_ident(chars[start - 1]) { + start -= 1; + } + let mut end = col; + while end + 1 < chars.len() && is_ident(chars[end + 1]) { + end += 1; + } + if chars[start].is_ascii_digit() { + return None; + } + Some(chars[start..=end].iter().collect()) +} + +/// Look up a variable name among the in-scope locals and `this` members. +fn lookup_var<'a>(s: &'a UiState, name: &str) -> Option<&'a str> { + s.locals + .iter() + .chain(s.members.iter()) + .find(|v| v.name == name) + .map(|v| v.value.as_str()) +} + +/// The Unity project root (the dir containing `Library/EditorInstance.json`), so the +/// file tree and breakpoint store work regardless of the process's launch directory. +fn project_root() -> std::path::PathBuf { + let cwd = std::env::current_dir().unwrap_or_default(); + if let Some(inst) = sdb::find_editor_instance(&cwd) { + if let Some(root) = inst.parent().and_then(|p| p.parent()) { + return root.to_path_buf(); + } + } + cwd +} + +/// `Library/Unterm/` in this project (the editor and debugger's shared dir). +fn unterm_state_path(name: &str) -> std::path::PathBuf { + project_root().join("Library").join("Unterm").join(name) +} + +/// Publish our PID so the editor launcher can detect (per project, via the path of +/// this file) that this project's debugger is already running. +fn write_pid_file() { + let p = unterm_state_path("debugger.pid"); + if let Some(dir) = p.parent() { + let _ = std::fs::create_dir_all(dir); + } + let _ = std::fs::write(p, std::process::id().to_string()); +} + +/// The editor's process id (from `Library/EditorInstance.json`), to recognise it among +/// activating apps and among the on-screen window list. +#[cfg(any(target_os = "macos", target_os = "windows"))] +fn editor_pid() -> Option { + let inst = sdb::find_editor_instance(&std::env::current_dir().ok()?)?; + let json: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(inst).ok()?).ok()?; + json.get("process_id").and_then(|v| v.as_i64()).map(|v| v as i32) +} + +/// Observe `NSWorkspace` app-activation; when our editor becomes frontmost, post +/// `Wake::Raise`. Runs in the debugger's own run loop, so it fires even while the +/// editor is frozen (suspended at a breakpoint). +#[cfg(target_os = "macos")] +fn install_activation_observer(proxy: EventLoopProxy) { + use objc2::{class, msg_send, runtime::AnyObject}; + use objc2_app_kit::NSWorkspaceDidActivateApplicationNotification; + let Some(epid) = editor_pid() else { + return; // no editor to follow + }; + unsafe { + let ws: *mut AnyObject = msg_send![class!(NSWorkspace), sharedWorkspace]; + if ws.is_null() { + return; + } + let center: *mut AnyObject = msg_send![ws, notificationCenter]; + if center.is_null() { + return; + } + let block = block2::RcBlock::new(move |_notif: *mut AnyObject| { + let ws: *mut AnyObject = msg_send![class!(NSWorkspace), sharedWorkspace]; + if ws.is_null() { + return; + } + let front: *mut AnyObject = msg_send![ws, frontmostApplication]; + if front.is_null() { + return; + } + let pid: i32 = msg_send![front, processIdentifier]; + if pid == epid { + proxy.send_event(Wake::Raise).ok(); + } + }); + let null = std::ptr::null::(); + // The center copies the block, so it may outlive this scope's `block`. + let _: *mut AnyObject = msg_send![ + center, + addObserverForName: NSWorkspaceDidActivateApplicationNotification, + object: null, + queue: null, + usingBlock: &*block, + ]; + } +} + +/// Slide our window to just below the editor's frontmost window — above other apps, but +/// not covering the editor (so no covering-flicker) and without stealing focus. +#[cfg(target_os = "macos")] +fn order_behind_editor(window: &Window) { + use objc2::{msg_send, runtime::AnyObject}; + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + let Some(pid) = editor_pid() else { + return; + }; + let Some(editor_win) = cgwindow::frontmost_window(pid) else { + return; + }; + let Ok(handle) = window.window_handle() else { + return; + }; + if let RawWindowHandle::AppKit(a) = handle.as_raw() { + unsafe { + let ns_view = a.ns_view.as_ptr() as *mut AnyObject; + let ns_window: *mut AnyObject = msg_send![ns_view, window]; + if !ns_window.is_null() { + // orderWindow:NSWindowBelow(-1) relativeTo:. + let below: isize = -1; + let _: () = + msg_send![ns_window, orderWindow: below, relativeTo: editor_win as isize]; + } + } + } +} + +/// Minimal CoreGraphics window-list FFI: find a process's frontmost on-screen window +/// number. Window number + owner pid + layer need no screen-recording permission. +#[cfg(target_os = "macos")] +mod cgwindow { + use core_foundation_sys::array::{CFArrayGetCount, CFArrayGetValueAtIndex, CFArrayRef}; + use core_foundation_sys::base::{CFRelease, CFTypeRef}; + use core_foundation_sys::dictionary::{CFDictionaryGetValueIfPresent, CFDictionaryRef}; + use core_foundation_sys::number::{kCFNumberSInt64Type, CFNumberGetValue, CFNumberRef}; + use core_foundation_sys::string::CFStringRef; + use std::ffi::c_void; + + #[link(name = "CoreGraphics", kind = "framework")] + extern "C" { + fn CGWindowListCopyWindowInfo(option: u32, relative_to: u32) -> CFArrayRef; + static kCGWindowOwnerPID: CFStringRef; + static kCGWindowNumber: CFStringRef; + static kCGWindowLayer: CFStringRef; + } + const ON_SCREEN_ONLY: u32 = 1 << 0; + const NULL_WINDOW: u32 = 0; + + unsafe fn dict_i64(dict: CFDictionaryRef, key: CFStringRef) -> Option { + let mut val: *const c_void = std::ptr::null(); + if CFDictionaryGetValueIfPresent(dict, key as *const c_void, &mut val) == 0 || val.is_null() + { + return None; + } + let mut out: i64 = 0; + if CFNumberGetValue( + val as CFNumberRef, + kCFNumberSInt64Type, + &mut out as *mut i64 as *mut c_void, + ) { + Some(out) + } else { + None + } + } + + pub fn frontmost_window(pid: i32) -> Option { + unsafe { + let arr = CGWindowListCopyWindowInfo(ON_SCREEN_ONLY, NULL_WINDOW); + if arr.is_null() { + return None; + } + let mut found = None; + // The list is front-to-back, so the first match is the frontmost window. + for i in 0..CFArrayGetCount(arr) { + let dict = CFArrayGetValueAtIndex(arr, i) as CFDictionaryRef; + if dict.is_null() { + continue; + } + // Only normal-level windows (layer 0); skip menus/overlays/etc. + if dict_i64(dict, kCGWindowOwnerPID) == Some(pid as i64) + && dict_i64(dict, kCGWindowLayer) == Some(0) + { + found = dict_i64(dict, kCGWindowNumber); + break; + } + } + CFRelease(arr as CFTypeRef); + found + } + } +} + +// --- Windows: same "follow the editor forward" via a foreground WinEvent hook --- + +#[cfg(windows)] +static WIN_EDITOR_PID: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); +#[cfg(windows)] +static WIN_PROXY: Mutex>> = Mutex::new(None); + +#[cfg(windows)] +unsafe extern "system" fn win_event_proc( + _hook: windows::Win32::UI::Accessibility::HWINEVENTHOOK, + _event: u32, + hwnd: windows::Win32::Foundation::HWND, + _id_object: i32, + _id_child: i32, + _thread: u32, + _time: u32, +) { + use std::sync::atomic::Ordering; + use windows::Win32::UI::WindowsAndMessaging::GetWindowThreadProcessId; + let mut pid = 0u32; + GetWindowThreadProcessId(hwnd, Some(&mut pid)); + if pid != 0 && pid == WIN_EDITOR_PID.load(Ordering::Relaxed) { + if let Ok(g) = WIN_PROXY.lock() { + if let Some(p) = g.as_ref() { + p.send_event(Wake::Raise).ok(); + } + } + } +} + +#[cfg(windows)] +fn install_activation_observer(proxy: EventLoopProxy) { + use std::sync::atomic::Ordering; + use windows::Win32::UI::Accessibility::SetWinEventHook; + use windows::Win32::UI::WindowsAndMessaging::{ + EVENT_SYSTEM_FOREGROUND, WINEVENT_OUTOFCONTEXT, WINEVENT_SKIPOWNPROCESS, + }; + let Some(pid) = editor_pid() else { + return; + }; + WIN_EDITOR_PID.store(pid as u32, Ordering::Relaxed); + *WIN_PROXY.lock().unwrap() = Some(proxy); + unsafe { + SetWinEventHook( + EVENT_SYSTEM_FOREGROUND, + EVENT_SYSTEM_FOREGROUND, + // Out-of-context hook: no DLL to load into other processes. + None, + Some(win_event_proc), + 0, + 0, + WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS, + ); + } +} + +#[cfg(windows)] +fn order_behind_editor(window: &Window) { + use std::sync::atomic::Ordering; + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + use windows::Win32::Foundation::HWND; + use windows::Win32::UI::WindowsAndMessaging::{ + GetForegroundWindow, GetWindowThreadProcessId, SetWindowPos, SWP_NOACTIVATE, SWP_NOMOVE, + SWP_NOSIZE, + }; + let pid = WIN_EDITOR_PID.load(Ordering::Relaxed); + if pid == 0 { + return; + } + let Ok(handle) = window.window_handle() else { + return; + }; + let RawWindowHandle::Win32(h) = handle.as_raw() else { + return; + }; + let ours = HWND(h.hwnd.get() as *mut core::ffi::c_void); + unsafe { + let fg = GetForegroundWindow(); + if fg.0.is_null() { + return; + } + let mut fgpid = 0u32; + GetWindowThreadProcessId(fg, Some(&mut fgpid)); + if fgpid != pid { + return; // the editor isn't actually the foreground window + } + // Insert our window directly below the editor's in z-order (no move/size/focus). + let _ = SetWindowPos(ours, Some(fg), 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE); + } +} + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +fn install_activation_observer(_proxy: EventLoopProxy) {} +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +fn order_behind_editor(_window: &Window) {} + +fn scan_cs_files() -> Vec<(String, String)> { + fn walk(dir: &std::path::Path, out: &mut Vec<(String, String)>) { + let Ok(rd) = std::fs::read_dir(dir) else { + return; + }; + for e in rd.flatten() { + let p = e.path(); + if p.is_dir() { + let name = p.file_name().and_then(|s| s.to_str()).unwrap_or(""); + if name.starts_with('.') || matches!(name, "Library" | "Temp" | "Logs" | "obj") { + continue; + } + walk(&p, out); + } else if p.extension().and_then(|s| s.to_str()) == Some("cs") { + let disp = p.file_name().and_then(|s| s.to_str()).unwrap_or("").to_string(); + out.push((p.to_string_lossy().into_owned(), disp)); + } + } + } + let root = project_root(); + let mut out = Vec::new(); + for d in [ + root.join("Assets"), + root.join("Packages"), + // Registry/cached packages (read-only) — needed to set breakpoints in them. + root.join("Library").join("PackageCache"), + ] { + if d.is_dir() { + walk(&d, &mut out); + } + } + if out.is_empty() { + walk(&root, &mut out); + } + out.sort_by(|a, b| a.0.cmp(&b.0)); + out.truncate(12000); + out +} + +fn set(state: &Arc>, proxy: &EventLoopProxy, f: impl FnOnce(&mut UiState)) { + if let Ok(mut s) = state.lock() { + f(&mut s); + } + proxy.send_event(Wake::Redraw).ok(); +} diff --git a/native/unterm/src/editorview.rs b/native/unterm/src/editorview.rs index 1d1481f..517f4b3 100644 --- a/native/unterm/src/editorview.rs +++ b/native/unterm/src/editorview.rs @@ -11,6 +11,7 @@ use std::path::PathBuf; use crate::diff::DiffFetcher; use crate::input::InputBox; +use crate::panel::PanelRenderer; /// Strip interior NULs so the text round-trips through a C string. fn clean(s: &str) -> CString { @@ -25,6 +26,27 @@ pub struct EditorView { word_snap: CString, /// Background git-index reader feeding the diff gutter markers. diff: DiffFetcher, + + /// Markdown-preview renderer (a [`PanelRenderer`] in document mode), created + /// lazily the first time preview is turned on. While `preview_on`, the render / + /// texture / scroll / mouse / copy paths route here instead of `edit`, so the + /// window blits the rendered Markdown of the LIVE buffer (unsaved edits + /// included) without a second copy of the text. `edit` stays the source of + /// truth; preview is read-only. + preview: Option, + preview_on: bool, + /// Cached geometry / theme / font / link-root, so a lazily-created preview is + /// configured to match `edit` and stays in sync as the host pushes changes. + width: u32, + height: u32, + scale: f32, + clear: (f64, f64, f64, f64), + fg: (u8, u8, u8), + font_path: String, + root: PathBuf, + /// Preview vertical scroll in physical px (0 = top); its own offset so + /// toggling preview doesn't disturb the editor's scroll position. + pv_scroll: f32, } impl EditorView { @@ -41,14 +63,76 @@ impl EditorView { cut_snap: CString::default(), word_snap: CString::default(), diff: DiffFetcher::new(), + preview: None, + preview_on: false, + width, + height, + scale, + clear: (0.051, 0.051, 0.051, 1.0), + fg: (210, 210, 214), + font_path: String::new(), + root: PathBuf::new(), + pv_scroll: 0.0, + } + } + + /// Build + configure the preview renderer from the cached geometry/theme/font, + /// if it doesn't exist yet. + fn ensure_preview(&mut self) { + if self.preview.is_some() { + return; + } + let mut p = PanelRenderer::new(self.width, self.height); + p.set_document(true); + p.set_scale(self.scale); + p.set_clear_color(self.clear.0, self.clear.1, self.clear.2, self.clear.3); + p.set_text_color(self.fg.0, self.fg.1, self.fg.2, 255); + if !self.font_path.is_empty() { + p.set_fonts(&self.font_path, &self.font_path, &self.font_path, &self.font_path); + } + p.set_root(self.root.clone()); + self.preview = Some(p); + } + + /// Toggle Markdown-preview mode (the window feeds the current buffer text). + pub fn set_preview(&mut self, on: bool) { + self.preview_on = on; + if on { + self.ensure_preview(); } } + pub fn preview_active(&self) -> bool { + self.preview_on + } + + /// The (existing-file) path token under a physical-px point in preview mode, + /// for click-to-open; empty when not over one or not previewing. + pub fn preview_token_at(&mut self, x: f32, y: f32) -> &CString { + let tok = if self.preview_on { + self.preview.as_ref().and_then(|p| p.token_at(x, y)).unwrap_or_default() + } else { + String::new() + }; + self.word_snap = clean(&tok); + &self.word_snap + } + /// Point the diff gutter at `path` (empty = clear) and kick a background fetch /// of its git HEAD + index versions. Call on load. pub fn set_path(&mut self, path: &str) { let p = path.trim(); self.diff.set_path(if p.is_empty() { None } else { Some(PathBuf::from(p)) }); + // Preview link resolution: a Markdown file's relative paths resolve against + // its own directory. Empty path → project cwd (empty root). + self.root = if p.is_empty() { + PathBuf::new() + } else { + PathBuf::from(p).parent().map(|d| d.to_path_buf()).unwrap_or_default() + }; + if let Some(pr) = self.preview.as_mut() { + pr.set_root(self.root.clone()); + } } /// Re-fetch the git texts (call on focus / after save / branch change). @@ -68,12 +152,23 @@ impl EditorView { } pub fn resize(&mut self, w: u32, h: u32, scale: f32) { + self.width = w.max(1); + self.height = h.max(1); + self.scale = scale; self.edit.set_scale(scale); self.edit.resize(w, h); + if let Some(p) = self.preview.as_mut() { + p.set_scale(scale); + p.resize(self.width, self.height); + } } pub fn set_scale(&mut self, scale: f32) { + self.scale = scale; self.edit.set_scale(scale); + if let Some(p) = self.preview.as_mut() { + p.set_scale(scale); + } } pub fn set_undo_limit(&mut self, limit: usize) { @@ -81,14 +176,26 @@ impl EditorView { } pub fn set_font(&mut self, path: &str) { + self.font_path = path.to_string(); self.edit.set_font(path); + if !path.is_empty() { + if let Some(p) = self.preview.as_mut() { + p.set_fonts(path, path, path, path); + } + } } /// Background rgba + foreground rgb, plus the syntect-vs-dark highlight theme. pub fn set_theme(&mut self, br: f64, bg: f64, bb: f64, ba: f64, fr: u8, fg: u8, fb: u8, dark: bool) { + self.clear = (br, bg, bb, ba); + self.fg = (fr, fg, fb); self.edit.set_clear_color(br, bg, bb, ba); self.edit.set_text_color(fr, fg, fb, 255); self.edit.set_dark(dark); + if let Some(p) = self.preview.as_mut() { + p.set_clear_color(br, bg, bb, ba); + p.set_text_color(fr, fg, fb, 255); + } } /// Tree-sitter language token (e.g. "cs"); empty = plain. @@ -98,14 +205,62 @@ impl EditorView { } pub fn render(&mut self) { - self.edit.render(); + if self.preview_on { + self.ensure_preview(); + let text = self.edit.text(); + if let Some(p) = self.preview.as_mut() { + // Clamp before rendering so the scrollbar and the layout agree. + let max = (p.content_height() - self.height as f32).max(0.0); + self.pv_scroll = self.pv_scroll.clamp(0.0, max); + p.set_scroll(self.pv_scroll); + p.render(&text); + } + } else { + self.edit.render(); + } } pub fn raw_texture(&self) -> *mut c_void { + if self.preview_on { + if let Some(p) = self.preview.as_ref() { + return p.raw_texture(); + } + } self.edit.raw_texture() } + /// The wgpu texture view the editor renders into (for in-process compositing). + pub fn texture_view(&self) -> &wgpu::TextureView { + self.edit.texture_view() + } + + /// Read-only mode: navigation/selection/scroll work; text edits are ignored. + pub fn set_read_only(&mut self, on: bool) { + self.edit.set_read_only(on); + } + + /// Reserve (and draw) the gutter's breakpoint-dot column (debugging enabled). + pub fn set_bp_gutter(&mut self, on: bool) { + self.edit.set_bp_gutter(on); + } + + /// Highlight a 0-based execution line (debugger current line), or `usize::MAX` to clear. + pub fn set_exec_line(&mut self, line: usize) { + self.edit + .set_exec_line(if line == usize::MAX { None } else { Some(line) }); + } + + /// The (line text, char column) under a physical-px point, for hover tooltips. + pub fn pos_at_pixel(&mut self, x: f32, y: f32) -> Option<(String, usize)> { + self.edit.pos_at_pixel(x, y) + } + pub fn content_height(&self) -> f32 { + if self.preview_on { + if let Some(p) = self.preview.as_ref() { + return p.content_height(); + } + } self.edit.content_height() } @@ -146,10 +301,29 @@ impl EditorView { } pub fn select_all(&mut self) { + if self.preview_on { + if let Some(p) = self.preview.as_mut() { + p.select_all(); + } + return; + } self.edit.select_all(); } pub fn mouse(&mut self, x: f32, y: f32, kind: u8) { + if self.preview_on { + // Read-only text selection: a press starts it, a drag extends it (the + // window doesn't forward mouse-up to native). Double/triple-click fall + // back to a plain press. + if let Some(p) = self.preview.as_mut() { + if kind == 1 { + p.selection_update(x, y); + } else { + p.selection_begin(x, y); + } + } + return; + } self.edit.mouse(x, y, kind); } @@ -205,19 +379,62 @@ impl EditorView { self.edit.revert_hunk(hunk_i); } + pub fn gutter_width(&self) -> f32 { + self.edit.gutter_width() + } + + pub fn line_at_y(&self, y: f32) -> usize { + self.edit.line_at_y_clamped(y) + } + + pub fn toggle_breakpoint(&mut self, line: usize) { + self.edit.toggle_breakpoint(line); + } + + pub fn set_breakpoints(&mut self, lines: &[u32]) { + self.edit.set_breakpoints(lines); + } + + #[allow(dead_code)] + pub fn breakpoints(&self) -> Vec { + self.edit.breakpoints() + } + pub fn scroll(&mut self, dy: f32) { + if self.preview_on { + let max = self + .preview + .as_ref() + .map_or(0.0, |p| (p.content_height() - self.height as f32).max(0.0)); + self.pv_scroll = (self.pv_scroll + dy).clamp(0.0, max); + return; + } self.edit.scroll_by(dy); } pub fn scroll_h(&mut self, dx: f32) { + if self.preview_on { + return; // document preview doesn't scroll horizontally + } self.edit.scroll_h_by(dx); } pub fn set_scroll(&mut self, px: f32) { + if self.preview_on { + let max = self + .preview + .as_ref() + .map_or(0.0, |p| (p.content_height() - self.height as f32).max(0.0)); + self.pv_scroll = px.clamp(0.0, max); + return; + } self.edit.set_scroll_v(px); } pub fn scroll_offset(&self) -> f32 { + if self.preview_on { + return self.pv_scroll; + } self.edit.scroll_offset() } @@ -298,6 +515,15 @@ impl EditorView { } pub fn copy(&mut self) -> &CString { + if self.preview_on { + let sel = self + .preview + .as_ref() + .map(|p| p.selected_text()) + .unwrap_or_default(); + self.copy_snap = clean(&sel); + return &self.copy_snap; + } self.copy_snap = clean(&self.edit.copy().unwrap_or_default()); &self.copy_snap } diff --git a/native/unterm/src/highlight.rs b/native/unterm/src/highlight.rs index d827207..bf1b754 100644 --- a/native/unterm/src/highlight.rs +++ b/native/unterm/src/highlight.rs @@ -45,6 +45,13 @@ const HL_NAMES: &[&str] = &[ "namespace", "label", "escape", + // Markdown (tree-sitter-md block + inline highlight queries). + "text.title", // headings + "text.literal", // code spans / fenced code + "text.emphasis", // *italic* + "text.strong", // **bold** + "text.uri", // links / autolinks + "text.reference", // link labels / references ]; /// Foreground colors for the dark theme, parallel to [`HL_NAMES`] (One Dark-ish). @@ -67,6 +74,12 @@ const DARK: &[(u8, u8, u8)] = &[ (229, 192, 123), // namespace (97, 175, 239), // label (86, 182, 194), // escape + (224, 108, 117), // text.title (heading) — coral + (152, 195, 121), // text.literal (code) — green + (198, 120, 221), // text.emphasis (italic) — purple + (229, 192, 123), // text.strong (bold) — gold + (97, 175, 239), // text.uri (link) — blue + (86, 182, 194), // text.reference — cyan ]; /// Foreground colors for the light theme, parallel to [`HL_NAMES`] (GitHub-ish). @@ -89,6 +102,12 @@ const LIGHT: &[(u8, u8, u8)] = &[ (36, 41, 47), // namespace (130, 80, 223), // label (5, 80, 174), // escape + (207, 34, 46), // text.title (heading) — red + (17, 99, 41), // text.literal (code) — green + (130, 80, 223), // text.emphasis (italic) — purple + (149, 56, 0), // text.strong (bold) — brown + (5, 80, 174), // text.uri (link) — blue + (5, 80, 174), // text.reference — blue ]; fn color_for(index: usize, dark: bool) -> Color { @@ -108,7 +127,7 @@ pub fn color_of(name: &str, dark: bool) -> Color { /// A grammar plus its compiled highlights query, built once and cached for the /// process. `cap_color` maps each query capture index to a color-table index. -struct LangConfig { +pub(crate) struct LangConfig { language: Language, query: Query, cap_color: Vec>, @@ -132,6 +151,38 @@ fn lang_config(lang_id: &str) -> Option<&'static LangConfig> { } } +/// Markdown needs two grammars: a block grammar (headings, fenced code, lists, +/// tables) and an inline grammar (emphasis, code spans, links) parsed over each +/// block's inline ranges. Each carries its own compiled query + capture→color map. +pub(crate) struct MdConfig { + block_lang: Language, + inline_lang: Language, + block_q: Query, + block_cap: Vec>, + inline_q: Query, + inline_cap: Vec>, +} + +/// The process-cached [`MdConfig`], or None if either grammar's query fails to +/// compile (the caller then has no highlighter and falls back to uniform color). +fn md_config() -> Option<&'static MdConfig> { + static CFG: OnceLock> = OnceLock::new(); + CFG.get_or_init(|| { + let block_lang: Language = tree_sitter_md::LANGUAGE.into(); + let inline_lang: Language = tree_sitter_md::INLINE_LANGUAGE.into(); + let block_q = Query::new(&block_lang, tree_sitter_md::HIGHLIGHT_QUERY_BLOCK) + .map_err(|e| log::warn!("unterm: md block query failed: {e}")) + .ok()?; + let inline_q = Query::new(&inline_lang, tree_sitter_md::HIGHLIGHT_QUERY_INLINE) + .map_err(|e| log::warn!("unterm: md inline query failed: {e}")) + .ok()?; + let block_cap = block_q.capture_names().iter().map(|n| name_to_index(n)).collect(); + let inline_cap = inline_q.capture_names().iter().map(|n| name_to_index(n)).collect(); + Some(MdConfig { block_lang, inline_lang, block_q, block_cap, inline_q, inline_cap }) + }) + .as_ref() +} + fn build_config(language: Language, highlights: &str) -> Option { let query = Query::new(&language, highlights) .map_err(|e| log::warn!("unterm: highlight query failed: {e}")) @@ -154,60 +205,164 @@ fn name_to_index(name: &str) -> Option { best.map(|(i, _)| i) } -/// Stateful incremental highlighter for one editor buffer. Holds the parser and -/// last syntax tree so successive [`Highlighter::highlight`] calls reparse only -/// the region that changed. -pub struct Highlighter { - cfg: &'static LangConfig, - parser: Parser, - tree: Option, - prev: String, +/// Stateful incremental highlighter for one editor buffer. Single-grammar +/// languages (C#) parse one tree; Markdown parses a block tree plus one inline +/// tree per inline node. Either way successive [`Highlighter::highlight`] calls +/// reparse only the region that changed (the previous tree is edited by the byte +/// delta and handed back to the parser). +pub(crate) enum Highlighter { + Ts { + cfg: &'static LangConfig, + parser: Parser, + tree: Option, + prev: String, + }, + Md { + cfg: &'static MdConfig, + /// Parses the document's block structure (incrementally, like `Ts`). + block_parser: Parser, + /// Re-set to each inline node's byte range per call and reparsed fresh. + inline_parser: Parser, + block_tree: Option, + prev: String, + }, } impl Highlighter { /// Build a highlighter for `lang_id`, or None if we have no grammar for it. pub fn new(lang_id: &str) -> Option { + if matches!(lang_id, "md" | "markdown") { + let cfg = md_config()?; + let mut block_parser = Parser::new(); + block_parser + .set_language(&cfg.block_lang) + .map_err(|e| log::warn!("unterm: md block set_language failed: {e}")) + .ok()?; + let mut inline_parser = Parser::new(); + inline_parser + .set_language(&cfg.inline_lang) + .map_err(|e| log::warn!("unterm: md inline set_language failed: {e}")) + .ok()?; + return Some(Highlighter::Md { + cfg, + block_parser, + inline_parser, + block_tree: None, + prev: String::new(), + }); + } let cfg = lang_config(lang_id)?; let mut parser = Parser::new(); parser .set_language(&cfg.language) .map_err(|e| log::warn!("unterm: set_language failed: {e}")) .ok()?; - Some(Highlighter { cfg, parser, tree: None, prev: String::new() }) + Some(Highlighter::Ts { cfg, parser, tree: None, prev: String::new() }) } /// (Re)highlight `text` into per-logical-line colored spans (split on `\n`, /// aligned 1:1 with the lines `cosmic_text` holds). Reuses the previous parse /// tree via an incremental edit when the text changed since the last call. pub fn highlight(&mut self, text: &str, dark: bool) -> Vec { - if let Some(tree) = self.tree.as_mut() { - if let Some(edit) = text_edit(&self.prev, text) { - tree.edit(&edit); + match self { + Highlighter::Ts { cfg, parser, tree, prev } => { + if let Some(t) = tree.as_mut() { + if let Some(edit) = text_edit(prev, text) { + t.edit(&edit); + } + } + *tree = parser.parse(text, tree.as_ref()); + prev.clear(); + prev.push_str(text); + let line_start = build_line_starts(text); + let mut out = empty_line_spans(line_start.len()); + if let Some(t) = tree.as_ref() { + run_query_into(&cfg.query, &cfg.cap_color, t, text, &line_start, dark, &mut out); + } + out + } + Highlighter::Md { cfg, block_parser, inline_parser, block_tree, prev } => { + if let Some(t) = block_tree.as_mut() { + if let Some(edit) = text_edit(prev, text) { + t.edit(&edit); + } + } + *block_tree = block_parser.parse(text, block_tree.as_ref()); + prev.clear(); + prev.push_str(text); + let line_start = build_line_starts(text); + let mut out = empty_line_spans(line_start.len()); + if let Some(bt) = block_tree.as_ref() { + // Block structure first (headings, fenced code, list markers, link + // labels), then the inline grammar over each `inline` node's byte + // range so the more specific inline spans (emphasis, code, links) + // win any overlap. Inline nodes carry raw text the block grammar + // leaves unparsed; we reparse each range via included-ranges (the + // resulting node offsets stay in document coordinates). + run_query_into(&cfg.block_q, &cfg.block_cap, bt, text, &line_start, dark, &mut out); + let mut ranges = Vec::new(); + collect_inline_ranges(bt.root_node(), &mut ranges); + for r in ranges { + if inline_parser.set_included_ranges(&[r]).is_err() { + continue; + } + if let Some(it) = inline_parser.parse(text, None) { + run_query_into(&cfg.inline_q, &cfg.inline_cap, &it, text, &line_start, dark, &mut out); + } + } + } + out } } - self.tree = self.parser.parse(text, self.tree.as_ref()); - self.prev.clear(); - self.prev.push_str(text); - match self.tree.as_ref() { - Some(tree) => spans_from_tree(self.cfg, tree, text, dark), - None => Vec::new(), - } } } -/// Run the highlights query over `tree` and clip each capture onto the logical -/// lines it touches. Later captures (more specific patterns) overwrite earlier -/// ones in any overlap (the caller applies the spans in order). -fn spans_from_tree(cfg: &LangConfig, tree: &Tree, text: &str, dark: bool) -> Vec { - // Byte offset of each logical line start (and an implicit end = text.len()). +/// Depth-first collect the byte ranges of every `inline` node in a Markdown block +/// tree. An `inline` node is a leaf of the block grammar carrying raw text that +/// the inline grammar re-parses (emphasis, code spans, links), so recursion stops +/// at one. +fn collect_inline_ranges(node: tree_sitter::Node, out: &mut Vec) { + if node.kind() == "inline" { + out.push(node.range()); + return; + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + collect_inline_ranges(child, out); + } +} + +/// Byte offset of each logical line start (line 0 begins at 0); the implicit end +/// of the last line is `text.len()`. +fn build_line_starts(text: &str) -> Vec { let mut line_start = vec![0usize]; for (i, b) in text.bytes().enumerate() { if b == b'\n' { line_start.push(i + 1); } } + line_start +} + +/// One empty [`LineSpans`] per logical line. +fn empty_line_spans(n_lines: usize) -> Vec { + (0..n_lines).map(|_| LineSpans { spans: Vec::new() }).collect() +} + +/// Run `query` over `tree` and clip each capture onto the logical lines it +/// touches, appending into `out` (pre-sized to the line count). Later captures +/// (more specific patterns, and later trees) overwrite earlier ones in any +/// overlap, since the caller applies the spans in order. +fn run_query_into( + query: &Query, + cap_color: &[Option], + tree: &Tree, + text: &str, + line_start: &[usize], + dark: bool, + out: &mut [LineSpans], +) { let n_lines = line_start.len(); - let mut out: Vec = (0..n_lines).map(|_| LineSpans { spans: Vec::new() }).collect(); let content_end = |line: usize| -> usize { if line + 1 < n_lines { line_start[line + 1] - 1 @@ -217,10 +372,10 @@ fn spans_from_tree(cfg: &LangConfig, tree: &Tree, text: &str, dark: bool) -> Vec }; let mut cursor = QueryCursor::new(); - let mut caps = cursor.captures(&cfg.query, tree.root_node(), text.as_bytes()); + let mut caps = cursor.captures(query, tree.root_node(), text.as_bytes()); while let Some((m, ci)) = caps.next() { let cap = m.captures[*ci]; - let Some(Some(idx)) = cfg.cap_color.get(cap.index as usize) else { continue }; + let Some(Some(idx)) = cap_color.get(cap.index as usize) else { continue }; let color = color_for(*idx, dark); let (start, end) = (cap.node.start_byte(), cap.node.end_byte()); let mut line = match line_start.binary_search(&start) { @@ -238,7 +393,6 @@ fn spans_from_tree(cfg: &LangConfig, tree: &Tree, text: &str, dark: bool) -> Vec line += 1; } } - out } /// A single [`InputEdit`] describing the net change between `old` and `new` as a @@ -336,4 +490,26 @@ mod tests { fn unknown_language_is_none() { assert!(Highlighter::new("no-such-lang").is_none()); } + + #[test] + fn markdown_highlights_heading_and_inline() { + // Heading (block grammar) on line 0; **bold** + `code` (inline grammar) on + // line 2 — exercises the block+inline coordination. + let src = "# Title\n\nSome **bold** and `code` here.\n"; + let mut hl = Highlighter::new("md").expect("markdown should highlight"); + let lines = hl.highlight(src, true); + assert_well_formed(src, &lines); + // The heading line gets colored spans (the `# ` marker and/or the title text). + assert!(!lines[0].spans.is_empty(), "heading line had no spans"); + // The inline line gets spans from the inline grammar (bold + code span). + assert!(lines[2].spans.len() >= 2, "inline spans: {}", lines[2].spans.len()); + // An incremental edit must match a from-scratch parse. + let src2 = "# Title!\n\nSome **bold** and `code` here.\n"; + let inc = hl.highlight(src2, true); + let fresh = Highlighter::new("md").unwrap().highlight(src2, true); + assert_eq!(inc.len(), fresh.len()); + for (a, f) in inc.iter().zip(fresh.iter()) { + assert_eq!(a.spans.len(), f.spans.len(), "line span-count mismatch after edit"); + } + } } diff --git a/native/unterm/src/input.rs b/native/unterm/src/input.rs index 5d8a72b..a767242 100644 --- a/native/unterm/src/input.rs +++ b/native/unterm/src/input.rs @@ -110,6 +110,18 @@ pub struct InputBox { /// Whether the host window owns keyboard focus. The caret is only drawn while /// focused, so an unfocused input box (background editor window) shows no caret. focused: bool, + /// Debugger breakpoint lines (0-based), shown as gutter dots in code mode. + breakpoints: std::collections::HashSet, + /// Whether the gutter reserves a breakpoint-dot column left of the line numbers + /// (only when debugging is enabled — otherwise the gutter stays at its plain + /// line-number width and no dots are drawn). + bp_gutter: bool, + /// Read-only mode: caret movement, selection, copy, scroll and find still work, + /// but text mutations are ignored (used by the debugger's shared source view). + read_only: bool, + /// The debugger's current execution line (0-based), highlighted amber. Distinct + /// from the caret's current-line highlight in `code_mode`. + exec_line: Option, /// Code-editor behaviors: no word-wrap (+ horizontal scroll), auto-indent, /// auto-close brackets, current-line + matching-bracket highlight, smart Home. code_mode: bool, @@ -238,6 +250,10 @@ impl InputBox { gutter_px: 0.0, gutter_cache: GutterCache::default(), focused: true, + breakpoints: std::collections::HashSet::new(), + bp_gutter: false, + read_only: false, + exec_line: None, code_mode: false, scroll_h: 0.0, caret_dirty: true, @@ -555,6 +571,63 @@ impl InputBox { } } + /// Gutter width (physical px) from the last render, for hit-testing clicks. + pub fn gutter_width(&self) -> f32 { + self.gutter_px + } + + /// The 0-based logical line nearest physical y (clamped to the last line; for + /// gutter-click breakpoint toggles, which snap to a line even past text end). + pub fn line_at_y_clamped(&self, y: f32) -> usize { + let pad = PAD * self.scale; + let by = (y - pad).max(0.0); + let mut line = 0usize; + self.editor.with_buffer(|buf| { + for run in buf.layout_runs() { + if run.line_top <= by { + line = run.line_i; + } + } + }); + line + } + + /// Toggle a breakpoint on a 0-based line. + pub fn toggle_breakpoint(&mut self, line: usize) { + if !self.breakpoints.remove(&line) { + self.breakpoints.insert(line); + } + } + + /// Replace the breakpoint set (0-based lines). + pub fn set_breakpoints(&mut self, lines: &[u32]) { + self.breakpoints = lines.iter().map(|&l| l as usize).collect(); + } + + /// Current breakpoint lines (0-based), ascending. + #[allow(dead_code)] // host (C#) is the source of truth; kept for tests/future query + pub fn breakpoints(&self) -> Vec { + let mut v: Vec = self.breakpoints.iter().map(|&l| l as u32).collect(); + v.sort_unstable(); + v + } + + /// Read-only mode: caret/selection/scroll/find still work; text edits are ignored. + pub fn set_read_only(&mut self, on: bool) { + self.read_only = on; + } + + /// Reserve (and draw) the breakpoint-dot column in the gutter. Off = plain + /// line-number gutter at its usual width, with no dots. + pub fn set_bp_gutter(&mut self, on: bool) { + self.bp_gutter = on; + } + + /// Highlight a 0-based execution line (the debugger's current line), or clear it. + pub fn set_exec_line(&mut self, line: Option) { + self.exec_line = line; + } + /// Scroll vertically by `dy` physical px (wheel). Clamped on the next render. pub fn scroll_by(&mut self, dy: f32) { self.scroll_v = (self.scroll_v + dy).max(0.0); @@ -1164,6 +1237,12 @@ impl InputBox { self.shared.raw_texture() } + /// The wgpu texture view the editor renders into (for in-process compositing, + /// e.g. the debugger showing this as an egui image via `register_native_texture`). + pub fn texture_view(&self) -> &wgpu::TextureView { + self.shared.view() + } + pub fn resize(&mut self, width: u32, height: u32) { let width = width.max(1); let height = height.max(1); @@ -1227,7 +1306,7 @@ impl InputBox { /// Insert typed or IME-committed text at the caret. Shaping happens on the /// next `render` via `shape_as_needed`, so no font system is needed here. pub fn insert(&mut self, text: &str) { - if text.is_empty() { + if text.is_empty() || self.read_only { return; } // A real insert (e.g. IME commit) replaces any in-progress composition. @@ -1340,6 +1419,22 @@ impl InputBox { pub fn key(&mut self, name: &str, _ctrl: bool, _alt: bool, shift: bool) { self.caret_dirty = true; // a key moves the caret → scroll to keep it visible + // Read-only (debugger source view): drop text-mutating keys, keep navigation. + if self.read_only + && matches!( + name, + "DeleteWordBack" + | "DeleteWordForward" + | "Return" + | "KeypadEnter" + | "Backspace" + | "Delete" + | "DeleteToLineStart" + ) + { + return; + } + // Code-aware word motion/deletion (`.` is a boundary except in floats). match name { "WordLeft" => { @@ -1448,6 +1543,30 @@ impl InputBox { } } + /// The (line text, character column) at a physical-px point, or None over the + /// gutter. Non-destructive: it hit-tests via a click then restores the caret and + /// selection, so it can drive hover tooltips without moving the cursor. + pub fn pos_at_pixel(&mut self, x: f32, y: f32) -> Option<(String, usize)> { + if x < self.gutter_px { + return None; + } + let pad = PAD * self.scale; + let bx = (x - pad - self.gutter_px).round() as i32; + let by = (y - pad).round() as i32; + let saved_cursor = self.editor.cursor(); + let saved_sel = self.editor.selection(); + { + let mut fs = gpu::lock_font_system(); + self.editor.action(&mut fs, Action::Click { x: bx, y: by }); + } + let cur = self.editor.cursor(); + self.editor.set_cursor(saved_cursor); + self.editor.set_selection(saved_sel); + let line = self.line_text(cur.line); + let col = byte_to_col(&line, cur.index); + Some((line, col)) + } + /// Mouse interaction at physical px relative to the box: kind 0 = click (place /// caret), 1 = drag (extend selection), 2 = double-click (word), 3 = triple /// (line). @@ -1521,7 +1640,7 @@ impl InputBox { /// string removes it (on commit/cancel). The caret sits at its end. pub fn set_preedit(&mut self, text: &str) { self.clear_preedit(); - if text.is_empty() { + if text.is_empty() || self.read_only { return; } self.preedit_anchor = Some(self.editor.cursor()); @@ -1579,13 +1698,19 @@ impl InputBox { // Code-editor gutter: width from the logical line count, shifting the text // area right by `gutter_w`. Zero (and no inset) when the gutter is off. + // With the breakpoint column enabled, a dot-sized strip is reserved LEFT of + // the numbers so dots and digits never overlap; without it the gutter stays + // at its plain line-number width. let line_count = self.editor.with_buffer(|b| b.lines.len()).max(1); + let bp_dot = (line_height * 0.52).min(font_size); + let bp_col = if self.bp_gutter { bp_dot + pad * 0.45 } else { 0.0 }; let gutter_w = if self.gutter { let digits = ((line_count as f32).log10().floor() as usize + 1).max(2); - // digits + a right gap for the numbers + a left lane for the diff markers. - // The whole gutter is the click target for the peek, so the marker lane - // also gives that a comfortable width. - (digits as f32 * font_size * 0.6 + pad * 2.0 + 8.0 * s).ceil() + // digits + a right gap for the numbers + a left lane for the diff markers + // (+ a breakpoint-dot column between the markers and the numbers, when + // debugging is enabled). The whole gutter is the click target for the + // peek, so the marker lane also gives that a comfortable width. + (digits as f32 * font_size * 0.6 + pad * 2.0 + 8.0 * s + bp_col).ceil() } else { 0.0 }; @@ -1754,6 +1879,29 @@ impl InputBox { } } + // Debugger execution line: a full-width amber band behind the text. + if let Some(el) = self.exec_line { + let mut top: Option = None; + self.editor.with_buffer(|buf| { + for run in buf.layout_runs() { + if run.line_i == el { + top = Some(run.line_top); + break; + } + } + }); + if let Some(t) = top { + quads.push(Quad { + x: 0.0, + y: pad + t, + w: width, + h: line_height, + color: [0.902, 0.706, 0.251, 0.20], + radius: 0.0, + }); + } + } + // Selection highlight (behind the text). if let Some((c1, c2)) = self.editor.selection_bounds() { self.editor.with_buffer(|buf| { @@ -1958,6 +2106,25 @@ impl InputBox { } } + // Breakpoint dots: always drawn when set (the caller only sets breakpoints + // when debugging is on). Placed to the RIGHT of the diff-marker lane — + // `bp_gutter` reserves their own column so dots never sit on the numbers, + // but even without it a set breakpoint stays visible. + if !self.breakpoints.is_empty() { + let d = bp_dot; + for (li, top) in &tops { + if self.breakpoints.contains(li) { + quads.push(Quad { + x: 8.0 * s + pad * 0.45, + y: pad + *top + (line_height - d) * 0.5, + w: d, + h: d, + color: [0.85, 0.16, 0.16, 1.0], + radius: d * 0.5, + }); + } + } + } let nc = self.text_color; let key = { let mut h = std::collections::hash_map::DefaultHasher::new(); diff --git a/native/unterm/src/lib.rs b/native/unterm/src/lib.rs index 996f8da..dc07ca5 100644 --- a/native/unterm/src/lib.rs +++ b/native/unterm/src/lib.rs @@ -18,6 +18,7 @@ mod agentview; mod browser; mod clock; mod control; +pub mod debugger; mod diff; mod editops; mod editorview; @@ -36,6 +37,7 @@ mod popup; mod pty; mod quads; mod renderer; +pub mod sdb; mod sessions; mod shell; #[cfg(any(target_os = "macos", windows))] @@ -1605,6 +1607,34 @@ pub extern "C" fn unterm_editor_refresh_diff(id: u64) { with_editor(id, (), |e| e.refresh_diff()); } +/// Toggle Markdown-preview mode: while on, `render`/`raw_texture`/scroll/mouse/ +/// copy operate on a rendered-Markdown view of the live buffer (read-only). +#[no_mangle] +pub extern "C" fn unterm_editor_set_preview(id: u64, on: bool) { + with_editor(id, (), |e| e.set_preview(on)); +} + +/// Whether Markdown-preview mode is currently on. +#[no_mangle] +pub extern "C" fn unterm_editor_preview_active(id: u64) -> bool { + with_editor(id, false, |e| e.preview_active()) +} + +/// The existing-file path token under (x, y) in preview mode (for click-to-open), +/// or an empty string. The pointer is valid until the next call on this editor. +/// +/// # Safety +/// `out_len` must be a valid pointer or null. +#[no_mangle] +pub unsafe extern "C" fn unterm_editor_preview_token_at( + id: u64, + x: f32, + y: f32, + out_len: *mut usize, +) -> *const c_char { + editor_string(id, out_len, |e| e.preview_token_at(x, y)) +} + /// Apply any finished background git fetch; returns true when new diff markers were /// applied (the host should re-render). Cheap to poll each editor tick. #[no_mangle] @@ -1842,6 +1872,43 @@ pub extern "C" fn unterm_editor_revert_hunk(id: u64, hunk_i: u32) { with_editor(id, (), |e| e.revert_hunk(hunk_i as usize)); } +/// Gutter width (physical px) from the last render, so the host can tell whether a +/// click landed in the gutter (breakpoint column) vs. the text area. +#[no_mangle] +pub extern "C" fn unterm_editor_gutter_width(id: u64) -> f32 { + with_editor(id, 0.0, |e| e.gutter_width()) +} + +/// The 0-based source line at physical y (for mapping a gutter click to a line). +#[no_mangle] +pub extern "C" fn unterm_editor_line_at_y(id: u64, y: f32) -> u32 { + with_editor(id, 0, |e| e.line_at_y(y) as u32) +} + +/// Toggle a breakpoint dot on a 0-based line. +#[no_mangle] +pub extern "C" fn unterm_editor_toggle_breakpoint(id: u64, line: u32) { + with_editor(id, (), |e| e.toggle_breakpoint(line as usize)); +} + +/// Reserve (and draw) the gutter's breakpoint-dot column — on when debugging is +/// enabled in the editor preferences, off for a plain line-number gutter. +#[no_mangle] +pub extern "C" fn unterm_editor_set_bp_gutter(id: u64, on: bool) { + with_editor(id, (), |e| e.set_bp_gutter(on)); +} + +/// Replace the breakpoint set (0-based lines), e.g. to restore on file open. +#[no_mangle] +pub unsafe extern "C" fn unterm_editor_set_breakpoints(id: u64, lines: *const u32, count: usize) { + let slice = if lines.is_null() || count == 0 { + &[][..] + } else { + unsafe { std::slice::from_raw_parts(lines, count) } + }; + with_editor(id, (), |e| e.set_breakpoints(slice)); +} + /// Scroll vertically by `dy` physical px (mouse wheel). #[no_mangle] pub extern "C" fn unterm_editor_scroll(id: u64, dy: f32) { diff --git a/native/unterm/src/panel.rs b/native/unterm/src/panel.rs index 123ef04..3cbc699 100644 --- a/native/unterm/src/panel.rs +++ b/native/unterm/src/panel.rs @@ -257,8 +257,13 @@ pub struct PanelRenderer { /// HiDPI factor: the panel renders at physical pixels and scales all sizes /// by this so text is crisp (no upscaling blur) on Retina displays. scale: f32, - /// Vertical scroll offset in physical px (0 = bottom-anchored / latest). + /// Vertical scroll offset in physical px. Chat: 0 = bottom-anchored (latest). + /// Document mode: 0 = top, increasing reveals content below. scroll: f32, + /// Document-viewer mode: content is TOP-anchored (a Markdown file read + /// top-down) rather than the chat's bottom-anchored transcript. No buttons / + /// tools / plan box / stamps appear for a plain file, so those paths stay inert. + document: bool, /// Laid-out content height in physical px (for the host's scrollbar). content_h: f32, /// Action buttons (e.g. permission options) drawn pinned at the bottom. @@ -290,6 +295,14 @@ pub struct PanelRenderer { /// resolves to an existing file here, so the underline matches what a click /// can actually open. root: std::path::PathBuf, + /// Memoized `token_is_file` results. The underline pass stats every + /// whitespace token in the transcript, so without this a re-render during + /// streaming would hit the filesystem thousands of times per frame. Cleared + /// when the block count changes (new message — files mentioned mid-stream may + /// exist by then) or the root changes. + link_stat_cache: std::cell::RefCell>, + /// Block count `link_stat_cache` was filled at (invalidation key). + link_stat_blocks: usize, /// Hash of every input the last fully-submitted frame was laid out from /// (text, geometry, scroll, selection, fold state, fonts, colors, target). @@ -348,11 +361,14 @@ impl PanelRenderer { font_bold_italic: None, scale: 1.0, scroll: 0.0, + document: false, content_h: 0.0, plan_scroll: 0.0, plan_max: 0.0, plan_rect: None, root: std::path::PathBuf::new(), + link_stat_cache: std::cell::RefCell::new(HashMap::new()), + link_stat_blocks: 0, buttons: Vec::new(), button_rects: Vec::new(), laid: Vec::new(), @@ -407,18 +423,26 @@ impl PanelRenderer { /// used to gate the clickable-path underline on the file actually existing. pub fn set_root(&mut self, root: std::path::PathBuf) { self.root = root; + self.link_stat_cache.borrow_mut().clear(); } /// Whether `tok` resolves (against `self.root`) to an existing file — i.e. a - /// path a click could actually open. + /// path a click could actually open. Memoized in `link_stat_cache`. fn token_is_file(&self, tok: &str) -> bool { + if let Some(&hit) = self.link_stat_cache.borrow().get(tok) { + return hit; + } let p = std::path::Path::new(tok); let full = if p.is_absolute() { p.to_path_buf() } else { self.root.join(p) }; - full.is_file() + let is_file = full.is_file(); + self.link_stat_cache + .borrow_mut() + .insert(tok.to_string(), is_file); + is_file } /// Scroll offset in physical px (0 = bottom). Clamped during layout. @@ -426,6 +450,12 @@ impl PanelRenderer { self.scroll = scroll.max(0.0); } + /// Document-viewer mode: top-anchor the content (0 = top) instead of the + /// chat transcript's bottom anchor. Used by the code editor's Markdown preview. + pub fn set_document(&mut self, on: bool) { + self.document = on; + } + /// Total laid-out content height in physical px (from the last render). pub fn content_height(&self) -> f32 { self.content_h @@ -614,10 +644,11 @@ impl PanelRenderer { quads } - /// Dotted-underline quads (physical px) under tokens that look like an - /// openable file path, so the user can see the path is clickable. The token - /// test mirrors the host's `IsEditable` (ends in a known extension) so the - /// underline marks exactly what a click opens. Fenced code blocks are skipped + /// Dotted-underline quads (physical px) under every token that resolves to an + /// existing file, so the user can see the path is clickable. Existence is the + /// only gate — the host routes a click through the configured script editor + /// and falls back to Unity (`AssetDatabase.OpenAsset` / the OS default app), + /// so anything real is openable somewhere. Fenced code blocks are skipped /// (their content isn't a clickable reference). fn link_underline_quads(&self) -> Vec { let s = self.scale; @@ -631,7 +662,7 @@ impl PanelRenderer { if blk.code_key.is_some() { continue; } - let ranges: Vec<_> = path_token_ranges(&blk.text) + let ranges: Vec<_> = token_ranges(&blk.text) .into_iter() .filter(|r| self.token_is_file(&blk.text[r.start..r.end])) .collect(); @@ -1070,7 +1101,12 @@ impl PanelRenderer { + buttons_h; self.content_h = total + pad * 2.0; let viewport_h = content_bottom - pad; - let mut y = if total <= viewport_h { + let mut y = if self.document { + // Document viewer: top-anchored. `scroll` (0 = top) reveals content + // below; clamped so the last line can't scroll past the bottom. + let max_scroll = (total - viewport_h).max(0.0); + pad - self.scroll.min(max_scroll) + } else if total <= viewport_h { pad } else { let max_scroll = total - viewport_h; @@ -1269,6 +1305,12 @@ impl PanelRenderer { quads.extend(self.selection_quads(overlay)); // Dotted underline under clickable file-path tokens (below the text). + // A new block means new content whose paths may exist by now (files are + // usually created before they're mentioned, but not mid-stream) — re-stat. + if self.laid.len() != self.link_stat_blocks { + self.link_stat_cache.borrow_mut().clear(); + self.link_stat_blocks = self.laid.len(); + } quads.extend(self.link_underline_quads()); // Action buttons: placed right after the last block (scrolling with the @@ -1960,19 +2002,12 @@ fn line_starts(buffer: &Buffer) -> Vec { starts } -/// Editable extensions the host (`UntermCodeEditorWindow.IsEditable`) will open. -/// Kept in sync so the dotted underline marks exactly the tokens a click opens. -const PATH_EXTS: &[&str] = &[ - ".cs", ".txt", ".json", ".xml", ".uxml", ".uss", ".shader", ".cginc", ".hlsl", ".compute", ".md", - ".markdown", ".yml", ".yaml", ".js", ".ts", ".py", ".rs", ".toml", ".csv", ".log", ".asmdef", - ".asmref", -]; - -/// Byte ranges (into `text`) of whitespace-delimited tokens that look like an -/// openable file path: trimmed of the same surrounding brackets/quotes as -/// `token_at`, and ending in a known editable extension. Used to dotted-underline -/// clickable paths in the transcript. -fn path_token_ranges(text: &str) -> Vec> { +/// Byte ranges (into `text`) of whitespace-delimited tokens, trimmed of the same +/// surrounding brackets/quotes as `token_at`. The caller filters these down to +/// tokens that resolve to existing files (`token_is_file`) to dotted-underline +/// clickable paths in the transcript — existence is the only gate; which files +/// actually open in what is the host's (and Unity's) decision. +fn token_ranges(text: &str) -> Vec> { let bytes = text.as_bytes(); let n = bytes.len(); let trim = |c: u8| b"()[]{}<>,;:\"'`".contains(&c); @@ -1996,10 +2031,7 @@ fn path_token_ranges(text: &str) -> Vec> { if s >= e { continue; } - let lower = text[s..e].to_ascii_lowercase(); - if PATH_EXTS.iter().any(|ext| lower.ends_with(ext)) { - out.push(s..e); - } + out.push(s..e); } out } diff --git a/native/unterm/src/sdb/mod.rs b/native/unterm/src/sdb/mod.rs new file mode 100644 index 0000000..6f65017 --- /dev/null +++ b/native/unterm/src/sdb/mod.rs @@ -0,0 +1,1017 @@ +//! Pure-Rust Mono soft-debugger (SDB) client. +//! +//! Connects to a Unity editor or player's Mono debugger agent over TCP, speaks the +//! debugger wire protocol directly (no .NET runtime, no PDB/mdb parser), and resolves +//! source breakpoints over the wire via `GET_DEBUG_INFO` sequence points. +//! +//! The agent pushes unsolicited composite events (it sends `VM_START` the instant we +//! connect), so the reader always demultiplexes replies from agent commands; events +//! seen while waiting for a reply are queued and drained by the caller. + +#![allow(dead_code)] + +pub mod value; +pub mod wire; + +use std::collections::VecDeque; +use std::io::Write; +use std::net::TcpStream; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use wire::{cs, kind, vm, Decoder, Encoder, Packet, Result, SdbError}; + +/// Negotiated protocol/runtime version (e.g. "mono 6.13.0", 2.58 on Unity 6). +#[derive(Debug, Clone)] +pub struct Version { + pub vm: String, + pub major: i32, + pub minor: i32, +} + +/// One decoded event out of a composite event packet. +#[derive(Debug, Clone, Default)] +pub struct Event { + pub kind: u8, + pub request_id: i32, + pub thread: u32, + /// For BREAKPOINT/STEP/METHOD_*: the method id. + pub method: u32, + /// For BREAKPOINT/STEP: the IL offset. + pub location: i64, + /// For TYPE_LOAD: the loaded type id. + pub type_id: u32, + /// For ASSEMBLY_LOAD/UNLOAD: the assembly id. + pub assembly: u32, + /// For APPDOMAIN_CREATE/UNLOAD: the domain id. + pub domain: u32, +} + +/// A composite event: a suspend policy plus one or more events. +#[derive(Debug, Clone)] +pub struct Composite { + pub suspend_policy: u8, + pub events: Vec, +} + +/// Mono's marker line for compiler-generated ("hidden") code; never a real stop. +pub const HIDDEN_LINE: i32 = 0xFEEFEE; + +/// One sequence point: an IL offset mapped to a source location. +#[derive(Debug, Clone)] +pub struct SeqPoint { + pub il_offset: i32, + pub line: i32, + pub source_idx: i32, + pub column: i32, + pub end_line: i32, + pub end_column: i32, +} + +impl SeqPoint { + pub fn is_hidden(&self) -> bool { + self.line == HIDDEN_LINE + } +} + +/// A method's debug info: its source files and the sequence-point table. +#[derive(Debug, Clone)] +pub struct DebugInfo { + pub max_il: i32, + pub sources: Vec, + pub seq_points: Vec, +} + +/// One call-stack frame. +#[derive(Debug, Clone)] +pub struct Frame { + pub id: i32, + pub method: u32, + pub il_offset: i32, + pub flags: u8, +} + +/// A method local variable's metadata. +#[derive(Debug, Clone)] +pub struct Local { + pub type_id: u32, + pub name: String, + pub live_start: i32, + pub live_end: i32, +} + +/// A type's field metadata. +#[derive(Debug, Clone)] +pub struct Field { + pub id: u32, + pub name: String, + pub type_id: u32, + pub attrs: i32, +} + +impl Field { + /// FieldAttributes.Static (0x10) — skip these for instance (`this`) values. + pub fn is_static(&self) -> bool { + self.attrs & 0x10 != 0 + } +} + +/// A modifier attached to an event request. +pub enum Modifier { + /// Restrict a breakpoint to a method + IL offset. + LocationOnly { method: u32, il: i64 }, + /// Restrict TYPE_LOAD events to types declared in these source files. + SourceFileOnly(Vec), + /// Configure a single-step. + Step { + thread: u32, + size: i32, + depth: i32, + filter: i32, + }, +} + +/// A live SDB connection. +pub struct Connection { + stream: TcpStream, + next_id: u32, + queued: VecDeque, + version: Option, +} + +impl Connection { + /// Wrap an already-connected stream and perform the handshake. + pub fn from_stream(stream: TcpStream) -> Result { + let mut c = Connection { + stream, + next_id: 0, + queued: VecDeque::new(), + version: None, + }; + c.handshake()?; + c.version = Some(c.query_version()?); + Ok(c) + } + + fn handshake(&mut self) -> Result<()> { + self.stream.write_all(wire::HANDSHAKE)?; + self.stream.flush()?; + // Bound the handshake read so a second debugger connecting to an agent that's + // already serving one (the agent accepts the TCP but never replies) fails fast + // instead of hanging forever on "connecting". + self.stream + .set_read_timeout(Some(std::time::Duration::from_secs(3))) + .ok(); + let mut buf = [0u8; wire::HANDSHAKE.len()]; + use std::io::Read; + let mut got = 0; + while got < buf.len() { + let n = self.stream.read(&mut buf[got..])?; + if n == 0 { + return Err(SdbError::Eof); + } + got += n; + } + self.stream.set_read_timeout(None).ok(); + if buf != wire::HANDSHAKE { + return Err(SdbError::Protocol("bad handshake reply".into())); + } + Ok(()) + } + + /// The negotiated version (always set after construction). + pub fn version(&self) -> &Version { + self.version.as_ref().expect("version queried at connect") + } + + /// Send a command and block until its reply, queueing any events that arrive first. + /// Returns the reply payload, or `SdbError::Agent` for a non-zero error code. + pub fn request(&mut self, cmd_set: u8, cmd: u8, payload: &[u8]) -> Result> { + self.next_id = self.next_id.wrapping_add(1); + let id = self.next_id; + wire::write_command(&mut self.stream, id, cmd_set, cmd, payload)?; + loop { + match wire::read_packet(&mut self.stream)? { + Packet::Reply { + id: rid, + error, + data, + } if rid == id => { + if error != 0 { + return Err(SdbError::Agent(error)); + } + return Ok(data); + } + Packet::Reply { id: rid, .. } => { + return Err(SdbError::Protocol(format!( + "reply id {rid} != expected {id}" + ))); + } + Packet::Command { cmd_set, cmd, data, .. } => { + self.absorb_command(cmd_set, cmd, &data); + } + } + } + } + + /// Block for the next composite event (draining the queue first). Set a read + /// timeout via [`set_read_timeout`] to bound the wait. + pub fn next_event(&mut self) -> Result { + if let Some(ev) = self.queued.pop_front() { + return Ok(ev); + } + loop { + match wire::read_packet(&mut self.stream)? { + Packet::Command { cmd_set, cmd, data, .. } => { + if let Some(ev) = decode_command(cmd_set, cmd, &data) { + return Ok(ev); + } + } + Packet::Reply { id, .. } => { + return Err(SdbError::Protocol(format!( + "unexpected reply id {id} while awaiting event" + ))); + } + } + } + } + + /// Pop a queued event without blocking, if one was buffered during a `request`. + pub fn take_queued(&mut self) -> Option { + self.queued.pop_front() + } + + fn absorb_command(&mut self, cmd_set: u8, cmd: u8, data: &[u8]) { + if let Some(ev) = decode_command(cmd_set, cmd, data) { + self.queued.push_back(ev); + } + } + + pub fn set_read_timeout(&self, t: Option) -> Result<()> { + self.stream.set_read_timeout(t)?; + Ok(()) + } + + // --- typed VM helpers --- + + fn query_version(&mut self) -> Result { + let data = self.request(cs::VM, vm::VERSION, &[])?; + let mut d = Decoder::new(&data); + let vm = d.string()?; + let major = d.int()?; + let minor = d.int()?; + Ok(Version { vm, major, minor }) + } + + /// All managed thread ids. + pub fn all_threads(&mut self) -> Result> { + let data = self.request(cs::VM, vm::ALL_THREADS, &[])?; + let mut d = Decoder::new(&data); + let n = d.uint()? as usize; + (0..n).map(|_| d.id()).collect() + } + + pub fn resume(&mut self) -> Result<()> { + self.request(cs::VM, vm::RESUME, &[]).map(|_| ()) + } + + pub fn suspend(&mut self) -> Result<()> { + self.request(cs::VM, vm::SUSPEND, &[]).map(|_| ()) + } + + /// Root AppDomain id. Note: on Unity this domain holds only mscorlib; user + /// assemblies live in a reloadable child domain surfaced via load events. + pub fn root_domain(&mut self) -> Result { + let data = self.request(cs::APPDOMAIN, wire::appdomain::GET_ROOT_DOMAIN, &[])?; + Decoder::new(&data).id() + } + + /// Assembly ids loaded in a domain. + pub fn domain_assemblies(&mut self, domain: u32) -> Result> { + let payload = Encoder::new().id(domain).finish(); + let data = self.request(cs::APPDOMAIN, wire::appdomain::GET_ASSEMBLIES, &payload)?; + let mut d = Decoder::new(&data); + let n = d.uint()? as usize; + (0..n).map(|_| d.id()).collect() + } + + pub fn assembly_name(&mut self, assembly: u32) -> Result { + let payload = Encoder::new().id(assembly).finish(); + let data = self.request(cs::ASSEMBLY, wire::assembly::GET_NAME, &payload)?; + Decoder::new(&data).string() + } + + // --- type / method introspection --- + + /// Type ids declaring code in `file`. Returns empty until those types are loaded + /// (on Unity, user types load into a child domain at play time — prefer the + /// TYPE_LOAD event path for arming pending breakpoints). + pub fn types_for_source_file(&mut self, file: &str, ignore_case: bool) -> Result> { + let payload = Encoder::new() + .string(file) + .byte(ignore_case as u8) + .finish(); + let data = self.request(cs::VM, vm::GET_TYPES_FOR_SOURCE_FILE, &payload)?; + let mut d = Decoder::new(&data); + let n = d.uint()? as usize; + (0..n).map(|_| d.id()).collect() + } + + /// Resolve a type by name within an assembly (`GET_TYPE`). Returns 0 if absent. + pub fn assembly_type(&mut self, assembly: u32, name: &str) -> Result { + let payload = Encoder::new().id(assembly).string(name).byte(0).finish(); + let data = self.request(cs::ASSEMBLY, wire::assembly::GET_TYPE, &payload)?; + Decoder::new(&data).id() + } + + pub fn type_methods(&mut self, type_id: u32) -> Result> { + let payload = Encoder::new().id(type_id).finish(); + let data = self.request(cs::TYPE, wire::ty::GET_METHODS, &payload)?; + let mut d = Decoder::new(&data); + let n = d.uint()? as usize; + (0..n).map(|_| d.id()).collect() + } + + pub fn method_name(&mut self, method: u32) -> Result { + let payload = Encoder::new().id(method).finish(); + let data = self.request(cs::METHOD, wire::method::GET_NAME, &payload)?; + Decoder::new(&data).string() + } + + /// The sequence-point table for a method (used to map file/line <-> IL offset). + /// + /// Unity's mono agent (verified on 6000.3.18f1 / mono 6.13.0 / protocol 2.58) + /// sends the *simple* debug-info format regardless of the reported version: + /// `int max_il`, a single length-prefixed source path, `int n_seq`, then + /// `n_seq` × (`int il_offset`, `int line`). There is no source-count prefix, + /// no source hash, no per-point source index, and no column/end fields. + pub fn debug_info(&mut self, method: u32) -> Result { + let payload = Encoder::new().id(method).finish(); + let data = self.request(cs::METHOD, wire::method::GET_DEBUG_INFO, &payload)?; + let mut d = Decoder::new(&data); + let max_il = d.int()?; + let source = d.string()?; + let nsp = d.uint()? as usize; + let mut seq_points = Vec::with_capacity(nsp); + for _ in 0..nsp { + let il_offset = d.int()?; + let line = d.int()?; + seq_points.push(SeqPoint { + il_offset, + line, + source_idx: 0, + column: 0, + end_line: line, + end_column: 0, + }); + } + Ok(DebugInfo { + max_il, + sources: vec![source], + seq_points, + }) + } + + /// Local variable metadata. Like `debug_info`, Unity's agent uses the simple + /// format (verified on mono 6.13.0 / 2.58): `int n`, `n` × type id, `n` × name + /// string, then `n` × (`int live_start`, `int live_end`). No scope-table prefix. + pub fn locals_info(&mut self, method: u32) -> Result> { + let payload = Encoder::new().id(method).finish(); + let data = self.request(cs::METHOD, wire::method::GET_LOCALS_INFO, &payload)?; + let mut d = Decoder::new(&data); + let n = d.uint()? as usize; + let types: Vec = (0..n).map(|_| d.id()).collect::>()?; + let names: Vec = (0..n).map(|_| d.string()).collect::>()?; + let mut locals = Vec::with_capacity(n); + for i in 0..n { + let live_start = d.int()?; + let live_end = d.int()?; + locals.push(Local { + type_id: types[i], + name: names[i].clone(), + live_start, + live_end, + }); + } + Ok(locals) + } + + // --- event requests --- + + /// Register an event request. Returns its request id (match it in events / pass + /// it to `clear_event`). + pub fn set_event(&mut self, kind: u8, suspend: u8, modifiers: &[Modifier]) -> Result { + let minor = self.version().minor; + let mut enc = Encoder::new(); + enc.byte(kind).byte(suspend).byte(modifiers.len() as u8); + for m in modifiers { + match m { + Modifier::LocationOnly { method, il } => { + enc.byte(wire::modifier::LOCATION_ONLY).id(*method).long(*il); + } + Modifier::SourceFileOnly(files) => { + enc.byte(wire::modifier::SOURCE_FILE_ONLY) + .int(files.len() as i32); + for f in files { + enc.string(f); + } + } + Modifier::Step { + thread, + size, + depth, + filter, + } => { + enc.byte(wire::modifier::STEP) + .id(*thread) + .int(*size) + .int(*depth); + if minor >= 16 { + enc.int(*filter); + } + } + } + } + let payload = enc.finish(); + let data = self.request(cs::EVENT_REQUEST, wire::evreq::SET, &payload)?; + Decoder::new(&data).int() + } + + pub fn clear_event(&mut self, kind: u8, request_id: i32) -> Result<()> { + let payload = Encoder::new().byte(kind).int(request_id).finish(); + self.request(cs::EVENT_REQUEST, wire::evreq::CLEAR, &payload) + .map(|_| ()) + } + + /// Subscribe to TYPE_LOAD events for types in the given source files. Suspends the + /// VM on load (policy ALL) so the caller can arm a breakpoint on the freshly-loaded + /// type BEFORE its methods run, then resume. Returns the request id. + pub fn watch_source_files(&mut self, files: &[String]) -> Result { + self.set_event( + kind::TYPE_LOAD, + wire::suspend::ALL, + &[Modifier::SourceFileOnly(files.to_vec())], + ) + } + + /// Set a breakpoint at a method + IL offset (suspend policy ALL). Returns its id. + pub fn set_breakpoint(&mut self, method: u32, il: i64) -> Result { + self.set_event( + kind::BREAKPOINT, + wire::suspend::ALL, + &[Modifier::LocationOnly { method, il }], + ) + } + + /// Arm a single step on `thread`. Returns the step request id (clear it after the + /// STEP event fires, or it keeps firing). + pub fn step(&mut self, thread: u32, size: i32, depth: i32) -> Result { + self.set_event( + kind::STEP, + wire::suspend::ALL, + &[Modifier::Step { + thread, + size, + depth, + filter: 0, + }], + ) + } + + // --- stop-state inspection --- + + /// A thread's name (empty for unnamed threads). + pub fn thread_name(&mut self, thread: u32) -> Result { + let payload = Encoder::new().id(thread).finish(); + let data = self.request(cs::THREAD, wire::thread::GET_NAME, &payload)?; + Decoder::new(&data).string() + } + + pub fn frames(&mut self, thread: u32) -> Result> { + let payload = Encoder::new().id(thread).int(0).int(-1).finish(); + let data = self.request(cs::THREAD, wire::thread::GET_FRAME_INFO, &payload)?; + let mut d = Decoder::new(&data); + let n = d.uint()? as usize; + let mut frames = Vec::with_capacity(n); + for _ in 0..n { + let id = d.int()?; + let method = d.id()?; + let il_offset = d.int()?; + let flags = d.byte()?; + frames.push(Frame { + id, + method, + il_offset, + flags, + }); + } + Ok(frames) + } + + pub fn frame_values( + &mut self, + thread: u32, + frame: i32, + positions: &[i32], + ) -> Result> { + let mut enc = Encoder::new(); + enc.id(thread).id(frame as u32).int(positions.len() as i32); + for p in positions { + enc.int(*p); + } + let payload = enc.finish(); + let data = self.request(cs::STACK_FRAME, wire::frame::GET_VALUES, &payload)?; + let mut d = Decoder::new(&data); + positions.iter().map(|_| value::decode_value(&mut d)).collect() + } + + pub fn frame_this(&mut self, thread: u32, frame: i32) -> Result { + let payload = Encoder::new().id(thread).id(frame as u32).finish(); + let data = self.request(cs::STACK_FRAME, wire::frame::GET_THIS, &payload)?; + value::decode_value(&mut Decoder::new(&data)) + } + + // --- object / field inspection --- + + /// The runtime type id of an object. + pub fn object_type(&mut self, obj: u32) -> Result { + let payload = Encoder::new().id(obj).finish(); + let data = self.request(cs::OBJECT_REF, wire::object::GET_TYPE, &payload)?; + Decoder::new(&data).id() + } + + /// The base (parent) type id of a type, or 0 for `System.Object`. From + /// `TYPE.GET_INFO`, whose stable prefix is: namespace, name, full-name (strings), + /// assembly, module, then the parent type id. + pub fn type_base(&mut self, type_id: u32) -> Result { + let payload = Encoder::new().id(type_id).finish(); + let data = self.request(cs::TYPE, wire::ty::GET_INFO, &payload)?; + let mut d = Decoder::new(&data); + let _ns = d.string()?; + let _name = d.string()?; + let _full = d.string()?; + let _assembly = d.id()?; + let _module = d.id()?; + d.id() // parent + } + + /// A type's declared fields (id, name, type, attrs). Verified layout on Unity: + /// `int count`, then per field `id`, `string name`, `id type`, `int attrs`. + pub fn type_fields(&mut self, type_id: u32) -> Result> { + let payload = Encoder::new().id(type_id).finish(); + let data = self.request(cs::TYPE, wire::ty::GET_FIELDS, &payload)?; + let mut d = Decoder::new(&data); + let n = d.uint()? as usize; + let mut fields = Vec::with_capacity(n); + for _ in 0..n { + let id = d.id()?; + let name = d.string()?; + let type_id = d.id()?; + let attrs = d.int()?; + fields.push(Field { + id, + name, + type_id, + attrs, + }); + } + Ok(fields) + } + + /// Read the given fields of an object (parallel to `field_ids`). + pub fn object_values(&mut self, obj: u32, field_ids: &[u32]) -> Result> { + let mut enc = Encoder::new(); + enc.id(obj).int(field_ids.len() as i32); + for f in field_ids { + enc.id(*f); + } + let payload = enc.finish(); + let data = self.request(cs::OBJECT_REF, wire::object::GET_VALUES, &payload)?; + let mut d = Decoder::new(&data); + field_ids.iter().map(|_| value::decode_value(&mut d)).collect() + } + + /// The element count of an array (first dimension; SZARRAYs are one-dimensional). + pub fn array_length(&mut self, arr: u32) -> Result { + let payload = Encoder::new().id(arr).finish(); + let data = self.request(cs::ARRAY_REF, wire::array::GET_LENGTH, &payload)?; + let mut d = Decoder::new(&data); + let rank = d.uint()? as usize; + let mut len = 0; + for i in 0..rank { + let dim = d.int()?; + let _lower_bound = d.int()?; + if i == 0 { + len = dim; + } + } + Ok(len) + } + + /// Read `count` array elements starting at `index`. + pub fn array_values(&mut self, arr: u32, index: i32, count: i32) -> Result> { + let payload = Encoder::new().id(arr).int(index).int(count).finish(); + let data = self.request(cs::ARRAY_REF, wire::array::GET_VALUES, &payload)?; + let mut d = Decoder::new(&data); + (0..count).map(|_| value::decode_value(&mut d)).collect() + } + + /// The contents of a managed string by its object id. + pub fn string_value(&mut self, string_id: u32) -> Result { + let payload = Encoder::new().id(string_id).finish(); + let data = self.request(cs::STRING_REF, wire::string_ref::GET_VALUE, &payload)?; + Decoder::new(&data).string() + } + + /// A type's name (`Namespace.Name`, or just `Name`). + pub fn type_name(&mut self, type_id: u32) -> Result { + let payload = Encoder::new().id(type_id).finish(); + let data = self.request(cs::TYPE, wire::ty::GET_INFO, &payload)?; + let mut d = Decoder::new(&data); + let ns = d.string()?; + let name = d.string()?; + Ok(if ns.is_empty() { name } else { format!("{ns}.{name}") }) + } +} + +/// Pick the IL location a source breakpoint should bind to, across all candidate +/// methods for a file. Snaps forward to the nearest non-hidden sequence point at or +/// after `line`, preferring the lowest line then the lowest IL offset. +pub fn resolve_line(candidates: &[(u32, DebugInfo)], file: &str, line: i32) -> Option<(u32, i64)> { + let want = basename(file).to_ascii_lowercase(); + let mut best: Option<(i32, i32, u32, i64)> = None; // (line, il, method, il64) + for (method, info) in candidates { + for sp in &info.seq_points { + if sp.is_hidden() || sp.line < line { + continue; + } + let src = info + .sources + .get(sp.source_idx as usize) + .map(|s| basename(s).to_ascii_lowercase()); + if src.as_deref() != Some(want.as_str()) { + continue; + } + let cand = (sp.line, sp.il_offset, *method, sp.il_offset as i64); + if best.map_or(true, |b| (cand.0, cand.1) < (b.0, b.1)) { + best = Some(cand); + } + } + } + best.map(|(_, _, m, il)| (m, il)) +} + +fn basename(path: &str) -> &str { + path.rsplit(['/', '\\']).next().unwrap_or(path) +} + +/// A human-readable name for an event kind (for logging). +pub fn kind_name(k: u8) -> &'static str { + match k { + kind::VM_START => "VM_START", + kind::VM_DEATH => "VM_DEATH", + kind::THREAD_START => "THREAD_START", + kind::THREAD_DEATH => "THREAD_DEATH", + kind::APPDOMAIN_CREATE => "APPDOMAIN_CREATE", + kind::APPDOMAIN_UNLOAD => "APPDOMAIN_UNLOAD", + kind::METHOD_ENTRY => "METHOD_ENTRY", + kind::METHOD_EXIT => "METHOD_EXIT", + kind::ASSEMBLY_LOAD => "ASSEMBLY_LOAD", + kind::ASSEMBLY_UNLOAD => "ASSEMBLY_UNLOAD", + kind::BREAKPOINT => "BREAKPOINT", + kind::STEP => "STEP", + kind::TYPE_LOAD => "TYPE_LOAD", + kind::EXCEPTION => "EXCEPTION", + kind::USER_BREAK => "USER_BREAK", + kind::USER_LOG => "USER_LOG", + _ => "UNKNOWN", + } +} + +/// Decode an agent command packet into a composite event, or `None` if it is not a +/// composite event we model. +fn decode_command(cmd_set: u8, cmd: u8, data: &[u8]) -> Option { + if cmd_set != cs::EVENT || cmd != wire::event::COMPOSITE { + return None; + } + decode_composite(data).ok() +} + +fn decode_composite(data: &[u8]) -> Result { + let mut d = Decoder::new(data); + let suspend_policy = d.byte()?; + let n = d.uint()? as usize; + let mut events = Vec::with_capacity(n); + for _ in 0..n { + let kind = d.byte()?; + let request_id = d.int()?; + let mut ev = Event { + kind, + request_id, + ..Default::default() + }; + match kind { + kind::VM_START | kind::THREAD_START | kind::THREAD_DEATH => { + ev.thread = d.id()?; + } + kind::APPDOMAIN_CREATE | kind::APPDOMAIN_UNLOAD => { + ev.thread = d.id()?; + ev.domain = d.id()?; + } + kind::ASSEMBLY_LOAD | kind::ASSEMBLY_UNLOAD => { + ev.thread = d.id()?; + ev.assembly = d.id()?; + } + kind::TYPE_LOAD => { + ev.thread = d.id()?; + ev.type_id = d.id()?; + } + kind::BREAKPOINT | kind::STEP => { + ev.thread = d.id()?; + ev.method = d.id()?; + ev.location = d.long()?; + } + kind::METHOD_ENTRY | kind::METHOD_EXIT => { + ev.thread = d.id()?; + ev.method = d.id()?; + } + kind::VM_DEATH => { + // No reliably-present tail across versions; this is terminal. + } + other => { + // Unknown kind: we cannot know its tail length to keep parsing a + // multi-event composite safely, so stop after recording it. + events.push(Event { + kind: other, + request_id, + ..Default::default() + }); + break; + } + } + events.push(ev); + } + Ok(Composite { + suspend_policy, + events, + }) +} + +/// Walk up from `start` looking for `Library/EditorInstance.json`. +pub fn find_editor_instance(start: &Path) -> Option { + let mut dir = Some(start); + while let Some(d) = dir { + let candidate = d.join("Library").join("EditorInstance.json"); + if candidate.is_file() { + return Some(candidate); + } + dir = d.parent(); + } + None +} + +/// The Unity editor's SDB agent listens on `56000 + (pid % 1000)` on loopback. +pub fn editor_port(process_id: u32) -> u16 { + (56000 + (process_id % 1000)) as u16 +} + +/// A Unity player's SDB agent port. Like the editor's, but keyed by the player's +/// multicast Guid instead of a pid (the pid isn't advertised). Verified on a live +/// 6000.x OSXPlayer: Guid 2625555618 → 56618. +pub fn player_sdb_port(guid: u32) -> u16 { + (56000 + (guid % 1000)) as u16 +} + +/// Open an SDB connection to an agent at `ip:port` (editor on loopback, or a player). +pub fn connect(ip: &str, port: u16) -> Result { + let stream = TcpStream::connect((ip, port))?; + stream.set_nodelay(true).ok(); + Connection::from_stream(stream) +} + +/// Read `Library/EditorInstance.json` and connect to that editor's Mono agent. +pub fn connect_editor(project_root: &Path) -> Result { + let (ip, port) = editor_endpoint(project_root)?; + connect(&ip, port) +} + +/// The (loopback ip, port) of this project's editor agent, from EditorInstance.json. +pub fn editor_endpoint(project_root: &Path) -> Result<(String, u16)> { + let path = find_editor_instance(project_root).ok_or_else(|| { + SdbError::Protocol(format!( + "no Library/EditorInstance.json under {}", + project_root.display() + )) + })?; + let text = std::fs::read_to_string(&path)?; + let json: serde_json::Value = serde_json::from_str(&text) + .map_err(|e| SdbError::Protocol(format!("parse {}: {e}", path.display())))?; + let pid = json + .get("process_id") + .and_then(|v| v.as_u64()) + .ok_or_else(|| SdbError::Protocol("EditorInstance.json missing process_id".into()))? + as u32; + Ok(("127.0.0.1".to_string(), editor_port(pid))) +} + +// --- Unity player discovery (multicast) --- + +/// The multicast group + port Unity players broadcast their presence on. +const DISCOVERY_GROUP: std::net::Ipv4Addr = std::net::Ipv4Addr::new(225, 0, 0, 222); +const DISCOVERY_PORT: u16 = 54997; + +/// A debuggable Unity player found via multicast discovery. +#[derive(Debug, Clone)] +pub struct Player { + pub guid: u32, + pub ip: String, + pub port: u16, + pub project: String, + /// The `[Id]` field, e.g. "OSXPlayer(1,MacBook-Air.local)". + pub label: String, +} + +/// Bind a UDP socket joined to Unity's player-discovery multicast group. +pub fn discovery_socket() -> std::io::Result { + use std::net::{Ipv4Addr, SocketAddr, UdpSocket}; + // Bind with SO_REUSEADDR so we can share the port with the editor's own listener. + let sock = { + let s = socket2::Socket::new( + socket2::Domain::IPV4, + socket2::Type::DGRAM, + Some(socket2::Protocol::UDP), + )?; + s.set_reuse_address(true)?; + #[cfg(unix)] + s.set_reuse_port(true)?; + s.bind(&SocketAddr::from((Ipv4Addr::UNSPECIFIED, DISCOVERY_PORT)).into())?; + let s: UdpSocket = s.into(); + s + }; + // Join the group on EVERY local IPv4 interface. On macOS, joining on INADDR_ANY + // alone binds only the default-route interface, so a player reachable over a + // VM/secondary interface is never received; join each interface explicitly. + let _ = sock.join_multicast_v4(&DISCOVERY_GROUP, &Ipv4Addr::UNSPECIFIED); + for ifip in local_ipv4_interfaces() { + let _ = sock.join_multicast_v4(&DISCOVERY_GROUP, &ifip); + } + sock.set_read_timeout(Some(std::time::Duration::from_millis(500)))?; + Ok(sock) +} + +/// Local IPv4 interface addresses (for per-interface multicast joins). +#[cfg(unix)] +fn local_ipv4_interfaces() -> Vec { + let mut out = Vec::new(); + unsafe { + let mut ifap: *mut libc::ifaddrs = std::ptr::null_mut(); + if libc::getifaddrs(&mut ifap) != 0 { + return out; + } + let mut p = ifap; + while !p.is_null() { + let addr = (*p).ifa_addr; + if !addr.is_null() && (*addr).sa_family as i32 == libc::AF_INET { + let sin = addr as *const libc::sockaddr_in; + // s_addr is stored in network byte order, i.e. the [a,b,c,d] octets. + out.push(std::net::Ipv4Addr::from((*sin).sin_addr.s_addr.to_ne_bytes())); + } + p = (*p).ifa_next; + } + libc::freeifaddrs(ifap); + } + out +} + +#[cfg(not(unix))] +fn local_ipv4_interfaces() -> Vec { + Vec::new() +} + +/// Parse a player announcement (`[IP] .. [Guid] .. [Debug] 1 ..`). Returns `None` for +/// non-debuggable players or malformed data. +pub fn parse_player(data: &[u8]) -> Option { + let text = std::str::from_utf8(data).ok()?; + let field = |name: &str| -> Option { + let tag = format!("[{name}] "); + let start = text.find(&tag)? + tag.len(); + let rest = &text[start..]; + let end = rest.find('[').unwrap_or(rest.len()); + Some(rest[..end].trim_end_matches('\0').trim().to_string()) + }; + if field("Debug").as_deref() != Some("1") { + return None; // script debugging not enabled → not attachable + } + let guid: u32 = field("Guid")?.parse().ok()?; + let ip = field("IP")?; + if ip.is_empty() { + return None; + } + Some(Player { + guid, + port: player_sdb_port(guid), + ip, + project: field("ProjectName").unwrap_or_default(), + label: field("Id").unwrap_or_default(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn editor_port_formula() { + assert_eq!(editor_port(46462), 56462); + assert_eq!(editor_port(13136), 56136); + assert_eq!(editor_port(56000), 56000); + } + + #[test] + fn decode_vm_start_composite() { + // suspend_policy=0, n=1, kind=VM_START(0), request_id=0, thread=1 + let mut e = Encoder::new(); + e.byte(0).int(1).byte(kind::VM_START).int(0).id(1); + let c = decode_composite(&e.finish()).unwrap(); + assert_eq!(c.suspend_policy, 0); + assert_eq!(c.events.len(), 1); + assert_eq!(c.events[0].kind, kind::VM_START); + assert_eq!(c.events[0].thread, 1); + } + + #[test] + fn decode_breakpoint_composite() { + // suspend_policy=2(ALL), n=1, kind=BREAKPOINT, req=7, thread=3, method=9, loc=42 + let mut e = Encoder::new(); + e.byte(2) + .int(1) + .byte(kind::BREAKPOINT) + .int(7) + .id(3) + .id(9) + .long(42); + let c = decode_composite(&e.finish()).unwrap(); + let ev = &c.events[0]; + assert_eq!(ev.kind, kind::BREAKPOINT); + assert_eq!(ev.request_id, 7); + assert_eq!(ev.thread, 3); + assert_eq!(ev.method, 9); + assert_eq!(ev.location, 42); + } + + #[test] + fn decoder_rejects_short_read() { + let mut d = Decoder::new(&[0, 0, 0]); + assert!(d.int().is_err()); + } + + fn sp(il: i32, line: i32) -> SeqPoint { + SeqPoint { + il_offset: il, + line, + source_idx: 0, + column: 0, + end_line: line, + end_column: 0, + } + } + + #[test] + fn resolve_snaps_forward_past_blank_and_hidden() { + // Rotator.cs Update(): line 35 decl, 36 `{` (no sp), 37 first statement. + let info = DebugInfo { + max_il: 100, + sources: vec!["/proj/Assets/Rotator.cs".into()], + seq_points: vec![ + sp(0, 35), + sp(2, HIDDEN_LINE), // compiler-generated, must be skipped + sp(6, 37), + sp(20, 41), + ], + }; + let c = [(9u32, info)]; + // Asking for line 36 (the brace) snaps forward to 37 -> il 6. + assert_eq!(resolve_line(&c, "Rotator.cs", 36), Some((9, 6))); + // Exact line 37 resolves to its own sp. + assert_eq!(resolve_line(&c, "Rotator.cs", 37), Some((9, 6))); + // A wrong file name resolves to nothing. + assert_eq!(resolve_line(&c, "Other.cs", 37), None); + } + + #[test] + fn resolve_prefers_lowest_line_then_il_across_methods() { + let a = DebugInfo { + max_il: 50, + sources: vec!["Rotator.cs".into()], + seq_points: vec![sp(10, 49), sp(4, 49)], + }; + let b = DebugInfo { + max_il: 50, + sources: vec!["Rotator.cs".into()], + seq_points: vec![sp(0, 51)], + }; + let c = [(1u32, a), (2u32, b)]; + // line 48 -> nearest is 49, lowest il on that line is 4 in method 1. + assert_eq!(resolve_line(&c, "Rotator.cs", 48), Some((1, 4))); + } +} diff --git a/native/unterm/src/sdb/value.rs b/native/unterm/src/sdb/value.rs new file mode 100644 index 0000000..bebde6c --- /dev/null +++ b/native/unterm/src/sdb/value.rs @@ -0,0 +1,138 @@ +//! Decoding of SDB tagged values (locals, `this`, fields). +//! +//! Mirrors mono's `buffer_add_value`: a leading element-type/value tag byte, then +//! the payload. Integer-family primitives (incl. bool/char) are written as 4-byte +//! ints; I8/U8 and native int/ptr as 8 bytes; R4/R8 as their bit patterns. + +use super::wire::{Decoder, Result}; + +/// Element type / value tag bytes (`MonoTypeEnum` + SDB-specific value tags). +pub mod tag { + pub const BOOLEAN: u8 = 0x02; + pub const CHAR: u8 = 0x03; + pub const I1: u8 = 0x04; + pub const U1: u8 = 0x05; + pub const I2: u8 = 0x06; + pub const U2: u8 = 0x07; + pub const I4: u8 = 0x08; + pub const U4: u8 = 0x09; + pub const I8: u8 = 0x0a; + pub const U8: u8 = 0x0b; + pub const R4: u8 = 0x0c; + pub const R8: u8 = 0x0d; + pub const STRING: u8 = 0x0e; + pub const PTR: u8 = 0x0f; + pub const VALUETYPE: u8 = 0x11; + pub const CLASS: u8 = 0x12; + pub const ARRAY: u8 = 0x14; + pub const I: u8 = 0x18; + pub const U: u8 = 0x19; + pub const OBJECT: u8 = 0x1c; + pub const SZARRAY: u8 = 0x1d; + // SDB-specific value ids. + pub const ID_NULL: u8 = 0xf0; + pub const ID_TYPE: u8 = 0xf1; + pub const ID_PARENT_VTYPE: u8 = 0xf2; + pub const ID_FIXED_ARRAY: u8 = 0xf3; +} + +/// A decoded value. Object-like values carry the opaque object id for further +/// inspection (string contents, fields, array elements) via the *_REF command sets. +#[derive(Debug, Clone)] +pub enum Value { + Bool(bool), + Char(u16), + Int(i64), + UInt(u64), + F32(f32), + F64(f64), + /// A managed string object id (fetch contents via STRING_REF.GET_VALUE). + String(u32), + /// A reference object id (Class/Object/Array/SzArray). `tag` keeps the kind. + Object { tag: u8, id: u32 }, + /// A boxed/inline value type with its fields. + ValueType { + klass: u32, + is_enum: bool, + fields: Vec, + }, + /// A null reference. + Null, + /// A type token (System.Type). + Type(u32), + /// Tag we don't model; decoding cannot continue past this. + Unknown(u8), +} + +impl Value { + /// A short, human-readable rendering (object ids shown as handles). + pub fn summary(&self) -> String { + match self { + Value::Bool(b) => b.to_string(), + Value::Char(c) => format!("'{}'", char::from_u32(*c as u32).unwrap_or('?')), + Value::Int(v) => v.to_string(), + Value::UInt(v) => v.to_string(), + Value::F32(v) => v.to_string(), + Value::F64(v) => v.to_string(), + Value::String(id) => format!("string#{id}"), + Value::Object { tag, id } => { + if *id == 0 { + "null".into() + } else { + format!("obj#{id} (tag 0x{tag:02x})") + } + } + Value::ValueType { klass, fields, .. } => { + format!("struct klass#{klass} ({} fields)", fields.len()) + } + Value::Null => "null".into(), + Value::Type(id) => format!("Type#{id}"), + Value::Unknown(t) => format!(""), + } + } +} + +/// Decode one tagged value from the cursor. +pub fn decode_value(d: &mut Decoder) -> Result { + let t = d.byte()?; + Ok(match t { + tag::BOOLEAN => Value::Bool(d.int()? != 0), + tag::CHAR => Value::Char(d.int()? as u16), + tag::I1 | tag::I2 | tag::I4 => Value::Int(d.int()? as i64), + tag::U1 | tag::U2 | tag::U4 => Value::UInt(d.int()? as u32 as u64), + tag::I8 => Value::Int(d.long()?), + tag::U8 => Value::UInt(d.long()? as u64), + tag::R4 => Value::F32(f32::from_bits(d.int()? as u32)), + tag::R8 => Value::F64(f64::from_bits(d.long()? as u64)), + tag::I | tag::U | tag::PTR => Value::Int(d.long()?), + tag::STRING => Value::String(d.id()?), + tag::CLASS | tag::OBJECT | tag::ARRAY | tag::SZARRAY => Value::Object { + tag: t, + id: d.id()?, + }, + tag::VALUETYPE => { + let is_enum = d.byte()? != 0; + let klass = d.id()?; + let n = d.uint()? as usize; + let mut fields = Vec::with_capacity(n); + for _ in 0..n { + fields.push(decode_value(d)?); + } + Value::ValueType { + klass, + is_enum, + fields, + } + } + tag::ID_NULL => { + // tag, then the element type byte, then the klass id (modern mono). + let _elem = d.byte()?; + let _klass = d.id()?; + Value::Null + } + tag::ID_TYPE => Value::Type(d.id()?), + // Unknown tag: we can't know its payload size, so the caller must stop + // decoding further values in this batch (the cursor is now unaligned). + other => Value::Unknown(other), + }) +} diff --git a/native/unterm/src/sdb/wire.rs b/native/unterm/src/sdb/wire.rs new file mode 100644 index 0000000..2064f01 --- /dev/null +++ b/native/unterm/src/sdb/wire.rs @@ -0,0 +1,406 @@ +//! Mono soft-debugger (SDB) wire protocol: framing, primitives, and constants. +//! +//! Verified against a live Unity 6000.3.18f1 editor (mono 6.13.0, protocol 2.58): +//! object/type/method/etc. ids are 4-byte big-endian ints; the 11-byte header is +//! big-endian; replies carry a 2-byte error code where a command carries cmdset/cmd. +//! See `~/.claude/plans/unterm-debugger-mono-soft-quokka.md` for the full map. + +use std::io::{self, Read, Write}; + +/// The 13-byte literal both sides exchange right after the TCP connect. +pub const HANDSHAKE: &[u8] = b"DWP-Handshake"; + +/// Fixed header length for every command and reply packet. +pub const HEADER_LEN: usize = 11; + +/// `flags` byte value identifying a reply (vs. a command/event from the agent). +pub const FLAG_REPLY: u8 = 0x80; + +/// Command sets (`debugger-agent.c` `CommandSet`). +pub mod cs { + pub const VM: u8 = 1; + pub const OBJECT_REF: u8 = 9; + pub const STRING_REF: u8 = 10; + pub const THREAD: u8 = 11; + pub const ARRAY_REF: u8 = 13; + pub const EVENT_REQUEST: u8 = 15; + pub const STACK_FRAME: u8 = 16; + pub const APPDOMAIN: u8 = 20; + pub const ASSEMBLY: u8 = 21; + pub const METHOD: u8 = 22; + pub const TYPE: u8 = 23; + pub const MODULE: u8 = 24; + pub const FIELD: u8 = 25; + pub const EVENT: u8 = 64; +} + +/// VM command set. +pub mod vm { + pub const VERSION: u8 = 1; + pub const ALL_THREADS: u8 = 2; + pub const SUSPEND: u8 = 3; + pub const RESUME: u8 = 4; + pub const EXIT: u8 = 5; + pub const DISPOSE: u8 = 6; + pub const INVOKE_METHOD: u8 = 7; + pub const SET_PROTOCOL_VERSION: u8 = 8; + pub const GET_TYPES_FOR_SOURCE_FILE: u8 = 11; + pub const GET_TYPES: u8 = 12; +} + +/// AppDomain command set. +pub mod appdomain { + pub const GET_ROOT_DOMAIN: u8 = 1; + pub const GET_FRIENDLY_NAME: u8 = 2; + pub const GET_ASSEMBLIES: u8 = 3; + pub const GET_CORLIB: u8 = 6; +} + +/// Assembly command set. +pub mod assembly { + pub const GET_LOCATION: u8 = 1; + pub const GET_ENTRY_POINT: u8 = 2; + pub const GET_MANIFEST_MODULE: u8 = 3; + pub const GET_OBJECT: u8 = 4; + pub const GET_TYPE: u8 = 5; + pub const GET_NAME: u8 = 6; +} + +/// Type command set. +pub mod ty { + pub const GET_INFO: u8 = 1; + pub const GET_METHODS: u8 = 2; + pub const GET_FIELDS: u8 = 3; + pub const GET_SOURCE_FILES: u8 = 6; + pub const GET_SOURCE_FILES_2: u8 = 13; + pub const GET_METHODS_BY_NAME_FLAGS: u8 = 15; +} + +/// ObjectRef command set. +pub mod object { + pub const GET_TYPE: u8 = 1; + pub const GET_VALUES: u8 = 2; +} + +/// StringRef command set. +pub mod string_ref { + pub const GET_VALUE: u8 = 1; +} + +/// ArrayRef command set. +pub mod array { + pub const GET_LENGTH: u8 = 1; + pub const GET_VALUES: u8 = 2; +} + +/// Method command set. +pub mod method { + pub const GET_NAME: u8 = 1; + pub const GET_DECLARING_TYPE: u8 = 2; + pub const GET_DEBUG_INFO: u8 = 3; + pub const GET_PARAM_INFO: u8 = 4; + pub const GET_LOCALS_INFO: u8 = 5; + pub const GET_INFO: u8 = 6; +} + +/// Thread command set. +pub mod thread { + pub const GET_FRAME_INFO: u8 = 1; + pub const GET_NAME: u8 = 2; + pub const GET_STATE: u8 = 3; + pub const GET_INFO: u8 = 4; + pub const GET_ID: u8 = 5; +} + +/// StackFrame command set. +pub mod frame { + pub const GET_VALUES: u8 = 1; + pub const GET_THIS: u8 = 2; + pub const SET_VALUES: u8 = 3; + pub const GET_DOMAIN: u8 = 4; +} + +/// EventRequest command set. +pub mod evreq { + pub const SET: u8 = 1; + pub const CLEAR: u8 = 2; + pub const CLEAR_ALL_BREAKPOINTS: u8 = 3; +} + +/// Event command set (agent -> client composite events). +pub mod event { + pub const COMPOSITE: u8 = 100; +} + +/// `EventKind` values (used in EVENT_REQUEST.SET and inside composite events). +pub mod kind { + pub const VM_START: u8 = 0; + pub const VM_DEATH: u8 = 1; + pub const THREAD_START: u8 = 2; + pub const THREAD_DEATH: u8 = 3; + pub const APPDOMAIN_CREATE: u8 = 4; + pub const APPDOMAIN_UNLOAD: u8 = 5; + pub const METHOD_ENTRY: u8 = 6; + pub const METHOD_EXIT: u8 = 7; + pub const ASSEMBLY_LOAD: u8 = 8; + pub const ASSEMBLY_UNLOAD: u8 = 9; + pub const BREAKPOINT: u8 = 10; + pub const STEP: u8 = 11; + pub const TYPE_LOAD: u8 = 12; + pub const EXCEPTION: u8 = 13; + pub const USER_BREAK: u8 = 15; + pub const USER_LOG: u8 = 16; +} + +/// Suspend policies for an event request / a composite event. +pub mod suspend { + pub const NONE: u8 = 0; + pub const EVENT_THREAD: u8 = 1; + pub const ALL: u8 = 2; +} + +/// Modifier kinds for EVENT_REQUEST.SET. +pub mod modifier { + pub const COUNT: u8 = 1; + pub const THREAD_ONLY: u8 = 3; + pub const LOCATION_ONLY: u8 = 7; + pub const EXCEPTION_ONLY: u8 = 8; + pub const STEP: u8 = 10; + pub const ASSEMBLY_ONLY: u8 = 11; + pub const SOURCE_FILE_ONLY: u8 = 12; + pub const TYPE_NAME_ONLY: u8 = 13; +} + +/// Step size / depth for a STEP modifier. +pub mod step { + pub const SIZE_MIN: i32 = 0; + pub const SIZE_LINE: i32 = 1; + pub const DEPTH_INTO: i32 = 0; + pub const DEPTH_OVER: i32 = 1; + pub const DEPTH_OUT: i32 = 2; +} + +/// Errors surfaced by the wire layer. +#[derive(Debug)] +pub enum SdbError { + Io(io::Error), + /// The agent returned a non-zero error code for a command. + Agent(u16), + /// A reply/packet was malformed or truncated. + Protocol(String), + /// The connection closed. + Eof, +} + +impl std::fmt::Display for SdbError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SdbError::Io(e) => write!(f, "io: {e}"), + SdbError::Agent(c) => write!(f, "agent error {c}"), + SdbError::Protocol(m) => write!(f, "protocol: {m}"), + SdbError::Eof => write!(f, "connection closed"), + } + } +} + +impl std::error::Error for SdbError {} + +impl From for SdbError { + fn from(e: io::Error) -> Self { + SdbError::Io(e) + } +} + +pub type Result = std::result::Result; + +/// A packet read off the wire: either a reply to one of our commands, or an +/// unsolicited command from the agent (composite events arrive this way). +#[derive(Debug)] +pub enum Packet { + Reply { id: u32, error: u16, data: Vec }, + Command { id: u32, cmd_set: u8, cmd: u8, data: Vec }, +} + +/// Big-endian payload builder. Ids are encoded as 4-byte ints (verified on Unity). +#[derive(Default)] +pub struct Encoder { + buf: Vec, +} + +impl Encoder { + pub fn new() -> Self { + Self { buf: Vec::new() } + } + + pub fn byte(&mut self, v: u8) -> &mut Self { + self.buf.push(v); + self + } + + pub fn int(&mut self, v: i32) -> &mut Self { + self.buf.extend_from_slice(&v.to_be_bytes()); + self + } + + pub fn long(&mut self, v: i64) -> &mut Self { + self.buf.extend_from_slice(&v.to_be_bytes()); + self + } + + pub fn id(&mut self, v: u32) -> &mut Self { + self.buf.extend_from_slice(&v.to_be_bytes()); + self + } + + pub fn string(&mut self, v: &str) -> &mut Self { + let b = v.as_bytes(); + self.int(b.len() as i32); + self.buf.extend_from_slice(b); + self + } + + /// Take the built payload, leaving the encoder empty. Takes `&mut self` so it + /// can terminate a fluent chain over a temporary (`Encoder::new().id(x).finish()`). + pub fn finish(&mut self) -> Vec { + std::mem::take(&mut self.buf) + } +} + +/// Big-endian payload reader over a borrowed slice. +pub struct Decoder<'a> { + buf: &'a [u8], + pos: usize, +} + +impl<'a> Decoder<'a> { + pub fn new(buf: &'a [u8]) -> Self { + Self { buf, pos: 0 } + } + + fn take(&mut self, n: usize) -> Result<&'a [u8]> { + let end = self.pos.checked_add(n).ok_or_else(overflow)?; + if end > self.buf.len() { + return Err(SdbError::Protocol(format!( + "short read: need {n} at {} of {}", + self.pos, + self.buf.len() + ))); + } + let s = &self.buf[self.pos..end]; + self.pos = end; + Ok(s) + } + + pub fn byte(&mut self) -> Result { + Ok(self.take(1)?[0]) + } + + pub fn int(&mut self) -> Result { + Ok(i32::from_be_bytes(self.take(4)?.try_into().unwrap())) + } + + pub fn uint(&mut self) -> Result { + Ok(u32::from_be_bytes(self.take(4)?.try_into().unwrap())) + } + + pub fn long(&mut self) -> Result { + Ok(i64::from_be_bytes(self.take(8)?.try_into().unwrap())) + } + + pub fn id(&mut self) -> Result { + self.uint() + } + + pub fn string(&mut self) -> Result { + let n = self.uint()? as usize; + let b = self.take(n)?; + Ok(String::from_utf8_lossy(b).into_owned()) + } + + /// Skip `n` bytes (e.g. a source-file hash we don't use). + pub fn skip(&mut self, n: usize) -> Result<()> { + self.take(n)?; + Ok(()) + } + + /// Bytes left unread. + pub fn remaining(&self) -> usize { + self.buf.len() - self.pos + } +} + +fn overflow() -> SdbError { + SdbError::Protocol("length overflow".into()) +} + +/// Send a command packet. Returns the number of bytes written for the header+body. +pub fn write_command( + w: &mut W, + id: u32, + cmd_set: u8, + cmd: u8, + payload: &[u8], +) -> Result<()> { + let len = (HEADER_LEN + payload.len()) as u32; + let mut hdr = [0u8; HEADER_LEN]; + hdr[0..4].copy_from_slice(&len.to_be_bytes()); + hdr[4..8].copy_from_slice(&id.to_be_bytes()); + hdr[8] = 0; // flags: command from client + hdr[9] = cmd_set; + hdr[10] = cmd; + w.write_all(&hdr)?; + w.write_all(payload)?; + w.flush()?; + Ok(()) +} + +/// Read exactly one packet (blocking). +pub fn read_packet(r: &mut R) -> Result { + let mut hdr = [0u8; HEADER_LEN]; + read_exact(r, &mut hdr)?; + let len = u32::from_be_bytes(hdr[0..4].try_into().unwrap()) as usize; + let id = u32::from_be_bytes(hdr[4..8].try_into().unwrap()); + let flags = hdr[8]; + if len < HEADER_LEN { + return Err(SdbError::Protocol(format!("bad packet length {len}"))); + } + let mut data = vec![0u8; len - HEADER_LEN]; + read_exact(r, &mut data)?; + if flags == FLAG_REPLY { + let error = u16::from_be_bytes(hdr[9..11].try_into().unwrap()); + Ok(Packet::Reply { id, error, data }) + } else { + Ok(Packet::Command { + id, + cmd_set: hdr[9], + cmd: hdr[10], + data, + }) + } +} + +/// Like `Read::read_exact` but maps a clean EOF (0 bytes at a boundary) to `Eof`. +fn read_exact(r: &mut R, mut buf: &mut [u8]) -> Result<()> { + let want = buf.len(); + let mut got = 0; + while !buf.is_empty() { + match r.read(buf) { + Ok(0) => { + return if got == 0 { + Err(SdbError::Eof) + } else { + Err(SdbError::Protocol(format!( + "truncated packet: {got}/{want} bytes" + ))) + }; + } + Ok(n) => { + got += n; + buf = &mut buf[n..]; + } + Err(ref e) if e.kind() == io::ErrorKind::Interrupted => {} + Err(e) => return Err(SdbError::Io(e)), + } + } + Ok(()) +}