reovim_server/session/client.rs
1//! Per-client role and editing state (#480 Client Architecture Unification).
2//!
3//! Defines the unified `Client` struct that replaces the old enum model.
4//! All clients now have `EditingState` for local viewport, smooth transitions,
5//! and pending keys buffer. The `relation` field controls input routing.
6//!
7//! # Architecture
8//!
9//! ```text
10//! Session (room with shared buffers)
11//! └─ clients: HashMap<ClientId, Client>
12//! └─ Client { id, relation, state, metadata }
13//! ├─ relation: None ← Independent (input → self)
14//! ├─ relation: Following(X) ← Spectator (input ignored)
15//! └─ relation: Sharing(X) ← Collaboration (input → X)
16//! ```
17//!
18//! # Behavior Matrix
19//!
20//! | Relation | My Input | I See | Use Case |
21//! |--------------|----------------|----------------|---------------------|
22//! | None | → my state | my state | Solo editing |
23//! | Following(X) | ignored | X's state | Spectator/present |
24//! | Sharing(X) | → X's state | X's state | Pair programming |
25//!
26//! # State Transitions
27//!
28//! ```text
29//! Independent ←→ Following(B) ←→ Sharing(B) ←→ Independent
30//! ```
31//!
32//! Transitions are validated by [`Client::try_set_relation()`] which returns
33//! [`TransitionResult`] indicating success or the required precondition.
34//!
35//! # Validation Rules
36//!
37//! - Cannot follow/share with self
38//! - Cannot create cycles (A → B → A)
39//! - Target client must exist
40//! - Following → Sharing upgrade may require cursor sync
41
42use std::{collections::HashMap, time::SystemTime};
43
44use {
45 reovim_driver_layout::RootCompositor,
46 reovim_driver_session::{
47 CursorPosition, ExtensionMap, KeySequence, Selection, SelectionMode, TabPageSet, Viewport,
48 Window, WindowLayout,
49 },
50 reovim_kernel::api::v1::{BufferId, HistoryRing, Jumplist, MarkBank, ModeStack, RegisterBank},
51};
52
53use super::{ClientId, ring_buffer::ClientRingBuffer};
54
55// ============================================================================
56// ClientRelation
57// ============================================================================
58
59/// Relation to another client for input routing.
60///
61/// Used as `Option<ClientRelation>` where `None` means independent.
62///
63/// # Invariants
64///
65/// - `Following { target }`: Input is ignored, sees target's state
66/// - `Sharing { with }`: Input routes to target's state, sees target's state
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum ClientRelation {
69 /// Read-only spectator. Input is ignored, sees target's state.
70 Following {
71 /// Client ID being followed.
72 target: ClientId,
73 },
74 /// Bidirectional co-editing. Input goes to target's state.
75 Sharing {
76 /// Client ID to share input with.
77 with: ClientId,
78 },
79}
80
81impl ClientRelation {
82 /// Get the target client ID.
83 #[must_use]
84 pub const fn target_id(&self) -> ClientId {
85 match *self {
86 Self::Following { target } => target,
87 Self::Sharing { with } => with,
88 }
89 }
90
91 /// Check if this is a Following relation.
92 #[must_use]
93 pub const fn is_following(&self) -> bool {
94 matches!(self, Self::Following { .. })
95 }
96
97 /// Check if this is a Sharing relation.
98 #[must_use]
99 pub const fn is_sharing(&self) -> bool {
100 matches!(self, Self::Sharing { .. })
101 }
102}
103
104// ============================================================================
105// TransitionResult
106// ============================================================================
107
108/// Result of a relation transition attempt.
109///
110/// Returned by [`Client::try_set_relation()`] to indicate success or
111/// the precondition that must be met before the transition can proceed.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub enum TransitionResult {
114 /// Transition succeeded.
115 Ok,
116 /// Transition requires cursor sync first (for Following → Sharing upgrade).
117 ///
118 /// The caller should sync the cursor to the target position, then retry.
119 RequiresCursorSync {
120 /// Current cursor position.
121 current: CursorPosition,
122 /// Target cursor position to sync to.
123 target: CursorPosition,
124 },
125 /// Target client not found.
126 TargetNotFound(ClientId),
127 /// Cannot create cycle (A → B → A transitively).
128 WouldCreateCycle,
129 /// Cannot follow/share with self.
130 CannotTargetSelf,
131}
132
133impl TransitionResult {
134 /// Check if the transition succeeded.
135 #[must_use]
136 pub const fn is_ok(&self) -> bool {
137 matches!(self, Self::Ok)
138 }
139
140 /// Check if the transition requires cursor sync.
141 #[must_use]
142 pub const fn requires_cursor_sync(&self) -> bool {
143 matches!(self, Self::RequiresCursorSync { .. })
144 }
145}
146
147// ============================================================================
148// Cycle Detection
149// ============================================================================
150
151/// Check if setting `start` to follow/share `target` would create a cycle.
152///
153/// A cycle occurs if traversing from `target` eventually leads back to `start`.
154fn would_create_cycle(
155 start: ClientId,
156 target: ClientId,
157 clients: &HashMap<ClientId, Client>,
158) -> bool {
159 would_create_cycle_impl(start, target, clients, 10)
160}
161
162fn would_create_cycle_impl(
163 start: ClientId,
164 target: ClientId,
165 clients: &HashMap<ClientId, Client>,
166 depth: usize,
167) -> bool {
168 if depth == 0 {
169 return false; // Safety limit
170 }
171 let Some(target_client) = clients.get(&target) else {
172 return false;
173 };
174 match target_client.relation {
175 Some(
176 ClientRelation::Following { target: next } | ClientRelation::Sharing { with: next },
177 ) => {
178 if next == start {
179 return true;
180 }
181 would_create_cycle_impl(start, next, clients, depth - 1)
182 }
183 None => false,
184 }
185}
186
187// ============================================================================
188// ClientMetadata
189// ============================================================================
190
191/// Metadata about a client connection.
192///
193/// Contains identity information for display and debugging.
194#[derive(Debug, Clone)]
195pub struct ClientMetadata {
196 /// Client type identifier ("tui", "android", "web", "cli").
197 pub client_type: String,
198 /// User-friendly display name ("laptop", "phone").
199 pub display_name: String,
200 /// When the client joined (Unix milliseconds).
201 pub joined_at_ms: u64,
202}
203
204impl ClientMetadata {
205 /// Create new metadata with current timestamp.
206 #[must_use]
207 #[allow(clippy::cast_possible_truncation)] // u128 millis to u64 - safe for next 500M years
208 pub fn new(client_type: impl Into<String>, display_name: impl Into<String>) -> Self {
209 Self {
210 client_type: client_type.into(),
211 display_name: display_name.into(),
212 joined_at_ms: SystemTime::now()
213 .duration_since(SystemTime::UNIX_EPOCH)
214 .map_or(0, |d| d.as_millis() as u64),
215 }
216 }
217
218 /// Create metadata with a specific timestamp (for testing).
219 #[must_use]
220 pub fn with_timestamp(
221 client_type: impl Into<String>,
222 display_name: impl Into<String>,
223 joined_at_ms: u64,
224 ) -> Self {
225 Self {
226 client_type: client_type.into(),
227 display_name: display_name.into(),
228 joined_at_ms,
229 }
230 }
231}
232
233impl Default for ClientMetadata {
234 fn default() -> Self {
235 Self::new("unknown", "unknown")
236 }
237}
238
239// ============================================================================
240// Client
241// ============================================================================
242
243/// A client in a session.
244///
245/// All clients have `EditingState` for local viewport, smooth transitions,
246/// and pending keys buffer. The `relation` field controls input routing.
247///
248/// # Property Invariants
249///
250/// 1. `relation = None` implies client is independent (input → self)
251/// 2. `relation = Some(Following{target})` implies input is ignored
252/// 3. `relation = Some(Sharing{with})` implies input routes to `with`
253/// 4. All clients ALWAYS have `state: EditingState` (non-optional)
254/// 5. No cycles allowed: if A → B, then B cannot → A (directly or transitively)
255#[derive(Debug, Clone)]
256pub struct Client {
257 /// Unique client identifier.
258 pub id: ClientId,
259 /// Relation to another client. `None` = independent.
260 pub relation: Option<ClientRelation>,
261 /// Editing state (mode, cursor, windows, etc.). ALWAYS present.
262 pub state: EditingState,
263 /// Client metadata (type, display name, join time).
264 pub metadata: ClientMetadata,
265 /// Per-client debug ring buffer (Phase #478/#481).
266 pub ring_buffer: ClientRingBuffer,
267}
268
269impl Client {
270 /// Create a new independent client with default editing state.
271 #[must_use]
272 pub fn new(id: ClientId, metadata: ClientMetadata) -> Self {
273 Self {
274 id,
275 relation: None,
276 state: EditingState::default(),
277 metadata,
278 ring_buffer: ClientRingBuffer::new(),
279 }
280 }
281
282 /// Create client with specific mode stack.
283 #[must_use]
284 pub fn with_mode_stack(id: ClientId, metadata: ClientMetadata, mode_stack: ModeStack) -> Self {
285 Self {
286 id,
287 relation: None,
288 state: EditingState::with_mode_stack(mode_stack),
289 metadata,
290 ring_buffer: ClientRingBuffer::new(),
291 }
292 }
293
294 /// Create client with mode stack and initial window.
295 #[must_use]
296 pub fn with_mode_stack_and_window(
297 id: ClientId,
298 metadata: ClientMetadata,
299 mode_stack: ModeStack,
300 window: Window,
301 ) -> Self {
302 Self {
303 id,
304 relation: None,
305 state: EditingState::with_mode_stack_and_window(mode_stack, window),
306 metadata,
307 ring_buffer: ClientRingBuffer::new(),
308 }
309 }
310
311 /// Get the ring buffer for this client.
312 #[must_use]
313 pub const fn ring_buffer(&self) -> &ClientRingBuffer {
314 &self.ring_buffer
315 }
316
317 /// Check if client is independent (no relation).
318 #[must_use]
319 pub const fn is_independent(&self) -> bool {
320 self.relation.is_none()
321 }
322
323 /// Check if client is following another.
324 #[must_use]
325 pub const fn is_following(&self) -> bool {
326 matches!(self.relation, Some(ClientRelation::Following { .. }))
327 }
328
329 /// Check if client is sharing with another.
330 #[must_use]
331 pub const fn is_sharing(&self) -> bool {
332 matches!(self.relation, Some(ClientRelation::Sharing { .. }))
333 }
334
335 /// Get the target client ID (for Following/Sharing), or `None` if independent.
336 #[must_use]
337 pub const fn target_id(&self) -> Option<ClientId> {
338 match self.relation {
339 Some(ClientRelation::Following { target }) => Some(target),
340 Some(ClientRelation::Sharing { with }) => Some(with),
341 None => None,
342 }
343 }
344
345 // ========================================================================
346 // State Transitions (Phase 2)
347 // ========================================================================
348
349 /// Validate a relation change without applying it.
350 ///
351 /// This is a static method that checks if a relation change is valid
352 /// without mutating the client. Used by `Session::set_client_relation()`.
353 ///
354 /// # Validation Rules
355 ///
356 /// 1. Cannot target self (returns `CannotTargetSelf`)
357 /// 2. Target must exist (returns `TargetNotFound`)
358 /// 3. Cannot create cycles (returns `WouldCreateCycle`)
359 /// 4. Following → Sharing with same target may require cursor sync
360 #[must_use]
361 pub fn validate_relation_change(
362 client: &Self,
363 new_relation: Option<ClientRelation>,
364 clients: &HashMap<ClientId, Self>,
365 ) -> TransitionResult {
366 // Check self-reference
367 if let Some(
368 ClientRelation::Following { target } | ClientRelation::Sharing { with: target },
369 ) = new_relation
370 {
371 if target == client.id {
372 return TransitionResult::CannotTargetSelf;
373 }
374 // Check target exists
375 let Some(target_client) = clients.get(&target) else {
376 return TransitionResult::TargetNotFound(target);
377 };
378 // Check for cycles
379 if would_create_cycle(client.id, target, clients) {
380 return TransitionResult::WouldCreateCycle;
381 }
382
383 // Edge case: Following → Sharing with same target requires cursor sync
384 if let (
385 Some(ClientRelation::Following { target: old_target }),
386 Some(ClientRelation::Sharing { with: new_target }),
387 ) = (&client.relation, &new_relation)
388 {
389 // Only check cursor sync when upgrading from Follow to Share with same target
390 if old_target == new_target {
391 let target_cursor = target_client
392 .state
393 .windows
394 .active()
395 .map_or_else(CursorPosition::default, |w| w.cursor);
396 let my_cursor = client
397 .state
398 .windows
399 .active()
400 .map_or_else(CursorPosition::default, |w| w.cursor);
401 if my_cursor != target_cursor {
402 return TransitionResult::RequiresCursorSync {
403 current: my_cursor,
404 target: target_cursor,
405 };
406 }
407 }
408 }
409 }
410
411 TransitionResult::Ok
412 }
413
414 /// Attempt to change relation with validation.
415 ///
416 /// Returns [`TransitionResult`] indicating success or required preconditions.
417 ///
418 /// # Validation Rules
419 ///
420 /// 1. Cannot target self (returns `CannotTargetSelf`)
421 /// 2. Target must exist (returns `TargetNotFound`)
422 /// 3. Cannot create cycles (returns `WouldCreateCycle`)
423 /// 4. Following → Sharing with same target may require cursor sync
424 ///
425 /// # Examples
426 ///
427 /// ```ignore
428 /// // Independent to Following
429 /// let result = client.try_set_relation(
430 /// Some(ClientRelation::Following { target: other_id }),
431 /// &clients,
432 /// );
433 /// assert!(result.is_ok());
434 ///
435 /// // Back to independent
436 /// let result = client.try_set_relation(None, &clients);
437 /// assert!(result.is_ok());
438 /// ```
439 pub fn try_set_relation(
440 &mut self,
441 new_relation: Option<ClientRelation>,
442 clients: &HashMap<ClientId, Self>,
443 ) -> TransitionResult {
444 let result = Self::validate_relation_change(self, new_relation, clients);
445 if result.is_ok() {
446 self.relation = new_relation;
447 }
448 result
449 }
450
451 /// Sync cursor to target client's position.
452 ///
453 /// Used for Following → Sharing transitions that require cursor alignment.
454 pub fn sync_cursor_to(&mut self, target: &Self) {
455 if let (Some(target_window), Some(my_window)) =
456 (target.state.windows.active(), self.state.windows.active_mut())
457 {
458 my_window.cursor = target_window.cursor;
459 }
460 }
461
462 /// Force set relation without validation.
463 ///
464 /// **Use sparingly** - prefer `try_set_relation()` for safety.
465 /// This is useful for initialization or internal operations where
466 /// validation has already been performed.
467 pub const fn set_relation_unchecked(&mut self, relation: Option<ClientRelation>) {
468 self.relation = relation;
469 }
470
471 /// Get the effective state for display.
472 ///
473 /// - Independent: own state
474 /// - Following: target's state (with depth limit)
475 /// - Sharing: target's state (with depth limit)
476 ///
477 /// Returns `None` if the target/chain doesn't exist or if there's a cycle.
478 #[must_use]
479 pub fn effective_state<'a>(
480 &'a self,
481 clients: &'a HashMap<ClientId, Self>,
482 ) -> Option<&'a EditingState> {
483 match self.relation {
484 None => Some(&self.state),
485 Some(
486 ClientRelation::Following { target } | ClientRelation::Sharing { with: target },
487 ) => Self::resolve_state(target, clients, 10),
488 }
489 }
490
491 /// Get mutable reference to the effective state for input routing.
492 ///
493 /// - Independent: Returns `None` (caller should use `self.state` directly)
494 /// - Following: Returns `None` (input is ignored)
495 /// - Sharing: Returns target's state (recursively)
496 ///
497 /// Note: For independent clients, the caller must handle `self.state` specially
498 /// due to Rust borrow rules (can't borrow self and return reference to self.state).
499 pub fn effective_state_mut<'a>(
500 &'a self,
501 clients: &'a mut HashMap<ClientId, Self>,
502 ) -> Option<&'a mut EditingState> {
503 match self.relation {
504 None => {
505 // Independent: caller should use self.state directly
506 // Can't return &mut self.state here due to borrow rules
507 None
508 }
509 Some(ClientRelation::Following { .. }) => None, // Input ignored for followers
510 Some(ClientRelation::Sharing { with }) => clients.get_mut(&with).map(|c| &mut c.state),
511 }
512 }
513
514 /// Resolve state through the chain with depth limit.
515 fn resolve_state(
516 target: ClientId,
517 clients: &HashMap<ClientId, Self>,
518 depth: usize,
519 ) -> Option<&EditingState> {
520 if depth == 0 {
521 return None; // Prevent infinite loops
522 }
523
524 let client = clients.get(&target)?;
525 match client.relation {
526 None => Some(&client.state),
527 Some(
528 ClientRelation::Following { target: next } | ClientRelation::Sharing { with: next },
529 ) => Self::resolve_state(next, clients, depth - 1),
530 }
531 }
532}
533
534// ============================================================================
535// EditingState
536// ============================================================================
537
538/// Editing state for a client.
539///
540/// Contains all per-client state needed for editing operations.
541/// All clients have this state - it's not just for "owners" anymore.
542///
543/// # Multi-Client Isolation (#471)
544///
545/// Each client owns their own `WindowLayout` with independent cursors.
546/// This ensures Client A's cursor/mode doesn't affect Client B.
547/// Buffers are still shared (all clients see same text content).
548///
549/// # Client Model Mapping (#480)
550///
551/// This struct maps to `ClientViewState` in the common client model.
552/// Only a subset is transmitted:
553///
554/// | `EditingState` field | `ClientViewState` field | Transform |
555/// |----------------------|-------------------------|-----------|
556/// | `mode_stack` | `mode` | `.current().name()` |
557/// | `windows` | `cursor: Position` | `.focused().cursor` |
558/// | `windows` | `buffer_id` | `.focused().buffer_id` |
559/// | `selection` | `selection` | `.to_driver_selection()` |
560///
561/// Per-client editing state (#471, #477).
562///
563/// Contains all client-specific state including mode, windows, viewport,
564/// selection, and module extensions. Each client has independent state
565/// to prevent cross-client interference (e.g., Client A's pending count
566/// affecting Client B's motions).
567pub struct EditingState {
568 /// Mode stack (current mode on top).
569 pub mode_stack: ModeStack,
570
571 /// Keys accumulated but not yet processed.
572 pub pending_keys: KeySequence,
573
574 /// Per-client window layout with independent cursors (#471).
575 ///
576 /// Each window contains its own cursor position. This replaces
577 /// the old shared `session.windows` that caused multi-client bugs.
578 pub windows: WindowLayout,
579
580 /// Viewport (visible area).
581 pub viewport: Viewport,
582
583 /// Active selection (for visual mode).
584 pub selection: Option<ClientSelection>,
585
586 /// Per-client module extensions (#477).
587 ///
588 /// Type-erased storage for module state like `VimSessionState`,
589 /// `SearchState`, `CmdlineState`. Each client has independent
590 /// extensions to prevent state leakage between clients.
591 ///
592 /// # Why Per-Client
593 ///
594 /// Without isolation, Client A pressing `5` (`pending_count=5`) would
595 /// cause Client B's `j` to move 5 lines instead of 1. This field
596 /// ensures complete module state isolation.
597 pub extensions: ExtensionMap,
598
599 /// Per-client compositor for window layout (#474).
600 ///
601 /// Each client owns their own compositor, cloned from the shared template
602 /// at join time. This ensures window IDs are consistent between the
603 /// compositor (geometry) and per-client windows (cursor/viewport).
604 ///
605 /// Before this field, the shared compositor and per-client windows used
606 /// independent ID namespaces, causing cross-namespace mismatches in
607 /// notifications and state queries.
608 pub compositor: Option<Box<dyn RootCompositor>>,
609
610 /// Per-client tab pages (#401).
611 ///
612 /// Manages tab page lifecycle. Each tab can have its own window layout
613 /// and compositor. Currently starts with a single default tab.
614 /// Future work will integrate with `windows` and `compositor` fields
615 /// so that `active_tab().windows()` becomes the source of truth.
616 pub tabs: TabPageSet,
617
618 /// Per-client register storage (#515).
619 ///
620 /// Each client owns their own registers (unnamed `""`, named `a-z`/`A-Z`).
621 /// System clipboard (`+`, `*`) remains shared via `ClipboardProvider`.
622 /// This prevents Client A's `"ayy` from overwriting Client B's register 'a'.
623 pub registers: RegisterBank,
624
625 /// Per-client clipboard history ring (#515).
626 ///
627 /// Tracks yank/delete history for numbered registers `0-9`.
628 /// Each client has independent history so Client A's deletes don't
629 /// shift Client B's numbered registers.
630 pub clipboard_history: HistoryRing,
631
632 /// Per-client local marks (a-z, per-client special marks) (#515).
633 ///
634 /// Each client owns their own local marks. Global marks (A-Z) remain
635 /// shared in `KernelContext.global_marks`.
636 pub local_marks: MarkBank,
637
638 /// Per-client jump list for Ctrl-O / Ctrl-I navigation (#654).
639 ///
640 /// Each client owns their own jump list. Jump positions are recorded
641 /// on cursor movements across buffer boundaries or large jumps.
642 pub jumplist: Jumplist,
643
644 /// Per-client active buffer (#471).
645 ///
646 /// Each client tracks which buffer they are viewing independently.
647 /// New clients are initialized with the first kernel buffer (scratch).
648 pub active_buffer: Option<BufferId>,
649
650 /// Per-client terminal dimensions (width, height) (#471).
651 ///
652 /// Each client has independent terminal size. Initialized to VT100
653 /// default (80, 24); updated when the client sends a resize RPC.
654 pub terminal_size: (u16, u16),
655}
656
657impl std::fmt::Debug for EditingState {
658 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
659 f.debug_struct("EditingState")
660 .field("mode_stack", &self.mode_stack)
661 .field("pending_keys", &self.pending_keys)
662 .field("windows", &self.windows)
663 .field("viewport", &self.viewport)
664 .field("selection", &self.selection)
665 .field("extensions", &self.extensions)
666 .field("compositor", &self.compositor.as_ref().map(|_| "..."))
667 .field("tabs", &self.tabs)
668 .field("registers", &self.registers)
669 .field("clipboard_history", &self.clipboard_history)
670 .field("local_marks", &self.local_marks)
671 .field("jumplist", &self.jumplist)
672 .field("active_buffer", &self.active_buffer)
673 .field("terminal_size", &self.terminal_size)
674 .finish()
675 }
676}
677
678// Manual Clone implementation (#477).
679//
680// ExtensionMap doesn't implement Clone (contains Box<dyn SessionExtensionDyn>).
681// Cloning creates fresh extensions - intentional for relation following where
682// spectators should have their own independent module state.
683impl Clone for EditingState {
684 fn clone(&self) -> Self {
685 Self {
686 mode_stack: self.mode_stack.clone(),
687 pending_keys: self.pending_keys.clone(),
688 windows: self.windows.clone(),
689 viewport: self.viewport, // Copy type
690 selection: self.selection.clone(),
691 extensions: ExtensionMap::new(), // Fresh extensions for cloned state
692 compositor: self.compositor.as_ref().map(|c| c.boxed_clone()), // #474
693 tabs: self.tabs.clone(), // #401
694 registers: self.registers.clone(), // #515
695 clipboard_history: self.clipboard_history.clone(), // #515
696 local_marks: self.local_marks.clone(), // #515
697 jumplist: self.jumplist.clone(), // #654
698 active_buffer: self.active_buffer, // #471
699 terminal_size: self.terminal_size, // #471
700 }
701 }
702}
703
704impl Default for EditingState {
705 fn default() -> Self {
706 // Use a placeholder mode - the actual home mode is set during session init
707 let placeholder_mode = reovim_kernel::api::v1::ModeId::new(
708 reovim_kernel::api::v1::ModuleId::new("default"),
709 "normal",
710 );
711 Self {
712 mode_stack: ModeStack::new(placeholder_mode),
713 pending_keys: KeySequence::new(),
714 windows: WindowLayout::empty(),
715 viewport: Viewport::default(),
716 selection: None,
717 extensions: ExtensionMap::new(),
718 compositor: None,
719 tabs: TabPageSet::new(),
720 registers: RegisterBank::new(),
721 clipboard_history: HistoryRing::new(),
722 local_marks: MarkBank::new(),
723 jumplist: Jumplist::new(),
724 active_buffer: None,
725 terminal_size: (80, 24),
726 }
727 }
728}
729
730impl EditingState {
731 /// Create editing state with a specific mode stack.
732 #[must_use]
733 pub fn with_mode_stack(mode_stack: ModeStack) -> Self {
734 Self {
735 mode_stack,
736 pending_keys: KeySequence::new(),
737 windows: WindowLayout::empty(),
738 viewport: Viewport::default(),
739 selection: None,
740 extensions: ExtensionMap::new(),
741 compositor: None,
742 tabs: TabPageSet::new(),
743 registers: RegisterBank::new(),
744 clipboard_history: HistoryRing::new(),
745 local_marks: MarkBank::new(),
746 jumplist: Jumplist::new(),
747 active_buffer: None,
748 terminal_size: (80, 24),
749 }
750 }
751
752 /// Create editing state with mode stack and initial window.
753 ///
754 /// Used when a client joins a session that already has buffers.
755 #[must_use]
756 pub fn with_mode_stack_and_window(mode_stack: ModeStack, window: Window) -> Self {
757 let mut windows = WindowLayout::empty();
758 windows.add(window);
759 Self {
760 mode_stack,
761 pending_keys: KeySequence::new(),
762 windows,
763 viewport: Viewport::default(),
764 selection: None,
765 extensions: ExtensionMap::new(),
766 compositor: None,
767 tabs: TabPageSet::new(),
768 registers: RegisterBank::new(),
769 clipboard_history: HistoryRing::new(),
770 local_marks: MarkBank::new(),
771 jumplist: Jumplist::new(),
772 active_buffer: None,
773 terminal_size: (80, 24),
774 }
775 }
776
777 /// Get the current mode ID.
778 #[must_use]
779 pub fn current_mode(&self) -> &reovim_kernel::api::v1::ModeId {
780 self.mode_stack.current()
781 }
782
783 /// Clear pending keys.
784 pub fn clear_pending_keys(&mut self) {
785 self.pending_keys.clear();
786 }
787
788 /// Borrow the 7 per-client mutable fields as a [`ClientContext`].
789 ///
790 /// This bundles the fields that `SessionRuntime` needs, avoiding
791 /// 7-argument parameter lists throughout the session execution chain.
792 pub fn client_context(&mut self) -> reovim_driver_session::ClientContext<'_> {
793 reovim_driver_session::ClientContext {
794 mode_stack: &mut self.mode_stack,
795 windows: &mut self.windows,
796 extensions: &mut self.extensions,
797 compositor: &mut self.compositor,
798 tabs: &mut self.tabs,
799 registers: &mut self.registers,
800 clipboard_history: &mut self.clipboard_history,
801 local_marks: &mut self.local_marks,
802 jumplist: &mut self.jumplist,
803 active_buffer: &mut self.active_buffer,
804 terminal_size: &mut self.terminal_size,
805 }
806 }
807}
808
809// ============================================================================
810// ClientSelection
811// ============================================================================
812
813/// Selection state for a client.
814///
815/// Wraps the driver's Selection with additional client-specific info.
816#[derive(Debug, Clone)]
817pub struct ClientSelection {
818 /// Anchor position (where selection started).
819 pub anchor: CursorPosition,
820
821 /// Current cursor position (selection endpoint).
822 pub cursor: CursorPosition,
823
824 /// Selection mode (char/line/block).
825 pub mode: SelectionMode,
826}
827
828impl ClientSelection {
829 /// Create a new selection.
830 #[must_use]
831 pub const fn new(anchor: CursorPosition, cursor: CursorPosition, mode: SelectionMode) -> Self {
832 Self {
833 anchor,
834 cursor,
835 mode,
836 }
837 }
838
839 /// Convert to the driver's Selection type.
840 #[must_use]
841 pub fn to_driver_selection(&self) -> Selection {
842 Selection {
843 start: self.anchor.into(),
844 end: self.cursor.into(),
845 mode: self.mode,
846 }
847 }
848}
849
850// ============================================================================
851// Tests
852// ============================================================================
853
854#[cfg(test)]
855#[allow(clippy::similar_names)] // client1/client2/clients are clear in test context
856#[path = "client_tests.rs"]
857mod tests;