diff --git a/core/src/document/loader.rs b/core/src/document/loader.rs index c94dc31..f361d0b 100644 --- a/core/src/document/loader.rs +++ b/core/src/document/loader.rs @@ -98,10 +98,4 @@ pub(crate) fn scan_directory_into(session: &mut SessionData, dir: &Path) { session.items = paths.into_iter().map(|p| SessionItem::new(p, 0)).collect(); } - - session.current_index = if session.items.is_empty() { - None - } else { - Some(0) - }; } diff --git a/core/src/document/manager.rs b/core/src/document/manager.rs index 29c7b0a..c7cb67d 100644 --- a/core/src/document/manager.rs +++ b/core/src/document/manager.rs @@ -1,18 +1,18 @@ // SPDX-License-Identifier: GPL-3.0-or-later // src/document/manager.rs // -// Document Manager – single entry point via SessionCommand. +// Document manager: session state, cursors, clipboard, and command dispatch. use crate::document::loader::{load_document, scan_directory_into}; use crate::document::session::command::SessionCommand; -use crate::document::session::data::{CollectionKind, SessionData}; +use crate::document::session::data::{CollectionKind, SessionData, SessionItem}; use crate::document::session::store; use crate::document::DocumentContent; use crate::error::Error; use log::warn; -use std::path::Path; +use std::path::{Path, PathBuf}; -#[allow(dead_code)] +/// The load state of the currently viewed document. #[derive(Debug)] pub enum DocumentState { Empty, @@ -21,9 +21,20 @@ pub enum DocumentState { Error(Error), } +/// Central state for all sessions, cursors, and the document clipboard. +/// +/// Two cursors are maintained: +/// - `active_session_index` – the currently active session +/// - `active_document_index` – the currently active document within that session +/// +/// All `SessionCommand` variants that do not carry an explicit target operate +/// on the active cursor. `SelectSession` and `SelectDocument` set them. pub struct DocumentManager { sessions: Vec, - pub active_session_name: Option, + active_session_index: Option, + active_document_index: Option, + clipboard: Option, + session_file_path: Option, pub state: DocumentState, } @@ -31,7 +42,10 @@ impl Default for DocumentManager { fn default() -> Self { Self { sessions: Vec::new(), - active_session_name: None, + active_session_index: None, + active_document_index: None, + clipboard: None, + session_file_path: None, state: DocumentState::Empty, } } @@ -43,79 +57,92 @@ impl DocumentManager { Self::default() } - /// The single public entry point for all session/collection state changes. + /// The single public entry point for all session and document state changes. pub fn handle(&mut self, command: SessionCommand) { match command { - // -- Lifecycle -- - SessionCommand::New { name, kind } => { - self.create_session(&name, kind); - } - SessionCommand::Open { path } => { - self.open_session_file(&path); - } - SessionCommand::Select { name } => { - if self.session(&name).is_some() { - self.active_session_name = Some(name); - } - } + // Session lifecycle + SessionCommand::NewSession { name } => self.new_session(&name), + SessionCommand::OpenSession { path } => self.open_session_file(&path), + SessionCommand::CloseSession | SessionCommand::RemoveSession => self.close_session(), + SessionCommand::SaveSession => self.save_session(), + SessionCommand::SelectSession { name } => self.select_session(&name), + SessionCommand::DuplicateSession { new_name } => self.duplicate_session(&new_name), + SessionCommand::RenameSession { name } => self.rename_session(&name), - // -- Collection management -- - SessionCommand::AddDirectory { dir } => { + // Collection management + SessionCommand::AddDirectoryToSession { dir } => { if let Some(session) = self.active_session_mut() { scan_directory_into(session, &dir); } + self.reset_document_cursor(); } - SessionCommand::AddDocument { path } => { - self.add_document(&path); - } - SessionCommand::CopyDocument { - source, - target, - source_index, - target_index, - } => { - self.copy_document(&source, &target, source_index, target_index); - } - SessionCommand::RemoveDocument { index } => { - self.remove_document(index); - } + SessionCommand::AddDocumentToSession { path } => self.add_document(&path), - // -- Navigation -- + // Document management + SessionCommand::SelectDocument { index } => self.select_document(index), + SessionCommand::CopyDocument => self.copy_document(), + SessionCommand::PasteDocument => self.paste_document(), + SessionCommand::RemoveDocument => self.remove_document(), SessionCommand::NextDocument => self.navigate_next(), SessionCommand::PreviousDocument => self.navigate_previous(), - SessionCommand::SelectDocument { index } => self.select_document(index), + SessionCommand::MoveDocumentUp => self.move_document_up(), + SessionCommand::MoveDocumentDown => self.move_document_down(), + SessionCommand::MoveDocumentTo { index } => self.move_document_to(index), } } - /// Read-only access to a session by name. - #[must_use] - pub fn session(&self, name: &str) -> Option<&SessionData> { - self.sessions.iter().find(|s| s.name == name) - } - /// Read-only access to the active session. #[must_use] pub fn active_session(&self) -> Option<&SessionData> { - let name = self.active_session_name.as_deref()?; + self.active_session_index.map(|i| &self.sessions[i]) + } + + /// The index of the currently active document within the active session. + #[must_use] + pub fn active_document_index(&self) -> Option { + self.active_document_index + } + + /// Read-only access to all sessions. + #[must_use] + pub fn sessions(&self) -> &[SessionData] { + &self.sessions + } + + /// Read-only access to a session by name. + #[must_use] + pub fn session_by_name(&self, name: &str) -> Option<&SessionData> { self.sessions.iter().find(|s| s.name == name) } // -- Private helpers -- - fn session_mut(&mut self, name: &str) -> Option<&mut SessionData> { - self.sessions.iter_mut().find(|s| s.name == name) - } - fn active_session_mut(&mut self) -> Option<&mut SessionData> { - let name = self.active_session_name.clone()?; - self.sessions.iter_mut().find(|s| s.name == name) + self.active_session_index.map(|i| &mut self.sessions[i]) } - fn create_session(&mut self, name: &str, kind: CollectionKind) { - if self.session(name).is_none() { - self.sessions - .push(SessionData::new(name, kind, None, Vec::new())); + fn index_by_name(&self, name: &str) -> Option { + self.sessions.iter().position(|s| s.name == name) + } + + /// Sets `active_document_index` to `Some(0)` if the active session has items, else `None`. + fn reset_document_cursor(&mut self) { + self.active_document_index = match self.active_session() { + Some(s) if !s.items.is_empty() => Some(0), + _ => None, + }; + } + + fn new_session(&mut self, name: &str) { + if self.index_by_name(name).is_some() { + return; } + self.sessions.push(SessionData::new( + name, + CollectionKind::DocumentCollection, + None, + Vec::new(), + )); } fn open_session_file(&mut self, path: &Path) { @@ -141,23 +168,19 @@ impl DocumentManager { false } }); - session.current_index = match session.current_index { - Some(i) if i < session.items.len() => Some(i), - _ if !session.items.is_empty() => Some(0), - _ => None, - }; } } } - let browser_name = loaded + let browser_index = loaded .iter() - .find(|s| s.kind == CollectionKind::DirectoryBrowser) - .map(|s| s.name.clone()); + .position(|s| s.kind == CollectionKind::DirectoryBrowser); self.sessions = loaded; - self.active_session_name = browser_name; + self.active_session_index = browser_index; + self.session_file_path = Some(path.to_path_buf()); self.state = DocumentState::Empty; + self.reset_document_cursor(); } Err(e) => { self.state = DocumentState::Error(e); @@ -165,15 +188,89 @@ impl DocumentManager { } } + fn close_session(&mut self) { + let Some(idx) = self.active_session_index else { + return; + }; + + // The DirectoryBrowser session is managed by the UI and cannot be closed. + if self.sessions[idx].kind == CollectionKind::DirectoryBrowser { + return; + } + + self.sessions.remove(idx); + self.active_document_index = None; + + self.active_session_index = if self.sessions.is_empty() { + None + } else { + Some(idx.saturating_sub(1)) + }; + } + + fn save_session(&mut self) { + let Some(path) = self.session_file_path.clone() else { + return; + }; + if let Err(e) = store::save_sessions(&path, &self.sessions) { + self.state = DocumentState::Error(e); + } + } + + fn select_session(&mut self, name: &str) { + if let Some(idx) = self.index_by_name(name) { + self.active_session_index = Some(idx); + self.reset_document_cursor(); + } + } + + fn duplicate_session(&mut self, new_name: &str) { + if self.index_by_name(new_name).is_some() { + return; + } + let Some(idx) = self.active_session_index else { + return; + }; + let items = self.sessions[idx].items.clone(); + self.sessions.push(SessionData::new( + new_name, + CollectionKind::DocumentCollection, + None, + items, + )); + } + + fn rename_session(&mut self, name: &str) { + let Some(idx) = self.active_session_index else { + return; + }; + + // The DirectoryBrowser session cannot be renamed. + if self.sessions[idx].kind == CollectionKind::DirectoryBrowser { + return; + } + + if self.index_by_name(name).is_some() { + return; + } + + self.sessions[idx].name = name.to_string(); + } + fn add_document(&mut self, path: &Path) { self.state = DocumentState::Loading; match load_document(path) { Ok(content) => { if let Some(session) = self.active_session_mut() { - if let Some(idx) = session.items.iter().position(|item| item.path == path) { - session.current_index = Some(idx); + if session.kind == CollectionKind::DocumentCollection + && !session.items.iter().any(|i| i.path == path) + { + session.items.push(SessionItem::new(path.to_path_buf(), 0)); } } + self.active_document_index = self + .active_session() + .and_then(|s| s.items.iter().position(|i| i.path == path)); self.state = DocumentState::Loaded(content); } Err(e) => { @@ -183,111 +280,145 @@ impl DocumentManager { } fn select_document(&mut self, index: usize) { - if let Some(session) = self.active_session_mut() { + if let Some(session) = self.active_session() { if index < session.items.len() { - session.current_index = Some(index); + self.active_document_index = Some(index); } } } + fn copy_document(&mut self) { + let Some(idx) = self.active_document_index else { + return; + }; + self.clipboard = self + .active_session() + .and_then(|s| s.items.get(idx)) + .cloned(); + } + + fn paste_document(&mut self) { + let Some(item) = self.clipboard.clone() else { + return; + }; + + if !matches!( + self.active_session().map(|s| &s.kind), + Some(CollectionKind::DocumentCollection) + ) { + return; + } + + let new_index = { + let Some(session) = self.active_session_mut() else { + return; + }; + + if session + .items + .iter() + .any(|i| i.path == item.path && i.page_index == item.page_index) + { + return; + } + + session.items.push(item); + session.items.len() - 1 + }; + + self.active_document_index = Some(new_index); + } + + fn remove_document(&mut self) { + if !matches!( + self.active_session().map(|s| &s.kind), + Some(CollectionKind::DocumentCollection) + ) { + return; + } + + let Some(idx) = self.active_document_index else { + return; + }; + + let new_index = { + let Some(session) = self.active_session_mut() else { + return; + }; + + if idx >= session.items.len() { + return; + } + + session.items.remove(idx); + let new_len = session.items.len(); + if new_len == 0 { + None + } else { + Some(idx.min(new_len - 1)) + } + }; + + self.active_document_index = new_index; + } + fn navigate_next(&mut self) { - if let Some(session) = self.active_session_mut() { - if let Some(idx) = session.current_index { - if idx + 1 < session.items.len() { - session.current_index = Some(idx + 1); - } + let len = self.active_session().map_or(0, |s| s.items.len()); + if let Some(idx) = self.active_document_index { + if idx + 1 < len { + self.active_document_index = Some(idx + 1); } } } fn navigate_previous(&mut self) { - if let Some(session) = self.active_session_mut() { - if let Some(idx) = session.current_index { - if idx > 0 { - session.current_index = Some(idx - 1); - } + if let Some(idx) = self.active_document_index { + if idx > 0 { + self.active_document_index = Some(idx - 1); } } } - fn copy_document( - &mut self, - source_name: &str, - target_name: &str, - source_index: usize, - target_index: usize, - ) { - let item = if let Some(s) = self.session(source_name) { - match s.items.get(source_index) { - Some(item) => item.clone(), - None => return, - } - } else { - self.state = DocumentState::Error(Error::Session(format!( - "Source session '{source_name}' not found" - ))); + fn move_document_up(&mut self) { + let Some(idx) = self.active_document_index else { return; }; - - let Some(target) = self.session(target_name) else { - self.state = DocumentState::Error(Error::Session(format!( - "Target session '{target_name}' not found" - ))); - return; - }; - - if target.kind != CollectionKind::DocumentCollection { - self.state = DocumentState::Error(Error::Session( - "Target must be a DocumentCollection".to_string(), - )); + if idx == 0 { return; } - - if target - .items - .iter() - .any(|i| i.path == item.path && i.page_index == item.page_index) - { - self.state = DocumentState::Error(Error::Session("Item already exists".to_string())); - return; - } - - if let Some(target) = self.session_mut(target_name) { - let idx = target_index.min(target.items.len()); - target.items.insert(idx, item); - } - } - - fn remove_document(&mut self, index: usize) { - // Check kind before taking a mutable borrow on the session, so that - // self.state can be set without conflicting borrows. - match self.active_session().map(|s| s.kind.clone()) { - None => return, - Some(CollectionKind::DirectoryBrowser) => { - self.state = DocumentState::Error(Error::Session( - "Can only remove from a DocumentCollection".to_string(), - )); - return; - } - Some(CollectionKind::DocumentCollection) => {} - } - let Some(session) = self.active_session_mut() else { return; }; + session.items.swap(idx, idx - 1); + self.active_document_index = Some(idx - 1); + } - if index >= session.items.len() { + fn move_document_down(&mut self) { + let Some(idx) = self.active_document_index else { + return; + }; + let Some(session) = self.active_session_mut() else { + return; + }; + if idx + 1 >= session.items.len() { return; } + session.items.swap(idx, idx + 1); + self.active_document_index = Some(idx + 1); + } - session.items.remove(index); - let new_len = session.items.len(); - if new_len == 0 { - session.current_index = None; - } else if let Some(current) = session.current_index { - if current >= new_len { - session.current_index = Some(new_len - 1); - } + fn move_document_to(&mut self, target: usize) { + let Some(idx) = self.active_document_index else { + return; + }; + let Some(session) = self.active_session_mut() else { + return; + }; + if idx == target || target >= session.items.len() { + return; } + let item = session.items.remove(idx); + session.items.insert(target, item); + self.active_document_index = Some(target); } } diff --git a/core/src/document/session/command.rs b/core/src/document/session/command.rs index 0b141e4..c4c385d 100644 --- a/core/src/document/session/command.rs +++ b/core/src/document/session/command.rs @@ -3,43 +3,66 @@ // // Commands that operate on sessions and their document collections. -use crate::document::session::data::CollectionKind; use std::path::PathBuf; /// Commands operating on sessions and their document collections. /// -/// All collection-mutating commands operate on the active session -/// unless they explicitly name a session. +/// Two implicit cursors exist in the manager: +/// - `active_session_index` – the currently active session +/// - `active_document_index` – the currently active document within that session +/// +/// `SelectSession` and `SelectDocument` set these cursors. All commands that +/// do not carry an explicit target operate on the active cursor. +/// +/// Only `DocumentCollection` sessions are user-managed. The single +/// `DirectoryBrowser` session is created by the UI and cannot be closed, +/// renamed, or removed. #[derive(Debug, Clone)] pub enum SessionCommand { - // Lifecycle - /// Create a new empty session of the given kind. - New { name: String, kind: CollectionKind }, - /// Open sessions from an existing .ron file. - Open { path: PathBuf }, - /// Switch the active session by name. - Select { name: String }, + // Session lifecycle + /// Create a new empty `DocumentCollection` session. + NewSession { name: String }, + /// Load sessions from an existing .ron file. + OpenSession { path: PathBuf }, + /// Close and remove the active session from the store. + CloseSession, + /// Persist all sessions to the .ron file. + SaveSession, + /// Set the active session cursor by name. + SelectSession { name: String }, + /// Duplicate the active session as a new `DocumentCollection`. + /// + /// When the source is a `DirectoryBrowser` this produces a snapshot: + /// the current items are copied into a new `DocumentCollection`. + DuplicateSession { new_name: String }, + /// Remove the active `DocumentCollection` session without saving. + RemoveSession, + /// Rename the active `DocumentCollection` session. + RenameSession { name: String }, - // Collection management - /// Scan a directory and replace the active session's items. - AddDirectory { dir: PathBuf }, - /// Add a single document to the active session. - AddDocument { path: PathBuf }, - /// Copy a document from one session to another. - CopyDocument { - source: String, - target: String, - source_index: usize, - target_index: usize, - }, - /// Remove a document by index from the active session. - RemoveDocument { index: usize }, + // Collection management within the active session + /// Scan a directory and populate the active `DirectoryBrowser` session. + AddDirectoryToSession { dir: PathBuf }, + /// Add a single document to the active `DocumentCollection` session. + AddDocumentToSession { path: PathBuf }, - // Navigation - /// Navigate to the next document in the active session. - NextDocument, - /// Navigate to the previous document in the active session. - PreviousDocument, - /// Select a document by index in the active session. + // Document management within the active session + /// Set the active document cursor by index. SelectDocument { index: usize }, + /// Copy the active document into the clipboard. + CopyDocument, + /// Paste the clipboard item at the end of the active session. + PasteDocument, + /// Remove the active document from the active session. + RemoveDocument, + /// Move the active document cursor to the next item. + NextDocument, + /// Move the active document cursor to the previous item. + PreviousDocument, + /// Move the active document one position towards the front. + MoveDocumentUp, + /// Move the active document one position towards the back. + MoveDocumentDown, + /// Move the active document to an explicit position. + MoveDocumentTo { index: usize }, } diff --git a/core/src/document/session/data.rs b/core/src/document/session/data.rs index 157982e..9697fdd 100644 --- a/core/src/document/session/data.rs +++ b/core/src/document/session/data.rs @@ -1,28 +1,19 @@ // SPDX-License-Identifier: GPL-3.0-or-later // src/document/session/data.rs // -// A session is the unit of work in Noctua. It groups a set of document items -// under a name and tracks which item is currently active. +// Pure data structures for sessions and their document items. // -// There are two kinds of collection: -// -// DirectoryBrowser -// Represents a directory on disk. Items are populated by scanning the -// directory at open time and re-scanned each time the session file is -// loaded – the persisted item list is intentionally ignored on load. -// The source directory is stored in `path`. -// -// DocumentCollection -// A curated, ordered list of documents (or individual pages within them). -// Items are persisted exactly as stored. Missing files are detected and -// removed with a warning when the session file is loaded. -// -// The on-disk representation of a session list is managed by `store.rs`. +// Operational state (active session cursor, active document cursor, clipboard) +// lives in the manager, not here. This module contains only what is persisted. use serde::{Deserialize, Serialize}; use std::path::PathBuf; /// Determines how a session's items are managed. +/// +/// A `DirectoryBrowser` session is created once by the UI and cannot be closed +/// or removed by the user. It can be duplicated via `DuplicateSession`, which +/// produces a new `DocumentCollection` containing a snapshot of the current items. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum CollectionKind { /// Items are derived from a directory scan; `path` holds the source directory. @@ -48,19 +39,19 @@ impl SessionItem { } } -/// The full state of a session. +/// The persistable state of a single session. +/// +/// Operational state (active document index, clipboard) lives in the manager. +/// This struct is serialized as-is to the RON session file. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SessionData { pub name: String, pub kind: CollectionKind, /// For `DirectoryBrowser`: the source directory to scan. - /// For `DocumentCollection`: None (items are managed individually). - /// The .ron session file path is tracked separately. + /// For `DocumentCollection`: `None` (items are managed individually). #[serde(default)] pub path: Option, pub items: Vec, - /// Index of the currently active item, or `None` if the session is empty. - pub current_index: Option, } impl SessionData { @@ -71,13 +62,11 @@ impl SessionData { path: Option, items: Vec, ) -> Self { - let current_index = if items.is_empty() { None } else { Some(0) }; Self { name: name.to_string(), kind, path, items, - current_index, } } } diff --git a/core/src/document/session/store.rs b/core/src/document/session/store.rs index 480728f..9d00c3c 100644 --- a/core/src/document/session/store.rs +++ b/core/src/document/session/store.rs @@ -54,7 +54,6 @@ pub fn load_sessions(path: &Path) -> Result> { /// /// Returns [`Error::Session`] if the sessions cannot be serialized. /// Returns [`Error::Io`] if the file cannot be written. -#[allow(dead_code)] pub fn save_sessions(path: &Path, sessions: &[SessionData]) -> Result<()> { let content = ron::ser::to_string_pretty(sessions, ron::ser::PrettyConfig::default()) .map_err(|e| Error::Session(format!("Failed to serialize sessions: {e}")))?;