diff --git a/Cargo.lock b/Cargo.lock index 513f4a8..a67ea70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2136,8 +2136,9 @@ dependencies = [ [[package]] name = "modalkit" -version = "0.0.15" -source = "git+https://github.com/ulyssa/modalkit?rev=f641162#f6411625caa1ddb764c91a3fc47e052cfdc405b5" +version = "0.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f38eef0b4f6377e73d1082d508cae1df4d99a94d25361538131d839f292aa49" dependencies = [ "anymap2", "arboard", @@ -2151,6 +2152,7 @@ dependencies = [ "ratatui", "regex", "ropey", + "serde", "textwrap", "thiserror", "unicode-segmentation", diff --git a/Cargo.toml b/Cargo.toml index b8dd957..802737b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,8 +51,7 @@ unicode-width = "0.1.10" url = {version = "^2.2.2", features = ["serde"]} [dependencies.modalkit] -git = "https://github.com/ulyssa/modalkit" -rev = "f641162" +version = "0.0.16" [dependencies.matrix-sdk] version = "0.6" diff --git a/docs/iamb.5.md b/docs/iamb.5.md index 532557b..5757245 100644 --- a/docs/iamb.5.md +++ b/docs/iamb.5.md @@ -36,6 +36,10 @@ These options are configured as a map under the profiles name. > Overwrite general settings for this account. The fields are identical to > those in *TUNABLES*. +**layout** (type: startup layout object) +> Overwrite general settings for this account. The fields are identical to +> those in *STARTUP LAYOUT*. + **dirs** (type: XDG overrides object) > Overwrite general settings for this account. The fields are identical to > those in *DIRECTORIES*. @@ -92,6 +96,23 @@ maps containing the following key value pairs. > _light-cyan_, _light-green_, _light-magenta_, _light-red_, > _light-yellow_, _magenta_, _none_, _red_, _white_, _yellow_ +# STARTUP LAYOUT + +Specifies what initial set of tabs and windows to show when starting the +client. Configured as an object under the key *layout*. + +**style** (type: string) +> Specifies what window layout to load when starting. Valid values are +> _restore_ to restore the layout from the last time the client was exited, +> _new_ to open a single window (uses the value of _default\_room_ if set), or +> _config_ to open the layout described under _tabs_. + +**tabs** (type: array of window objects) +> If **style** is set to _config_, then this value will be used to open a set +> of tabs and windows at startup. Each object can contain either a **window** +> key specifying a username, room identifier or room alias to show, or a +> **split** key specifying an array of window objects. + # DIRECTORIES Specifies the directories to save data in. Configured as a map under the key diff --git a/src/base.rs b/src/base.rs index b275866..25ecd7f 100644 --- a/src/base.rs +++ b/src/base.rs @@ -1,12 +1,23 @@ use std::borrow::Cow; use std::collections::{HashMap, HashSet}; +use std::convert::TryFrom; +use std::fmt::{self, Display}; use std::hash::Hash; use std::str::FromStr; use std::sync::Arc; use std::time::{Duration, Instant}; use emojis::Emoji; +use serde::{ + de::Error as SerdeError, + de::Visitor, + Deserialize, + Deserializer, + Serialize, + Serializer, +}; use tokio::sync::Mutex as AsyncMutex; +use url::Url; use matrix_sdk::{ encryption::verification::SasVerification, @@ -715,17 +726,159 @@ impl ApplicationStore for ChatStore {} #[derive(Clone, Debug, Eq, Hash, PartialEq)] pub enum IambId { + /// A Matrix room. Room(OwnedRoomId), + + /// The `:rooms` window. DirectList, + + /// The `:members` window for a given Matrix room. MemberList(OwnedRoomId), + + /// The `:rooms` window. RoomList, + + /// The `:spaces` window. SpaceList, + + /// The `:verify` window. VerifyList, + + /// The `:welcome` window. Welcome, } +impl Display for IambId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + IambId::Room(room_id) => { + write!(f, "iamb://room/{room_id}") + }, + IambId::MemberList(room_id) => { + write!(f, "iamb://members/{room_id}") + }, + IambId::DirectList => f.write_str("iamb://dms"), + IambId::RoomList => f.write_str("iamb://rooms"), + IambId::SpaceList => f.write_str("iamb://spaces"), + IambId::VerifyList => f.write_str("iamb://verify"), + IambId::Welcome => f.write_str("iamb://welcome"), + } + } +} + impl ApplicationWindowId for IambId {} +impl Serialize for IambId { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for IambId { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_str(IambIdVisitor) + } +} + +struct IambIdVisitor; + +impl<'de> Visitor<'de> for IambIdVisitor { + type Value = IambId; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a valid window URL") + } + + fn visit_str(self, value: &str) -> Result + where + E: SerdeError, + { + let Ok(url) = Url::parse(value) else { + return Err(E::custom("Invalid iamb window URL")); + }; + + if url.scheme() != "iamb" { + return Err(E::custom("Invalid iamb window URL")); + } + + match url.domain() { + Some("room") => { + let Some(path) = url.path_segments() else { + return Err(E::custom("Invalid members window URL")); + }; + + let &[room_id] = path.collect::>().as_slice() else { + return Err(E::custom("Invalid members window URL")); + }; + + let Ok(room_id) = OwnedRoomId::try_from(room_id) else { + return Err(E::custom("Invalid room identifier")); + }; + + Ok(IambId::Room(room_id)) + }, + Some("members") => { + let Some(path) = url.path_segments() else { + return Err(E::custom( "Invalid members window URL")); + }; + + let &[room_id] = path.collect::>().as_slice() else { + return Err(E::custom( "Invalid members window URL")); + }; + + let Ok(room_id) = OwnedRoomId::try_from(room_id) else { + return Err(E::custom("Invalid room identifier")); + }; + + Ok(IambId::MemberList(room_id)) + }, + Some("dms") => { + if url.path() != "" { + return Err(E::custom("iamb://dms takes no path")); + } + + Ok(IambId::DirectList) + }, + Some("rooms") => { + if url.path() != "" { + return Err(E::custom("iamb://rooms takes no path")); + } + + Ok(IambId::RoomList) + }, + Some("spaces") => { + if url.path() != "" { + return Err(E::custom("iamb://spaces takes no path")); + } + + Ok(IambId::SpaceList) + }, + Some("verify") => { + if url.path() != "" { + return Err(E::custom("iamb://verify takes no path")); + } + + Ok(IambId::VerifyList) + }, + Some("welcome") => { + if url.path() != "" { + return Err(E::custom("iamb://welcome takes no path")); + } + + Ok(IambId::Welcome) + }, + Some(s) => Err(E::custom(format!("{s:?} is not a valid window"))), + None => Err(E::custom("Invalid iamb window URL")), + } + } +} + #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum RoomFocus { Scrollback, diff --git a/src/config.rs b/src/config.rs index d1f6d4f..7b91f50 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,7 +9,7 @@ use std::path::{Path, PathBuf}; use std::process; use clap::Parser; -use matrix_sdk::ruma::{OwnedUserId, UserId}; +use matrix_sdk::ruma::{OwnedRoomAliasId, OwnedRoomId, OwnedUserId, UserId}; use serde::{de::Error as SerdeError, de::Visitor, Deserialize, Deserializer}; use tracing::Level; use url::Url; @@ -19,6 +19,8 @@ use modalkit::tui::{ text::Span, }; +use super::base::IambId; + macro_rules! usage { ( $($args: tt)* ) => { println!($($args)*); @@ -339,12 +341,43 @@ impl Directories { } } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(untagged)] +pub enum WindowPath { + AliasId(OwnedRoomAliasId), + RoomId(OwnedRoomId), + UserId(OwnedUserId), + Window(IambId), +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(untagged, deny_unknown_fields)] +pub enum WindowLayout { + Window { window: WindowPath }, + Split { split: Vec }, +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "lowercase", tag = "style")] +pub enum Layout { + /// Restore the layout from the previous session. + #[default] + Restore, + + /// Open a single window using the `default_room` value. + New, + + /// Open the window layouts described under `tabs`. + Config { tabs: Vec }, +} + #[derive(Clone, Deserialize)] pub struct ProfileConfig { pub user_id: OwnedUserId, pub url: Url, pub settings: Option, pub dirs: Option, + pub layout: Option, } #[derive(Clone, Deserialize)] @@ -353,6 +386,7 @@ pub struct IambConfig { pub default_profile: Option, pub settings: Option, pub dirs: Option, + pub layout: Option, } impl IambConfig { @@ -376,11 +410,13 @@ impl IambConfig { #[derive(Clone)] pub struct ApplicationSettings { pub matrix_dir: PathBuf, + pub layout_json: PathBuf, pub session_json: PathBuf, pub profile_name: String, pub profile: ProfileConfig, pub tunables: TunableValues, pub dirs: DirectoryValues, + pub layout: Layout, } impl ApplicationSettings { @@ -401,6 +437,7 @@ impl ApplicationSettings { default_profile, dirs, settings: global, + layout, } = IambConfig::load(config_json.as_path())?; validate_profile_names(&profiles); @@ -424,10 +461,17 @@ impl ApplicationSettings { ); }; + let layout = profile.layout.take().or(layout).unwrap_or_default(); + let tunables = global.unwrap_or_default(); let tunables = profile.settings.take().unwrap_or_default().merge(tunables); let tunables = tunables.values(); + let dirs = dirs.unwrap_or_default(); + let dirs = profile.dirs.take().unwrap_or_default().merge(dirs); + let dirs = dirs.values(); + + // Set up paths that live inside the profile's data directory. let mut profile_dir = config_dir.clone(); profile_dir.push("profiles"); profile_dir.push(profile_name.as_str()); @@ -438,17 +482,23 @@ impl ApplicationSettings { let mut session_json = profile_dir; session_json.push("session.json"); - let dirs = dirs.unwrap_or_default(); - let dirs = profile.dirs.take().unwrap_or_default().merge(dirs); - let dirs = dirs.values(); + // Set up paths that live inside the profile's cache directory. + let mut cache_dir = dirs.cache.clone(); + cache_dir.push("profiles"); + cache_dir.push(profile_name.as_str()); + + let mut layout_json = cache_dir.clone(); + layout_json.push("layout.json"); let settings = ApplicationSettings { matrix_dir, + layout_json, session_json, profile_name, profile, tunables, dirs, + layout, }; Ok(settings) @@ -496,6 +546,7 @@ impl ApplicationSettings { mod tests { use super::*; use matrix_sdk::ruma::user_id; + use std::convert::TryFrom; #[test] fn test_profile_name_invalid() { @@ -589,4 +640,62 @@ mod tests { })]; assert_eq!(res.users, Some(users.into_iter().collect())); } + + #[test] + fn test_parse_layout() { + let user = WindowPath::UserId(user_id!("@user:example.com").to_owned()); + let alias = WindowPath::AliasId(OwnedRoomAliasId::try_from("#room:example.com").unwrap()); + let room = WindowPath::RoomId(OwnedRoomId::try_from("!room:example.com").unwrap()); + let dms = WindowPath::Window(IambId::DirectList); + let welcome = WindowPath::Window(IambId::Welcome); + + let res: Layout = serde_json::from_str("{\"style\": \"restore\"}").unwrap(); + assert_eq!(res, Layout::Restore); + + let res: Layout = serde_json::from_str("{\"style\": \"new\"}").unwrap(); + assert_eq!(res, Layout::New); + + let res: Layout = serde_json::from_str( + "{\"style\": \"config\", \"tabs\": [{\"window\":\"@user:example.com\"}]}", + ) + .unwrap(); + assert_eq!(res, Layout::Config { + tabs: vec![WindowLayout::Window { window: user.clone() }] + }); + + let res: Layout = serde_json::from_str( + "{\ + \"style\": \"config\",\ + \"tabs\": [\ + {\"split\":[\ + {\"window\":\"@user:example.com\"},\ + {\"window\":\"#room:example.com\"}\ + ]},\ + {\"split\":[\ + {\"window\":\"!room:example.com\"},\ + {\"split\":[\ + {\"window\":\"iamb://dms\"},\ + {\"window\":\"iamb://welcome\"}\ + ]}\ + ]}\ + ]}", + ) + .unwrap(); + let split1 = WindowLayout::Split { + split: vec![ + WindowLayout::Window { window: user.clone() }, + WindowLayout::Window { window: alias }, + ], + }; + let split2 = WindowLayout::Split { + split: vec![WindowLayout::Window { window: dms }, WindowLayout::Window { + window: welcome, + }], + }; + let split3 = WindowLayout::Split { + split: vec![WindowLayout::Window { window: room }, split2], + }; + let tabs = vec![split1, split3]; + assert_eq!(res, Layout::Config { tabs }); + } } diff --git a/src/main.rs b/src/main.rs index 5edc49a..d18cbad 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,7 +6,7 @@ use std::collections::VecDeque; use std::convert::TryFrom; use std::fmt::Display; use std::fs::{create_dir_all, File}; -use std::io::{stdout, BufReader, Stdout}; +use std::io::{stdout, BufReader, BufWriter, Stdout}; use std::ops::DerefMut; use std::process; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -89,6 +89,7 @@ use modalkit::{ Jumpable, Promptable, Scrollable, + TabAction, TabContainer, TabCount, UIError, @@ -103,21 +104,120 @@ use modalkit::{ input::{bindings::BindingMachine, dialog::Pager, key::TerminalKey}, widgets::{ cmdbar::CommandBarState, - screen::{Screen, ScreenState}, + screen::{FocusList, Screen, ScreenState, TabLayoutDescription}, + windows::WindowLayoutDescription, TerminalCursor, TerminalExtOps, Window, }, }; +fn config_tab_to_desc( + layout: config::WindowLayout, + store: &mut ProgramStore, +) -> IambResult> { + let desc = match layout { + config::WindowLayout::Window { window } => { + let ChatStore { names, worker, .. } = &mut store.application; + + let window = match window { + config::WindowPath::UserId(user_id) => { + let name = user_id.to_string(); + let room_id = worker.join_room(name.clone())?; + names.insert(name, room_id.clone()); + IambId::Room(room_id) + }, + config::WindowPath::RoomId(room_id) => IambId::Room(room_id), + config::WindowPath::AliasId(alias) => { + let name = alias.to_string(); + let room_id = worker.join_room(name.clone())?; + names.insert(name, room_id.clone()); + IambId::Room(room_id) + }, + config::WindowPath::Window(id) => id, + }; + + WindowLayoutDescription::Window { window, length: None } + }, + config::WindowLayout::Split { split } => { + let children = split + .into_iter() + .map(|child| config_tab_to_desc(child, store)) + .collect::>>()?; + + WindowLayoutDescription::Split { children, length: None } + }, + }; + + Ok(desc) +} + +fn setup_screen( + settings: ApplicationSettings, + store: &mut ProgramStore, +) -> IambResult> { + let cmd = CommandBarState::new(store); + let dims = crossterm::terminal::size()?; + let area = Rect::new(0, 0, dims.0, dims.1); + + match settings.layout { + config::Layout::Restore => { + if let Ok(layout) = std::fs::read(&settings.layout_json) { + let tabs: TabLayoutDescription = + serde_json::from_slice(&layout).map_err(IambError::from)?; + let tabs = tabs.to_layout(area.into(), store)?; + + return Ok(ScreenState::from_list(tabs, cmd)); + } + }, + config::Layout::New => {}, + config::Layout::Config { tabs } => { + let mut list = FocusList::default(); + + for tab in tabs.into_iter() { + let tab = config_tab_to_desc(tab, store)?; + let tab = tab.to_layout(area.into(), store)?; + list.push(tab); + } + + return Ok(ScreenState::from_list(list, cmd)); + }, + } + + let win = settings + .tunables + .default_room + .and_then(|room| IambWindow::find(room, store).ok()) + .or_else(|| IambWindow::open(IambId::Welcome, store).ok()) + .unwrap(); + + return Ok(ScreenState::new(win, cmd)); +} + struct Application { - store: AsyncProgramStore, - worker: Requester, + /// Terminal backend. terminal: Terminal>, - bindings: KeyManager, - actstack: VecDeque<(ProgramAction, ProgramContext)>, + + /// State for the Matrix client, editing, etc. + store: AsyncProgramStore, + + /// UI state (open tabs, command bar, etc.) to use when rendering. screen: ScreenState, + + /// Handle to communicate synchronously with the Matrix worker task. + worker: Requester, + + /// Mapped keybindings. + bindings: KeyManager, + + /// Pending actions to run. + actstack: VecDeque<(ProgramAction, ProgramContext)>, + + /// Whether or not the terminal is currently focused. focused: bool, + + /// The tab layout before the last executed [TabAction]. + last_layout: Option>, } impl Application { @@ -141,16 +241,7 @@ impl Application { let bindings = KeyManager::new(bindings); let mut locked = store.lock().await; - - let win = settings - .tunables - .default_room - .and_then(|room| IambWindow::find(room, locked.deref_mut()).ok()) - .or_else(|| IambWindow::open(IambId::Welcome, locked.deref_mut()).ok()) - .unwrap(); - - let cmd = CommandBarState::new(locked.deref_mut()); - let screen = ScreenState::new(win, cmd); + let screen = setup_screen(settings, locked.deref_mut())?; let worker = locked.application.worker.clone(); drop(locked); @@ -165,6 +256,7 @@ impl Application { actstack, screen, focused: true, + last_layout: None, }) } @@ -312,7 +404,6 @@ impl Application { Action::Macro(act) => self.bindings.macro_command(&act, &ctx, store)?, Action::Scroll(style) => self.screen.scroll(&style, &ctx, store)?, Action::ShowInfoMessage(info) => Some(info), - Action::Tab(cmd) => self.screen.tab_command(&cmd, &ctx, store)?, Action::Window(cmd) => self.screen.window_command(&cmd, &ctx, store)?, Action::Jump(l, dir, count) => { @@ -328,6 +419,13 @@ impl Application { }, // UI actions. + Action::Tab(cmd) => { + if let TabAction::Close(_, _) = &cmd { + self.last_layout = self.screen.as_description().into(); + } + + self.screen.tab_command(&cmd, &ctx, store)? + }, Action::RedrawScreen => { self.screen.clear_message(); self.redraw(true, store)?; @@ -494,6 +592,19 @@ impl Application { } } + if let Some(ref layout) = self.last_layout { + let locked = self.store.lock().await; + let path = locked.application.settings.layout_json.as_path(); + path.parent().map(create_dir_all).transpose()?; + + let file = File::create(path)?; + let writer = BufWriter::new(file); + + if let Err(e) = serde_json::to_writer(writer, layout) { + tracing::error!("Failed to save window layout while exiting: {}", e); + } + } + crossterm::terminal::disable_raw_mode()?; execute!(self.terminal.backend_mut(), LeaveAlternateScreen)?; self.terminal.show_cursor()?; diff --git a/src/message/printer.rs b/src/message/printer.rs index 03196b9..531b5b4 100644 --- a/src/message/printer.rs +++ b/src/message/printer.rs @@ -156,6 +156,10 @@ impl<'a> TextPrinter<'a> { pub fn push_str(&mut self, s: &'a str, style: Style) { let style = self.base_style.patch(style); + if self.width == 0 { + return; + } + for mut word in UnicodeSegmentation::split_word_bounds(s) { if let "\n" | "\r\n" = word { // Render embedded newlines as spaces. diff --git a/src/tests.rs b/src/tests.rs index ce9014c..f1d7692 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -195,16 +195,20 @@ pub fn mock_tunables() -> TunableValues { pub fn mock_settings() -> ApplicationSettings { ApplicationSettings { matrix_dir: PathBuf::new(), + layout_json: PathBuf::new(), session_json: PathBuf::new(), + profile_name: "test".into(), profile: ProfileConfig { user_id: user_id!("@user:example.com").to_owned(), url: Url::parse("https://example.com").unwrap(), settings: None, dirs: None, + layout: None, }, tunables: mock_tunables(), dirs: mock_dirs(), + layout: Default::default(), } }