From 5f56ba608238b088d1e5bc9544e5284e182c9816 Mon Sep 17 00:00:00 2001 From: Xuesong Peng Date: Sat, 25 Jul 2026 11:35:30 +0800 Subject: [PATCH 1/2] Expose bindings required by PowerShell module --- RimeSharp/Rime.cs | 8 +++++++- RimeSharp/RimeLevers.cs | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/RimeSharp/Rime.cs b/RimeSharp/Rime.cs index 093df8a..0fca129 100644 --- a/RimeSharp/Rime.cs +++ b/RimeSharp/Rime.cs @@ -35,6 +35,9 @@ RimeSessionId contextObject [UnmanagedFunctionPointer(CallingConvention.Cdecl)] internal delegate RimeSessionId CreateSession(); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + internal delegate bool FindSession(RimeSessionId sessionId); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] internal delegate bool DestroySession(RimeSessionId sessionId); @@ -202,6 +205,8 @@ public void SetNotificationHandler(RimeNotificationHandler handler) public RimeSessionId CreateSession() => _api.CreateSession(); + public bool FindSession(RimeSessionId sessionId) => _api.FindSession(sessionId); + public bool DestroySession(RimeSessionId sessionId) => _api.DestroySession(sessionId); public bool ProcessKey(RimeSessionId sessionId, int keyCode, int mask) @@ -393,7 +398,8 @@ internal struct RimeAPI public DBool SyncUserData; [MarshalAs(UnmanagedType.FunctionPtr)] public CreateSession CreateSession; - public IntPtr FindSession; // unused + [MarshalAs(UnmanagedType.FunctionPtr)] + public FindSession FindSession; [MarshalAs(UnmanagedType.FunctionPtr)] public DestroySession DestroySession; public IntPtr CleanupStaleSessions; // unused diff --git a/RimeSharp/RimeLevers.cs b/RimeSharp/RimeLevers.cs index b800ff0..6dd8384 100644 --- a/RimeSharp/RimeLevers.cs +++ b/RimeSharp/RimeLevers.cs @@ -57,7 +57,7 @@ internal bool GetAvailableSchemaList(IntPtr ptr, out RimeSchemaList list) => _levers.GetAvailableSchemaList(ptr, out list); internal bool GetSelectedSchemaList(IntPtr ptr, out RimeSchemaList list) - => _levers.GetAvailableSchemaList(ptr, out list); + => _levers.GetSelectedSchemaList(ptr, out list); internal void SchemaListDestroy(ref RimeSchemaList list) => _levers.SchemaListDestroy(ref list); From 9174f457dd1dd59a29b032f70d8a93a02248968f Mon Sep 17 00:00:00 2001 From: Xuesong Peng Date: Sat, 25 Jul 2026 11:36:20 +0800 Subject: [PATCH 2/2] Add RimeSharp PowerShell module --- .../Cmdlets/CandidateCmdlets.cs | 232 +++++++ RimeSharp.PowerShell/Cmdlets/KeyCmdlets.cs | 135 ++++ .../Cmdlets/LifecycleCmdlets.cs | 293 ++++++++ .../Cmdlets/NotificationCmdlets.cs | 99 +++ RimeSharp.PowerShell/Cmdlets/OptionCmdlets.cs | 105 +++ RimeSharp.PowerShell/Cmdlets/PageCmdlets.cs | 66 ++ RimeSharp.PowerShell/Cmdlets/QueryCmdlets.cs | 131 ++++ RimeSharp.PowerShell/Cmdlets/SchemaCmdlets.cs | 115 ++++ .../Cmdlets/SessionValidation.cs | 19 + .../Cmdlets/StateLabelCmdlets.cs | 65 ++ .../Cmdlets/SwitcherSchemaCmdlets.cs | 66 ++ RimeSharp.PowerShell/Examples/ApiConsole.ps1 | 634 ++++++++++++++++++ .../RimeSharp.PowerShell.csproj | 26 + .../RimeSharp.PowerShell.psd1 | 44 ++ .../RimeSharp.PowerShell.psm1 | 124 ++++ .../Types/RimeCandidateInfo.cs | 18 + .../Types/RimeNotification.cs | 35 + RimeSharp.PowerShell/Types/RimeResponse.cs | 28 + RimeSharp.PowerShell/Types/RimeSchemaInfo.cs | 18 + RimeSharp.PowerShell/Types/RimeSession.cs | 15 + RimeSharp.PowerShell/Types/RimeSnapshots.cs | 131 ++++ .../Types/RimeSwitcherSchemaInfo.cs | 16 + RimeSharp.PowerShell/build.ps1 | 40 ++ RimeSharp.sln | 6 + 24 files changed, 2461 insertions(+) create mode 100644 RimeSharp.PowerShell/Cmdlets/CandidateCmdlets.cs create mode 100644 RimeSharp.PowerShell/Cmdlets/KeyCmdlets.cs create mode 100644 RimeSharp.PowerShell/Cmdlets/LifecycleCmdlets.cs create mode 100644 RimeSharp.PowerShell/Cmdlets/NotificationCmdlets.cs create mode 100644 RimeSharp.PowerShell/Cmdlets/OptionCmdlets.cs create mode 100644 RimeSharp.PowerShell/Cmdlets/PageCmdlets.cs create mode 100644 RimeSharp.PowerShell/Cmdlets/QueryCmdlets.cs create mode 100644 RimeSharp.PowerShell/Cmdlets/SchemaCmdlets.cs create mode 100644 RimeSharp.PowerShell/Cmdlets/SessionValidation.cs create mode 100644 RimeSharp.PowerShell/Cmdlets/StateLabelCmdlets.cs create mode 100644 RimeSharp.PowerShell/Cmdlets/SwitcherSchemaCmdlets.cs create mode 100644 RimeSharp.PowerShell/Examples/ApiConsole.ps1 create mode 100644 RimeSharp.PowerShell/RimeSharp.PowerShell.csproj create mode 100644 RimeSharp.PowerShell/RimeSharp.PowerShell.psd1 create mode 100644 RimeSharp.PowerShell/RimeSharp.PowerShell.psm1 create mode 100644 RimeSharp.PowerShell/Types/RimeCandidateInfo.cs create mode 100644 RimeSharp.PowerShell/Types/RimeNotification.cs create mode 100644 RimeSharp.PowerShell/Types/RimeResponse.cs create mode 100644 RimeSharp.PowerShell/Types/RimeSchemaInfo.cs create mode 100644 RimeSharp.PowerShell/Types/RimeSession.cs create mode 100644 RimeSharp.PowerShell/Types/RimeSnapshots.cs create mode 100644 RimeSharp.PowerShell/Types/RimeSwitcherSchemaInfo.cs create mode 100644 RimeSharp.PowerShell/build.ps1 diff --git a/RimeSharp.PowerShell/Cmdlets/CandidateCmdlets.cs b/RimeSharp.PowerShell/Cmdlets/CandidateCmdlets.cs new file mode 100644 index 0000000..c4081ce --- /dev/null +++ b/RimeSharp.PowerShell/Cmdlets/CandidateCmdlets.cs @@ -0,0 +1,232 @@ +using System.Management.Automation; + +namespace RimeSharp.PowerShell.Cmdlets; + +/// +/// Enumerate candidates by their global index. +/// +[Cmdlet(VerbsCommon.Get, "RimeCandidate")] +[OutputType(typeof(RimeCandidateInfo))] +public sealed class GetRimeCandidateCmdlet : PSCmdlet, IDisposable +{ + private Rime? _rime; + + [Parameter(Position = 0)] + [ValidateRange(0, int.MaxValue)] + public int Start { get; set; } + + [Parameter(Position = 1)] + [ValidateRange(0, int.MaxValue)] + public int Count { get; set; } = int.MaxValue; + + [Parameter( + Position = 2, + ValueFromPipeline = true + )] + public RimeSession? Session { get; set; } + + protected override void BeginProcessing() + { + _rime = Rime.Instance(); + Session ??= SessionState.PSVariable.GetValue("global:RimeDefaultSession") as RimeSession; + } + + protected override void ProcessRecord() + { + if (_rime is null) return; + if (Session is null) + { + var ex = new InvalidOperationException( + "No RIME session specified. Pipe a session from Start-Rime or use -Session."); + ThrowTerminatingError(new ErrorRecord(ex, "RimeSessionMissing", + ErrorCategory.InvalidOperation, null)); + return; + } + + SessionValidation.EnsureSessionValid(this, _rime, Session); + var candidates = _rime.GetCandidates(Session.Id, Start, Count); + for (var i = 0; i < candidates.Length; ++i) + { + WriteObject(new RimeCandidateInfo(Start + i, candidates[i])); + } + } + + protected override void StopProcessing() => Dispose(); + public void Dispose() => _rime = null; +} + +/// +/// Select a candidate by its global or current-page index. +/// +[Cmdlet(VerbsCommon.Select, "RimeCandidate")] +[OutputType(typeof(void))] +public sealed class SelectRimeCandidateCmdlet : PSCmdlet, IDisposable +{ + private Rime? _rime; + + [Parameter( + Position = 0, + Mandatory = true + )] + [ValidateRange(0, int.MaxValue)] + public int Index { get; set; } + + [Parameter( + Position = 1, + ValueFromPipeline = true + )] + public RimeSession? Session { get; set; } + + [Parameter] + public SwitchParameter OnCurrentPage { get; set; } + + protected override void BeginProcessing() + { + _rime = Rime.Instance(); + Session ??= SessionState.PSVariable.GetValue("global:RimeDefaultSession") as RimeSession; + } + + protected override void ProcessRecord() + { + if (_rime is null) return; + if (Session is null) + { + var ex = new InvalidOperationException( + "No RIME session specified. Pipe a session from Start-Rime or use -Session."); + ThrowTerminatingError(new ErrorRecord(ex, "RimeSessionMissing", + ErrorCategory.InvalidOperation, null)); + return; + } + + SessionValidation.EnsureSessionValid(this, _rime, Session); + if (!_rime.SelectCandidate(Session.Id, Index, OnCurrentPage)) + { + var ex = new ArgumentException( + $"Candidate index {Index} could not be selected.", nameof(Index)); + ThrowTerminatingError(new ErrorRecord(ex, "RimeCandidateSelectionFailed", + ErrorCategory.InvalidArgument, Index)); + } + } + + protected override void StopProcessing() => Dispose(); + public void Dispose() => _rime = null; +} + +/// +/// Remove a candidate by its global or current-page index. +/// +[Cmdlet(VerbsCommon.Remove, "RimeCandidate", SupportsShouldProcess = true)] +[OutputType(typeof(void))] +public sealed class RemoveRimeCandidateCmdlet : PSCmdlet, IDisposable +{ + private Rime? _rime; + + [Parameter( + Position = 0, + Mandatory = true + )] + [ValidateRange(0, int.MaxValue)] + public int Index { get; set; } + + [Parameter( + Position = 1, + ValueFromPipeline = true + )] + public RimeSession? Session { get; set; } + + [Parameter] + public SwitchParameter OnCurrentPage { get; set; } + + protected override void BeginProcessing() + { + _rime = Rime.Instance(); + Session ??= SessionState.PSVariable.GetValue("global:RimeDefaultSession") as RimeSession; + } + + protected override void ProcessRecord() + { + if (_rime is null) return; + if (Session is null) + { + var ex = new InvalidOperationException( + "No RIME session specified. Pipe a session from Start-Rime or use -Session."); + ThrowTerminatingError(new ErrorRecord(ex, "RimeSessionMissing", + ErrorCategory.InvalidOperation, null)); + return; + } + + SessionValidation.EnsureSessionValid(this, _rime, Session); + if (!ShouldProcess($"candidate index {Index}", "Remove RIME candidate")) + { + return; + } + + if (!_rime.DeleteCandidate(Session.Id, Index, OnCurrentPage)) + { + var ex = new ArgumentException( + $"Candidate index {Index} could not be removed.", nameof(Index)); + ThrowTerminatingError(new ErrorRecord(ex, "RimeCandidateRemovalFailed", + ErrorCategory.InvalidArgument, Index)); + } + } + + protected override void StopProcessing() => Dispose(); + public void Dispose() => _rime = null; +} + +/// +/// Highlight a candidate by its global or current-page index. +/// +[Cmdlet(VerbsLifecycle.Invoke, "RimeHighlight")] +[OutputType(typeof(void))] +public sealed class InvokeRimeHighlightCmdlet : PSCmdlet, IDisposable +{ + private Rime? _rime; + + [Parameter( + Position = 0, + Mandatory = true + )] + [ValidateRange(0, int.MaxValue)] + public int Index { get; set; } + + [Parameter( + Position = 1, + ValueFromPipeline = true + )] + public RimeSession? Session { get; set; } + + [Parameter] + public SwitchParameter OnCurrentPage { get; set; } + + protected override void BeginProcessing() + { + _rime = Rime.Instance(); + Session ??= SessionState.PSVariable.GetValue("global:RimeDefaultSession") as RimeSession; + } + + protected override void ProcessRecord() + { + if (_rime is null) return; + if (Session is null) + { + var ex = new InvalidOperationException( + "No RIME session specified. Pipe a session from Start-Rime or use -Session."); + ThrowTerminatingError(new ErrorRecord(ex, "RimeSessionMissing", + ErrorCategory.InvalidOperation, null)); + return; + } + + SessionValidation.EnsureSessionValid(this, _rime, Session); + if (!_rime.HighlightCandidate(Session.Id, Index, OnCurrentPage)) + { + var ex = new ArgumentException( + $"Candidate index {Index} could not be highlighted.", nameof(Index)); + ThrowTerminatingError(new ErrorRecord(ex, "RimeCandidateHighlightFailed", + ErrorCategory.InvalidArgument, Index)); + } + } + + protected override void StopProcessing() => Dispose(); + public void Dispose() => _rime = null; +} diff --git a/RimeSharp.PowerShell/Cmdlets/KeyCmdlets.cs b/RimeSharp.PowerShell/Cmdlets/KeyCmdlets.cs new file mode 100644 index 0000000..0cd8829 --- /dev/null +++ b/RimeSharp.PowerShell/Cmdlets/KeyCmdlets.cs @@ -0,0 +1,135 @@ +using System.Management.Automation; + +namespace RimeSharp.PowerShell.Cmdlets; + +/// +/// Simulate a key sequence (e.g. "nihao" or "Down") and return a +/// containing commit, status, and context. +/// Internally calls SimulateKeySequence. +/// +[Cmdlet(VerbsCommunications.Send, "RimeKey")] +[OutputType(typeof(RimeResponse))] +public sealed class SendRimeKeyCmdlet : PSCmdlet, IDisposable +{ + private Rime? _rime; + + [Parameter( + Position = 1, + ValueFromPipeline = true + )] + public RimeSession? Session { get; set; } + + [Parameter( + Position = 0, + Mandatory = true + )] + public string Sequence { get; set; } = ""; + + protected override void BeginProcessing() + { + _rime = Rime.Instance(); + Session ??= SessionState.PSVariable.GetValue("global:RimeDefaultSession") as RimeSession; + } + + protected override void ProcessRecord() + { + if (_rime is null) return; + if (Session is null) + { + var ex = new InvalidOperationException( + "No RIME session specified. Pipe a session from Start-Rime or use -Session."); + ThrowTerminatingError(new ErrorRecord(ex, "RimeSessionMissing", + ErrorCategory.InvalidOperation, null)); + return; + } + + SessionValidation.EnsureSessionValid(this, _rime, Session); + if (!_rime.SimulateKeySequence(Session.Id, Sequence)) + { + var ex = new ArgumentException( + $"Key sequence '{Sequence}' could not be simulated.", nameof(Sequence)); + ThrowTerminatingError(new ErrorRecord(ex, "RimeKeySequenceFailed", + ErrorCategory.InvalidArgument, Sequence)); + return; + } + + using var commit = _rime.GetCommit(Session.Id); + using var status = _rime.GetStatus(Session.Id); + using var context = _rime.GetContext(Session.Id); + + var response = new RimeResponse( + commit.Text, + new RimeStatusSnapshot(status), + new RimeContextSnapshot(context)); + WriteObject(response); + } + + protected override void StopProcessing() + { + Dispose(); + } + + public void Dispose() + { + _rime = null; + } +} + +/// +/// Send a single raw key event (key code + modifier mask) to a RIME session. +/// Uses ProcessKey for low-level key handling. +/// +[Cmdlet(VerbsCommunications.Send, "RimeKeyEvent")] +[OutputType(typeof(bool))] +public sealed class SendRimeKeyEventCmdlet : PSCmdlet, IDisposable +{ + private Rime? _rime; + + [Parameter( + Position = 2, + ValueFromPipeline = true + )] + public RimeSession? Session { get; set; } + + [Parameter( + Position = 0, + Mandatory = true + )] + public int KeyCode { get; set; } + + [Parameter(Position = 1)] + public int Mask { get; set; } + + protected override void BeginProcessing() + { + _rime = Rime.Instance(); + Session ??= SessionState.PSVariable.GetValue("global:RimeDefaultSession") as RimeSession; + } + + protected override void ProcessRecord() + { + if (_rime is null) return; + if (Session is null) + { + var ex = new InvalidOperationException( + "No RIME session specified. Pipe a session from Start-Rime or use -Session."); + ThrowTerminatingError(new ErrorRecord(ex, "RimeSessionMissing", + ErrorCategory.InvalidOperation, null)); + return; + } + + SessionValidation.EnsureSessionValid(this, _rime, Session); + var handled = _rime.ProcessKey(Session.Id, KeyCode, Mask); + WriteObject(handled); + } + + protected override void StopProcessing() + { + Dispose(); + } + + public void Dispose() + { + _rime = null; + } +} diff --git a/RimeSharp.PowerShell/Cmdlets/LifecycleCmdlets.cs b/RimeSharp.PowerShell/Cmdlets/LifecycleCmdlets.cs new file mode 100644 index 0000000..46bad17 --- /dev/null +++ b/RimeSharp.PowerShell/Cmdlets/LifecycleCmdlets.cs @@ -0,0 +1,293 @@ +using System.Management.Automation; +using System.Threading; + +namespace RimeSharp.PowerShell.Cmdlets; + +/// +/// Initialize the RIME engine and return a for pipeline binding. +/// +[Cmdlet(VerbsLifecycle.Start, "Rime")] +[OutputType(typeof(RimeSession))] +public sealed class StartRimeCmdlet : PSCmdlet, IDisposable +{ + private Rime? _rime; + private bool _isSetup; + private int _stopRequested; + + [Parameter(Position = 0)] + public string AppName { get; set; } = "RimeSharp.PowerShell"; + + [Parameter(Position = 1)] + public string SharedDataDir { get; set; } = "shared"; + + [Parameter(Position = 2)] + public string UserDataDir { get; set; } = "user"; + + [Parameter] + public string? DistributionName { get; set; } + + [Parameter] + public string? DistributionCodeName { get; set; } + + [Parameter] + public string? DistributionVersion { get; set; } + + [Parameter] + public string? Modules { get; set; } + + [Parameter] + public int MinLogLevel { get; set; } + + [Parameter] + public string? LogDir { get; set; } + + [Parameter] + public string? PrebuiltDataDir { get; set; } + + [Parameter] + public string? StagingDir { get; set; } + + [Parameter] + public SwitchParameter SetDefaultSession { get; set; } + + protected override void BeginProcessing() + { + if (!RimeEngineLifecycle.TryBeginStart()) + { + var ex = new InvalidOperationException( + "RIME is already started in this process. Stop the active session before starting another."); + ThrowTerminatingError(new ErrorRecord( + ex, + "RimeAlreadyStarted", + ErrorCategory.InvalidOperation, + null)); + return; + } + + try + { + StartEngine(); + } + catch + { + CleanupFailedStart(); + RimeEngineLifecycle.CompleteStop(); + throw; + } + } + + private void StartEngine() + { + _rime = Rime.Instance(); + ThrowIfStopRequested(); + + SharedDataDir = ResolvePath(SharedDataDir); + UserDataDir = ResolvePath(UserDataDir); + LogDir = ResolveOptionalPath(LogDir); + PrebuiltDataDir = ResolveOptionalPath(PrebuiltDataDir); + StagingDir = ResolveOptionalPath(StagingDir); + + var traits = new RimeTraits + { + AppName = AppName, + SharedDataDir = SharedDataDir, + UserDataDir = UserDataDir, + }; + + if (DistributionName is not null) traits.DistributionName = DistributionName; + if (DistributionCodeName is not null) traits.DistributionCodeName = DistributionCodeName; + if (DistributionVersion is not null) traits.DistributionVersion = DistributionVersion; + if (Modules is not null) traits.Modules = Modules; + if (LogDir is not null) traits.LogDir = LogDir; + if (PrebuiltDataDir is not null) traits.PrebuiltDataDir = PrebuiltDataDir; + if (StagingDir is not null) traits.StagingDir = StagingDir; + traits.MinLogLevel = MinLogLevel; + + _rime.Setup(ref traits); + _isSetup = true; + ThrowIfStopRequested(); + RimeNotificationBridge.OnEngineSetup(_rime); + _rime.Initialize(ref traits); + ThrowIfStopRequested(); + + if (_rime.StartMaintenance(fullCheck: true)) + { + _rime.JoinMaintenanceThread(); + } + ThrowIfStopRequested(); + + var sessionId = _rime.CreateSession(); + if (sessionId == 0) + { + var ex = new InvalidOperationException("Failed to create RIME session."); + ThrowTerminatingError(new ErrorRecord(ex, "RimeSessionFailed", + ErrorCategory.ResourceUnavailable, null)); + return; + } + ThrowIfStopRequested(); + + var session = new RimeSession(sessionId); + WriteObject(session); + + if (SetDefaultSession) + { + SessionState.PSVariable.Set("global:RimeDefaultSession", session); + } + } + + private void CleanupFailedStart() + { + if (!_isSetup || _rime is null) return; + + try + { + RimeNotificationBridge.OnEngineFinalizing(_rime); + _rime.Finalize1(); + } + catch + { + // Preserve the original startup failure. + } + } + + protected override void StopProcessing() + { + Volatile.Write(ref _stopRequested, 1); + } + + public void Dispose() + { + _rime = null; + } + + private void ThrowIfStopRequested() + { + if (Volatile.Read(ref _stopRequested) != 0) + { + throw new OperationCanceledException("Start-Rime was stopped."); + } + } + + private string ResolvePath(string path) + => SessionState.Path.GetUnresolvedProviderPathFromPSPath(path); + + private string? ResolveOptionalPath(string? path) + { + if (path is null || path.Length == 0) + { + return path; + } + + return ResolvePath(path); + } +} + +/// +/// Destroy the active RIME session and finalize the single engine lifecycle. +/// +[Cmdlet(VerbsLifecycle.Stop, "Rime")] +[OutputType(typeof(void))] +public sealed class StopRimeCmdlet : PSCmdlet, IDisposable +{ + private readonly object _finalizationSync = new(); + private Rime? _rime; + private bool _isFinalized; + private bool _sessionDestroyed; + + [Parameter( + Position = 0, + ValueFromPipeline = true + )] + public RimeSession? Session { get; set; } + + protected override void BeginProcessing() + { + _rime = Rime.Instance(); + Session ??= SessionState.PSVariable.GetValue("global:RimeDefaultSession") as RimeSession; + } + + protected override void ProcessRecord() + { + lock (_finalizationSync) + { + if (_isFinalized) return; + StopSession(); + } + } + + private void StopSession() + { + if (_rime is null) return; + if (Session is null) + { + var ex = new InvalidOperationException( + "No RIME session specified. Pipe a session from Start-Rime or use -Session."); + ThrowTerminatingError(new ErrorRecord(ex, "RimeSessionMissing", + ErrorCategory.InvalidOperation, null)); + return; + } + + if (!_rime.DestroySession(Session.Id)) + { + var ex = new ArgumentException( + $"Session {Session.Id} is invalid or already destroyed.", nameof(Session)); + ThrowTerminatingError(new ErrorRecord(ex, "RimeSessionInvalid", + ErrorCategory.InvalidArgument, Session)); + return; + } + _sessionDestroyed = true; + + var defaultSession = SessionState.PSVariable.GetValue( + "global:RimeDefaultSession") as RimeSession; + if (defaultSession?.Id == Session.Id) + { + SessionState.PSVariable.Remove("global:RimeDefaultSession"); + } + } + + protected override void EndProcessing() + { + FinalizeEngine(); + } + + protected override void StopProcessing() + { + FinalizeEngine(); + } + + public void Dispose() + { + FinalizeEngine(); + } + + private void FinalizeEngine() + { + lock (_finalizationSync) + { + if (_isFinalized || !_sessionDestroyed || _rime is null) return; + _isFinalized = true; + + try + { + RimeNotificationBridge.OnEngineFinalizing(_rime); + _rime.Finalize1(); + } + finally + { + _rime = null; + RimeEngineLifecycle.CompleteStop(); + } + } + } +} + +internal static class RimeEngineLifecycle +{ + private static int s_isActive; + + internal static bool TryBeginStart() + => Interlocked.CompareExchange(ref s_isActive, 1, 0) == 0; + + internal static void CompleteStop() + => Volatile.Write(ref s_isActive, 0); +} diff --git a/RimeSharp.PowerShell/Cmdlets/NotificationCmdlets.cs b/RimeSharp.PowerShell/Cmdlets/NotificationCmdlets.cs new file mode 100644 index 0000000..dac9d3f --- /dev/null +++ b/RimeSharp.PowerShell/Cmdlets/NotificationCmdlets.cs @@ -0,0 +1,99 @@ +using System.Collections.Concurrent; +using System.Management.Automation; + +namespace RimeSharp.PowerShell.Cmdlets; + +/// +/// Enable native RIME notifications and queue them for PowerShell-safe delivery. +/// Register before Start-Rime to receive initialization and deployment messages. +/// +[Cmdlet(VerbsLifecycle.Register, "RimeNotification")] +[OutputType(typeof(void))] +public sealed class RegisterRimeNotificationCmdlet : PSCmdlet +{ + protected override void ProcessRecord() + { + RimeNotificationBridge.Register(); + } +} + +/// +/// Dequeue all pending native RIME notifications. +/// +[Cmdlet(VerbsCommunications.Receive, "RimeNotification")] +[OutputType(typeof(RimeNotification))] +public sealed class ReceiveRimeNotificationCmdlet : PSCmdlet +{ + protected override void ProcessRecord() + { + while (RimeNotificationBridge.TryDequeue(out var notification)) + { + if (notification is not null) + { + WriteObject(notification); + } + } + } +} + +internal static class RimeNotificationBridge +{ + private static readonly object s_syncRoot = new(); + private static readonly ConcurrentQueue s_notifications = new(); + private static readonly RimeNotificationHandler s_handler = OnNotification; + + private static bool s_registered; + private static Rime? s_rime; + + internal static void Register() + { + lock (s_syncRoot) + { + s_registered = true; + s_rime?.SetNotificationHandler(s_handler); + } + } + + internal static void OnEngineSetup(Rime rime) + { + lock (s_syncRoot) + { + while (s_notifications.TryDequeue(out _)) + { + } + + s_rime = rime; + if (s_registered) + { + rime.SetNotificationHandler(s_handler); + } + } + } + + internal static void OnEngineFinalizing(Rime rime) + { + lock (s_syncRoot) + { + if (ReferenceEquals(s_rime, rime)) + { + s_rime = null; + } + } + } + + internal static bool TryDequeue(out RimeNotification? notification) + => s_notifications.TryDequeue(out notification); + + private static void OnNotification( + UIntPtr contextObject, + UIntPtr sessionId, + string messageType, + string messageValue) + { + s_notifications.Enqueue(new RimeNotification( + contextObject, + sessionId, + messageType ?? string.Empty, + messageValue ?? string.Empty)); + } +} diff --git a/RimeSharp.PowerShell/Cmdlets/OptionCmdlets.cs b/RimeSharp.PowerShell/Cmdlets/OptionCmdlets.cs new file mode 100644 index 0000000..4deccfd --- /dev/null +++ b/RimeSharp.PowerShell/Cmdlets/OptionCmdlets.cs @@ -0,0 +1,105 @@ +using System.Management.Automation; + +namespace RimeSharp.PowerShell.Cmdlets; + +/// +/// Get the value of a boolean option for a RIME session. +/// +[Cmdlet(VerbsCommon.Get, "RimeOption")] +[OutputType(typeof(bool))] +public sealed class GetRimeOptionCmdlet : PSCmdlet, IDisposable +{ + private Rime? _rime; + + [Parameter( + Position = 0, + Mandatory = true + )] + [ValidateNotNullOrEmpty] + public string Name { get; set; } = ""; + + [Parameter( + Position = 1, + ValueFromPipeline = true + )] + public RimeSession? Session { get; set; } + + protected override void BeginProcessing() + { + _rime = Rime.Instance(); + Session ??= SessionState.PSVariable.GetValue("global:RimeDefaultSession") as RimeSession; + } + + protected override void ProcessRecord() + { + if (_rime is null) return; + if (Session is null) + { + var ex = new InvalidOperationException( + "No RIME session specified. Pipe a session from Start-Rime or use -Session."); + ThrowTerminatingError(new ErrorRecord(ex, "RimeSessionMissing", + ErrorCategory.InvalidOperation, null)); + return; + } + + SessionValidation.EnsureSessionValid(this, _rime, Session); + WriteObject(_rime.GetOption(Session.Id, Name)); + } + + protected override void StopProcessing() => Dispose(); + public void Dispose() => _rime = null; +} + +/// +/// Set the value of a boolean option for a RIME session. +/// +[Cmdlet(VerbsCommon.Set, "RimeOption")] +[OutputType(typeof(void))] +public sealed class SetRimeOptionCmdlet : PSCmdlet, IDisposable +{ + private Rime? _rime; + + [Parameter( + Position = 0, + Mandatory = true + )] + [ValidateNotNullOrEmpty] + public string Name { get; set; } = ""; + + [Parameter( + Position = 1, + Mandatory = true + )] + public bool Value { get; set; } + + [Parameter( + Position = 2, + ValueFromPipeline = true + )] + public RimeSession? Session { get; set; } + + protected override void BeginProcessing() + { + _rime = Rime.Instance(); + Session ??= SessionState.PSVariable.GetValue("global:RimeDefaultSession") as RimeSession; + } + + protected override void ProcessRecord() + { + if (_rime is null) return; + if (Session is null) + { + var ex = new InvalidOperationException( + "No RIME session specified. Pipe a session from Start-Rime or use -Session."); + ThrowTerminatingError(new ErrorRecord(ex, "RimeSessionMissing", + ErrorCategory.InvalidOperation, null)); + return; + } + + SessionValidation.EnsureSessionValid(this, _rime, Session); + _rime.SetOption(Session.Id, Name, Value); + } + + protected override void StopProcessing() => Dispose(); + public void Dispose() => _rime = null; +} diff --git a/RimeSharp.PowerShell/Cmdlets/PageCmdlets.cs b/RimeSharp.PowerShell/Cmdlets/PageCmdlets.cs new file mode 100644 index 0000000..9a1e581 --- /dev/null +++ b/RimeSharp.PowerShell/Cmdlets/PageCmdlets.cs @@ -0,0 +1,66 @@ +using System.Management.Automation; + +namespace RimeSharp.PowerShell.Cmdlets; + +/// +/// Specifies the direction in which to move through candidate pages. +/// +public enum PageDirection +{ + Next, + Previous, +} + +/// +/// Move to the next or previous candidate page. +/// +[Cmdlet(VerbsCommon.Set, "RimePage")] +[OutputType(typeof(void))] +public sealed class SetRimePageCmdlet : PSCmdlet, IDisposable +{ + private Rime? _rime; + + [Parameter( + Position = 0, + Mandatory = true + )] + public PageDirection Direction { get; set; } + + [Parameter( + Position = 1, + ValueFromPipeline = true + )] + public RimeSession? Session { get; set; } + + protected override void BeginProcessing() + { + _rime = Rime.Instance(); + Session ??= SessionState.PSVariable.GetValue("global:RimeDefaultSession") as RimeSession; + } + + protected override void ProcessRecord() + { + if (_rime is null) return; + if (Session is null) + { + var ex = new InvalidOperationException( + "No RIME session specified. Pipe a session from Start-Rime or use -Session."); + ThrowTerminatingError(new ErrorRecord(ex, "RimeSessionMissing", + ErrorCategory.InvalidOperation, null)); + return; + } + + SessionValidation.EnsureSessionValid(this, _rime, Session); + var backward = Direction == PageDirection.Previous; + if (!_rime.ChangePage(Session.Id, backward)) + { + var ex = new InvalidOperationException( + $"Could not move to the {Direction.ToString().ToLowerInvariant()} candidate page."); + ThrowTerminatingError(new ErrorRecord(ex, "RimePageChangeFailed", + ErrorCategory.InvalidOperation, Session)); + } + } + + protected override void StopProcessing() => Dispose(); + public void Dispose() => _rime = null; +} diff --git a/RimeSharp.PowerShell/Cmdlets/QueryCmdlets.cs b/RimeSharp.PowerShell/Cmdlets/QueryCmdlets.cs new file mode 100644 index 0000000..5a9f10e --- /dev/null +++ b/RimeSharp.PowerShell/Cmdlets/QueryCmdlets.cs @@ -0,0 +1,131 @@ +using System.Management.Automation; + +namespace RimeSharp.PowerShell.Cmdlets; + +/// +/// Get the last committed text from a RIME session. +/// +[Cmdlet(VerbsCommon.Get, "RimeCommit")] +[OutputType(typeof(string))] +public sealed class GetRimeCommitCmdlet : PSCmdlet, IDisposable +{ + private Rime? _rime; + + [Parameter( + Position = 0, + ValueFromPipeline = true + )] + public RimeSession? Session { get; set; } + + protected override void BeginProcessing() + { + _rime = Rime.Instance(); + Session ??= SessionState.PSVariable.GetValue("global:RimeDefaultSession") as RimeSession; + } + + protected override void ProcessRecord() + { + if (_rime is null) return; + if (Session is null) + { + var ex = new InvalidOperationException( + "No RIME session specified. Pipe a session from Start-Rime or use -Session."); + ThrowTerminatingError(new ErrorRecord(ex, "RimeSessionMissing", + ErrorCategory.InvalidOperation, null)); + return; + } + + SessionValidation.EnsureSessionValid(this, _rime, Session); + using var commit = _rime.GetCommit(Session.Id); + WriteObject(commit.Text); + } + + protected override void StopProcessing() => Dispose(); + public void Dispose() => _rime = null; +} + +/// +/// Get a managed snapshot of the current input context from a RIME session +/// (preedit, candidates, menu). +/// +[Cmdlet(VerbsCommon.Get, "RimeContext")] +[OutputType(typeof(RimeContextSnapshot))] +public sealed class GetRimeContextCmdlet : PSCmdlet, IDisposable +{ + private Rime? _rime; + + [Parameter( + Position = 0, + ValueFromPipeline = true + )] + public RimeSession? Session { get; set; } + + protected override void BeginProcessing() + { + _rime = Rime.Instance(); + Session ??= SessionState.PSVariable.GetValue("global:RimeDefaultSession") as RimeSession; + } + + protected override void ProcessRecord() + { + if (_rime is null) return; + if (Session is null) + { + var ex = new InvalidOperationException( + "No RIME session specified. Pipe a session from Start-Rime or use -Session."); + ThrowTerminatingError(new ErrorRecord(ex, "RimeSessionMissing", + ErrorCategory.InvalidOperation, null)); + return; + } + + SessionValidation.EnsureSessionValid(this, _rime, Session); + using var context = _rime.GetContext(Session.Id); + WriteObject(new RimeContextSnapshot(context)); + } + + protected override void StopProcessing() => Dispose(); + public void Dispose() => _rime = null; +} + +/// +/// Get a managed snapshot of the current engine status from a RIME session +/// (schema, mode flags). +/// +[Cmdlet(VerbsCommon.Get, "RimeStatus")] +[OutputType(typeof(RimeStatusSnapshot))] +public sealed class GetRimeStatusCmdlet : PSCmdlet, IDisposable +{ + private Rime? _rime; + + [Parameter( + Position = 0, + ValueFromPipeline = true + )] + public RimeSession? Session { get; set; } + + protected override void BeginProcessing() + { + _rime = Rime.Instance(); + Session ??= SessionState.PSVariable.GetValue("global:RimeDefaultSession") as RimeSession; + } + + protected override void ProcessRecord() + { + if (_rime is null) return; + if (Session is null) + { + var ex = new InvalidOperationException( + "No RIME session specified. Pipe a session from Start-Rime or use -Session."); + ThrowTerminatingError(new ErrorRecord(ex, "RimeSessionMissing", + ErrorCategory.InvalidOperation, null)); + return; + } + + SessionValidation.EnsureSessionValid(this, _rime, Session); + using var status = _rime.GetStatus(Session.Id); + WriteObject(new RimeStatusSnapshot(status)); + } + + protected override void StopProcessing() => Dispose(); + public void Dispose() => _rime = null; +} diff --git a/RimeSharp.PowerShell/Cmdlets/SchemaCmdlets.cs b/RimeSharp.PowerShell/Cmdlets/SchemaCmdlets.cs new file mode 100644 index 0000000..72eeff1 --- /dev/null +++ b/RimeSharp.PowerShell/Cmdlets/SchemaCmdlets.cs @@ -0,0 +1,115 @@ +using System.Management.Automation; + +namespace RimeSharp.PowerShell.Cmdlets; + +/// +/// List available RIME schemas and identify the schema active for a session. +/// +[Cmdlet(VerbsCommon.Get, "RimeSchema")] +[OutputType(typeof(RimeSchemaInfo))] +public sealed class GetRimeSchemaCmdlet : PSCmdlet, IDisposable +{ + private Rime? _rime; + + [Parameter( + Position = 0, + ValueFromPipeline = true + )] + public RimeSession? Session { get; set; } + + protected override void BeginProcessing() + { + _rime = Rime.Instance(); + Session ??= SessionState.PSVariable.GetValue("global:RimeDefaultSession") as RimeSession; + } + + protected override void ProcessRecord() + { + if (_rime is null) return; + if (Session is null) + { + var ex = new InvalidOperationException( + "No RIME session specified. Pipe a session from Start-Rime or use -Session."); + ThrowTerminatingError(new ErrorRecord(ex, "RimeSessionMissing", + ErrorCategory.InvalidOperation, null)); + return; + } + + SessionValidation.EnsureSessionValid(this, _rime, Session); + using var status = _rime.GetStatus(Session.Id); + var currentSchemaId = status.SchemaId; + if (string.IsNullOrEmpty(currentSchemaId)) + { + var ex = new ArgumentException( + $"Session {Session.Id} is invalid or has no active schema.", nameof(Session)); + ThrowTerminatingError(new ErrorRecord(ex, "RimeCurrentSchemaUnavailable", + ErrorCategory.InvalidArgument, Session)); + return; + } + + foreach (var schema in _rime.GetSchemaList()) + { + WriteObject(new RimeSchemaInfo( + schema.SchemaId, + schema.Name, + string.Equals(schema.SchemaId, currentSchemaId, StringComparison.Ordinal))); + } + } + + protected override void StopProcessing() => Dispose(); + public void Dispose() => _rime = null; +} + +/// +/// Select the active schema for a RIME session. +/// +[Cmdlet(VerbsCommon.Set, "RimeSchema")] +[OutputType(typeof(void))] +public sealed class SetRimeSchemaCmdlet : PSCmdlet, IDisposable +{ + private Rime? _rime; + + [Parameter( + Position = 0, + Mandatory = true + )] + [ValidateNotNullOrEmpty] + public string SchemaId { get; set; } = ""; + + [Parameter( + Position = 1, + ValueFromPipeline = true + )] + public RimeSession? Session { get; set; } + + protected override void BeginProcessing() + { + _rime = Rime.Instance(); + Session ??= SessionState.PSVariable.GetValue("global:RimeDefaultSession") as RimeSession; + } + + protected override void ProcessRecord() + { + if (_rime is null) return; + if (Session is null) + { + var ex = new InvalidOperationException( + "No RIME session specified. Pipe a session from Start-Rime or use -Session."); + ThrowTerminatingError(new ErrorRecord(ex, "RimeSessionMissing", + ErrorCategory.InvalidOperation, null)); + return; + } + + SessionValidation.EnsureSessionValid(this, _rime, Session); + if (!_rime.SelectSchema(Session.Id, SchemaId)) + { + var ex = new ArgumentException( + $"Schema '{SchemaId}' could not be selected.", nameof(SchemaId)); + ThrowTerminatingError(new ErrorRecord(ex, "RimeSchemaSelectionFailed", + ErrorCategory.InvalidArgument, SchemaId)); + } + } + + protected override void StopProcessing() => Dispose(); + public void Dispose() => _rime = null; +} diff --git a/RimeSharp.PowerShell/Cmdlets/SessionValidation.cs b/RimeSharp.PowerShell/Cmdlets/SessionValidation.cs new file mode 100644 index 0000000..dbbc89b --- /dev/null +++ b/RimeSharp.PowerShell/Cmdlets/SessionValidation.cs @@ -0,0 +1,19 @@ +using System.Management.Automation; + +namespace RimeSharp.PowerShell.Cmdlets; + +internal static class SessionValidation +{ + internal static void EnsureSessionValid( + PSCmdlet cmdlet, + Rime rime, + RimeSession session) + { + if (rime.FindSession(session.Id)) return; + + var ex = new ArgumentException( + $"Session {session.Id} is invalid or already destroyed.", nameof(session)); + cmdlet.ThrowTerminatingError(new ErrorRecord(ex, "RimeSessionInvalid", + ErrorCategory.InvalidArgument, session)); + } +} diff --git a/RimeSharp.PowerShell/Cmdlets/StateLabelCmdlets.cs b/RimeSharp.PowerShell/Cmdlets/StateLabelCmdlets.cs new file mode 100644 index 0000000..ae945b1 --- /dev/null +++ b/RimeSharp.PowerShell/Cmdlets/StateLabelCmdlets.cs @@ -0,0 +1,65 @@ +using System.Management.Automation; + +namespace RimeSharp.PowerShell.Cmdlets; + +/// +/// Get the display label for a RIME option state. +/// +[Cmdlet(VerbsCommon.Get, "RimeStateLabel")] +[OutputType(typeof(string))] +public sealed class GetRimeStateLabelCmdlet : PSCmdlet, IDisposable +{ + private Rime? _rime; + + [Parameter( + Position = 0, + Mandatory = true, + ValueFromPipelineByPropertyName = true + )] + [Alias("OptionName")] + [ValidateNotNullOrEmpty] + public string Name { get; set; } = ""; + + [Parameter( + Position = 1, + Mandatory = true, + ValueFromPipelineByPropertyName = true + )] + [Alias("OptionState")] + public bool State { get; set; } + + [Parameter( + Position = 2, + ValueFromPipeline = true, + ValueFromPipelineByPropertyName = true + )] + public RimeSession? Session { get; set; } + + [Parameter] + public SwitchParameter Abbreviated { get; set; } + + protected override void BeginProcessing() + { + _rime = Rime.Instance(); + Session ??= SessionState.PSVariable.GetValue("global:RimeDefaultSession") as RimeSession; + } + + protected override void ProcessRecord() + { + if (_rime is null) return; + if (Session is null) + { + var ex = new InvalidOperationException( + "No RIME session specified. Pipe a session from Start-Rime or use -Session."); + ThrowTerminatingError(new ErrorRecord(ex, "RimeSessionMissing", + ErrorCategory.InvalidOperation, null)); + return; + } + + SessionValidation.EnsureSessionValid(this, _rime, Session); + WriteObject(_rime.GetStateLabel(Session.Id, Name, State, Abbreviated)); + } + + protected override void StopProcessing() => Dispose(); + public void Dispose() => _rime = null; +} diff --git a/RimeSharp.PowerShell/Cmdlets/SwitcherSchemaCmdlets.cs b/RimeSharp.PowerShell/Cmdlets/SwitcherSchemaCmdlets.cs new file mode 100644 index 0000000..7e792aa --- /dev/null +++ b/RimeSharp.PowerShell/Cmdlets/SwitcherSchemaCmdlets.cs @@ -0,0 +1,66 @@ +using System.Management.Automation; + +namespace RimeSharp.PowerShell.Cmdlets; + +/// +/// List schemas available to or selected in the RIME switcher. +/// +[Cmdlet(VerbsCommon.Get, "RimeSwitcherSchema")] +[OutputType(typeof(RimeSwitcherSchemaInfo))] +public sealed class GetRimeSwitcherSchemaCmdlet : PSCmdlet +{ + private const string AvailableParameterSet = "Available"; + private const string SelectedParameterSet = "Selected"; + + [Parameter( + Mandatory = true, + ParameterSetName = AvailableParameterSet + )] + public SwitchParameter Available { get; set; } + + [Parameter( + Mandatory = true, + ParameterSetName = SelectedParameterSet + )] + public SwitchParameter Selected { get; set; } + + protected override void ProcessRecord() + { + RimeSwitcherSchemaInfo[] schemaInfo; + try + { + using var settings = new RimeSwitcherSettings(); + if (settings.IsInvalid || !settings.LoadSettings()) + { + throw new InvalidOperationException( + "Failed to load RIME switcher settings."); + } + + var schemas = ParameterSetName == SelectedParameterSet + ? settings.GetSelectedSchemaList() + : settings.GetAvailableSchemaList(); + + schemaInfo = new RimeSwitcherSchemaInfo[schemas.Length]; + for (var i = 0; i < schemas.Length; ++i) + { + schemaInfo[i] = new RimeSwitcherSchemaInfo( + schemas[i].SchemaId, + schemas[i].Name); + } + } + catch (Exception ex) + { + ThrowTerminatingError(new ErrorRecord( + ex, + "RimeSwitcherSettingsLoadFailed", + ErrorCategory.ResourceUnavailable, + null)); + return; + } + + foreach (var schema in schemaInfo) + { + WriteObject(schema); + } + } +} diff --git a/RimeSharp.PowerShell/Examples/ApiConsole.ps1 b/RimeSharp.PowerShell/Examples/ApiConsole.ps1 new file mode 100644 index 0000000..6a38832 --- /dev/null +++ b/RimeSharp.PowerShell/Examples/ApiConsole.ps1 @@ -0,0 +1,634 @@ +#requires -Version 5.1 + +<# +.SYNOPSIS + Runs an interactive API console for the RimeSharp.PowerShell module. + +.DESCRIPTION + Demonstrates the PowerShell cmdlets with a command loop modeled after + librime's rime_api_console and the RimeSharp.Test console application. + + SharedDataDir and UserDataDir are resolved by Start-Rime from the current + PowerShell location. On Windows, set LIBRIME_LIB_DIR or add the directory + containing rime.dll to PATH before starting this script. + +.PARAMETER SharedDataDir + Directory containing shared RIME data. + +.PARAMETER UserDataDir + Directory containing user-specific RIME data. + +.PARAMETER AppName + Application name reported to librime. + +.EXAMPLE + $env:LIBRIME_LIB_DIR = 'C:\path\to\librime\bin' + pwsh .\RimeSharp.PowerShell\Examples\ApiConsole.ps1 ` + -SharedDataDir .\shared ` + -UserDataDir .\user +#> + +[CmdletBinding()] +param( + [string]$SharedDataDir = 'shared', + + [string]$UserDataDir = 'user', + + [string]$AppName = 'rime.console' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$moduleDirectory = Split-Path -Parent $PSScriptRoot +$moduleManifest = Join-Path $moduleDirectory 'RimeSharp.PowerShell.psd1' + +Import-Module -Name $moduleManifest -ErrorAction Stop + +function Write-ConsoleError { + param( + [Parameter(Mandatory = $true)] + [string]$Message + ) + + $Host.UI.WriteErrorLine($Message) +} + +function Show-RimeStatusSnapshot { + param( + [Parameter(Mandatory = $true)] + [object]$Status + ) + + Write-Host ("schema: {0} / {1}" -f $Status.SchemaId, $Status.SchemaName) + + $flags = [System.Collections.Generic.List[string]]::new() + if ($Status.IsDisabled) { + $flags.Add('disabled') + } + if ($Status.IsComposing) { + $flags.Add('composing') + } + if ($Status.IsAsciiMode) { + $flags.Add('ascii') + } + if ($Status.IsFullShape) { + $flags.Add('full_shape') + } + if ($Status.IsSimplified) { + $flags.Add('simplified') + } + if ($Status.IsTraditional) { + $flags.Add('traditional') + } + if ($Status.IsAsciiPunct) { + $flags.Add('ascii_punct') + } + + Write-Host ("status: {0}" -f ($flags -join ' ')) +} + +function Show-RimeCompositionSnapshot { + param( + [Parameter(Mandatory = $true)] + [object]$Composition + ) + + if ($null -eq $Composition.Preedit) { + return + } + + $preedit = $Composition.Preedit + $line = [System.Text.StringBuilder]::new() + + for ($i = 0; $i -le $preedit.Length; $i++) { + if ($Composition.SelectionStart -lt $Composition.SelectionEnd) { + if ($i -eq $Composition.SelectionStart) { + [void]$line.Append('[') + } + elseif ($i -eq $Composition.SelectionEnd) { + [void]$line.Append(']') + } + } + + if ($i -eq $Composition.CursorPosition) { + [void]$line.Append('|') + } + + if ($i -lt $preedit.Length) { + [void]$line.Append($preedit[$i]) + } + } + + Write-Host ($line.ToString()) +} + +function Show-RimeMenuSnapshot { + param( + [Parameter(Mandatory = $true)] + [object]$Context + ) + + $menu = $Context.Menu + if ($menu.NumCandidates -eq 0) { + return + } + + $pageMarker = if ($menu.IsLastPage) { '$' } else { ' ' } + Write-Host ("page: {0}{1} (of size {2})" -f + ($menu.PageNo + 1), $pageMarker, $menu.PageSize) + + for ($i = 0; $i -lt $menu.Candidates.Count; $i++) { + $candidate = $menu.Candidates[$i] + $label = $i + 1 + + if ($i -lt $Context.SelectLabels.Count -and + -not [string]::IsNullOrEmpty($Context.SelectLabels[$i])) { + $label = $Context.SelectLabels[$i] + } + + $comment = if ([string]::IsNullOrEmpty($candidate.Comment)) { + '' + } + else { + " $($candidate.Comment)" + } + + if ($i -eq $menu.HighlightedCandidateIndex) { + Write-Host ("{0}. [{1}]{2}" -f $label, $candidate.Text, $comment) + } + else { + Write-Host ("{0}. {1}{2}" -f $label, $candidate.Text, $comment) + } + } +} + +function Show-RimeContextSnapshot { + param( + [Parameter(Mandatory = $true)] + [object]$Context + ) + + if ($Context.Composition.Length -gt 0 -or + $Context.Menu.NumCandidates -gt 0) { + Show-RimeCompositionSnapshot -Composition $Context.Composition + } + else { + Write-Host '(not composing)' + } + + Show-RimeMenuSnapshot -Context $Context +} + +function Show-RimeResponse { + param( + [Parameter(Mandatory = $true)] + [object]$Response + ) + + if (-not [string]::IsNullOrEmpty($Response.Commit)) { + Write-Host ("commit: {0}" -f $Response.Commit) + } + + Show-RimeStatusSnapshot -Status $Response.Status + Show-RimeContextSnapshot -Context $Response.Context +} + +function Show-RimeState { + param( + [Parameter(Mandatory = $true)] + [object]$Session + ) + + $commit = Get-RimeCommit -Session $Session + $status = Get-RimeStatus -Session $Session + $context = Get-RimeContext -Session $Session + + if (-not [string]::IsNullOrEmpty($commit)) { + Write-Host ("commit: {0}" -f $commit) + } + + Show-RimeStatusSnapshot -Status $status + Show-RimeContextSnapshot -Context $context +} + +function Receive-AndShowRimeNotification { + $notifications = @(Receive-RimeNotification) + foreach ($notification in $notifications) { + Write-Host ("message: [{0}] [{1}] [{2}]" -f + $notification.SessionId, + $notification.MessageType, + $notification.MessageValue) + + if (-not [string]::IsNullOrEmpty($notification.OptionName) -and + $null -ne $notification.OptionState -and + $null -ne $notification.Session) { + try { + $label = Get-RimeStateLabel ` + -Name $notification.OptionName ` + -State ([bool]$notification.OptionState) ` + -Session $notification.Session + + if (-not [string]::IsNullOrEmpty($label)) { + Write-Host ("updated option: {0} = {1} // {2}" -f + $notification.OptionName, + $notification.OptionState, + $label) + } + } + catch { + Write-ConsoleError ( + "Unable to resolve the option state label: {0}" -f + $_.Exception.Message) + } + } + } +} + +function ConvertTo-ZeroBasedIndex { + param( + [Parameter(Mandatory = $true)] + [string]$Value, + + [Parameter(Mandatory = $true)] + [string]$Description + ) + + $parsed = 0 + if (-not [int]::TryParse($Value, [ref]$parsed) -or $parsed -lt 1) { + Write-ConsoleError ( + "Invalid {0}: '{1}'. Enter a positive integer." -f + $Description, + $Value) + return $null + } + + return $parsed - 1 +} + +function Show-ApiConsoleHelp { + Write-Host @' +Commands: + help + Show this command list. + print + Print the current commit, status, composition, and candidate page. + print schema list + List schemas and mark the current schema. + print available schemas + List schemas available through the RIME switcher. + print selected schemas + List schemas selected in the RIME switcher. + select schema SCHEMA_ID + Select a schema. + print candidate list + Enumerate all candidates using one-based display indexes. + select candidate INDEX + Select a candidate on the current page. + delete INDEX + Delete a candidate by its global display index. + delete on current page INDEX + Delete a candidate by its current-page display index. + highlight candidate INDEX + Highlight a candidate on the current page. + prev + Move to the previous candidate page. + next + Move to the next candidate page. + get option OPTION + Print the boolean value of an option. + set option OPTION + Enable an option. + set option !OPTION + Disable an option. + key event KEY_CODE [MASK] + Send a raw key event and optional modifier mask. + synchronize + Report that user-data synchronization is not exposed by this module. + reload + Recreate the RIME engine and session. + exit + Stop RIME and leave the console. + +Any other line is passed to Send-RimeKey as a key sequence. An empty line sends +a carriage return, matching librime's rime_api_console behavior. +'@ +} + +function Start-ApiConsoleSession { + Write-Host 'initializing...' + $startedSession = Start-Rime ` + -AppName $AppName ` + -SharedDataDir $SharedDataDir ` + -UserDataDir $UserDataDir + Write-Host 'ready.' + return $startedSession +} + +function Invoke-ApiConsoleCommand { + param( + [Parameter(Mandatory = $true)] + [string]$Line, + + [Parameter(Mandatory = $true)] + [object]$Session + ) + + switch -Regex ($Line) { + '^help$' { + Show-ApiConsoleHelp + return $true + } + + '^print$' { + Show-RimeState -Session $Session + return $true + } + + '^print schema list$' { + $schemas = @(Get-RimeSchema -Session $Session) + Write-Host 'schema list:' + + for ($i = 0; $i -lt $schemas.Count; $i++) { + $currentMarker = if ($schemas[$i].IsCurrent) { '*' } else { ' ' } + Write-Host ("{0}{1}. {2} [{3}]" -f + $currentMarker, + ($i + 1), + $schemas[$i].SchemaId, + $schemas[$i].Name) + } + + $current = $schemas | Where-Object IsCurrent | Select-Object -First 1 + if ($null -ne $current) { + Write-Host ("current schema: [{0}]" -f $current.SchemaId) + } + return $true + } + + '^print available schemas$' { + $schemas = @(Get-RimeSwitcherSchema -Available) + Write-Host 'available schemas:' + for ($i = 0; $i -lt $schemas.Count; $i++) { + Write-Host ("{0}. {1} [{2}]" -f + ($i + 1), + $schemas[$i].SchemaId, + $schemas[$i].Name) + } + return $true + } + + '^print selected schemas$' { + $schemas = @(Get-RimeSwitcherSchema -Selected) + Write-Host 'selected schemas:' + for ($i = 0; $i -lt $schemas.Count; $i++) { + Write-Host ("{0}. {1} [{2}]" -f + ($i + 1), + $schemas[$i].SchemaId, + $schemas[$i].Name) + } + return $true + } + + '^select schema (.+)$' { + $schemaId = $Matches[1] + Set-RimeSchema -SchemaId $schemaId -Session $Session + Write-Host ("selected schema: [{0}]" -f $schemaId) + Show-RimeState -Session $Session + return $true + } + + '^print candidate list$' { + $candidates = @(Get-RimeCandidate -Session $Session) + if ($candidates.Count -eq 0) { + Write-Host 'no candidates.' + return $true + } + + foreach ($candidate in $candidates) { + $comment = if ([string]::IsNullOrEmpty($candidate.Comment)) { + '' + } + else { + " ($($candidate.Comment))" + } + + Write-Host ("{0}. {1}{2}" -f + ($candidate.Index + 1), + $candidate.Text, + $comment) + } + return $true + } + + '^select candidate (.+)$' { + $index = ConvertTo-ZeroBasedIndex ` + -Value $Matches[1] ` + -Description 'candidate index' + if ($null -ne $index) { + Select-RimeCandidate ` + -Index $index ` + -OnCurrentPage ` + -Session $Session + Show-RimeState -Session $Session + } + return $true + } + + '^delete on current page (.+)$' { + $index = ConvertTo-ZeroBasedIndex ` + -Value $Matches[1] ` + -Description 'candidate index' + if ($null -ne $index) { + Remove-RimeCandidate ` + -Index $index ` + -OnCurrentPage ` + -Session $Session ` + -Confirm:$false + Show-RimeState -Session $Session + } + return $true + } + + '^delete (.+)$' { + $index = ConvertTo-ZeroBasedIndex ` + -Value $Matches[1] ` + -Description 'candidate index' + if ($null -ne $index) { + Remove-RimeCandidate ` + -Index $index ` + -Session $Session ` + -Confirm:$false + Show-RimeState -Session $Session + } + return $true + } + + '^highlight candidate (.+)$' { + $index = ConvertTo-ZeroBasedIndex ` + -Value $Matches[1] ` + -Description 'candidate index' + if ($null -ne $index) { + Invoke-RimeHighlight ` + -Index $index ` + -OnCurrentPage ` + -Session $Session + Show-RimeState -Session $Session + } + return $true + } + + '^set option (.+)$' { + $option = $Matches[1] + $isOn = $true + + if ($option.StartsWith('!')) { + $isOn = $false + $option = $option.Substring(1) + } + + if ([string]::IsNullOrWhiteSpace($option)) { + Write-ConsoleError 'An option name is required.' + return $true + } + + Set-RimeOption ` + -Name $option ` + -Value $isOn ` + -Session $Session + Write-Host ("{0} set {1}." -f + $option, + $(if ($isOn) { 'on' } else { 'off' })) + return $true + } + + '^get option (.+)$' { + $option = $Matches[1] + $value = Get-RimeOption -Name $option -Session $Session + Write-Host ("{0} = {1}" -f $option, $value) + return $true + } + + '^prev$' { + Set-RimePage -Direction Previous -Session $Session + Show-RimeState -Session $Session + return $true + } + + '^next$' { + Set-RimePage -Direction Next -Session $Session + Show-RimeState -Session $Session + return $true + } + + '^key event (-?\d+)(?:\s+(-?\d+))?$' { + $keyCode = [int]$Matches[1] + $mask = if ($Matches.ContainsKey(2)) { + [int]$Matches[2] + } + else { + 0 + } + + $handled = Send-RimeKeyEvent ` + -KeyCode $keyCode ` + -Mask $mask ` + -Session $Session + Write-Host ("handled: {0}" -f $handled) + if ($handled) { + Show-RimeState -Session $Session + } + return $true + } + + '^synchronize$' { + Write-ConsoleError ( + 'User-data synchronization is not exposed by ' + + 'RimeSharp.PowerShell.') + return $true + } + + '^reload$' { + $script:reloadRequested = $true + return $true + } + + '^exit$' { + $script:exitRequested = $true + return $true + } + } + + return $false +} + +$script:exitRequested = $false +$script:reloadRequested = $false +$session = $null + +Register-RimeNotification + +try { + $session = Start-ApiConsoleSession + Receive-AndShowRimeNotification + Show-ApiConsoleHelp + + while (-not $script:exitRequested) { + Write-Host 'rime> ' -NoNewline + $line = [Console]::ReadLine() + + if ($null -eq $line) { + break + } + + if ($line.Length -eq 0) { + $line = "`r" + } + + try { + $handled = Invoke-ApiConsoleCommand ` + -Line $line ` + -Session $session + + Receive-AndShowRimeNotification + + if ($script:reloadRequested) { + $script:reloadRequested = $false + Stop-Rime -Session $session + $session = $null + try { + $session = Start-ApiConsoleSession + } + catch { + $script:exitRequested = $true + throw + } + Receive-AndShowRimeNotification + continue + } + + if ($handled) { + continue + } + + $response = Send-RimeKey ` + -Sequence $line ` + -Session $session + Show-RimeResponse -Response $response + Receive-AndShowRimeNotification + } + catch { + Write-ConsoleError $_.Exception.Message + } + } +} +finally { + if ($null -ne $session) { + try { + Stop-Rime -Session $session + } + catch { + Write-ConsoleError ( + "Failed to stop RIME cleanly: {0}" -f $_.Exception.Message) + } + } +} diff --git a/RimeSharp.PowerShell/RimeSharp.PowerShell.csproj b/RimeSharp.PowerShell/RimeSharp.PowerShell.csproj new file mode 100644 index 0000000..c41301b --- /dev/null +++ b/RimeSharp.PowerShell/RimeSharp.PowerShell.csproj @@ -0,0 +1,26 @@ + + + + net8.0;net472 + 12.0 + enable + enable + + + + + + + + + + + All + + + + + + + + diff --git a/RimeSharp.PowerShell/RimeSharp.PowerShell.psd1 b/RimeSharp.PowerShell/RimeSharp.PowerShell.psd1 new file mode 100644 index 0000000..18967bf --- /dev/null +++ b/RimeSharp.PowerShell/RimeSharp.PowerShell.psd1 @@ -0,0 +1,44 @@ +@{ + RootModule = 'RimeSharp.PowerShell.psm1' + ModuleVersion = '0.1.0' + GUID = 'a3f1b8c7-2d4e-5f6a-8b9c-0d1e2f3a4b5c' + Author = 'RimeInn Contributors' + CompanyName = 'RimeInn' + Copyright = '(c) RimeInn Contributors. Apache 2.0.' + Description = 'PowerShell cmdlets for RIME Input Method Engine' + PowerShellVersion = '5.1' + RequiredAssemblies = @() + RequiredModules = @() + FunctionsToExport = @() + CmdletsToExport = @( + 'Start-Rime' + 'Stop-Rime' + 'Send-RimeKey' + 'Send-RimeKeyEvent' + 'Get-RimeCommit' + 'Get-RimeContext' + 'Get-RimeStatus' + 'Get-RimeCandidate' + 'Select-RimeCandidate' + 'Set-RimePage' + 'Get-RimeSchema' + 'Set-RimeSchema' + 'Get-RimeSwitcherSchema' + 'Get-RimeOption' + 'Set-RimeOption' + 'Get-RimeStateLabel' + 'Remove-RimeCandidate' + 'Invoke-RimeHighlight' + 'Register-RimeNotification' + 'Receive-RimeNotification' + ) + VariablesToExport = @() + AliasesToExport = @() + PrivateData = @{ + PSData = @{ + Tags = @('RIME', 'InputMethod', 'IME') + LicenseUri = 'https://github.com/rimeinn/RimeSharp/blob/master/LICENSE.txt' + ProjectUri = 'https://github.com/rimeinn/RimeSharp' + } + } +} diff --git a/RimeSharp.PowerShell/RimeSharp.PowerShell.psm1 b/RimeSharp.PowerShell/RimeSharp.PowerShell.psm1 new file mode 100644 index 0000000..927649d --- /dev/null +++ b/RimeSharp.PowerShell/RimeSharp.PowerShell.psm1 @@ -0,0 +1,124 @@ +# PowerShell 7+ runs on CoreCLR (.NET 8) — can only load net8.0 assemblies. +# Windows PowerShell 5.1 runs on .NET Framework CLR — can only load net472 assemblies. +# Detection is based on PSEdition, not on what runtimes are installed on the machine. + +# Packaged PowerShell hosts do not ask the Windows loader to search PATH for +# native libraries. Resolve LIBRIME_LIB_DIR and PATH explicitly, then preload +# rime.dll before any managed RIME type can initialize its P/Invoke table. +if ($env:OS -eq 'Windows_NT') { + $librimeLibDir = [Environment]::GetEnvironmentVariable('LIBRIME_LIB_DIR') + $searchDirectories = @() + if (-not [string]::IsNullOrWhiteSpace($librimeLibDir)) { + $searchDirectories += $librimeLibDir + } + if (-not [string]::IsNullOrWhiteSpace($env:PATH)) { + $searchDirectories += $env:PATH -split [IO.Path]::PathSeparator + } + + $rimeDll = $null + foreach ($directory in $searchDirectories) { + if ([string]::IsNullOrWhiteSpace($directory)) { continue } + + $directory = [Environment]::ExpandEnvironmentVariables( + $directory.Trim().Trim([char]'"')) + $candidate = [IO.Path]::Combine($directory, 'rime.dll') + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + $rimeDll = (Resolve-Path -LiteralPath $candidate).ProviderPath + break + } + } + + if ($null -ne $rimeDll) { + if (-not ('RimeSharp.PowerShell.NativeLibraryLoader' -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; + +namespace RimeSharp.PowerShell +{ + public static class NativeLibraryLoader + { + private const uint LoadLibrarySearchDllLoadDir = 0x00000100; + private const uint LoadLibrarySearchDefaultDirs = 0x00001000; + + [DllImport("kernel32.dll", EntryPoint = "LoadLibraryExW", + CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)] + private static extern IntPtr LoadLibraryEx( + string fileName, + IntPtr fileHandle, + uint flags); + + public static IntPtr Load(string path) + { + var handle = LoadLibraryEx( + path, + IntPtr.Zero, + LoadLibrarySearchDllLoadDir | LoadLibrarySearchDefaultDirs); + if (handle == IntPtr.Zero) + { + throw new Win32Exception( + Marshal.GetLastWin32Error(), + "Failed to load native RIME library: " + path); + } + + return handle; + } + } +} +'@ + } + + $script:rimeNativeHandle = + [RimeSharp.PowerShell.NativeLibraryLoader]::Load($rimeDll) + } elseif (-not [string]::IsNullOrWhiteSpace($librimeLibDir)) { + throw "rime.dll not found in LIBRIME_LIB_DIR or PATH." + } +} + +$tf = if ($PSVersionTable.PSEdition -eq 'Core') { 'net8.0' } else { 'net472' } +$moduleDir = $PSScriptRoot + +# Probe Release first (published module), then Debug (development). +$loaded = $false +foreach ($config in @('Release', 'Debug')) { + $assemblyDir = Join-Path (Join-Path (Join-Path $moduleDir 'bin') $config) $tf + $binaryPath = Join-Path $assemblyDir 'RimeSharp.PowerShell.dll' + if (-not (Test-Path $binaryPath)) { continue } + + $rimeAssembly = Join-Path $assemblyDir 'RimeSharp.dll' + if (Test-Path $rimeAssembly) { + Add-Type -Path $rimeAssembly + } + + Import-Module -Name $binaryPath -DisableNameChecking + $loaded = $true + break +} + +if (-not $loaded) { + throw "RimeSharp.PowerShell.dll not found. Run build.ps1 first." +} + +Export-ModuleMember -Cmdlet @( + 'Start-Rime' + 'Stop-Rime' + 'Send-RimeKey' + 'Send-RimeKeyEvent' + 'Get-RimeCommit' + 'Get-RimeContext' + 'Get-RimeStatus' + 'Get-RimeCandidate' + 'Select-RimeCandidate' + 'Set-RimePage' + 'Get-RimeSchema' + 'Set-RimeSchema' + 'Get-RimeSwitcherSchema' + 'Get-RimeOption' + 'Set-RimeOption' + 'Get-RimeStateLabel' + 'Remove-RimeCandidate' + 'Invoke-RimeHighlight' + 'Register-RimeNotification' + 'Receive-RimeNotification' +) diff --git a/RimeSharp.PowerShell/Types/RimeCandidateInfo.cs b/RimeSharp.PowerShell/Types/RimeCandidateInfo.cs new file mode 100644 index 0000000..bcc30fb --- /dev/null +++ b/RimeSharp.PowerShell/Types/RimeCandidateInfo.cs @@ -0,0 +1,18 @@ +namespace RimeSharp.PowerShell; + +/// +/// Describes a RIME candidate and its global index. +/// +public sealed class RimeCandidateInfo +{ + public int Index { get; } + public string Text { get; } + public string? Comment { get; } + + internal RimeCandidateInfo(int index, RimeCandidate candidate) + { + Index = index; + Text = candidate.Text; + Comment = candidate.Comment; + } +} diff --git a/RimeSharp.PowerShell/Types/RimeNotification.cs b/RimeSharp.PowerShell/Types/RimeNotification.cs new file mode 100644 index 0000000..3997686 --- /dev/null +++ b/RimeSharp.PowerShell/Types/RimeNotification.cs @@ -0,0 +1,35 @@ +namespace RimeSharp.PowerShell; + +/// +/// Fully managed notification raised by the native RIME engine. +/// +public sealed class RimeNotification +{ + public ulong ContextObject { get; } + public ulong SessionId { get; } + public RimeSession? Session { get; } + public string MessageType { get; } + public string MessageValue { get; } + public string? OptionName { get; } + public bool? OptionState { get; } + + internal RimeNotification( + UIntPtr contextObject, + UIntPtr sessionId, + string messageType, + string messageValue) + { + ContextObject = contextObject.ToUInt64(); + SessionId = sessionId.ToUInt64(); + Session = sessionId == UIntPtr.Zero ? null : new RimeSession(sessionId); + MessageType = messageType; + MessageValue = messageValue; + + if (string.Equals(messageType, "option", StringComparison.Ordinal) + && !string.IsNullOrEmpty(messageValue)) + { + OptionState = messageValue[0] != '!'; + OptionName = OptionState.Value ? messageValue : messageValue.Substring(1); + } + } +} diff --git a/RimeSharp.PowerShell/Types/RimeResponse.cs b/RimeSharp.PowerShell/Types/RimeResponse.cs new file mode 100644 index 0000000..567ee39 --- /dev/null +++ b/RimeSharp.PowerShell/Types/RimeResponse.cs @@ -0,0 +1,28 @@ +namespace RimeSharp.PowerShell; + +/// +/// Composite result returned by Send-RimeKey containing all render-relevant +/// state after processing a key sequence. The response is fully managed and owns +/// no native resources. +/// +public sealed class RimeResponse +{ + /// Committed text, if any. + public string? Commit { get; } + + /// Current engine status flags (schema, mode, composing…). + public RimeStatusSnapshot Status { get; } + + /// Current input context (preedit, candidates, menu). + public RimeContextSnapshot Context { get; } + + internal RimeResponse( + string? commit, + RimeStatusSnapshot status, + RimeContextSnapshot context) + { + Commit = commit; + Status = status; + Context = context; + } +} diff --git a/RimeSharp.PowerShell/Types/RimeSchemaInfo.cs b/RimeSharp.PowerShell/Types/RimeSchemaInfo.cs new file mode 100644 index 0000000..d92488d --- /dev/null +++ b/RimeSharp.PowerShell/Types/RimeSchemaInfo.cs @@ -0,0 +1,18 @@ +namespace RimeSharp.PowerShell; + +/// +/// Describes an available RIME schema and whether it is active for the session. +/// +public sealed class RimeSchemaInfo +{ + public string SchemaId { get; } + public string Name { get; } + public bool IsCurrent { get; } + + internal RimeSchemaInfo(string schemaId, string name, bool isCurrent) + { + SchemaId = schemaId; + Name = name; + IsCurrent = isCurrent; + } +} diff --git a/RimeSharp.PowerShell/Types/RimeSession.cs b/RimeSharp.PowerShell/Types/RimeSession.cs new file mode 100644 index 0000000..7cbb370 --- /dev/null +++ b/RimeSharp.PowerShell/Types/RimeSession.cs @@ -0,0 +1,15 @@ +namespace RimeSharp.PowerShell; + +/// +/// Wraps a native RIME session ID for pipeline binding. +/// Not constructable by user code — created by Start-Rime. +/// +public sealed class RimeSession +{ + internal UIntPtr Id { get; } + + internal RimeSession(UIntPtr id) + { + Id = id; + } +} diff --git a/RimeSharp.PowerShell/Types/RimeSnapshots.cs b/RimeSharp.PowerShell/Types/RimeSnapshots.cs new file mode 100644 index 0000000..7dfcb13 --- /dev/null +++ b/RimeSharp.PowerShell/Types/RimeSnapshots.cs @@ -0,0 +1,131 @@ +using System.Text; + +namespace RimeSharp.PowerShell; + +/// +/// Managed snapshot of a RIME composition. +/// +/// +/// Length and position properties use UTF-16 code-unit indexes so that they +/// can be applied directly to the .NET string. +/// +public sealed class RimeCompositionSnapshot +{ + public int Length { get; } + public int CursorPosition { get; } + public int SelectionStart { get; } + public int SelectionEnd { get; } + public string? Preedit { get; } + + internal RimeCompositionSnapshot(RimeComposition composition) + { + Preedit = composition.Preedit; + if (Preedit is null) return; + + Length = Preedit.Length; + var utf8Preedit = Encoding.UTF8.GetBytes(Preedit); + CursorPosition = ToUtf16Index(utf8Preedit, composition.CursorPos); + SelectionStart = ToUtf16Index(utf8Preedit, composition.SelStart); + SelectionEnd = ToUtf16Index(utf8Preedit, composition.SelEnd); + } + + private static int ToUtf16Index(byte[] utf8Text, int utf8Offset) + { + var clampedOffset = Math.Min(Math.Max(utf8Offset, 0), utf8Text.Length); + return Encoding.UTF8.GetCharCount(utf8Text, 0, clampedOffset); + } +} + +/// +/// Managed snapshot of a RIME candidate. +/// +public sealed class RimeCandidateSnapshot +{ + public string Text { get; } + public string? Comment { get; } + + internal RimeCandidateSnapshot(RimeCandidate candidate) + { + Text = candidate.Text; + Comment = candidate.Comment; + } +} + +/// +/// Managed snapshot of a RIME candidate menu. +/// +public sealed class RimeMenuSnapshot +{ + public int PageSize { get; } + public int PageNo { get; } + public bool IsLastPage { get; } + public int HighlightedCandidateIndex { get; } + public int NumCandidates { get; } + public string SelectKeys { get; } + public RimeCandidateSnapshot[] Candidates { get; } + + internal RimeMenuSnapshot(RimeMenu menu) + { + PageSize = menu.PageSize; + PageNo = menu.PageNo; + IsLastPage = menu.IsLastPage; + HighlightedCandidateIndex = menu.HighlightedCandidateIndex; + NumCandidates = menu.NumCandidates; + SelectKeys = menu.SelectKeys; + + var candidates = menu.Candidates; + Candidates = new RimeCandidateSnapshot[candidates.Length]; + for (var i = 0; i < candidates.Length; ++i) + { + Candidates[i] = new RimeCandidateSnapshot(candidates[i]); + } + } +} + +/// +/// Managed snapshot of a RIME input context. +/// +public sealed class RimeContextSnapshot +{ + public RimeCompositionSnapshot Composition { get; } + public RimeMenuSnapshot Menu { get; } + public string? CommitTextPreview { get; } + public string?[] SelectLabels { get; } + + internal RimeContextSnapshot(RimeContext context) + { + Composition = new RimeCompositionSnapshot(context.Composition); + Menu = new RimeMenuSnapshot(context.Menu); + CommitTextPreview = context.CommitTextPreview; + SelectLabels = context.SelectLabels; + } +} + +/// +/// Managed snapshot of RIME engine status. +/// +public sealed class RimeStatusSnapshot +{ + public string? SchemaId { get; } + public string? SchemaName { get; } + public bool IsDisabled { get; } + public bool IsComposing { get; } + public bool IsAsciiMode { get; } + public bool IsFullShape { get; } + public bool IsSimplified { get; } + public bool IsTraditional { get; } + public bool IsAsciiPunct { get; } + + internal RimeStatusSnapshot(RimeStatus status) + { + SchemaId = status.SchemaId; + SchemaName = status.SchemaName; + IsDisabled = status.IsDisabled; + IsComposing = status.IsComposing; + IsAsciiMode = status.IsAsciiMode; + IsFullShape = status.IsFullShape; + IsSimplified = status.IsSimplified; + IsTraditional = status.IsTraditional; + IsAsciiPunct = status.IsAsciiPunct; + } +} diff --git a/RimeSharp.PowerShell/Types/RimeSwitcherSchemaInfo.cs b/RimeSharp.PowerShell/Types/RimeSwitcherSchemaInfo.cs new file mode 100644 index 0000000..75664fc --- /dev/null +++ b/RimeSharp.PowerShell/Types/RimeSwitcherSchemaInfo.cs @@ -0,0 +1,16 @@ +namespace RimeSharp.PowerShell; + +/// +/// Describes a schema listed in the RIME switcher settings. +/// +public sealed class RimeSwitcherSchemaInfo +{ + public string SchemaId { get; } + public string Name { get; } + + internal RimeSwitcherSchemaInfo(string schemaId, string name) + { + SchemaId = schemaId; + Name = name; + } +} diff --git a/RimeSharp.PowerShell/build.ps1 b/RimeSharp.PowerShell/build.ps1 new file mode 100644 index 0000000..0416857 --- /dev/null +++ b/RimeSharp.PowerShell/build.ps1 @@ -0,0 +1,40 @@ +<# +.SYNOPSIS + Builds RimeSharp.PowerShell and copies outputs into the module layout. + +.DESCRIPTION + Runs `dotnet build` for both target frameworks, then assembles the + output DLLs (RimeSharp.dll + RimeSharp.PowerShell.dll) into framework + subdirectories under the module folder so it is ready for Import-Module. + + The result directory structure: + RimeSharp.PowerShell/ + ├── RimeSharp.PowerShell.psd1 + ├── RimeSharp.PowerShell.psm1 + ├── net8.0/ + │ ├── RimeSharp.dll + │ └── RimeSharp.PowerShell.dll + └── net472/ + ├── RimeSharp.dll + └── RimeSharp.PowerShell.dll +#> + +[CmdletBinding()] +param( + [ValidateSet("Debug", "Release")] + [string]$Configuration = "Debug" +) + +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent $PSScriptRoot + +Write-Host "Building RimeSharp (multi-target)..." -ForegroundColor Cyan +dotnet build "$root\RimeSharp\RimeSharp.csproj" -c $Configuration +if ($LASTEXITCODE -ne 0) { throw "RimeSharp build failed." } + +Write-Host "Building RimeSharp.PowerShell (multi-target)..." -ForegroundColor Cyan +dotnet build "$PSScriptRoot\RimeSharp.PowerShell.csproj" -c $Configuration +if ($LASTEXITCODE -ne 0) { throw "RimeSharp.PowerShell build failed." } + +Write-Host "Module ready at: $PSScriptRoot" -ForegroundColor Green +Write-Host "Import with: Import-Module $PSScriptRoot\RimeSharp.PowerShell.psd1" -ForegroundColor DarkGray diff --git a/RimeSharp.sln b/RimeSharp.sln index 5132e50..fd0f435 100644 --- a/RimeSharp.sln +++ b/RimeSharp.sln @@ -7,6 +7,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RimeSharp", "RimeSharp\Rime EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RimeSharp.Test", "RimeSharp.Test\RimeSharp.Test.csproj", "{D0B0625A-3B14-4FD1-8F1F-2E1DE22F3B58}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RimeSharp.PowerShell", "RimeSharp.PowerShell\RimeSharp.PowerShell.csproj", "{6F8D6C48-E0D4-4F86-9E1E-26157D61B5E4}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -21,6 +23,10 @@ Global {D0B0625A-3B14-4FD1-8F1F-2E1DE22F3B58}.Debug|Any CPU.Build.0 = Debug|Any CPU {D0B0625A-3B14-4FD1-8F1F-2E1DE22F3B58}.Release|Any CPU.ActiveCfg = Release|Any CPU {D0B0625A-3B14-4FD1-8F1F-2E1DE22F3B58}.Release|Any CPU.Build.0 = Release|Any CPU + {6F8D6C48-E0D4-4F86-9E1E-26157D61B5E4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6F8D6C48-E0D4-4F86-9E1E-26157D61B5E4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6F8D6C48-E0D4-4F86-9E1E-26157D61B5E4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6F8D6C48-E0D4-4F86-9E1E-26157D61B5E4}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE