use std::num::NonZeroU32;
use std::path::PathBuf;
use std::rc::Rc;
use std::time::{Duration, Instant};
use anyhow::{Context as _, Result};
use badterm_core::dialer::{self, CrtMode, Directory, Entry};
use badterm_core::export::{self, AnsiOptions};
use badterm_core::screen::{Cell, Screen};
use badterm_core::session::{NetEvent, Session, SessionConfig, SessionEvent, State};
use badterm_core::transfer::{Direction, Outcome};
use badterm_core::{Encoding, TermEvent};
use winit::application::ApplicationHandler;
use winit::dpi::PhysicalSize;
use winit::event::{ElementState, KeyEvent, MouseButton, MouseScrollDelta, WindowEvent};
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoopProxy};
use winit::keyboard::{KeyCode, ModifiersState, PhysicalKey};
use winit::window::{Fullscreen, UserAttentionType, Window, WindowId};
use crate::bezel::{self, Bezel};
use crate::input::{self, Key, Mods};
use crate::render::{Display, Font, Frame, Renderer, Scaling};
use crate::secrets::{self, Keychain, Store};
use crate::selection::Selection;
use crate::settings::{BellMode, Folders, Music, ScaleMode, Settings};
use crate::sound::Player;
use crate::ui::art::{self as art_ui, ArtAction, ArtView};
use crate::ui::dialer::{self as dialer_ui, Action, DialerUi};
use crate::ui::files::{FilePicker, PickerAction};
use crate::ui::login::{LoginAction, LoginPrompt};
use crate::ui::save::{SaveFormat, SavePrompt};
use crate::ui::settings::{self as settings_ui, SettingsUi};
use crate::ui::theme::{self, ThemeFile};
use crate::ui::transfer::{self as transfer_ui, DownloadAction, DownloadPrompt, Proto, UploadAction, UploadPrompt};
use crate::ui::{self, HOTKEY, expand_home, format_date};
mod call;
mod draw;
mod keys;
mod mouse;
mod prefs;
mod shape;
const COLS: usize = 80;
const ROWS: usize = 25;
const GRID_ROWS: usize = ROWS + 1;
const SCROLLBACK_LINES: usize = 10_000;
const TICK: Duration = Duration::from_millis(267);
const NOTICE_TIME: Duration = Duration::from_secs(4);
const UPDATE_NOTICE_TIME: Duration = Duration::from_secs(20);
const REDIAL_DELAY: Duration = Duration::from_secs(15);
const MAX_REDIALS: u32 = 10;
const PACE_FRAME: Duration = Duration::from_millis(16);
const BELL_GAP: Duration = Duration::from_millis(250);
const FLASH: Duration = Duration::from_millis(120);
pub enum UserEvent {
Net(u64, NetEvent),
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
OpenUrl(String),
UpdateAvailable(String),
}
struct Redial {
entry: Entry,
index: Option<usize>,
attempt: u32,
at: Instant,
}
pub struct Paths {
pub directory: PathBuf,
pub settings: PathBuf,
pub captures: PathBuf,
pub known_hosts: PathBuf,
pub downloads: PathBuf,
pub files: PathBuf,
pub ssh_keys: Option<PathBuf>,
pub themes: PathBuf,
pub bezels: PathBuf,
pub bbs_cache: PathBuf,
}
impl Paths {
pub fn new() -> Paths {
let proj = directories::ProjectDirs::from("dev", "Bad Cluster", "BadTerm");
let (config, data, cache) = match &proj {
Some(p) => (p.config_dir().to_path_buf(), p.data_dir().to_path_buf(), p.cache_dir().to_path_buf()),
None => (PathBuf::from("."), PathBuf::from("."), PathBuf::from(".")),
};
if let Some(old) = directories::ProjectDirs::from("org", "Phosphor", "Phosphor") {
move_old_folder(old.config_dir(), &config);
move_old_folder(old.data_dir(), &data);
}
let files = directories::UserDirs::new()
.map(|u| u.download_dir().unwrap_or(u.home_dir()).to_path_buf())
.unwrap_or_else(|| PathBuf::from("."));
Paths {
directory: config.join("dialer.toml"),
settings: config.join("settings.toml"),
themes: config.join("themes"),
bezels: config.join("bezels"),
bbs_cache: cache.join("bbs"),
captures: data.join("captures"),
known_hosts: config.join("known_hosts"),
downloads: files.join("BadTerm"),
files,
ssh_keys: directories::BaseDirs::new().map(|b| b.home_dir().join(".ssh")),
}
}
}
fn move_old_folder(old: &std::path::Path, new: &std::path::Path) {
if !old.is_dir() || new.exists() {
return;
}
if let Some(parent) = new.parent() {
let _ = std::fs::create_dir_all(parent);
}
match std::fs::rename(old, new) {
Ok(()) => log::info!("moved {} to {}", old.display(), new.display()),
Err(e) => log::warn!("moving {} to {}: {e}", old.display(), new.display()),
}
}
pub fn load_directory(path: &std::path::Path) -> (Directory, Option<String>) {
match Directory::load(path) {
Ok(d) => (d, None),
Err(e) => {
let backup = path.with_extension(format!("toml.bad-{}", dialer::now()));
let moved = std::fs::rename(path, &backup).is_ok();
let mut msg = format!("Could not read the dialing directory: {e:#}.");
if moved {
msg.push_str(&format!(" It was moved to {}.", backup.display()));
}
(Directory::default(), Some(msg))
}
}
}
struct Gfx {
window: Rc<Window>,
surface: softbuffer::Surface<Rc<Window>, Rc<Window>>,
_context: softbuffer::Context<Rc<Window>>,
}
pub struct App {
proxy: EventLoopProxy<UserEvent>,
gfx: Option<Gfx>,
renderer: Renderer,
paths: Paths,
directory: Directory,
dialer: DialerUi,
dialer_screen: Screen,
dialer_open: bool,
pending_dial: Option<(Entry, Option<usize>)>,
idle_screen: Screen,
settings_ui: SettingsUi,
settings_open: bool,
help_open: bool,
confirm_exit: bool,
splash_until: Option<Instant>,
update: Option<String>,
art_picker: Option<FilePicker>,
art: Option<ArtView>,
art_dir: PathBuf,
view_at_start: Option<PathBuf>,
bezel: Option<Bezel>,
bezel_name: String,
bezel_frame: Option<((usize, usize), bezel::Rendered)>,
update_checked: bool,
page_screen: Screen,
status: Screen,
overlay: Screen,
upload_prompt: Option<UploadPrompt>,
download_prompt: Option<DownloadPrompt>,
upload_dir: PathBuf,
upload_protocol: usize,
download_protocol: usize,
save_prompt: Option<SavePrompt>,
selection: Option<Selection>,
dragging: bool,
mouse: (f64, f64),
settings: Settings,
player: Player,
view_rect: (usize, usize, usize, usize),
shape: shape::WindowShape,
login: Option<LoginPrompt>,
remember: Option<String>,
secrets: Rc<dyn Store>,
keychain_ok: bool,
transfer_direction: Option<Direction>,
session: Option<Session>,
session_id: u64,
session_index: Option<usize>,
macro_steps: std::collections::VecDeque<crate::macros::Step>,
macro_at: Instant,
redials: u32,
redial: Option<Redial>,
scroll: usize,
mods: ModifiersState,
tick: u64,
next_tick: Instant,
notice: Option<(String, Instant)>,
startup: Option<Entry>,
last_bell: Option<Instant>,
flash_until: Option<Instant>,
}
impl App {
pub fn new(proxy: EventLoopProxy<UserEvent>, paths: Paths, startup: Option<Entry>, view: Option<PathBuf>) -> App {
let (mut directory, mut load_error) = load_directory(&paths.directory);
directory.sort();
if load_error.is_none()
&& directory.offer_defaults()
&& let Err(e) = directory.save(&paths.directory)
{
load_error = Some(format!("Could not save the dialing directory: {e:#}"));
}
let settings = Settings::load(&paths.settings);
ui::theme::set_current(settings.colors);
let secrets: Rc<dyn Store> = Rc::new(Keychain);
let mut dialer = DialerUi::new(paths.files.clone(), secrets.clone());
let dialer_open = load_error.is_some();
if let Some(msg) = load_error {
dialer.show_message(msg);
}
App {
proxy,
gfx: None,
renderer: Renderer::new(Font::vga_8x16(), COLS, GRID_ROWS),
paths,
directory,
dialer,
dialer_screen: Screen::new(dialer_ui::COLS, dialer_ui::ROWS, 0),
dialer_open,
pending_dial: None,
idle_screen: idle_screen(None),
settings_ui: SettingsUi::default(),
settings_open: false,
help_open: false,
confirm_exit: false,
splash_until: None,
update: None,
art_picker: None,
art: None,
art_dir: PathBuf::new(),
view_at_start: view,
bezel: None,
bezel_name: String::new(),
bezel_frame: None,
update_checked: false,
page_screen: Screen::new(settings_ui::COLS, settings_ui::ROWS, 0),
status: Screen::new(COLS, 1, 0),
overlay: Screen::new(COLS, ROWS, 0),
upload_prompt: None,
download_prompt: None,
upload_dir: PathBuf::new(),
upload_protocol: 0,
download_protocol: 0,
save_prompt: None,
selection: None,
dragging: false,
mouse: (0.0, 0.0),
view_rect: (0, 0, 1, 1),
shape: shape::WindowShape::default(),
settings,
player: Player::default(),
login: None,
remember: None,
secrets,
keychain_ok: true,
transfer_direction: None,
session: None,
session_id: 0,
session_index: None,
redials: 0,
macro_steps: std::collections::VecDeque::new(),
macro_at: Instant::now(),
redial: None,
scroll: 0,
mods: ModifiersState::empty(),
tick: 0,
next_tick: Instant::now() + TICK,
notice: None,
startup,
last_bell: None,
flash_until: None,
}
}
fn create_window(&mut self, event_loop: &ActiveEventLoop) -> Result<()> {
let (nw, nh) = (self.renderer.native_width() as u32, self.renderer.native_height() as u32);
let attrs = Window::default_attributes()
.with_title("BadTerm")
.with_inner_size(PhysicalSize::new(nw * 2, nh * 2))
.with_min_inner_size(PhysicalSize::new(nw, nh))
.with_window_icon(window_icon());
#[cfg(all(unix, not(target_os = "macos")))]
let attrs = {
use winit::platform::{wayland::WindowAttributesExtWayland, x11::WindowAttributesExtX11};
let attrs = WindowAttributesExtWayland::with_name(attrs, "badterm", "badterm");
WindowAttributesExtX11::with_name(attrs, "badterm", "badterm")
};
let window = Rc::new(event_loop.create_window(attrs)?);
self.shape.min = (nw, nh);
let context = softbuffer::Context::new(window.clone()).map_err(|e| anyhow::anyhow!("{e}"))?;
let surface = softbuffer::Surface::new(&context, window.clone()).map_err(|e| anyhow::anyhow!("{e}"))?;
self.gfx = Some(Gfx { window, surface, _context: context });
Ok(())
}
fn request_redraw(&self) {
if let Some(g) = &self.gfx {
g.window.request_redraw();
}
}
fn set_title(&self, title: &str) {
if let Some(g) = &self.gfx {
g.window.set_title(title);
}
}
fn notify(&mut self, msg: impl Into<String>) {
self.notice = Some((msg.into(), Instant::now() + NOTICE_TIME));
}
fn save_directory(&mut self) {
if self.keychain_ok
&& let Err(e) = secrets::migrate(&mut self.directory, &*self.secrets)
{
self.keychain_ok = false;
log::warn!("keychain unavailable: {e}");
self.notify("No keychain available; passwords are kept in the directory file");
}
if let Err(e) = self.directory.save(&self.paths.directory) {
self.dialer.show_message(format!("Could not save the dialing directory: {e:#}"));
}
}
fn attention(&self) {
if let Some(g) = self.gfx.as_ref().filter(|g| !g.window.has_focus()) {
g.window.request_user_attention(Some(UserAttentionType::Informational));
}
}
}
impl ApplicationHandler<UserEvent> for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.gfx.is_some() {
return;
}
if let Err(e) = self.create_window(event_loop) {
log::error!("creating window: {e:#}");
return event_loop.exit();
}
self.load_bezel();
if self.settings.splash && self.startup.is_none() && self.view_at_start.is_none() {
self.splash_until = Some(Instant::now() + ui::splash::TIME);
}
if let Some(path) = self.view_at_start.take() {
self.view_art(&path);
}
if let Some(e) = self.startup.take() {
self.dial_address(e);
}
self.check_for_updates();
}
fn user_event(&mut self, _: &ActiveEventLoop, event: UserEvent) {
match event {
UserEvent::Net(id, ev) => self.on_net(id, ev),
UserEvent::UpdateAvailable(version) => {
let msg = format!("BadTerm {version} is out: {}", badterm_core::update::DOWNLOAD_PAGE);
self.notice = Some((msg, Instant::now() + UPDATE_NOTICE_TIME));
self.update = Some(version);
self.idle_screen = idle_screen(self.update.as_deref());
self.request_redraw();
}
UserEvent::OpenUrl(url) => match Entry::from_address(&url) {
Some(e) if self.gfx.is_none() => self.startup = Some(e),
Some(e) => {
self.splash_until = None;
if let Some(g) = &self.gfx {
g.window.focus_window();
}
self.dial_address(e);
self.request_redraw();
}
None => self.notify(format!("Not a BBS address: {url}")),
},
}
}
fn window_event(&mut self, event_loop: &ActiveEventLoop, _: WindowId, event: WindowEvent) {
match event {
WindowEvent::CloseRequested => {
let in_call = self.session.as_ref().is_some_and(|s| s.state == State::Connected);
if in_call && !self.confirm_exit {
self.confirm_exit = true;
self.request_redraw();
} else {
event_loop.exit();
}
}
WindowEvent::Resized(size) => {
self.shape.resized(size);
self.request_redraw();
}
WindowEvent::RedrawRequested => self.redraw(),
WindowEvent::ModifiersChanged(m) => self.mods = m.state(),
WindowEvent::KeyboardInput { event, .. } if event.state == ElementState::Pressed => {
self.on_key(event_loop, &event)
}
WindowEvent::CursorMoved { position, .. } => self.on_mouse_move(position.x, position.y),
WindowEvent::MouseInput { state, button, .. } => self.on_mouse_button(button, state),
WindowEvent::MouseWheel { delta, .. } => {
let lines = match delta {
MouseScrollDelta::LineDelta(_, y) => (y * 3.0) as isize,
MouseScrollDelta::PixelDelta(p) => (p.y / 16.0) as isize,
};
self.on_wheel(lines);
}
WindowEvent::DroppedFile(path)
if self.session.as_ref().is_none_or(|s| s.state != State::Connected)
&& !self.dialer_open
&& is_art(&path) =>
{
self.view_art(&path);
self.request_redraw();
}
WindowEvent::DroppedFile(path) if self.session.is_none() || self.dialer_open => {
self.dialer_open = true;
match dialer::import(&path) {
Ok(entries) => {
let added = self.directory.merge(entries);
self.save_directory();
self.dialer.show_message(format!("Imported {added} new entries from {}", path.display()));
}
Err(e) => self.dialer.show_message(format!("Import failed: {e:#}")),
}
self.request_redraw();
}
WindowEvent::DroppedFile(path) => {
self.upload_prompt = None;
self.start_upload(vec![path]);
self.request_redraw();
}
_ => {}
}
}
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
let now = Instant::now();
if now >= self.next_tick {
self.tick += 1;
self.next_tick = now + TICK;
if let Some(s) = self.session.as_mut() {
s.tick();
self.session_events();
}
if self.redial.as_ref().is_some_and(|r| now >= r.at) {
self.redial_now();
}
self.request_redraw();
}
let mut wake = self.next_tick;
if let Some(until) = self.flash_until {
if now >= until {
self.flash_until = None;
self.request_redraw();
} else {
wake = wake.min(until);
}
}
if let Some(until) = self.splash_until {
if now >= until {
self.splash_until = None;
self.request_redraw();
} else {
wake = wake.min(until);
}
}
if let Some(at) = self.shape.due() {
if now >= at {
self.snap_window_shape();
} else {
wake = wake.min(at);
}
}
if !self.macro_steps.is_empty() {
self.run_macro();
if !self.macro_steps.is_empty() {
wake = wake.min(self.macro_at);
}
}
if let Some(at) = self.session.as_ref().and_then(|s| s.next_tick()) {
if now >= at {
if let Some(s) = self.session.as_mut() {
s.tick();
}
self.session_events();
self.request_redraw();
} else {
wake = wake.min(at);
}
}
if let Some(s) = self.session.as_mut().filter(|s| s.pacing()) {
s.pump();
self.session_events();
self.request_redraw();
wake = wake.min(now + PACE_FRAME);
}
event_loop.set_control_flow(ControlFlow::WaitUntil(wake));
}
}
fn window_icon() -> Option<winit::window::Icon> {
let rgba = include_bytes!("../../assets/icon/badterm-64.rgba");
winit::window::Icon::from_rgba(rgba.to_vec(), 64, 64).map_err(|e| log::warn!("window icon: {e}")).ok()
}
fn terminal_rows(session: &Session, scroll: usize) -> Vec<&[Cell]> {
let sc = &session.term.screen;
let sb = sc.scrollback_len();
let top = sb - scroll.min(sb);
(top..top + sc.rows()).map(|i| if i < sb { sc.scrollback_line(i) } else { sc.row(i - sb) }).collect()
}
fn file_stem(name: &str) -> String {
name.chars().map(|c| if c.is_alphanumeric() { c } else { '_' }).collect()
}
fn stamp(now: u64) -> String {
format!("{}-{}", format_date(now), now % 86_400)
}
fn truncate(s: &str, n: usize) -> String {
s.chars().take(n).collect()
}
fn is_art(path: &std::path::Path) -> bool {
let name = path.to_string_lossy().to_ascii_lowercase();
art_ui::EXTENSIONS.iter().any(|x| name.ends_with(x))
}
fn macro_index(code: KeyCode) -> Option<usize> {
const DIGITS: [KeyCode; 10] = [
KeyCode::Digit1,
KeyCode::Digit2,
KeyCode::Digit3,
KeyCode::Digit4,
KeyCode::Digit5,
KeyCode::Digit6,
KeyCode::Digit7,
KeyCode::Digit8,
KeyCode::Digit9,
KeyCode::Digit0,
];
DIGITS.iter().position(|&d| d == code)
}
pub(crate) fn idle_screen(update: Option<&str>) -> Screen {
let t = ui::theme::current();
let mut s = Screen::new(COLS, ROWS, 0);
s.fill_rect(0, 0, COLS, ROWS, Cell::new(b' ', t.help.fg(), t.help.bg()));
let version = format!(
"BadTerm {} ({} {}), the ANSI-BBS client",
env!("CARGO_PKG_VERSION"),
std::env::consts::OS,
std::env::consts::ARCH
);
let lines = [
(version, t.info),
("Copyright (C) 2026 Bad Cluster".to_string(), t.help),
("Free software under the GNU GPL, version 3 or later; no warranty.".to_string(), t.help),
(badterm_core::update::DOWNLOAD_PAGE.to_string(), t.help),
(String::new(), t.help),
(format!("{HOTKEY}-D Dialing directory {HOTKEY}-O Settings {HOTKEY}-Z Help {HOTKEY}-X Exit"), t.help_key),
];
for (i, (text, a)) in lines.iter().enumerate() {
ui::label(&mut s, 0, i, text, a.fg(), a.bg());
}
if let Some(v) = update {
let text = format!("BadTerm {v} is out: {}", badterm_core::update::DOWNLOAD_PAGE);
ui::label(&mut s, 0, lines.len() + 1, &text, t.help_key.fg(), t.help_key.bg());
}
s
}
pub fn import_into_directory(paths: &Paths, file: &std::path::Path) -> Result<(usize, usize)> {
let mut dir = Directory::load(&paths.directory)?;
let entries = dialer::import(file)?;
let total = entries.len();
let added = dir.merge(entries);
dir.save(&paths.directory).context("saving dialing directory")?;
Ok((added, total))
}