use std::sync::Arc;
use ratatui_kit_keymap::{Keymap, KeymapWarning};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReaderAction {
ScrollUp,
ScrollDown,
PrevChapter,
NextChapter,
PageUp,
PageDown,
GoTop,
GoBottom,
VolumeUp,
VolumeDown,
TogglePlay,
ToggleTitle,
ToggleReadMode,
ToggleInfo,
ToggleTts,
ToggleReaderSettings,
}
#[derive(Debug, Clone)]
pub struct AppKeymap {
pub reader: Arc<Keymap<ReaderAction>>,
}
impl Default for AppKeymap {
fn default() -> Self {
Self {
reader: Arc::new(reader_defaults()),
}
}
}
fn reader_defaults() -> Keymap<ReaderAction> {
Keymap::builder()
.bind(ReaderAction::ScrollUp, ["up", "k"])
.desc(ReaderAction::ScrollUp, "向上滚动(章首连按翻上一章)")
.bind(ReaderAction::ScrollDown, ["down", "j"])
.desc(ReaderAction::ScrollDown, "向下滚动(章末连按翻下一章)")
.bind(ReaderAction::PrevChapter, ["left", "h"])
.desc(ReaderAction::PrevChapter, "上一章(章首)")
.bind(ReaderAction::NextChapter, ["right", "l"])
.desc(ReaderAction::NextChapter, "下一章")
.bind(ReaderAction::PageUp, ["pageup", "ctrl-b"])
.desc(ReaderAction::PageUp, "上一页(章首连按翻上一章)")
.bind(ReaderAction::PageDown, ["pagedown", "ctrl-f"])
.desc(ReaderAction::PageDown, "下一页(章末连按翻下一章)")
.bind(ReaderAction::GoTop, ["home"])
.desc(ReaderAction::GoTop, "跳到开头")
.bind(ReaderAction::GoBottom, ["end"])
.desc(ReaderAction::GoBottom, "跳到结尾")
.bind(ReaderAction::VolumeUp, ["+"])
.desc(ReaderAction::VolumeUp, "增大音量")
.bind(ReaderAction::VolumeDown, ["-"])
.desc(ReaderAction::VolumeDown, "减小音量")
.bind(ReaderAction::TogglePlay, ["p"])
.desc(ReaderAction::TogglePlay, "播放/暂停")
.bind(ReaderAction::ToggleTitle, ["v"])
.desc(ReaderAction::ToggleTitle, "隐藏/显示标题")
.bind(ReaderAction::ToggleReadMode, ["tab"])
.desc(ReaderAction::ToggleReadMode, "切换章节选择/阅读模式")
.bind(ReaderAction::ToggleInfo, ["i", "I"])
.desc(ReaderAction::ToggleInfo, "打开/关闭快捷键帮助")
.bind(ReaderAction::ToggleTts, ["t", "T"])
.desc(ReaderAction::ToggleTts, "打开/关闭TTS设置")
.bind(ReaderAction::ToggleReaderSettings, ["o", "O"])
.desc(ReaderAction::ToggleReaderSettings, "打开/关闭阅读设置")
.build()
}
pub fn load_keymap() -> (AppKeymap, Vec<String>) {
let mut keymap = AppKeymap::default();
let Ok(dir) = crate::utils::novel_catch_dir() else {
return (keymap, Vec::new());
};
let path = dir.join("keybindings.toml");
if !path.exists() {
return (keymap, Vec::new());
}
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(e) => {
return (
keymap,
vec![format!("keybindings.toml 读取失败:{e},已使用默认按键")],
);
}
};
let table: ratatui_kit_keymap::toml::Table = match ratatui_kit_keymap::toml::from_str(&text) {
Ok(table) => table,
Err(e) => {
return (
keymap,
vec![format!(
"keybindings.toml 不是合法 TOML:{e}\n已使用默认按键"
)],
);
}
};
let mut messages = Vec::new();
if let Some(reader_table) = table.get("reader").and_then(|v| v.as_table()).cloned() {
let mut reader = (*keymap.reader).clone();
messages.extend(
reader
.merge_toml_table(reader_table)
.iter()
.map(render_warning),
);
keymap.reader = Arc::new(reader);
}
(keymap, messages)
}
fn render_warning(warning: &KeymapWarning) -> String {
match warning {
KeymapWarning::ParseError { action, input, .. } => {
format!("[reader] {action} 的键位「{input}」无法解析,已回退默认键位")
}
KeymapWarning::InvalidEntry { action, .. } => {
format!("[reader] {action} 的值类型不对(应为键位字符串或字符串列表),已回退默认键位")
}
KeymapWarning::Conflict { key, actions, .. } => {
format!(
"[reader] 键 \"{key}\" 被绑定到多个操作({}),冲突的自定义已回退默认",
actions.join("、")
)
}
KeymapWarning::UnknownAction { name, .. } => {
format!("[reader] 未知操作 \"{name}\",该条已忽略")
}
other => other.to_string(),
}
}
pub fn display_keys(keymap: &Keymap<ReaderAction>, action: ReaderAction) -> String {
let mut names: Vec<String> = Vec::new();
for name in keymap.describe(action) {
let pretty = prettify(&name);
if !names.contains(&pretty) {
names.push(pretty);
}
}
names.join(" / ")
}
pub fn display_first_key(keymap: &Keymap<ReaderAction>, action: ReaderAction) -> String {
keymap
.describe(action)
.first()
.map(|name| prettify(name))
.unwrap_or_else(|| "?".to_string())
}
fn prettify(name: &str) -> String {
if let Some(ch) = name.strip_prefix("Shift-")
&& ch.chars().count() == 1
{
return ch.to_uppercase();
}
match name {
"Up" => "↑".to_string(),
"Down" => "↓".to_string(),
"Left" => "←".to_string(),
"Right" => "→".to_string(),
"Hyphen" => "-".to_string(),
name if name.chars().count() == 1 => name.to_uppercase(),
name => name.to_string(),
}
}
#[cfg(test)]
mod tests;