use dotmax::image::{ColorMode, DitheringMethod};
use dotmax::media::{list_webcams, MediaPlayer, WebcamPlayer};
use dotmax::BrailleGrid;
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
use crossterm::{cursor, execute};
use std::fmt::Write as FmtWrite;
use std::io::{stdin, stdout, Write};
use std::time::{Duration, Instant};
#[derive(Debug, Clone)]
struct TunerState {
dithering: DitheringMethod,
use_otsu: bool, manual_threshold: u8, brightness: f32,
contrast: f32,
gamma: f32,
color_mode: ColorMode,
show_help: bool,
}
impl Default for TunerState {
fn default() -> Self {
Self {
dithering: DitheringMethod::FloydSteinberg,
use_otsu: true,
manual_threshold: 128,
brightness: 1.0,
contrast: 1.0,
gamma: 1.0,
color_mode: ColorMode::Monochrome,
show_help: false,
}
}
}
impl TunerState {
const fn dithering_name(&self) -> &'static str {
match self.dithering {
DitheringMethod::None => "None",
DitheringMethod::FloydSteinberg => "FloydSteinberg",
DitheringMethod::Bayer => "Bayer",
DitheringMethod::Atkinson => "Atkinson",
}
}
fn cycle_dithering(&mut self) {
self.dithering = match self.dithering {
DitheringMethod::FloydSteinberg => DitheringMethod::Bayer,
DitheringMethod::Bayer => DitheringMethod::Atkinson,
DitheringMethod::Atkinson => DitheringMethod::None,
DitheringMethod::None => DitheringMethod::FloydSteinberg,
};
}
fn toggle_threshold_mode(&mut self) {
self.use_otsu = !self.use_otsu;
}
fn adjust_threshold(&mut self, delta: i16) {
let new_val = (self.manual_threshold as i16 + delta).clamp(0, 255);
self.manual_threshold = new_val as u8;
}
const fn threshold_value(&self) -> Option<u8> {
if self.use_otsu {
None
} else {
Some(self.manual_threshold)
}
}
fn threshold_display(&self) -> String {
if self.use_otsu {
"Auto (Otsu)".to_string()
} else {
format!("{}", self.manual_threshold)
}
}
fn adjust_brightness(&mut self, delta: f32) {
self.brightness = (self.brightness + delta).clamp(0.1, 3.0);
}
fn adjust_contrast(&mut self, delta: f32) {
self.contrast = (self.contrast + delta).clamp(0.1, 3.0);
}
fn adjust_gamma(&mut self, delta: f32) {
self.gamma = (self.gamma + delta).clamp(0.1, 3.0);
}
fn toggle_color_mode(&mut self) {
self.color_mode = match self.color_mode {
ColorMode::TrueColor => ColorMode::Monochrome,
ColorMode::Monochrome | ColorMode::Grayscale => ColorMode::TrueColor,
};
}
const fn color_mode_name(&self) -> &'static str {
match self.color_mode {
ColorMode::Monochrome => "Mono",
ColorMode::Grayscale => "Gray",
ColorMode::TrueColor => "TrueColor",
}
}
fn apply_to_player(&self, player: &mut WebcamPlayer) {
player.set_dithering(self.dithering);
player.set_threshold(self.threshold_value());
player.set_brightness(self.brightness);
player.set_contrast(self.contrast);
player.set_gamma(self.gamma);
player.set_color_mode(self.color_mode);
}
fn print_final_settings(&self) {
println!("\n╔══════════════════════════════════════════════════════════════╗");
println!("║ FINAL TUNER SETTINGS ║");
println!("╠══════════════════════════════════════════════════════════════╣");
println!("║ ║");
println!("║ Dithering: {:15} ║", self.dithering_name());
println!("║ Threshold: {:15} ║", self.threshold_display());
println!("║ Brightness: {:15.2} ║", self.brightness);
println!("║ Contrast: {:15.2} ║", self.contrast);
println!("║ Gamma: {:15.2} ║", self.gamma);
println!("║ Color Mode: {:15} ║", self.color_mode_name());
println!("║ ║");
println!("╠══════════════════════════════════════════════════════════════╣");
println!("║ Copy-paste code: ║");
println!("╚══════════════════════════════════════════════════════════════╝");
println!();
println!("// WebcamPlayer settings:");
println!("let player = WebcamPlayer::builder()");
println!(" .dithering(DitheringMethod::{:?})", self.dithering);
if self.use_otsu {
println!(" .threshold(None) // Auto (Otsu)");
} else {
println!(" .threshold(Some({}))", self.manual_threshold);
}
println!(" .brightness({:.2})", self.brightness);
println!(" .contrast({:.2})", self.contrast);
println!(" .gamma({:.2})", self.gamma);
println!(" .color_mode(ColorMode::{:?})", self.color_mode);
println!(" .build()?;");
println!();
println!("// Or apply to existing player:");
println!("player.set_dithering(DitheringMethod::{:?});", self.dithering);
if self.use_otsu {
println!("player.set_threshold(None);");
} else {
println!("player.set_threshold(Some({}));", self.manual_threshold);
}
println!("player.set_brightness({:.2});", self.brightness);
println!("player.set_contrast({:.2});", self.contrast);
println!("player.set_gamma({:.2});", self.gamma);
println!("player.set_color_mode(ColorMode::{:?});", self.color_mode);
println!();
}
}
fn main() -> dotmax::Result<()> {
println!("=== Webcam Tuner ===\n");
let camera_index = select_camera()?;
println!("\nStarting webcam tuner...");
println!("Press 'h' or '?' for help, 'q' to quit.\n");
std::thread::sleep(Duration::from_millis(500));
run_webcam_tuner(camera_index)
}
fn select_camera() -> dotmax::Result<usize> {
let cameras = list_webcams();
if cameras.is_empty() {
println!("No webcams detected on this system.");
println!("\nTroubleshooting:");
println!(" - Ensure a webcam is connected");
println!(" - On Linux: check that /dev/video* devices exist");
println!(" - On macOS: grant camera access in System Preferences");
println!(" - On Windows: ensure camera drivers are installed");
return Err(dotmax::DotmaxError::CameraNotFound {
device: "any".to_string(),
available: vec![],
});
}
if cameras.len() == 1 {
println!("Found camera: {}", cameras[0].name);
return Ok(0);
}
println!("Available webcams:\n");
for (i, cam) in cameras.iter().enumerate() {
println!(" [{i}] {}", cam.name);
if !cam.description.is_empty() {
println!(" {}", cam.description);
}
}
println!();
loop {
print!("Select camera (0-{}): ", cameras.len() - 1);
stdout().flush()?;
let mut input = String::new();
stdin().read_line(&mut input)?;
let input = input.trim();
if input.is_empty() {
println!("Using camera 0: {}", cameras[0].name);
return Ok(0);
}
match input.parse::<usize>() {
Ok(idx) if idx < cameras.len() => {
println!("Using camera {idx}: {}", cameras[idx].name);
return Ok(idx);
}
Ok(idx) => {
println!("Invalid selection: {idx}. Please enter 0-{}.", cameras.len() - 1);
}
Err(_) => {
println!("Please enter a number.");
}
}
}
}
fn run_webcam_tuner(camera_index: usize) -> dotmax::Result<()> {
let mut player = WebcamPlayer::from_device(camera_index)?;
println!(
"Camera: {}x{} @ {:.1} fps (reported)",
player.width(),
player.height(),
player.fps()
);
std::thread::sleep(Duration::from_millis(1000));
terminal::enable_raw_mode()?;
let mut stdout = stdout();
execute!(stdout, EnterAlternateScreen, cursor::Hide)?;
let mut state = TunerState::default();
let mut last_frame_time = Instant::now();
let mut avg_fps = 0.0f64;
let result = (|| -> dotmax::Result<()> {
loop {
while event::poll(Duration::from_millis(1))? {
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => {
match handle_key_event(key, &mut state) {
KeyAction::Continue => {
state.apply_to_player(&mut player);
}
KeyAction::Quit => {
execute!(stdout, LeaveAlternateScreen)?;
crossterm::terminal::disable_raw_mode()?;
state.print_final_settings();
return Ok(());
}
KeyAction::None => {}
}
}
Event::Resize(w, h) => {
player.handle_resize(w as usize, h as usize);
}
_ => {} }
}
match player.next_frame() {
Some(Ok((grid, _delay))) => {
let frame_elapsed = last_frame_time.elapsed();
last_frame_time = Instant::now();
let instant_fps = 1.0 / frame_elapsed.as_secs_f64();
avg_fps = avg_fps.mul_add(0.9, instant_fps * 0.1);
render_frame(&mut stdout, &grid, &state, avg_fps)?;
let _ = event::poll(Duration::from_millis(1));
}
Some(Err(e)) => return Err(e),
None => {
break;
}
}
}
Ok(())
})();
execute!(stdout, cursor::Show, LeaveAlternateScreen)?;
terminal::disable_raw_mode()?;
result
}
enum KeyAction {
Continue, Quit, None, }
fn handle_key_event(key: crossterm::event::KeyEvent, state: &mut TunerState) -> KeyAction {
if state.show_help {
if !matches!(key.code, KeyCode::Modifier(_)) {
state.show_help = false;
return KeyAction::Continue;
}
return KeyAction::None;
}
match key.code {
KeyCode::Char('q' | 'Q') | KeyCode::Esc => KeyAction::Quit,
KeyCode::Char('d' | 'D') => {
state.cycle_dithering();
KeyAction::Continue
}
KeyCode::Char('t' | 'T') => {
state.toggle_threshold_mode();
KeyAction::Continue
}
KeyCode::Char('+' | '=') => {
state.adjust_threshold(10);
KeyAction::Continue
}
KeyCode::Char('-') => {
state.adjust_threshold(-10);
KeyAction::Continue
}
KeyCode::Char('[') => {
state.adjust_threshold(-1);
KeyAction::Continue
}
KeyCode::Char(']') => {
state.adjust_threshold(1);
KeyAction::Continue
}
KeyCode::Char('B') => {
state.adjust_brightness(0.1);
KeyAction::Continue
}
KeyCode::Char('b') => {
state.adjust_brightness(-0.1);
KeyAction::Continue
}
KeyCode::Char('C') => {
state.adjust_contrast(0.1);
KeyAction::Continue
}
KeyCode::Char('c') => {
state.adjust_contrast(-0.1);
KeyAction::Continue
}
KeyCode::Char('G') => {
state.adjust_gamma(0.1);
KeyAction::Continue
}
KeyCode::Char('g') => {
state.adjust_gamma(-0.1);
KeyAction::Continue
}
KeyCode::Char('m' | 'M') => {
state.toggle_color_mode();
KeyAction::Continue
}
KeyCode::Char('r' | 'R') => {
*state = TunerState::default();
KeyAction::Continue
}
KeyCode::Char('h' | 'H' | '?') => {
state.show_help = !state.show_help;
KeyAction::Continue
}
_ => KeyAction::None,
}
}
fn render_frame(
stdout: &mut impl Write,
grid: &BrailleGrid,
state: &TunerState,
fps: f64,
) -> dotmax::Result<()> {
let (term_width, term_height) = terminal::size().unwrap_or((80, 24));
let hud_height = if state.show_help { 8 } else { 3 };
let max_grid_lines = (term_height as usize).saturating_sub(hud_height);
execute!(stdout, cursor::MoveTo(0, 0))?;
let grid_lines = grid.height().min(max_grid_lines);
for y in 0..grid_lines {
execute!(stdout, cursor::MoveTo(0, y as u16))?;
render_grid_line(stdout, grid, y, term_width as usize)?;
}
for y in grid_lines..max_grid_lines {
execute!(stdout, cursor::MoveTo(0, y as u16))?;
write!(stdout, "{}", " ".repeat(term_width as usize))?;
}
let hud_start = max_grid_lines as u16;
render_hud(stdout, state, fps, term_width, hud_start)?;
stdout.flush()?;
Ok(())
}
fn render_grid_line(
stdout: &mut impl Write,
grid: &BrailleGrid,
y: usize,
max_width: usize,
) -> dotmax::Result<()> {
let width = grid.width().min(max_width);
let mut output = String::with_capacity(width * 10);
let mut last_color: Option<dotmax::Color> = None;
for x in 0..width {
let ch = grid.get_char(x, y);
let color = grid.get_color(x, y);
if color != last_color {
if let Some(c) = color {
let _ = write!(output, "\x1b[38;2;{};{};{}m", c.r, c.g, c.b);
} else {
output.push_str("\x1b[0m");
}
last_color = color;
}
output.push(ch);
}
output.push_str("\x1b[0m");
if width < max_width {
output.push_str(&" ".repeat(max_width - width));
}
write!(stdout, "{}", output)?;
Ok(())
}
fn render_hud(
stdout: &mut impl Write,
state: &TunerState,
fps: f64,
term_width: u16,
start_row: u16,
) -> dotmax::Result<()> {
let inv_on = "\x1b[7m"; let inv_off = "\x1b[0m";
let width = term_width as usize;
execute!(stdout, cursor::MoveTo(0, start_row))?;
if state.show_help {
let help_lines = [
" Webcam Tuner Controls ",
" [D] Cycle dithering [T] Toggle threshold (Otsu/Manual) ",
" [B/b] Brightness +/- [C/c] Contrast +/- [G/g] Gamma +/- ",
" [+/-] Threshold +/-10 []/[] Threshold +/-1 (fine tune) ",
" [M] Toggle color mode [R] Reset all settings ",
" [H/?] Toggle help [Q/Esc] Quit ",
"",
" Press any key to dismiss this help ",
];
for (i, line) in help_lines.iter().enumerate() {
execute!(stdout, cursor::MoveToNextLine(1))?;
if i == 0 {
}
write!(stdout, "{}{}{}", inv_on, pad(line, width), inv_off)?;
}
} else {
let line1 = format!(
" [D] {} [T] {} [M] {} FPS: {:.1} ",
state.dithering_name(),
state.threshold_display(),
state.color_mode_name(),
fps
);
let line2 = format!(
" [B] {:.1} [C] {:.1} [G] {:.1} [H]elp [R]eset [Q]uit ",
state.brightness, state.contrast, state.gamma
);
let defaults = TunerState::default();
let changed = state.dithering != defaults.dithering
|| state.use_otsu != defaults.use_otsu
|| (state.brightness - defaults.brightness).abs() > 0.01
|| (state.contrast - defaults.contrast).abs() > 0.01
|| (state.gamma - defaults.gamma).abs() > 0.01
|| state.color_mode != defaults.color_mode;
let line3 = if changed {
" * Settings modified - press [R] to reset "
} else {
" Using default settings "
};
write!(stdout, "{}{}{}", inv_on, pad(&line1, width), inv_off)?;
execute!(stdout, cursor::MoveToNextLine(1))?;
write!(stdout, "{}{}{}", inv_on, pad(&line2, width), inv_off)?;
execute!(stdout, cursor::MoveToNextLine(1))?;
write!(stdout, "{}{}{}", inv_on, pad(line3, width), inv_off)?;
}
Ok(())
}
fn pad(s: &str, width: usize) -> String {
if s.len() >= width {
s[..width].to_string()
} else {
format!("{}{}", s, " ".repeat(width - s.len()))
}
}