Prepare v0.0.2 pre-release
CI / macos-latest (push) Has been cancelled
CI / ubuntu-latest (push) Has been cancelled
CI / windows-latest (push) Has been cancelled

This commit is contained in:
pleb
2026-07-21 10:27:02 -07:00
parent 456893415d
commit 266483f3cf
17 changed files with 578 additions and 58 deletions
+5
View File
@@ -19,3 +19,8 @@ jobs:
- run: cargo clippy --all-targets -- -D warnings
- run: cargo test
- run: cargo build --release
- uses: actions/upload-artifact@v4
with:
name: mudmouth-${{ matrix.os }}
path: target/release/mudmouth*
if-no-files-found: error
+3
View File
@@ -1,3 +1,6 @@
/target
/dist
/result-*
*.log
.DS_Store
.env
Generated
+1 -1
View File
@@ -1051,7 +1051,7 @@ dependencies = [
[[package]]
name = "mudmouth"
version = "0.1.0"
version = "0.0.2"
dependencies = [
"anyhow",
"chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "mudmouth"
version = "0.1.0"
version = "0.0.2"
edition = "2024"
description = "A classic MUD-inspired EverQuest log reader with Kokoro voices"
license = "MIT"
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Mudmouth contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+11 -45
View File
@@ -13,6 +13,10 @@ It follows an EverQuest log, gives NPC dialogue a Kokoro-FastAPI voice, and
remembers the chosen voice for each NPC. It is entirely local: your log,
speaker mappings, generated audio, and TTS endpoint stay on your computer.
<video controls preload="metadata" width="960" src="docs/mudmouth-demo-01.mp4">
Your browser does not support HTML5 video.
</video>
## What it does
- Watches an EverQuest log without replaying historical lines on startup.
@@ -34,8 +38,9 @@ speaker mappings, generated audio, and TTS endpoint stay on your computer.
- Rust stable (edition 2024)
- A terminal with ANSI support: Windows Terminal, iTerm2, Terminal.app, GNOME
Terminal, and similar terminals work well.
- A reachable [Kokoro-FastAPI](docs/kokoro-fastapi.json) instance. The default
is `http://127.0.0.1:8880`.
- A reachable [Kokoro-FastAPI](docs/kokoro-fastapi.json) instance. Install and
run it using the upstream [Kokoro-FastAPI instructions](https://github.com/remsky/Kokoro-FastAPI);
Mudmouth uses `http://127.0.0.1:8880` by default.
- An EverQuest log with `/log on`.
No `ffplay`, `mpv`, PipeWire command, or other external audio player is
@@ -43,36 +48,8 @@ required; Mudmouth plays returned WAV audio itself.
## Build and run
On NixOS, enter the included development shell. It provides the ALSA
development metadata required by the native Linux audio backend:
```bash
doas systemctl start podman-kokoro-fastapi.service
nix-shell --run 'cargo run --release -- --log "$HOME/Games/EverQuestLegends/Logs/eqlog_Pleb_oggok.txt"'
```
On other platforms, or when Rust and the native audio development packages are
already installed:
```bash
cargo run --release -- --log "$HOME/Games/EverQuestLegends/Logs/eqlog_Pleb_oggok.txt"
```
Or persist the paths through the app:
```bash
cargo run --release
```
Press `s`, select **EverQuest log**, and enter the file path. Change the
Kokoro endpoint from the same screen. Mudmouth validates the endpoint in the
background and the header reads `CONNECTED`, `CHECKING`, or `UNREACHABLE`.
Changing a log path starts watching the new file immediately. A command-line
endpoint override is also available:
```bash
cargo run --release -- --kokoro-url http://127.0.0.1:8880
```
See [development and build notes](docs/development.md) for local setup,
running Mudmouth, validation, and multi-platform release packaging.
## TUI controls
@@ -80,6 +57,8 @@ cargo run --release -- --kokoro-url http://127.0.0.1:8880
| --- | --- |
| `d` | Dashboard / live tavern scroll |
| `s` | Settings |
| `p` | Choose EverQuest game folder during first-time setup |
| `r` | Rescan for character logs during first-time setup |
| `v` | NPC voice manager |
| `?` | Help |
| `q` | Quit |
@@ -109,16 +88,3 @@ repeat_window_seconds = 15
repeat_second_speed = 1.5
repeat_third_plus_speed = 2.0
```
## Development
```bash
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test
cargo build --release
```
The parser tests cover the observed log formats, including NPC dialogue,
channel-chat exclusion, zone events, normalization, persistent/random speaker
voice policies, and log truncation.
+4
View File
@@ -6,6 +6,10 @@ kokoro_url = "http://127.0.0.1:8880"
# Set this to your EverQuest log, or configure it through the TUI.
# log_path = "/home/you/Games/EverQuestLegends/Logs/eqlog_Character_server.txt"
# Saved after first-time setup; Mudmouth scans this folder and its Logs folder
# for eqlog_*.txt files.
# game_directory = "/home/you/Games/EverQuestLegends"
volume = 0.85
max_queue_length = 10
+66
View File
@@ -0,0 +1,66 @@
# Development and build notes
## Local development
Mudmouth requires Rust stable. On NixOS, the included shell also provides the
ALSA development metadata used by the Linux audio backend:
```bash
nix-shell
```
Run the normal validation suite before submitting a change:
```bash
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test
cargo build --release
```
The parser tests cover the observed log formats, including NPC dialogue,
channel-chat exclusion, zone events, normalization, persistent/random speaker
voice policies, log truncation, and first-run log discovery.
## Running locally
Start the local Kokoro FastAPI service when necessary, then pass an EverQuest
log directly:
```bash
doas systemctl start podman-kokoro-fastapi.service
cargo run --release -- --log "$HOME/Games/EverQuestLegends/Logs/eqlog_Pleb_oggok.txt"
```
Starting without a log path opens first-time setup. Mudmouth scans common Steam
and Daybreak install folders for `eqlog_*.txt` files and lets you choose a
character; if it finds none, enter the EverQuest game directory. Use `[r]` to
rescan after enabling `/log on` in EverQuest. You can also change the log path
and Kokoro endpoint from **Settings**:
```bash
cargo run --release
cargo run --release -- --kokoro-url http://127.0.0.1:8880
```
## Release builds
Build Linux and Windows from an x86_64 Linux builder with `release.nix`. Build
macOS on a native x86_64 macOS runner, where Apple's SDK is available. The CI
workflow performs these native builds on every push and retains their binaries
as build artifacts. Package the three binaries with:
```bash
nix-build release.nix -A linux -o result-linux
nix-build release.nix -A windows -o result-windows
# On x86_64 macOS:
nix-build release.nix -A macos -o result-macos
./scripts/package-release.sh \
--linux result-linux/bin/mudmouth \
--macos result-macos/bin/mudmouth \
--windows result-windows/bin/mudmouth.exe
```
The packager writes platform-qualified archives and `SHA256SUMS.txt` to
`dist/`. `scripts/publish-release.sh` uploads those exact assets as a Gitea
pre-release after the worktree is committed and clean.
Binary file not shown.
+20
View File
@@ -0,0 +1,20 @@
{ pkgs ? import <nixpkgs> {} }:
let
buildMudmouth = packageSet:
packageSet.rustPlatform.buildRustPackage {
pname = "mudmouth";
version = "0.0.2";
src = ./.;
cargoLock.lockFile = ./Cargo.lock;
nativeBuildInputs = [ packageSet.pkg-config ];
buildInputs = packageSet.lib.optionals packageSet.stdenv.hostPlatform.isLinux [
packageSet.alsa-lib
];
doCheck = false;
};
in {
linux = buildMudmouth pkgs;
macos = buildMudmouth pkgs.pkgsCross.x86_64-darwin;
windows = buildMudmouth pkgs.pkgsCross.mingwW64;
}
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
echo "Usage: $0 --linux PATH --macos PATH --windows PATH" >&2
exit 2
}
linux_binary=
macos_binary=
windows_binary=
while (($#)); do
case "$1" in
--linux) linux_binary=${2:-}; shift 2 ;;
--macos) macos_binary=${2:-}; shift 2 ;;
--windows) windows_binary=${2:-}; shift 2 ;;
*) usage ;;
esac
done
[[ -n "$linux_binary" && -f "$linux_binary" ]] || usage
[[ -n "$macos_binary" && -f "$macos_binary" ]] || usage
[[ -n "$windows_binary" && -f "$windows_binary" ]] || usage
version=$(sed -n 's/^version = "\\([^"]*\\)"/\\1/p' Cargo.toml | head -n 1)
[[ -n "$version" ]] || { echo "Could not read the package version." >&2; exit 1; }
dist=dist
stage=$(mktemp -d)
repository_root=$PWD
trap 'rm -rf "$stage"' EXIT
rm -rf "$dist"
mkdir -p "$dist"
package_tarball() {
local platform=$1 binary=$2
local root="mudmouth-v${version}-${platform}"
mkdir -p "$stage/$root"
cp "$binary" "$stage/$root/mudmouth"
cp README.md LICENSE config.toml "$stage/$root/"
tar -C "$stage" -czf "$dist/$root.tar.gz" "$root"
rm -rf "$stage/$root"
}
package_tarball x86_64-unknown-linux-gnu "$linux_binary"
package_tarball x86_64-apple-darwin "$macos_binary"
windows_root="mudmouth-v${version}-x86_64-pc-windows-gnu"
mkdir -p "$stage/$windows_root"
cp "$windows_binary" "$stage/$windows_root/mudmouth.exe"
cp README.md LICENSE config.toml "$stage/$windows_root/"
(
cd "$stage"
zip -q -r "$repository_root/$dist/${windows_root}.zip" "$windows_root"
)
(
cd "$dist"
sha256sum -- *.tar.gz *.zip > SHA256SUMS.txt
)
printf 'Created release assets in %s:\n' "$dist"
cat "$dist/SHA256SUMS.txt"
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
version=$(sed -n 's/^version = "\\([^"]*\\)"/\\1/p' Cargo.toml | head -n 1)
tag="v${version}"
dist=dist
checksums="$dist/SHA256SUMS.txt"
notes=$(mktemp)
trap 'rm -f "$notes"' EXIT
[[ -f "$checksums" ]] || { echo "Missing $checksums; run scripts/package-release.sh first." >&2; exit 1; }
cat > "$notes" <<EOF
## Pre-release
First multi-platform pre-release of Mudmouth. It includes first-run EverQuest
log discovery, setup, and the refreshed development documentation.
## SHA-256
\`\`\`
$(cat "$checksums")
\`\`\`
EOF
for asset in "$dist"/mudmouth-v"$version"-*.tar.gz "$dist"/mudmouth-v"$version"-*.zip "$checksums"; do
"$HOME/.agents/skills/publish-gitea-release/scripts/gitea-publish-asset.sh" \
--tag "$tag" \
--asset "$asset" \
--title "Mudmouth $tag (pre-release)" \
--body-file "$notes"
done
+2
View File
@@ -4,6 +4,8 @@ pkgs.mkShell {
packages = with pkgs; [
cargo
rustc
rustfmt
clippy
pkg-config
alsa-lib
];
+2
View File
@@ -12,6 +12,7 @@ pub const APP_DIRECTORY: &str = "mudmouth";
pub struct Config {
pub kokoro_url: String,
pub log_path: Option<PathBuf>,
pub game_directory: Option<PathBuf>,
pub volume: f32,
pub max_queue_length: usize,
pub repeat_window_seconds: u64,
@@ -32,6 +33,7 @@ impl Default for Config {
Self {
kokoro_url: "http://127.0.0.1:8880".into(),
log_path: None,
game_directory: None,
volume: 0.85,
max_queue_length: 10,
repeat_window_seconds: 15,
+208
View File
@@ -0,0 +1,208 @@
use std::{
collections::BTreeSet,
env, fs,
path::{Path, PathBuf},
};
const STEAM_GAME_FOLDERS: &[&str] = &["Everquest F2P", "EverQuest"];
const DAYBREAK_GAME_FOLDERS: &[&str] = &["EverQuest", "Everquest", "EverQuest Legends"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CharacterLog {
pub character: String,
pub server: Option<String>,
pub path: PathBuf,
pub game_directory: PathBuf,
}
impl CharacterLog {
pub fn display_name(&self) -> String {
self.server.as_ref().map_or_else(
|| self.character.clone(),
|server| format!("{}{server}", self.character),
)
}
}
pub fn discover_character_logs(configured_game_directory: Option<&Path>) -> Vec<CharacterLog> {
let mut game_directories = BTreeSet::new();
if let Some(directory) = configured_game_directory {
game_directories.insert(directory.to_path_buf());
}
game_directories.extend(default_game_directories());
let mut logs = game_directories
.into_iter()
.flat_map(|directory| logs_in_game_directory(&directory))
.collect::<Vec<_>>();
logs.sort_by_key(|log| log.display_name());
logs.dedup_by(|left, right| left.path == right.path);
logs
}
pub fn logs_in_game_directory(game_directory: &Path) -> Vec<CharacterLog> {
[game_directory.to_path_buf(), game_directory.join("Logs")]
.into_iter()
.flat_map(|directory| logs_in_directory(&directory, game_directory))
.collect()
}
fn logs_in_directory(directory: &Path, game_directory: &Path) -> Vec<CharacterLog> {
let Ok(entries) = fs::read_dir(directory) else {
return Vec::new();
};
entries
.filter_map(Result::ok)
.filter_map(|entry| {
let path = entry.path();
path.is_file()
.then(|| character_log_from_path(path, game_directory.to_path_buf()))
.flatten()
})
.collect()
}
fn character_log_from_path(path: PathBuf, game_directory: PathBuf) -> Option<CharacterLog> {
let file_name = path.file_name()?.to_string_lossy().into_owned();
let stem = file_name.strip_suffix(".txt")?;
let stem = stem
.strip_prefix("eqlog_")
.or_else(|| stem.strip_prefix("EQLOG_"))?;
if stem.is_empty() {
return None;
}
let (character, server) = stem
.split_once('_')
.map_or((stem, None), |(character, server)| {
(character, Some(server))
});
(!character.is_empty()).then(|| CharacterLog {
character: character.to_owned(),
server: server
.filter(|server| !server.is_empty())
.map(str::to_owned),
path,
game_directory,
})
}
fn default_game_directories() -> BTreeSet<PathBuf> {
let mut directories = BTreeSet::new();
for steam_library in steam_libraries() {
for game_folder in STEAM_GAME_FOLDERS {
directories.insert(steam_library.join("steamapps/common").join(game_folder));
}
}
if cfg!(target_os = "windows") {
for public_directory in windows_public_directories() {
for game_folder in DAYBREAK_GAME_FOLDERS {
directories.insert(
public_directory
.join("Daybreak Game Company/Installed Games")
.join(game_folder),
);
}
}
directories.insert(PathBuf::from(r"C:\EverQuest"));
directories.insert(PathBuf::from(r"C:\Program Files\EverQuest"));
directories.insert(PathBuf::from(r"C:\Program Files (x86)\EverQuest"));
for game_folder in DAYBREAK_GAME_FOLDERS {
directories.insert(
PathBuf::from(r"C:\Users\Public\Daybreak Game Company\Installed Games")
.join(game_folder),
);
}
}
directories
}
fn windows_public_directories() -> Vec<PathBuf> {
let mut directories = vec![PathBuf::from(r"C:\Users\Public")];
if let Some(directory) = env::var_os("PUBLIC") {
directories.push(PathBuf::from(directory));
}
directories
}
fn steam_libraries() -> BTreeSet<PathBuf> {
let mut libraries = BTreeSet::new();
let mut steam_roots = BTreeSet::new();
for variable in ["PROGRAMFILES(X86)", "PROGRAMFILES", "USERPROFILE"] {
if let Some(value) = env::var_os(variable) {
steam_roots.insert(PathBuf::from(value).join("Steam"));
}
}
if let Some(home) = dirs::home_dir() {
steam_roots.extend([
home.join(".steam/steam"),
home.join(".local/share/Steam"),
home.join(".var/app/com.valvesoftware.Steam/.local/share/Steam"),
]);
}
if cfg!(target_os = "windows") {
steam_roots.extend([
PathBuf::from(r"C:\Program Files (x86)\Steam"),
PathBuf::from(r"C:\Program Files\Steam"),
]);
}
for steam_root in steam_roots {
libraries.insert(steam_root.clone());
if let Ok(contents) = fs::read_to_string(steam_root.join("steamapps/libraryfolders.vdf")) {
libraries.extend(steam_library_paths_from_vdf(&contents));
}
}
libraries
}
fn steam_library_paths_from_vdf(contents: &str) -> Vec<PathBuf> {
contents
.lines()
.filter_map(|line| {
let quoted = line.split('"').skip(1).step_by(2).collect::<Vec<_>>();
(quoted.first() == Some(&"path") && quoted.get(1).is_some())
.then(|| PathBuf::from(quoted[1].replace(r"\\", r"\")))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn finds_character_logs_in_the_game_logs_directory() {
let temporary = tempdir().unwrap();
let game_directory = temporary.path().join("EverQuest");
let logs_directory = game_directory.join("Logs");
fs::create_dir_all(&logs_directory).unwrap();
fs::write(logs_directory.join("eqlog_Pleb_oggok.txt"), "").unwrap();
fs::write(game_directory.join("eqlog_Lucy_tunare.txt"), "").unwrap();
fs::write(game_directory.join("notes.txt"), "").unwrap();
let logs = logs_in_game_directory(&game_directory);
assert_eq!(logs.len(), 2);
assert!(logs.iter().any(|log| log.display_name() == "Pleb — oggok"));
assert!(logs.iter().any(|log| log.display_name() == "Lucy — tunare"));
}
#[test]
fn reads_steam_library_paths() {
let contents = r#"
"libraryfolders"
{
"0"
{
"path" "D:\\SteamLibrary"
}
}
"#;
assert_eq!(
steam_library_paths_from_vdf(contents),
vec![PathBuf::from(r"D:\SteamLibrary")]
);
}
}
+4 -6
View File
@@ -1,4 +1,5 @@
mod config;
mod eq_discovery;
mod events;
mod kokoro;
mod speakers;
@@ -76,15 +77,12 @@ async fn main() -> Result<()> {
);
let tailer_generation = Arc::new(AtomicUsize::new(0));
if let Some(path) = config.log_path.clone() {
if config.log_path.as_ref().is_some_and(|path| path.is_file()) {
let path = config.log_path.clone().expect("log path was checked");
spawn_log_tailer(path, worker_sender.clone(), tailer_generation.clone(), 0);
}
let mut app = App::new(config, speakers);
if app.config.log_path.is_none() {
app.activity
.push_front("First run: press [s] and configure your EverQuest log path.".into());
}
let channels = RuntimeChannels {
worker_receiver,
worker_sender,
@@ -128,7 +126,7 @@ async fn run_loop(
.audio_control
.send(AudioCommand::SetVolume(app.config.volume));
let generation = channels.tailer_generation.fetch_add(1, Ordering::Relaxed) + 1;
if let Some(path) = app.config.log_path.clone() {
if let Some(path) = app.config.log_path.clone().filter(|path| path.is_file()) {
spawn_log_tailer(
path,
channels.worker_sender.clone(),
+135 -5
View File
@@ -1,5 +1,6 @@
use crate::{
config::Config,
eq_discovery::{CharacterLog, discover_character_logs},
speakers::SpeakerStore,
workers::{KokoroStatus, WorkerEvent},
};
@@ -22,6 +23,7 @@ const LOGO: &str = r#"
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Screen {
Dashboard,
Setup,
Settings,
Speakers,
Help,
@@ -44,8 +46,10 @@ pub struct App {
pub activity: VecDeque<String>,
pub selected_speaker: usize,
pub selected_voice: usize,
pub selected_character_log: usize,
pub settings_cursor: usize,
pub speaker_filter: String,
pub character_logs: Vec<CharacterLog>,
pub splash_until: std::time::Instant,
pub input: Option<Input>,
}
@@ -53,27 +57,52 @@ pub struct App {
#[derive(Debug)]
pub enum Input {
Endpoint(String),
GameDirectory(String),
LogPath(String),
SpeakerSearch(String),
}
impl App {
pub fn new(config: Config, speakers: SpeakerStore) -> Self {
let character_logs = discover_character_logs(config.game_directory.as_deref());
let log_is_configured = config.log_path.as_ref().is_some_and(|path| path.is_file());
let setup_input = (!log_is_configured && character_logs.is_empty()).then(|| {
Input::GameDirectory(
config
.game_directory
.as_ref()
.map_or_else(String::new, |path| path.display().to_string()),
)
});
let mut activity = VecDeque::from(["The tavern is quiet. Waiting for log lines…".into()]);
if !log_is_configured {
activity.push_front(if character_logs.is_empty() {
"No EverQuest logs found. Enter your EverQuest game folder.".into()
} else {
format!("Found {} EverQuest character log(s).", character_logs.len())
});
}
Self {
config,
speakers,
screen: Screen::Dashboard,
screen: if log_is_configured {
Screen::Dashboard
} else {
Screen::Setup
},
kokoro_status: KokoroStatus::Checking,
voices: Vec::new(),
current_zone: None,
queue_depth: 0,
activity: VecDeque::from(["The tavern is quiet. Waiting for log lines…".into()]),
activity,
selected_speaker: 0,
selected_voice: 0,
selected_character_log: 0,
settings_cursor: 0,
speaker_filter: String::new(),
character_logs,
splash_until: std::time::Instant::now() + std::time::Duration::from_millis(1700),
input: None,
input: setup_input,
}
}
@@ -124,6 +153,7 @@ impl App {
None
}
_ => match self.screen {
Screen::Setup => self.handle_setup_key(key),
Screen::Settings => self.handle_settings_key(key),
Screen::Speakers => self.handle_speakers_key(key),
_ => None,
@@ -142,6 +172,12 @@ impl App {
self.config.kokoro_url = value.trim().trim_end_matches('/').to_string();
true
}
Input::GameDirectory(value) => {
self.config.game_directory = (!value.trim().is_empty())
.then(|| std::path::PathBuf::from(value.trim()));
self.refresh_character_logs();
true
}
Input::LogPath(value) => {
self.config.log_path = (!value.trim().is_empty())
.then(|| std::path::PathBuf::from(value.trim()));
@@ -162,12 +198,18 @@ impl App {
}
}
KeyCode::Backspace => match &mut input {
Input::Endpoint(value) | Input::LogPath(value) | Input::SpeakerSearch(value) => {
Input::Endpoint(value)
| Input::GameDirectory(value)
| Input::LogPath(value)
| Input::SpeakerSearch(value) => {
value.pop();
}
},
KeyCode::Char(character) => match &mut input {
Input::Endpoint(value) | Input::LogPath(value) | Input::SpeakerSearch(value) => {
Input::Endpoint(value)
| Input::GameDirectory(value)
| Input::LogPath(value)
| Input::SpeakerSearch(value) => {
value.push(character);
}
},
@@ -218,6 +260,53 @@ impl App {
None
}
fn handle_setup_key(&mut self, key: KeyEvent) -> Option<UiAction> {
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
self.selected_character_log = self.selected_character_log.saturating_sub(1)
}
KeyCode::Down | KeyCode::Char('j') => {
self.selected_character_log = (self.selected_character_log + 1)
.min(self.character_logs.len().saturating_sub(1))
}
KeyCode::Enter => {
if let Some(log) = self.character_logs.get(self.selected_character_log) {
self.config.log_path = Some(log.path.clone());
self.config.game_directory = Some(log.game_directory.clone());
self.screen = Screen::Dashboard;
self.activity
.push_front(format!("Following {}", log.display_name()));
return Some(UiAction::ConfigChanged);
}
}
KeyCode::Char('p') => {
self.input = Some(Input::GameDirectory(
self.config
.game_directory
.as_ref()
.map_or_else(String::new, |path| path.display().to_string()),
));
}
KeyCode::Char('r') => self.refresh_character_logs(),
_ => {}
}
None
}
fn refresh_character_logs(&mut self) {
self.character_logs = discover_character_logs(self.config.game_directory.as_deref());
self.selected_character_log = 0;
self.activity.push_front(if self.character_logs.is_empty() {
"No character logs found. In EverQuest, enable logging with /log on, then press [r]."
.into()
} else {
format!(
"Found {} EverQuest character log(s).",
self.character_logs.len()
)
});
}
fn handle_speakers_key(&mut self, key: KeyEvent) -> Option<UiAction> {
let keys = self.speaker_keys();
match key.code {
@@ -291,6 +380,7 @@ pub fn draw(frame: &mut Frame, app: &App) {
draw_header(frame, app, sections[0]);
match app.screen {
Screen::Dashboard => draw_dashboard(frame, app, sections[1]),
Screen::Setup => draw_setup(frame, app, sections[1]),
Screen::Settings => draw_settings(frame, app, sections[1]),
Screen::Speakers => draw_speakers(frame, app, sections[1]),
Screen::Help => draw_help(frame, sections[1]),
@@ -305,6 +395,45 @@ pub fn draw(frame: &mut Frame, app: &App) {
}
}
fn draw_setup(frame: &mut Frame, app: &App, area: Rect) {
let logs = app
.character_logs
.iter()
.enumerate()
.map(|(index, log)| {
ListItem::new(format!("{} {}", log.display_name(), log.path.display())).style(
if index == app.selected_character_log {
Style::default().fg(Color::Yellow).bold()
} else {
Style::default()
},
)
})
.collect::<Vec<_>>();
let title = if logs.is_empty() {
" FIRST-TIME SETUP — [p] folder [r] rescan "
} else {
" FIRST-TIME SETUP — ↑↓ select character, Enter continue; [p] folder [r] rescan "
};
let empty = "No character logs found yet. Enter your EverQuest game folder.\n\n\
After logging into a character, use /log on and press [r] here.";
frame.render_widget(
List::new(logs).block(Block::default().title(title).borders(Borders::ALL)),
area,
);
if app.character_logs.is_empty() {
frame.render_widget(
Paragraph::new(empty)
.style(Style::default().fg(Color::Gray))
.wrap(Wrap { trim: true }),
area.inner(Margin {
vertical: 1,
horizontal: 2,
}),
);
}
}
fn draw_splash(frame: &mut Frame) {
let layout = Layout::vertical([
Constraint::Percentage(25),
@@ -518,6 +647,7 @@ fn draw_input(frame: &mut Frame, input: &Input) {
let area = centered_rect(80, 20, frame.area());
let (label, value) = match input {
Input::Endpoint(value) => ("Kokoro endpoint", value),
Input::GameDirectory(value) => ("EverQuest game folder", value),
Input::LogPath(value) => ("EverQuest log path", value),
Input::SpeakerSearch(value) => ("Search NPC", value),
};