pub(crate) mod bootstrap;
pub mod ctrl_h;
pub mod events;
pub(crate) mod terminal;
use std::io;
use std::process::ExitCode;
use std::sync::{Arc, RwLock};
use color_eyre::eyre;
use kimun_core::{NoteVault, VaultConfig};
use ratatui::Terminal;
use ratatui::prelude::Backend;
use crate::app_screen::browse::BrowseScreen;
use crate::app_screen::editor::EditorScreen;
use crate::app_screen::onboarding::OnboardingScreen;
use crate::app_screen::preferences::PreferencesScreen;
use crate::app_screen::start::StartScreen;
use crate::app_screen::{AppScreen, ScreenKind};
use crate::components::events::{
AppEvent, AppTx, AppTxExt, FileOp, InputEvent, ScreenEvent, UpdateFlow,
};
use crate::keys::action_shortcuts::ActionShortcuts;
use crate::keys::key_event_to_combo;
use crate::settings::{AppSettings, SharedSettings};
use clap::Parser;
use color_eyre::Result;
use events::EventHandler;
use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "kimun", about = "Kimün notes", version)]
pub struct Cli {
#[arg(long, value_name = "FILE")]
pub config: Option<PathBuf>,
#[command(subcommand)]
pub command: Option<crate::cli::CliCommand>,
}
pub fn main() -> Result<ExitCode> {
color_eyre::install()?;
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
runtime.block_on(entry())
}
async fn entry() -> Result<ExitCode> {
let log_dir: PathBuf = kimun_core::system::log_dir().into_path_buf();
let _guard = bootstrap::init_logging(&log_dir);
bootstrap::install_panic_hook(log_dir.join("kimun.log"));
let cli = Cli::parse();
match cli.command {
Some(command) => run_cli_command(command, cli.config).await,
None => {
run_tui(cli.config).await?;
Ok(ExitCode::SUCCESS)
}
}
}
async fn run_cli_command(
command: crate::cli::CliCommand,
config: Option<PathBuf>,
) -> Result<ExitCode> {
match crate::cli::run_cli(command, config).await {
Ok(()) => Ok(ExitCode::SUCCESS),
Err(report) => {
if let Some(msg) = report
.downcast_ref::<kimun_core::error::VaultError>()
.and_then(|ve| ve.user_message())
{
eprintln!("Error: {msg}");
return Ok(ExitCode::from(2));
}
Err(report)
}
}
}
pub async fn run_tui(config_path: Option<PathBuf>) -> Result<()> {
let mut app = App::new(config_path).await?;
let (mouse_capture, ctrl_h_setting) = {
let s = app.settings.read().unwrap();
(s.mouse(), s.ctrl_h)
};
let mut session = terminal::TerminalSession::enter(mouse_capture)?;
let ctrl_h = ctrl_h::CtrlHPolicy::resolve(ctrl_h_setting, session.keyboard_enhanced());
let mut events = EventHandler::new(ctrl_h);
app.parked_flashes.extend(unreachable_binding_notice(
&app,
&session,
ctrl_h_setting,
ctrl_h,
));
spawn_update_check(&app, events.app_sender());
respawn_rag(&mut app, &events.app_sender());
let outcome = run_app(session.terminal_mut(), &mut app, &mut events).await;
drop(session);
if let Err(e) = outcome {
tracing::error!("fatal error: {e}");
return Err(e.into());
}
crate::components::text_editor::widener_metrics::dump_if_enabled();
Ok(())
}
pub struct App {
pub current_screen: Box<dyn AppScreen>,
pub settings: SharedSettings,
pub vault: Option<Arc<NoteVault>>,
pub screen_generation: u64,
pub update: Option<crate::update::UpdateStatus>,
pub parked_flashes: Vec<String>,
pub rag_sync_task: Option<tokio::task::JoinHandle<()>>,
pub rag_status: crate::rag::RagStatus,
}
impl App {
pub async fn new(config_path: Option<std::path::PathBuf>) -> eyre::Result<Self> {
let loaded_settings = match config_path {
Some(path) => AppSettings::load_from_file(path)?,
None => AppSettings::load_from_disk()?,
};
Ok(Self::from_settings(Arc::new(RwLock::new(loaded_settings))).await)
}
pub async fn from_settings(settings: SharedSettings) -> Self {
let vault = Self::open_vault(&settings).await;
Self {
current_screen: Box::new(StartScreen::new(settings.clone(), vault.clone())),
settings,
vault,
screen_generation: 0,
update: None,
parked_flashes: Vec::new(),
rag_sync_task: None,
rag_status: crate::rag::RagStatus::Disabled,
}
}
pub async fn open_vault(settings: &SharedSettings) -> Option<Arc<NoteVault>> {
let (workspace_path, cache_path, inbox_path) = {
let s = settings.read().unwrap();
let wp = s.resolve_workspace_path();
let name = s.current_workspace_name();
let cache = name.as_ref().map(|n| s.index_for(n));
let ip = s
.workspace_config
.as_ref()
.and_then(|wc| wc.get_current_workspace())
.map(|e| e.effective_inbox_path());
(wp, cache, ip)
};
let workspace = workspace_path?;
let mut config = VaultConfig::new(workspace.clone());
if let Some(cp) = cache_path {
config = config.with_index(cp);
}
match NoteVault::new(config).await {
Ok(mut v) => {
if let Some(ref ip) = inbox_path {
v.set_inbox_path(kimun_core::nfs::VaultPath::new(ip));
}
Some(Arc::new(v))
}
Err(e) => {
tracing::error!("could not open vault at {}: {e}", workspace);
None
}
}
}
}
fn respawn_rag(app: &mut App, tx: &crate::components::events::AppTx) {
if let Some(handle) = app.rag_sync_task.take() {
handle.abort();
}
if let Some(vault) = app.vault.clone() {
app.rag_sync_task = crate::rag::spawn_rag_sync(vault, &app.settings, tx.clone());
}
}
async fn switch_screen(app: &mut App, tx: &AppTx, new_screen: ScreenEvent) {
app.current_screen.on_exit(tx).await;
let shows_flashes = matches!(new_screen, ScreenEvent::OpenEditor(..));
let mut screen: Box<dyn AppScreen> = match new_screen {
ScreenEvent::Start => Box::new(StartScreen::new(app.settings.clone(), app.vault.clone())),
ScreenEvent::OpenPreferences => Box::new(PreferencesScreen::new(app.settings.clone())),
ScreenEvent::OpenPreferencesWithError(msg) => {
Box::new(PreferencesScreen::new_with_error(app.settings.clone(), msg))
}
ScreenEvent::OpenEditor(note_vault, vault_path) => Box::new(EditorScreen::new(
note_vault,
vault_path,
app.settings.clone(),
)),
ScreenEvent::OpenBrowse(note_vault, vault_path) => Box::new(BrowseScreen::new(
note_vault,
vault_path,
app.settings.clone(),
)),
ScreenEvent::OpenOnboarding => Box::new(OnboardingScreen::new(app.settings.clone())),
};
screen.on_enter(tx).await;
if let Some(status) = app.update.clone() {
screen
.handle_app_message(AppEvent::Update(UpdateFlow::Available(status)), tx)
.await;
}
if shows_flashes && !app.parked_flashes.is_empty() {
let msg = std::mem::take(&mut app.parked_flashes).join(" · ");
screen
.handle_app_message(AppEvent::FlashMessage(msg), tx)
.await;
}
screen
.handle_app_message(AppEvent::RagStatus(app.rag_status), tx)
.await;
app.current_screen = screen;
app.screen_generation = app.screen_generation.wrapping_add(1);
}
fn unreachable_binding_notice(
app: &App,
session: &terminal::TerminalSession,
setting: crate::settings::CtrlHSetting,
ctrl_h: ctrl_h::CtrlHPolicy,
) -> Option<String> {
use crate::keys::reachability::{TerminalKeys, unreachable_actions};
let keys = TerminalKeys::detected(
session.keyboard_enhanced(),
ctrl_h.loss_is_worth_reporting(setting),
);
let stranded = {
let settings = app.settings.read().unwrap();
unreachable_actions(&settings.key_bindings, keys)
};
if stranded.is_empty() {
return None;
}
for u in &stranded {
for (combo, fate) in &u.combos {
tracing::warn!("key binding {combo} for {}: {fate}", u.action);
}
}
Some(notice_text(&stranded, setting, ctrl_h))
}
#[allow(
dead_code,
reason = "read by the width-budget test, not by notice_text"
)]
const FLASH_WIDTH_BUDGET: usize = 72;
const FLASH_NAME_LIMIT: usize = 2;
fn notice_text(
stranded: &[crate::keys::reachability::Unreachable],
setting: crate::settings::CtrlHSetting,
policy: ctrl_h::CtrlHPolicy,
) -> String {
use crate::keys::key_combo::{KeyCombo, KeyModifiers};
use crate::keys::key_strike::KeyStrike;
use crate::keys::reachability::Reach;
let ctrl_h_combo = KeyCombo::new(KeyModifiers::new().and_ctrl(), KeyStrike::KeyH);
let backspace_key = KeyCombo::new(KeyModifiers::new(), KeyStrike::Backspace);
let is_ctrl_h_loss = |u: &&crate::keys::reachability::Unreachable| {
policy == ctrl_h::CtrlHPolicy::Backspace
&& u.combos.iter().any(|(combo, reach)| {
*combo == ctrl_h_combo && *reach == Reach::Shadowed(backspace_key)
})
};
let (ctrl_h_group, plain_group): (Vec<_>, Vec<_>) = stranded.iter().partition(is_ctrl_h_loss);
let join_names = |group: &[&crate::keys::reachability::Unreachable]| {
let names: Vec<String> = group.iter().map(|u| u.action.to_string()).collect();
if names.len() <= FLASH_NAME_LIMIT {
names.join(", ")
} else {
format!(
"{} and {} more",
names[..FLASH_NAME_LIMIT].join(", "),
names.len() - FLASH_NAME_LIMIT
)
}
};
let ctrl_h_clause = (!ctrl_h_group.is_empty()).then(|| {
let names = join_names(&ctrl_h_group);
let setting = format!("{setting:?}").to_lowercase();
format!("{names}: Ctrl+H is Backspace (ctrl_h = \"{setting}\")")
});
let plain_clause = (!plain_group.is_empty())
.then(|| format!("{}: no usable key here", join_names(&plain_group)));
match (ctrl_h_clause, plain_clause) {
(Some(a), None) => format!("{a} — run `kimun doctor`"),
(None, Some(b)) => format!("{b} — run `kimun doctor`"),
(Some(a), Some(b)) => format!("{a}; {b} — run `kimun doctor`"),
(None, None) => {
unreachable!("unreachable_binding_notice never calls this with an empty scan")
}
}
}
fn spawn_update_check(app: &App, tx: AppTx) {
if !app.settings.read().unwrap().update_check() {
return;
}
let Ok(config_dir) = crate::settings::config_dir() else {
return;
};
tokio::spawn(async move {
match crate::update::check_now(config_dir, false).await {
Ok(Some(status)) if status.should_notify() => {
let _ = tx.send(AppEvent::Update(UpdateFlow::Available(status)));
}
Ok(_) => {}
Err(e) => tracing::debug!("update check failed: {e}"),
}
});
}
pub async fn run_app<B: Backend>(
terminal: &mut Terminal<B>,
app: &mut App,
events: &mut EventHandler,
) -> io::Result<()>
where
B::Error: std::error::Error + Send + Sync + 'static,
{
let tx = events.app_sender();
app.current_screen.on_enter(&tx).await;
loop {
terminal
.draw(|f| app.current_screen.render(f))
.map_err(io::Error::other)?;
let mut event = events.next().await;
loop {
match event {
AppEvent::Quit => {
app.current_screen.on_exit(&tx).await;
return Ok(());
}
AppEvent::Redraw => {
}
AppEvent::Input(input) => {
match input {
InputEvent::Key(key) => {
tracing::debug!(
"KEY: code={:?} mods={:?} kind={:?}",
key.code,
key.modifiers,
key.kind
);
if let Some(combo) = key_event_to_combo(&key) {
let action = {
let s = app.settings.read().unwrap();
tracing::debug!(
"COMBO: {} → {:?}",
combo,
s.key_bindings.get_action(&combo)
);
s.key_bindings.get_action(&combo)
};
let handled_global = match action {
Some(ActionShortcuts::Quit) => {
tx.send(AppEvent::Quit).ok();
true
}
Some(ActionShortcuts::OpenPreferences) => {
let already_on_settings = app.current_screen.get_kind()
== ScreenKind::Preferences;
if !already_on_settings {
tx.send(AppEvent::OpenScreen(
ScreenEvent::OpenPreferences,
))
.ok();
}
true
}
_ => false,
};
if handled_global {
match events.try_next() {
Some(next) => {
event = next;
continue;
}
None => break,
}
}
}
app.current_screen.handle_input(&InputEvent::Key(key), &tx);
}
InputEvent::Mouse(mouse_event) => {
app.current_screen
.handle_input(&InputEvent::Mouse(mouse_event), &tx);
}
InputEvent::Paste(text) => {
app.current_screen
.handle_input(&InputEvent::Paste(text), &tx);
}
}
}
msg => {
let before_gen = app.screen_generation;
handle_app_message(msg, app, &tx).await?;
if app.screen_generation != before_gen {
break;
}
}
}
match events.try_next() {
Some(next) => event = next,
None => break,
}
}
}
}
async fn handle_app_message(msg: AppEvent, app: &mut App, tx: &AppTx) -> io::Result<()> {
match msg {
AppEvent::Redraw => {}
AppEvent::OpenScreen(screen) => {
switch_screen(app, tx, screen).await;
}
AppEvent::ParkFlash(msg) => app.parked_flashes.push(msg),
AppEvent::OpenPath { path, emphasis } => {
let unhandled = app.current_screen.try_open_path(path, emphasis, tx).await;
if let Some(path) = unhandled {
if let Some(vault) = app.vault.clone() {
if path.is_note() {
tx.send(AppEvent::OpenScreen(ScreenEvent::OpenEditor(vault, path)))
.ok();
} else {
tx.send(AppEvent::OpenScreen(ScreenEvent::OpenBrowse(vault, path)))
.ok();
}
} else {
tx.send(AppEvent::OpenScreen(ScreenEvent::OpenOnboarding))
.ok();
}
}
}
AppEvent::OpenAttachment(path) => {
let unhandled = app.current_screen.try_open_attachment(path, tx).await;
if let Some(path) = unhandled
&& let Some(vault) = app.vault.clone()
{
tx.send(AppEvent::OpenScreen(ScreenEvent::OpenEditor(vault, path)))
.ok();
}
}
AppEvent::OpenJournal => {
if let Some(vault) = app.vault.clone()
&& let Ok((details, _, created)) = vault.journal_entry().await
{
tx.announce_and_open(details.path, created);
}
}
AppEvent::PreferencesSaved | AppEvent::OnboardingFinished => {
app.vault = App::open_vault(&app.settings).await;
respawn_rag(app, tx);
tx.send(AppEvent::OpenScreen(ScreenEvent::Start)).ok();
}
AppEvent::ClosePreferences => {
tx.send(AppEvent::OpenScreen(ScreenEvent::Start)).ok();
}
AppEvent::VaultConflict(msg) => {
{
let mut s = app.settings.write().unwrap();
s.clear_workspace();
s.save_to_disk().ok();
}
app.vault = None;
respawn_rag(app, tx);
switch_screen(app, tx, ScreenEvent::OpenPreferencesWithError(msg)).await;
}
AppEvent::WorkspaceSwitched(name) => {
{
let mut s = app.settings.write().unwrap();
if let Some(ref mut wc) = s.workspace_config {
wc.global.current_workspace = name;
}
s.save_to_disk().ok();
}
app.vault = App::open_vault(&app.settings).await;
respawn_rag(app, tx);
tx.send(AppEvent::OpenScreen(ScreenEvent::Start)).ok();
}
AppEvent::Update(flow) => {
match &flow {
UpdateFlow::Available(status) => app.update = Some(status.clone()),
UpdateFlow::Dismiss(version) => {
if let Ok(config_dir) = crate::settings::config_dir()
&& let Err(e) = crate::update::dismiss(&config_dir, version)
{
tracing::debug!("could not persist update dismissal: {e}");
}
app.update = None;
}
UpdateFlow::Applied => app.update = None,
UpdateFlow::Apply | UpdateFlow::ShowDialog => {}
}
app.current_screen
.handle_app_message(AppEvent::Update(flow), tx)
.await;
}
AppEvent::RagStatus(status) => {
app.rag_status = status;
app.current_screen
.handle_app_message(AppEvent::RagStatus(status), tx)
.await;
}
AppEvent::FileOp(op) => {
match &op {
FileOp::Renamed { from, to } | FileOp::Moved { from, to } => {
app.settings
.read()
.unwrap()
.follow_rename_in_history(from, to);
}
FileOp::Deleted(path) => {
app.settings.read().unwrap().follow_delete_in_history(path);
}
_ => {}
}
app.current_screen
.handle_app_message(AppEvent::FileOp(op), tx)
.await;
}
other => {
app.current_screen.handle_app_message(other, tx).await;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, RwLock};
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
use super::App;
use crate::app_screen::ScreenKind;
use crate::keys::action_shortcuts::ActionShortcuts;
use crate::keys::key_event_to_combo;
use crate::settings::AppSettings;
#[tokio::test]
async fn from_settings_without_a_workspace_starts_on_the_start_screen_with_no_vault() {
let settings = Arc::new(RwLock::new(AppSettings::default()));
let app = App::from_settings(settings).await;
assert!(app.vault.is_none());
assert_eq!(app.current_screen.get_kind(), ScreenKind::Start);
assert_eq!(app.screen_generation, 0);
}
#[test]
fn settings_keybinding_sends_open_settings() {
let settings = AppSettings::default();
let key = KeyEvent {
code: KeyCode::Char(','),
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
};
let combo = key_event_to_combo(&key).expect("Ctrl+, should produce a combo");
let action = settings.key_bindings.get_action(&combo);
assert_eq!(action, Some(ActionShortcuts::OpenPreferences));
}
#[tokio::test]
async fn the_key_warning_waits_for_a_screen_that_shows_flashes() {
use crate::components::events::ScreenEvent;
use kimun_core::nfs::VaultPath;
use kimun_core::{NoteVault, VaultConfig};
let settings = Arc::new(RwLock::new(AppSettings::default()));
let mut app = App::from_settings(settings).await;
app.parked_flashes.push("stranded".to_string());
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
super::switch_screen(&mut app, &tx, ScreenEvent::OpenOnboarding).await;
assert_eq!(
app.parked_flashes,
vec!["stranded".to_string()],
"onboarding cannot show a flash, so the warning must still be parked"
);
let dir = tempfile::TempDir::new().unwrap();
let vault = Arc::new(
NoteVault::new(VaultConfig::new(crate::test_support::sys(dir.path())))
.await
.unwrap(),
);
super::switch_screen(
&mut app,
&tx,
ScreenEvent::OpenEditor(vault, VaultPath::root()),
)
.await;
assert!(
app.parked_flashes.is_empty(),
"the editor shows flashes, so the one-shot is delivered and gone"
);
}
async fn app_with_history(history: &[&str], scratch: &tempfile::TempDir) -> App {
let workspace = crate::test_support::sys(scratch.path());
let settings = AppSettings::for_test_workspace("ws", &workspace, workspace.clone());
settings.seed_test_history(history);
App::from_settings(Arc::new(RwLock::new(settings))).await
}
async fn file_op(app: &mut App, op: crate::components::events::FileOp) {
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
super::handle_app_message(crate::components::events::AppEvent::FileOp(op), app, &tx)
.await
.unwrap();
}
fn history_of(app: &App) -> Vec<String> {
app.settings
.read()
.unwrap()
.current_last_paths()
.iter()
.map(|p| {
let mut p = p.clone();
p.to_relative();
p.to_string()
})
.collect()
}
#[tokio::test]
async fn renaming_a_note_rewrites_it_in_the_history() {
use crate::components::events::FileOp;
use kimun_core::nfs::VaultPath;
let scratch = tempfile::TempDir::new().unwrap();
let mut app = app_with_history(&["a.md", "other.md"], &scratch).await;
file_op(
&mut app,
FileOp::Renamed {
from: VaultPath::new("a.md"),
to: VaultPath::new("b.md"),
},
)
.await;
assert_eq!(history_of(&app), vec!["b.md", "other.md"]);
}
#[tokio::test]
async fn moving_a_directory_rewrites_the_notes_beneath_it() {
use crate::components::events::FileOp;
use kimun_core::nfs::VaultPath;
let scratch = tempfile::TempDir::new().unwrap();
let mut app = app_with_history(&["dir/a.md", "dirx/b.md"], &scratch).await;
file_op(
&mut app,
FileOp::Moved {
from: VaultPath::new("dir"),
to: VaultPath::new("archive/dir"),
},
)
.await;
assert_eq!(history_of(&app), vec!["archive/dir/a.md", "dirx/b.md"]);
}
#[tokio::test]
async fn deleting_drops_the_note_or_everything_beneath_the_directory() {
use crate::components::events::FileOp;
use kimun_core::nfs::VaultPath;
let scratch = tempfile::TempDir::new().unwrap();
let mut app =
app_with_history(&["a.md", "dir/b.md", "dirx/c.md", "keep.md"], &scratch).await;
file_op(&mut app, FileOp::Deleted(VaultPath::new("a.md"))).await;
file_op(&mut app, FileOp::Deleted(VaultPath::new("dir"))).await;
assert_eq!(history_of(&app), vec!["dirx/c.md", "keep.md"]);
}
#[tokio::test]
async fn every_parked_flash_reaches_the_footer() {
use crate::components::events::{AppEvent, ScreenEvent};
use kimun_core::nfs::VaultPath;
let settings = Arc::new(RwLock::new(AppSettings::default()));
let mut app = App::from_settings(settings).await;
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
for notice in ["first notice", "second notice"] {
super::handle_app_message(AppEvent::ParkFlash(notice.to_string()), &mut app, &tx)
.await
.unwrap();
}
let vault = crate::test_support::temp_vault("app").await;
super::switch_screen(
&mut app,
&tx,
ScreenEvent::OpenEditor(vault, VaultPath::root()),
)
.await;
let mut terminal =
ratatui::Terminal::new(ratatui::backend::TestBackend::new(160, 30)).unwrap();
terminal.draw(|f| app.current_screen.render(f)).unwrap();
let flat: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|c| c.symbol())
.collect();
assert!(flat.contains("first notice"), "first notice lost");
assert!(flat.contains("second notice"), "second notice lost");
}
#[test]
fn the_notice_names_ctrl_h_when_auto_chose_backspace() {
use crate::app::ctrl_h::CtrlHPolicy;
use crate::keys::key_combo::{KeyCombo, KeyModifiers};
use crate::keys::key_strike::KeyStrike;
use crate::keys::reachability::{Reach, Unreachable};
use crate::settings::CtrlHSetting;
let ctrl_h = KeyCombo::new(KeyModifiers::new().and_ctrl(), KeyStrike::KeyH);
let stranded = vec![Unreachable {
action: ActionShortcuts::FocusSidebar,
combos: vec![(
ctrl_h,
Reach::Shadowed(KeyCombo::new(KeyModifiers::new(), KeyStrike::Backspace)),
)],
}];
let auto = super::notice_text(&stranded, CtrlHSetting::Auto, CtrlHPolicy::Backspace);
assert!(auto.contains("FocusSidebar"), "{auto}");
assert!(auto.contains("ctrl_h"), "{auto}");
assert!(auto.contains("kimun doctor"), "{auto}");
let theirs = super::notice_text(&stranded, CtrlHSetting::Auto, CtrlHPolicy::Chord);
assert!(!theirs.contains("ctrl_h"), "{theirs}");
assert!(theirs.contains("no usable key"), "{theirs}");
}
#[test]
fn the_notice_keeps_an_unrelated_stranding_out_of_the_ctrl_h_clause() {
use crate::app::ctrl_h::CtrlHPolicy;
use crate::keys::key_combo::{KeyCombo, KeyModifiers};
use crate::keys::key_strike::KeyStrike;
use crate::keys::reachability::{Reach, Unreachable};
use crate::settings::CtrlHSetting;
let ctrl_h = KeyCombo::new(KeyModifiers::new().and_ctrl(), KeyStrike::KeyH);
let ctrl_i = KeyCombo::new(KeyModifiers::new().and_ctrl(), KeyStrike::KeyI);
let stranded = vec![
Unreachable {
action: ActionShortcuts::FocusSidebar,
combos: vec![(
ctrl_h,
Reach::Shadowed(KeyCombo::new(KeyModifiers::new(), KeyStrike::Backspace)),
)],
},
Unreachable {
action: ActionShortcuts::QuickNote,
combos: vec![(
ctrl_i,
Reach::Shadowed(KeyCombo::new(KeyModifiers::new(), KeyStrike::Tab)),
)],
},
];
let text = super::notice_text(&stranded, CtrlHSetting::Auto, CtrlHPolicy::Backspace);
let ctrl_h_clause = text
.split_once("; ")
.map(|(first, _)| first)
.unwrap_or_else(|| panic!("expected the ctrl_h clause joined with '; ': {text}"));
assert!(
ctrl_h_clause.contains("FocusSidebar"),
"ctrl_h clause: {ctrl_h_clause}"
);
assert!(
!ctrl_h_clause.contains("QuickNote"),
"ctrl_h clause: {ctrl_h_clause}"
);
assert!(text.contains("QuickNote"), "{text}");
}
#[test]
fn the_single_clause_notice_fits_the_flash_width_budget() {
use crate::app::ctrl_h::CtrlHPolicy;
use crate::keys::key_combo::{KeyCombo, KeyModifiers};
use crate::keys::key_strike::KeyStrike;
use crate::keys::reachability::{Reach, Unreachable};
use crate::settings::CtrlHSetting;
let ctrl_h = KeyCombo::new(KeyModifiers::new().and_ctrl(), KeyStrike::KeyH);
let stranded = vec![Unreachable {
action: ActionShortcuts::FocusSidebar,
combos: vec![(
ctrl_h,
Reach::Shadowed(KeyCombo::new(KeyModifiers::new(), KeyStrike::Backspace)),
)],
}];
let text = super::notice_text(&stranded, CtrlHSetting::Auto, CtrlHPolicy::Backspace);
assert!(
text.chars().count() <= super::FLASH_WIDTH_BUDGET,
"{} chars, over the {}-column budget: {text}",
text.chars().count(),
super::FLASH_WIDTH_BUDGET
);
}
}