wip
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -2089,6 +2089,7 @@ name = "seq"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arboard",
|
"arboard",
|
||||||
|
"clap",
|
||||||
"cpal",
|
"cpal",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
"doux",
|
"doux",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ rusty_link = "0.4"
|
|||||||
ratatui = "0.29"
|
ratatui = "0.29"
|
||||||
crossterm = "0.28"
|
crossterm = "0.28"
|
||||||
cpal = "0.15"
|
cpal = "0.15"
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
rhai = { version = "1.24", features = ["sync"] }
|
rhai = { version = "1.24", features = ["sync"] }
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|||||||
203
seq/src/app.rs
203
seq/src/app.rs
@@ -1,3 +1,4 @@
|
|||||||
|
use doux::audio::AudioDeviceInfo;
|
||||||
use rand::rngs::StdRng;
|
use rand::rngs::StdRng;
|
||||||
use rand::SeedableRng;
|
use rand::SeedableRng;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -20,6 +21,12 @@ pub enum Focus {
|
|||||||
Editor,
|
Editor,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum PatternField {
|
||||||
|
Length,
|
||||||
|
Speed,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Eq)]
|
#[derive(Clone, PartialEq, Eq)]
|
||||||
pub enum Modal {
|
pub enum Modal {
|
||||||
None,
|
None,
|
||||||
@@ -28,6 +35,8 @@ pub enum Modal {
|
|||||||
LoadFrom(String),
|
LoadFrom(String),
|
||||||
RenameBank { bank: usize, name: String },
|
RenameBank { bank: usize, name: String },
|
||||||
RenamePattern { bank: usize, pattern: usize, name: String },
|
RenamePattern { bank: usize, pattern: usize, name: String },
|
||||||
|
SetPattern { field: PatternField, input: String },
|
||||||
|
AddSamplePath(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||||
@@ -37,6 +46,41 @@ pub enum PatternsViewLevel {
|
|||||||
Patterns { bank: usize },
|
Patterns { bank: usize },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AudioConfig {
|
||||||
|
pub output_device: Option<String>,
|
||||||
|
pub input_device: Option<String>,
|
||||||
|
pub channels: u16,
|
||||||
|
pub buffer_size: u32,
|
||||||
|
pub sample_rate: f32,
|
||||||
|
pub sample_paths: Vec<PathBuf>,
|
||||||
|
pub sample_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AudioConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
output_device: None,
|
||||||
|
input_device: None,
|
||||||
|
channels: 2,
|
||||||
|
buffer_size: 512,
|
||||||
|
sample_rate: 44100.0,
|
||||||
|
sample_paths: Vec::new(),
|
||||||
|
sample_count: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum AudioFocus {
|
||||||
|
#[default]
|
||||||
|
OutputDevice,
|
||||||
|
InputDevice,
|
||||||
|
Channels,
|
||||||
|
BufferSize,
|
||||||
|
SamplePaths,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
pub tempo: f64,
|
pub tempo: f64,
|
||||||
pub beat: f64,
|
pub beat: f64,
|
||||||
@@ -80,6 +124,12 @@ pub struct App {
|
|||||||
pub clipboard: Option<arboard::Clipboard>,
|
pub clipboard: Option<arboard::Clipboard>,
|
||||||
pub doc_topic: usize,
|
pub doc_topic: usize,
|
||||||
pub doc_scroll: usize,
|
pub doc_scroll: usize,
|
||||||
|
|
||||||
|
pub audio_config: AudioConfig,
|
||||||
|
pub audio_focus: AudioFocus,
|
||||||
|
pub available_output_devices: Vec<AudioDeviceInfo>,
|
||||||
|
pub available_input_devices: Vec<AudioDeviceInfo>,
|
||||||
|
pub restart_pending: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
@@ -125,6 +175,12 @@ impl App {
|
|||||||
clipboard: arboard::Clipboard::new().ok(),
|
clipboard: arboard::Clipboard::new().ok(),
|
||||||
doc_topic: 0,
|
doc_topic: 0,
|
||||||
doc_scroll: 0,
|
doc_scroll: 0,
|
||||||
|
|
||||||
|
audio_config: AudioConfig::default(),
|
||||||
|
audio_focus: AudioFocus::default(),
|
||||||
|
available_output_devices: doux::audio::list_output_devices(),
|
||||||
|
available_input_devices: doux::audio::list_input_devices(),
|
||||||
|
restart_pending: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,4 +547,151 @@ impl App {
|
|||||||
self.compile_current_step();
|
self.compile_current_step();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn open_pattern_modal(&mut self, field: PatternField) {
|
||||||
|
let current = match field {
|
||||||
|
PatternField::Length => self.current_edit_pattern().length.to_string(),
|
||||||
|
PatternField::Speed => self.current_edit_pattern().speed.label().to_string(),
|
||||||
|
};
|
||||||
|
self.modal = Modal::SetPattern { field, input: current };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn refresh_audio_devices(&mut self) {
|
||||||
|
self.available_output_devices = doux::audio::list_output_devices();
|
||||||
|
self.available_input_devices = doux::audio::list_input_devices();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next_audio_focus(&mut self) {
|
||||||
|
self.audio_focus = match self.audio_focus {
|
||||||
|
AudioFocus::OutputDevice => AudioFocus::InputDevice,
|
||||||
|
AudioFocus::InputDevice => AudioFocus::Channels,
|
||||||
|
AudioFocus::Channels => AudioFocus::BufferSize,
|
||||||
|
AudioFocus::BufferSize => AudioFocus::SamplePaths,
|
||||||
|
AudioFocus::SamplePaths => AudioFocus::OutputDevice,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prev_audio_focus(&mut self) {
|
||||||
|
self.audio_focus = match self.audio_focus {
|
||||||
|
AudioFocus::OutputDevice => AudioFocus::SamplePaths,
|
||||||
|
AudioFocus::InputDevice => AudioFocus::OutputDevice,
|
||||||
|
AudioFocus::Channels => AudioFocus::InputDevice,
|
||||||
|
AudioFocus::BufferSize => AudioFocus::Channels,
|
||||||
|
AudioFocus::SamplePaths => AudioFocus::BufferSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next_output_device(&mut self) {
|
||||||
|
if self.available_output_devices.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let current_idx = self.current_output_device_index();
|
||||||
|
let next_idx = (current_idx + 1) % self.available_output_devices.len();
|
||||||
|
self.audio_config.output_device = Some(self.available_output_devices[next_idx].name.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prev_output_device(&mut self) {
|
||||||
|
if self.available_output_devices.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let current_idx = self.current_output_device_index();
|
||||||
|
let prev_idx = (current_idx + self.available_output_devices.len() - 1) % self.available_output_devices.len();
|
||||||
|
self.audio_config.output_device = Some(self.available_output_devices[prev_idx].name.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_output_device_index(&self) -> usize {
|
||||||
|
match &self.audio_config.output_device {
|
||||||
|
Some(name) => self
|
||||||
|
.available_output_devices
|
||||||
|
.iter()
|
||||||
|
.position(|d| &d.name == name)
|
||||||
|
.unwrap_or(0),
|
||||||
|
None => self
|
||||||
|
.available_output_devices
|
||||||
|
.iter()
|
||||||
|
.position(|d| d.is_default)
|
||||||
|
.unwrap_or(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next_input_device(&mut self) {
|
||||||
|
if self.available_input_devices.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let current_idx = self.current_input_device_index();
|
||||||
|
let next_idx = (current_idx + 1) % self.available_input_devices.len();
|
||||||
|
self.audio_config.input_device = Some(self.available_input_devices[next_idx].name.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prev_input_device(&mut self) {
|
||||||
|
if self.available_input_devices.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let current_idx = self.current_input_device_index();
|
||||||
|
let prev_idx = (current_idx + self.available_input_devices.len() - 1) % self.available_input_devices.len();
|
||||||
|
self.audio_config.input_device = Some(self.available_input_devices[prev_idx].name.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_input_device_index(&self) -> usize {
|
||||||
|
match &self.audio_config.input_device {
|
||||||
|
Some(name) => self
|
||||||
|
.available_input_devices
|
||||||
|
.iter()
|
||||||
|
.position(|d| &d.name == name)
|
||||||
|
.unwrap_or(0),
|
||||||
|
None => self
|
||||||
|
.available_input_devices
|
||||||
|
.iter()
|
||||||
|
.position(|d| d.is_default)
|
||||||
|
.unwrap_or(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn adjust_channels(&mut self, delta: i16) {
|
||||||
|
let new_val = (self.audio_config.channels as i16 + delta).clamp(1, 64) as u16;
|
||||||
|
self.audio_config.channels = new_val;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn adjust_buffer_size(&mut self, delta: i32) {
|
||||||
|
let new_val = (self.audio_config.buffer_size as i32 + delta).clamp(64, 4096) as u32;
|
||||||
|
self.audio_config.buffer_size = new_val;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn trigger_restart(&mut self) {
|
||||||
|
self.restart_pending = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn current_output_device_name(&self) -> &str {
|
||||||
|
match &self.audio_config.output_device {
|
||||||
|
Some(name) => name,
|
||||||
|
None => self
|
||||||
|
.available_output_devices
|
||||||
|
.iter()
|
||||||
|
.find(|d| d.is_default)
|
||||||
|
.map(|d| d.name.as_str())
|
||||||
|
.unwrap_or("Default"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn current_input_device_name(&self) -> &str {
|
||||||
|
match &self.audio_config.input_device {
|
||||||
|
Some(name) => name,
|
||||||
|
None => self
|
||||||
|
.available_input_devices
|
||||||
|
.iter()
|
||||||
|
.find(|d| d.is_default)
|
||||||
|
.map(|d| d.name.as_str())
|
||||||
|
.unwrap_or("None"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_sample_path(&mut self, path: PathBuf) {
|
||||||
|
if !self.audio_config.sample_paths.contains(&path) {
|
||||||
|
self.audio_config.sample_paths.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_last_sample_path(&mut self) {
|
||||||
|
self.audio_config.sample_paths.pop();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ use crate::link::LinkState;
|
|||||||
use crate::model::Project;
|
use crate::model::Project;
|
||||||
use crate::script::{Rng, ScriptEngine, StepContext, Variables};
|
use crate::script::{Rng, ScriptEngine, StepContext, Variables};
|
||||||
|
|
||||||
|
pub struct AudioStreamConfig {
|
||||||
|
pub output_device: Option<String>,
|
||||||
|
pub channels: u16,
|
||||||
|
pub buffer_size: u32,
|
||||||
|
}
|
||||||
|
|
||||||
pub const MAX_SLOTS: usize = 8;
|
pub const MAX_SLOTS: usize = 8;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Default)]
|
#[derive(Clone, Copy, Default)]
|
||||||
@@ -39,6 +45,7 @@ impl AudioState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_stream(
|
pub fn build_stream(
|
||||||
|
config: &AudioStreamConfig,
|
||||||
engine: Arc<Mutex<Engine>>,
|
engine: Arc<Mutex<Engine>>,
|
||||||
link: Arc<LinkState>,
|
link: Arc<LinkState>,
|
||||||
playing: Arc<AtomicBool>,
|
playing: Arc<AtomicBool>,
|
||||||
@@ -49,16 +56,28 @@ pub fn build_stream(
|
|||||||
slot_changes: Arc<Mutex<Vec<SlotChange>>>,
|
slot_changes: Arc<Mutex<Vec<SlotChange>>>,
|
||||||
variables: Variables,
|
variables: Variables,
|
||||||
rng: Rng,
|
rng: Rng,
|
||||||
) -> (Stream, f32) {
|
) -> Result<(Stream, f32), String> {
|
||||||
let host = cpal::default_host();
|
let host = cpal::default_host();
|
||||||
let device = host.default_output_device().expect("no output device");
|
|
||||||
let config = device.default_output_config().expect("no default config");
|
let device = match &config.output_device {
|
||||||
let sample_rate = config.sample_rate().0 as f32;
|
Some(name) => doux::audio::find_output_device(name)
|
||||||
|
.ok_or_else(|| format!("Device not found: {name}"))?,
|
||||||
|
None => host.default_output_device().ok_or("No default output device")?,
|
||||||
|
};
|
||||||
|
|
||||||
|
let default_config = device.default_output_config().map_err(|e| e.to_string())?;
|
||||||
|
let sample_rate = default_config.sample_rate().0 as f32;
|
||||||
|
|
||||||
|
let buffer_size = if config.buffer_size > 0 {
|
||||||
|
cpal::BufferSize::Fixed(config.buffer_size)
|
||||||
|
} else {
|
||||||
|
cpal::BufferSize::Default
|
||||||
|
};
|
||||||
|
|
||||||
let stream_config = cpal::StreamConfig {
|
let stream_config = cpal::StreamConfig {
|
||||||
channels: 2,
|
channels: config.channels,
|
||||||
sample_rate: config.sample_rate(),
|
sample_rate: default_config.sample_rate(),
|
||||||
buffer_size: cpal::BufferSize::Default,
|
buffer_size,
|
||||||
};
|
};
|
||||||
|
|
||||||
let quantum = 4.0;
|
let quantum = 4.0;
|
||||||
@@ -66,11 +85,12 @@ pub fn build_stream(
|
|||||||
let script_engine = ScriptEngine::new();
|
let script_engine = ScriptEngine::new();
|
||||||
|
|
||||||
let sr = sample_rate;
|
let sr = sample_rate;
|
||||||
|
let channels = config.channels as usize;
|
||||||
let stream = device
|
let stream = device
|
||||||
.build_output_stream(
|
.build_output_stream(
|
||||||
&stream_config,
|
&stream_config,
|
||||||
move |data: &mut [f32], _| {
|
move |data: &mut [f32], _| {
|
||||||
let buffer_samples = data.len() / 2;
|
let buffer_samples = data.len() / channels;
|
||||||
let buffer_time_ns = (buffer_samples as f64 / sr as f64 * 1e9) as u64;
|
let buffer_time_ns = (buffer_samples as f64 / sr as f64 * 1e9) as u64;
|
||||||
|
|
||||||
let is_playing = playing.load(Ordering::Relaxed);
|
let is_playing = playing.load(Ordering::Relaxed);
|
||||||
@@ -168,8 +188,8 @@ pub fn build_stream(
|
|||||||
|err| eprintln!("stream error: {err}"),
|
|err| eprintln!("stream error: {err}"),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.expect("failed to build stream");
|
.map_err(|e| format!("Failed to build stream: {e}"))?;
|
||||||
|
|
||||||
stream.play().expect("failed to play stream");
|
stream.play().map_err(|e| format!("Failed to play stream: {e}"))?;
|
||||||
(stream, sample_rate)
|
Ok((stream, sample_rate))
|
||||||
}
|
}
|
||||||
|
|||||||
231
seq/src/main.rs
231
seq/src/main.rs
@@ -15,6 +15,7 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
|
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
|
||||||
use crossterm::terminal::{
|
use crossterm::terminal::{
|
||||||
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
||||||
@@ -24,16 +25,42 @@ use doux::Engine;
|
|||||||
use ratatui::prelude::CrosstermBackend;
|
use ratatui::prelude::CrosstermBackend;
|
||||||
use ratatui::Terminal;
|
use ratatui::Terminal;
|
||||||
|
|
||||||
use app::{App, Focus, Modal};
|
use app::{App, AudioFocus, Focus, Modal, PatternField};
|
||||||
use audio::{SlotChange, MAX_SLOTS};
|
use audio::{AudioStreamConfig, SlotChange, MAX_SLOTS};
|
||||||
use link::LinkState;
|
use link::LinkState;
|
||||||
use model::Project;
|
use model::Project;
|
||||||
use page::Page;
|
use page::Page;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "seq", about = "A step sequencer with Ableton Link support")]
|
||||||
|
struct Args {
|
||||||
|
/// Directory containing audio samples to load (can be specified multiple times)
|
||||||
|
#[arg(short, long)]
|
||||||
|
samples: Vec<PathBuf>,
|
||||||
|
|
||||||
|
/// Output audio device (name or index)
|
||||||
|
#[arg(short, long)]
|
||||||
|
output: Option<String>,
|
||||||
|
|
||||||
|
/// Input audio device (name or index)
|
||||||
|
#[arg(short, long)]
|
||||||
|
input: Option<String>,
|
||||||
|
|
||||||
|
/// Number of output channels
|
||||||
|
#[arg(short, long, default_value = "2")]
|
||||||
|
channels: u16,
|
||||||
|
|
||||||
|
/// Audio buffer size in samples
|
||||||
|
#[arg(short, long, default_value = "512")]
|
||||||
|
buffer: u32,
|
||||||
|
}
|
||||||
|
|
||||||
const TEMPO: f64 = 120.0;
|
const TEMPO: f64 = 120.0;
|
||||||
const QUANTUM: f64 = 4.0;
|
const QUANTUM: f64 = 4.0;
|
||||||
|
|
||||||
fn main() -> io::Result<()> {
|
fn main() -> io::Result<()> {
|
||||||
|
let args = Args::parse();
|
||||||
|
|
||||||
let link = Arc::new(LinkState::new(TEMPO, QUANTUM));
|
let link = Arc::new(LinkState::new(TEMPO, QUANTUM));
|
||||||
link.enable();
|
link.enable();
|
||||||
|
|
||||||
@@ -48,10 +75,32 @@ fn main() -> io::Result<()> {
|
|||||||
|
|
||||||
let mut app = App::new(TEMPO, QUANTUM);
|
let mut app = App::new(TEMPO, QUANTUM);
|
||||||
|
|
||||||
|
// Apply CLI args to audio config
|
||||||
|
app.audio_config.output_device = args.output;
|
||||||
|
app.audio_config.input_device = args.input;
|
||||||
|
app.audio_config.channels = args.channels;
|
||||||
|
app.audio_config.buffer_size = args.buffer;
|
||||||
|
app.audio_config.sample_paths = args.samples;
|
||||||
|
|
||||||
let engine = Arc::new(Mutex::new(Engine::new(44100.0)));
|
let engine = Arc::new(Mutex::new(Engine::new(44100.0)));
|
||||||
let project = Arc::new(Mutex::new(Project::default()));
|
let project = Arc::new(Mutex::new(Project::default()));
|
||||||
|
|
||||||
let (_stream, sample_rate) = audio::build_stream(
|
// Load sample directories
|
||||||
|
for path in &app.audio_config.sample_paths {
|
||||||
|
let index = doux::loader::scan_samples_dir(path);
|
||||||
|
let count = index.len();
|
||||||
|
engine.lock().unwrap().sample_index.extend(index);
|
||||||
|
app.audio_config.sample_count += count;
|
||||||
|
}
|
||||||
|
|
||||||
|
let stream_config = AudioStreamConfig {
|
||||||
|
output_device: app.audio_config.output_device.clone(),
|
||||||
|
channels: app.audio_config.channels,
|
||||||
|
buffer_size: app.audio_config.buffer_size,
|
||||||
|
};
|
||||||
|
|
||||||
|
let (mut stream, sample_rate) = audio::build_stream(
|
||||||
|
&stream_config,
|
||||||
Arc::clone(&engine),
|
Arc::clone(&engine),
|
||||||
Arc::clone(&link),
|
Arc::clone(&link),
|
||||||
Arc::clone(&playing),
|
Arc::clone(&playing),
|
||||||
@@ -62,7 +111,10 @@ fn main() -> io::Result<()> {
|
|||||||
Arc::clone(&slot_changes),
|
Arc::clone(&slot_changes),
|
||||||
Arc::clone(&app.variables),
|
Arc::clone(&app.variables),
|
||||||
Arc::clone(&app.rng),
|
Arc::clone(&app.rng),
|
||||||
);
|
)
|
||||||
|
.expect("Failed to start audio");
|
||||||
|
|
||||||
|
app.audio_config.sample_rate = sample_rate;
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut eng = engine.lock().unwrap();
|
let mut eng = engine.lock().unwrap();
|
||||||
@@ -77,6 +129,69 @@ fn main() -> io::Result<()> {
|
|||||||
let mut terminal = Terminal::new(backend)?;
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
if app.restart_pending {
|
||||||
|
app.restart_pending = false;
|
||||||
|
drop(stream);
|
||||||
|
|
||||||
|
let new_config = AudioStreamConfig {
|
||||||
|
output_device: app.audio_config.output_device.clone(),
|
||||||
|
channels: app.audio_config.channels,
|
||||||
|
buffer_size: app.audio_config.buffer_size,
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut eng = engine.lock().unwrap();
|
||||||
|
*eng = Engine::new_with_channels(eng.sr, new_config.channels as usize);
|
||||||
|
}
|
||||||
|
|
||||||
|
match audio::build_stream(
|
||||||
|
&new_config,
|
||||||
|
Arc::clone(&engine),
|
||||||
|
Arc::clone(&link),
|
||||||
|
Arc::clone(&playing),
|
||||||
|
Arc::clone(&project),
|
||||||
|
slot_steps.clone(),
|
||||||
|
Arc::clone(&event_count),
|
||||||
|
Arc::clone(&slot_data),
|
||||||
|
Arc::clone(&slot_changes),
|
||||||
|
Arc::clone(&app.variables),
|
||||||
|
Arc::clone(&app.rng),
|
||||||
|
) {
|
||||||
|
Ok((new_stream, sr)) => {
|
||||||
|
stream = new_stream;
|
||||||
|
app.audio_config.sample_rate = sr;
|
||||||
|
{
|
||||||
|
let mut eng = engine.lock().unwrap();
|
||||||
|
eng.sr = sr;
|
||||||
|
eng.isr = 1.0 / sr;
|
||||||
|
}
|
||||||
|
app.status_message = Some("Audio restarted".to_string());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
app.status_message = Some(format!("Restart failed: {e}"));
|
||||||
|
let (fallback_stream, _) = audio::build_stream(
|
||||||
|
&AudioStreamConfig {
|
||||||
|
output_device: None,
|
||||||
|
channels: 2,
|
||||||
|
buffer_size: 512,
|
||||||
|
},
|
||||||
|
Arc::clone(&engine),
|
||||||
|
Arc::clone(&link),
|
||||||
|
Arc::clone(&playing),
|
||||||
|
Arc::clone(&project),
|
||||||
|
slot_steps.clone(),
|
||||||
|
Arc::clone(&event_count),
|
||||||
|
Arc::clone(&slot_data),
|
||||||
|
Arc::clone(&slot_changes),
|
||||||
|
Arc::clone(&app.variables),
|
||||||
|
Arc::clone(&app.rng),
|
||||||
|
)
|
||||||
|
.expect("Failed to restart with defaults");
|
||||||
|
stream = fallback_stream;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
app.update_from_link(&link);
|
app.update_from_link(&link);
|
||||||
app.playing = playing.load(Ordering::Relaxed);
|
app.playing = playing.load(Ordering::Relaxed);
|
||||||
app.event_count = event_count.load(Ordering::Relaxed);
|
app.event_count = event_count.load(Ordering::Relaxed);
|
||||||
@@ -206,6 +321,71 @@ fn main() -> io::Result<()> {
|
|||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
|
Modal::SetPattern { field, input } => match key.code {
|
||||||
|
KeyCode::Enter => {
|
||||||
|
let field = *field;
|
||||||
|
let (bank, pattern) = (app.edit_bank, app.edit_pattern);
|
||||||
|
match field {
|
||||||
|
PatternField::Length => {
|
||||||
|
if let Ok(len) = input.parse::<usize>() {
|
||||||
|
app.project.pattern_at_mut(bank, pattern).set_length(len);
|
||||||
|
let new_len = app.project.pattern_at(bank, pattern).length;
|
||||||
|
if app.current_step >= new_len {
|
||||||
|
app.current_step = new_len - 1;
|
||||||
|
}
|
||||||
|
app.status_message = Some(format!("Length set to {new_len}"));
|
||||||
|
} else {
|
||||||
|
app.status_message = Some("Invalid length".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PatternField::Speed => {
|
||||||
|
if let Some(speed) = model::PatternSpeed::from_label(input) {
|
||||||
|
app.project.pattern_at_mut(bank, pattern).speed = speed;
|
||||||
|
app.status_message = Some(format!("Speed set to {}", speed.label()));
|
||||||
|
} else {
|
||||||
|
app.status_message = Some("Invalid speed (try 1/8x, 1/4x, 1/2x, 1x, 2x, 4x, 8x)".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.modal = Modal::None;
|
||||||
|
}
|
||||||
|
KeyCode::Esc => {
|
||||||
|
app.modal = Modal::None;
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
input.pop();
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
input.push(c);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
Modal::AddSamplePath(path) => match key.code {
|
||||||
|
KeyCode::Enter => {
|
||||||
|
let sample_path = PathBuf::from(path.as_str());
|
||||||
|
if sample_path.is_dir() {
|
||||||
|
let index = doux::loader::scan_samples_dir(&sample_path);
|
||||||
|
let count = index.len();
|
||||||
|
engine.lock().unwrap().sample_index.extend(index);
|
||||||
|
app.audio_config.sample_count += count;
|
||||||
|
app.add_sample_path(sample_path);
|
||||||
|
app.status_message = Some(format!("Added {count} samples"));
|
||||||
|
} else {
|
||||||
|
app.status_message = Some("Path is not a directory".to_string());
|
||||||
|
}
|
||||||
|
app.modal = Modal::None;
|
||||||
|
}
|
||||||
|
KeyCode::Esc => {
|
||||||
|
app.modal = Modal::None;
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
path.pop();
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
path.push(c);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
Modal::None => {
|
Modal::None => {
|
||||||
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||||
|
|
||||||
@@ -263,6 +443,8 @@ fn main() -> io::Result<()> {
|
|||||||
}
|
}
|
||||||
KeyCode::Char('[') => app.speed_decrease(),
|
KeyCode::Char('[') => app.speed_decrease(),
|
||||||
KeyCode::Char(']') => app.speed_increase(),
|
KeyCode::Char(']') => app.speed_increase(),
|
||||||
|
KeyCode::Char('L') => app.open_pattern_modal(PatternField::Length),
|
||||||
|
KeyCode::Char('S') => app.open_pattern_modal(PatternField::Speed),
|
||||||
KeyCode::Char('c') if ctrl => app.copy_step(),
|
KeyCode::Char('c') if ctrl => app.copy_step(),
|
||||||
KeyCode::Char('v') if ctrl => app.paste_step(),
|
KeyCode::Char('v') if ctrl => app.paste_step(),
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -354,6 +536,47 @@ fn main() -> io::Result<()> {
|
|||||||
KeyCode::Char('q') => {
|
KeyCode::Char('q') => {
|
||||||
app.modal = Modal::ConfirmQuit { selected: false };
|
app.modal = Modal::ConfirmQuit { selected: false };
|
||||||
}
|
}
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => {
|
||||||
|
app.prev_audio_focus();
|
||||||
|
}
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => {
|
||||||
|
app.next_audio_focus();
|
||||||
|
}
|
||||||
|
KeyCode::Left => match app.audio_focus {
|
||||||
|
AudioFocus::OutputDevice => app.prev_output_device(),
|
||||||
|
AudioFocus::InputDevice => app.prev_input_device(),
|
||||||
|
AudioFocus::Channels => app.adjust_channels(-1),
|
||||||
|
AudioFocus::BufferSize => app.adjust_buffer_size(-64),
|
||||||
|
AudioFocus::SamplePaths => app.remove_last_sample_path(),
|
||||||
|
},
|
||||||
|
KeyCode::Right => match app.audio_focus {
|
||||||
|
AudioFocus::OutputDevice => app.next_output_device(),
|
||||||
|
AudioFocus::InputDevice => app.next_input_device(),
|
||||||
|
AudioFocus::Channels => app.adjust_channels(1),
|
||||||
|
AudioFocus::BufferSize => app.adjust_buffer_size(64),
|
||||||
|
AudioFocus::SamplePaths => {}
|
||||||
|
},
|
||||||
|
KeyCode::Char('R') => {
|
||||||
|
app.trigger_restart();
|
||||||
|
// Reload samples on restart
|
||||||
|
let mut eng = engine.lock().unwrap();
|
||||||
|
eng.sample_index.clear();
|
||||||
|
app.audio_config.sample_count = 0;
|
||||||
|
for path in &app.audio_config.sample_paths {
|
||||||
|
let index = doux::loader::scan_samples_dir(path);
|
||||||
|
app.audio_config.sample_count += index.len();
|
||||||
|
eng.sample_index.extend(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('A') => {
|
||||||
|
app.modal = Modal::AddSamplePath(String::new());
|
||||||
|
}
|
||||||
|
KeyCode::Char('D') => {
|
||||||
|
app.refresh_audio_devices();
|
||||||
|
let out_count = app.available_output_devices.len();
|
||||||
|
let in_count = app.available_input_devices.len();
|
||||||
|
app.status_message = Some(format!("Found {out_count} output, {in_count} input devices"));
|
||||||
|
}
|
||||||
KeyCode::Char('h') => {
|
KeyCode::Char('h') => {
|
||||||
engine.lock().unwrap().hush();
|
engine.lock().unwrap().hush();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,19 @@ impl PatternSpeed {
|
|||||||
Self::Octo => Self::Quad,
|
Self::Octo => Self::Quad,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn from_label(s: &str) -> Option<Self> {
|
||||||
|
match s.trim() {
|
||||||
|
"1/8x" | "1/8" | "0.125x" => Some(Self::Eighth),
|
||||||
|
"1/4x" | "1/4" | "0.25x" => Some(Self::Quarter),
|
||||||
|
"1/2x" | "1/2" | "0.5x" => Some(Self::Half),
|
||||||
|
"1x" | "1" => Some(Self::Normal),
|
||||||
|
"2x" | "2" => Some(Self::Double),
|
||||||
|
"4x" | "4" => Some(Self::Quad),
|
||||||
|
"8x" | "8" => Some(Self::Octo),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use ratatui::text::{Line, Span};
|
|||||||
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
|
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
|
|
||||||
use crate::app::{App, Modal};
|
use crate::app::{App, Modal, PatternField};
|
||||||
use crate::page::Page;
|
use crate::page::Page;
|
||||||
use crate::views::{audio_view, doc_view, main_view, patterns_view};
|
use crate::views::{audio_view, doc_view, main_view, patterns_view};
|
||||||
|
|
||||||
@@ -276,5 +276,68 @@ fn render_modal(frame: &mut Frame, app: &App) {
|
|||||||
);
|
);
|
||||||
frame.render_widget(modal, area);
|
frame.render_widget(modal, area);
|
||||||
}
|
}
|
||||||
|
Modal::SetPattern { field, input } => {
|
||||||
|
let (title, hint) = match field {
|
||||||
|
PatternField::Length => ("Set Length (2-32)", "Enter number"),
|
||||||
|
PatternField::Speed => ("Set Speed", "1/8x, 1/4x, 1/2x, 1x, 2x, 4x, 8x"),
|
||||||
|
};
|
||||||
|
let width = 45.min(term.width.saturating_sub(4));
|
||||||
|
let height = 6.min(term.height.saturating_sub(4));
|
||||||
|
let area = centered_rect(width, height, term);
|
||||||
|
frame.render_widget(Clear, area);
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(title)
|
||||||
|
.border_style(Style::new().fg(Color::Yellow));
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
|
let rows = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(inner);
|
||||||
|
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(Line::from(vec![
|
||||||
|
Span::raw("> "),
|
||||||
|
Span::styled(input, Style::new().fg(Color::Cyan)),
|
||||||
|
Span::styled("█", Style::new().fg(Color::White)),
|
||||||
|
])),
|
||||||
|
rows[0],
|
||||||
|
);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(Span::styled(hint, Style::new().fg(Color::DarkGray))),
|
||||||
|
rows[1],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Modal::AddSamplePath(path) => {
|
||||||
|
let width = 60.min(term.width.saturating_sub(4));
|
||||||
|
let height = 6.min(term.height.saturating_sub(4));
|
||||||
|
let area = centered_rect(width, height, term);
|
||||||
|
frame.render_widget(Clear, area);
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title("Add Sample Path")
|
||||||
|
.border_style(Style::new().fg(Color::Magenta));
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
|
let rows = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(inner);
|
||||||
|
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(Line::from(vec![
|
||||||
|
Span::raw("> "),
|
||||||
|
Span::styled(path, Style::new().fg(Color::Cyan)),
|
||||||
|
Span::styled("█", Style::new().fg(Color::White)),
|
||||||
|
])),
|
||||||
|
rows[0],
|
||||||
|
);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(Span::styled(
|
||||||
|
"Enter directory path containing samples",
|
||||||
|
Style::new().fg(Color::DarkGray),
|
||||||
|
)),
|
||||||
|
rows[1],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,141 @@ use ratatui::text::{Line, Span};
|
|||||||
use ratatui::widgets::{Block, Borders, Gauge, Paragraph};
|
use ratatui::widgets::{Block, Borders, Gauge, Paragraph};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
|
|
||||||
use crate::app::App;
|
use crate::app::{App, AudioFocus};
|
||||||
|
|
||||||
pub fn render(frame: &mut Frame, app: &App, area: Rect) {
|
pub fn render(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
render_stats(frame, app, area);
|
let [config_area, stats_area] =
|
||||||
|
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).areas(area);
|
||||||
|
|
||||||
|
render_config(frame, app, config_area);
|
||||||
|
render_stats(frame, app, stats_area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate_name(name: &str, max_len: usize) -> String {
|
||||||
|
if name.len() > max_len {
|
||||||
|
format!("{}...", &name[..max_len.saturating_sub(3)])
|
||||||
|
} else {
|
||||||
|
name.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_config(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title("Configuration")
|
||||||
|
.border_style(Style::new().fg(Color::Magenta));
|
||||||
|
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
|
let [output_area, input_area, channels_area, buffer_area, rate_area, samples_area, _, hints_area] =
|
||||||
|
Layout::vertical([
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Min(1),
|
||||||
|
Constraint::Length(1),
|
||||||
|
])
|
||||||
|
.areas(inner);
|
||||||
|
|
||||||
|
let highlight = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD);
|
||||||
|
let normal = Style::new().fg(Color::White);
|
||||||
|
let dim = Style::new().fg(Color::DarkGray);
|
||||||
|
|
||||||
|
let output_name = truncate_name(app.current_output_device_name(), 25);
|
||||||
|
let output_style = if app.audio_focus == AudioFocus::OutputDevice {
|
||||||
|
highlight
|
||||||
|
} else {
|
||||||
|
normal
|
||||||
|
};
|
||||||
|
let output_line = Line::from(vec![
|
||||||
|
Span::styled("Output ", dim),
|
||||||
|
Span::styled("< ", output_style),
|
||||||
|
Span::styled(output_name, output_style),
|
||||||
|
Span::styled(" >", output_style),
|
||||||
|
]);
|
||||||
|
frame.render_widget(Paragraph::new(output_line), output_area);
|
||||||
|
|
||||||
|
let input_name = truncate_name(app.current_input_device_name(), 25);
|
||||||
|
let input_style = if app.audio_focus == AudioFocus::InputDevice {
|
||||||
|
highlight
|
||||||
|
} else {
|
||||||
|
normal
|
||||||
|
};
|
||||||
|
let input_line = Line::from(vec![
|
||||||
|
Span::styled("Input ", dim),
|
||||||
|
Span::styled("< ", input_style),
|
||||||
|
Span::styled(input_name, input_style),
|
||||||
|
Span::styled(" >", input_style),
|
||||||
|
]);
|
||||||
|
frame.render_widget(Paragraph::new(input_line), input_area);
|
||||||
|
|
||||||
|
let channels_style = if app.audio_focus == AudioFocus::Channels {
|
||||||
|
highlight
|
||||||
|
} else {
|
||||||
|
normal
|
||||||
|
};
|
||||||
|
let channels_line = Line::from(vec![
|
||||||
|
Span::styled("Channels ", dim),
|
||||||
|
Span::styled("< ", channels_style),
|
||||||
|
Span::styled(format!("{:2}", app.audio_config.channels), channels_style),
|
||||||
|
Span::styled(" >", channels_style),
|
||||||
|
]);
|
||||||
|
frame.render_widget(Paragraph::new(channels_line), channels_area);
|
||||||
|
|
||||||
|
let buffer_style = if app.audio_focus == AudioFocus::BufferSize {
|
||||||
|
highlight
|
||||||
|
} else {
|
||||||
|
normal
|
||||||
|
};
|
||||||
|
let buffer_line = Line::from(vec![
|
||||||
|
Span::styled("Buffer ", dim),
|
||||||
|
Span::styled("< ", buffer_style),
|
||||||
|
Span::styled(format!("{:4}", app.audio_config.buffer_size), buffer_style),
|
||||||
|
Span::styled(" >", buffer_style),
|
||||||
|
]);
|
||||||
|
frame.render_widget(Paragraph::new(buffer_line), buffer_area);
|
||||||
|
|
||||||
|
let rate_line = Line::from(vec![
|
||||||
|
Span::styled("Rate ", dim),
|
||||||
|
Span::styled(format!("{:.0} Hz", app.audio_config.sample_rate), normal),
|
||||||
|
]);
|
||||||
|
frame.render_widget(Paragraph::new(rate_line), rate_area);
|
||||||
|
|
||||||
|
let samples_style = if app.audio_focus == AudioFocus::SamplePaths {
|
||||||
|
highlight
|
||||||
|
} else {
|
||||||
|
normal
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut sample_lines = vec![Line::from(vec![
|
||||||
|
Span::styled("Samples ", dim),
|
||||||
|
Span::styled(
|
||||||
|
format!("{} paths, {} indexed", app.audio_config.sample_paths.len(), app.audio_config.sample_count),
|
||||||
|
samples_style,
|
||||||
|
),
|
||||||
|
])];
|
||||||
|
|
||||||
|
for (i, path) in app.audio_config.sample_paths.iter().take(2).enumerate() {
|
||||||
|
let path_str = path.to_string_lossy();
|
||||||
|
let display = truncate_name(&path_str, 35);
|
||||||
|
sample_lines.push(Line::from(vec![
|
||||||
|
Span::styled(" ", dim),
|
||||||
|
Span::styled(format!("{}: {}", i + 1, display), Style::new().fg(Color::DarkGray)),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
frame.render_widget(Paragraph::new(sample_lines), samples_area);
|
||||||
|
|
||||||
|
let hints_line = Line::from(vec![
|
||||||
|
Span::styled("[R] Restart ", Style::new().fg(Color::Cyan)),
|
||||||
|
Span::styled("[A] Add path ", Style::new().fg(Color::DarkGray)),
|
||||||
|
Span::styled("[D] Refresh", Style::new().fg(Color::DarkGray)),
|
||||||
|
]);
|
||||||
|
frame.render_widget(Paragraph::new(hints_line), hints_area);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_stats(frame: &mut Frame, app: &App, area: Rect) {
|
fn render_stats(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ pub fn render(frame: &mut Frame, app: &mut App, area: Rect) {
|
|||||||
.areas(area);
|
.areas(area);
|
||||||
|
|
||||||
let [seq_area, editor_area] =
|
let [seq_area, editor_area] =
|
||||||
Layout::vertical([Constraint::Length(9), Constraint::Fill(1)]).areas(main_area);
|
Layout::vertical([Constraint::Fill(3), Constraint::Fill(2)]).areas(main_area);
|
||||||
|
|
||||||
render_sequencer(frame, app, seq_area);
|
render_sequencer(frame, app, seq_area);
|
||||||
render_editor(frame, app, editor_area);
|
render_editor(frame, app, editor_area);
|
||||||
@@ -60,10 +60,13 @@ fn render_sequencer(frame: &mut Frame, app: &App, area: Rect) {
|
|||||||
};
|
};
|
||||||
let steps_per_row = length.div_ceil(num_rows);
|
let steps_per_row = length.div_ceil(num_rows);
|
||||||
|
|
||||||
|
let spacing = num_rows.saturating_sub(1) as u16;
|
||||||
|
let row_height = inner.height.saturating_sub(spacing) / num_rows as u16;
|
||||||
|
|
||||||
let row_constraints: Vec<Constraint> = (0..num_rows * 2 - 1)
|
let row_constraints: Vec<Constraint> = (0..num_rows * 2 - 1)
|
||||||
.map(|i| {
|
.map(|i| {
|
||||||
if i % 2 == 0 {
|
if i % 2 == 0 {
|
||||||
Constraint::Fill(1)
|
Constraint::Length(row_height)
|
||||||
} else {
|
} else {
|
||||||
Constraint::Length(1)
|
Constraint::Length(1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,17 +127,41 @@ fn render_grid(
|
|||||||
(false, false, false) => (Color::Rgb(45, 48, 55), Color::Rgb(120, 125, 135)),
|
(false, false, false) => (Color::Rgb(45, 48, 55), Color::Rgb(120, 125, 135)),
|
||||||
};
|
};
|
||||||
|
|
||||||
let label = names.get(idx).and_then(|n| *n).unwrap_or_else(|| "");
|
let name = names.get(idx).and_then(|n| *n).unwrap_or("");
|
||||||
let label = if label.is_empty() {
|
let number = format!("{:02}", idx + 1);
|
||||||
format!("{:02}", idx + 1)
|
let cell = cols[col];
|
||||||
} else {
|
|
||||||
label.to_string()
|
|
||||||
};
|
|
||||||
let tile = Paragraph::new(label)
|
|
||||||
.alignment(Alignment::Center)
|
|
||||||
.style(Style::new().bg(bg).fg(fg).add_modifier(Modifier::BOLD));
|
|
||||||
|
|
||||||
frame.render_widget(tile, cols[col]);
|
// Fill background
|
||||||
|
frame.render_widget(Block::default().style(Style::new().bg(bg)), cell);
|
||||||
|
|
||||||
|
let top_area = Rect::new(cell.x, cell.y, cell.width, 1);
|
||||||
|
let center_y = cell.y + cell.height / 2;
|
||||||
|
let center_area = Rect::new(cell.x, center_y, cell.width, 1);
|
||||||
|
|
||||||
|
if name.is_empty() {
|
||||||
|
// Number centered
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(number)
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.style(Style::new().fg(fg).add_modifier(Modifier::BOLD)),
|
||||||
|
center_area,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Number centered at top
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(number)
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.style(Style::new().fg(fg).add_modifier(Modifier::DIM)),
|
||||||
|
top_area,
|
||||||
|
);
|
||||||
|
// Name centered in middle
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(name)
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.style(Style::new().fg(fg).add_modifier(Modifier::BOLD)),
|
||||||
|
center_area,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,16 +203,40 @@ fn render_pattern_grid(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let name = names.get(idx).and_then(|n| *n).unwrap_or("");
|
let name = names.get(idx).and_then(|n| *n).unwrap_or("");
|
||||||
let label = if name.is_empty() {
|
let number = format!("{}{:02}", prefix, idx + 1);
|
||||||
format!("{}{:02}", prefix, idx + 1)
|
let cell = cols[col];
|
||||||
} else {
|
|
||||||
format!("{}{}", prefix, name)
|
|
||||||
};
|
|
||||||
let tile = Paragraph::new(label)
|
|
||||||
.alignment(Alignment::Center)
|
|
||||||
.style(Style::new().bg(bg).fg(fg).add_modifier(Modifier::BOLD));
|
|
||||||
|
|
||||||
frame.render_widget(tile, cols[col]);
|
// Fill background
|
||||||
|
frame.render_widget(Block::default().style(Style::new().bg(bg)), cell);
|
||||||
|
|
||||||
|
let top_area = Rect::new(cell.x, cell.y, cell.width, 1);
|
||||||
|
let center_y = cell.y + cell.height / 2;
|
||||||
|
let center_area = Rect::new(cell.x, center_y, cell.width, 1);
|
||||||
|
|
||||||
|
if name.is_empty() {
|
||||||
|
// Number centered
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(number)
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.style(Style::new().fg(fg).add_modifier(Modifier::BOLD)),
|
||||||
|
center_area,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Number centered at top
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(number)
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.style(Style::new().fg(fg).add_modifier(Modifier::DIM)),
|
||||||
|
top_area,
|
||||||
|
);
|
||||||
|
// Name centered in middle
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(name)
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.style(Style::new().fg(fg).add_modifier(Modifier::BOLD)),
|
||||||
|
center_area,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user