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
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);