chain word and better save/load UI
This commit is contained in:
@@ -77,6 +77,45 @@ impl Default for AudioConfig {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ListSelectState {
|
||||
pub cursor: usize,
|
||||
pub scroll_offset: usize,
|
||||
}
|
||||
|
||||
impl ListSelectState {
|
||||
pub fn move_up(&mut self) {
|
||||
if self.cursor > 0 {
|
||||
self.cursor -= 1;
|
||||
if self.cursor < self.scroll_offset {
|
||||
self.scroll_offset = self.cursor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_down(&mut self, item_count: usize) {
|
||||
if self.cursor + 1 < item_count {
|
||||
self.cursor += 1;
|
||||
if self.cursor >= self.scroll_offset + 5 {
|
||||
self.scroll_offset = self.cursor - 4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn page_up(&mut self) {
|
||||
self.cursor = self.cursor.saturating_sub(5);
|
||||
if self.cursor < self.scroll_offset {
|
||||
self.scroll_offset = self.cursor;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn page_down(&mut self, item_count: usize) {
|
||||
self.cursor = (self.cursor + 5).min(item_count.saturating_sub(1));
|
||||
if self.cursor >= self.scroll_offset + 5 {
|
||||
self.scroll_offset = self.cursor.saturating_sub(4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum AudioFocus {
|
||||
#[default]
|
||||
@@ -127,6 +166,8 @@ pub struct AudioSettings {
|
||||
pub focus: AudioFocus,
|
||||
pub output_devices: Vec<AudioDeviceInfo>,
|
||||
pub input_devices: Vec<AudioDeviceInfo>,
|
||||
pub output_list: ListSelectState,
|
||||
pub input_list: ListSelectState,
|
||||
pub restart_pending: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
@@ -138,6 +179,8 @@ impl Default for AudioSettings {
|
||||
focus: AudioFocus::default(),
|
||||
output_devices: doux::audio::list_output_devices(),
|
||||
input_devices: doux::audio::list_input_devices(),
|
||||
output_list: ListSelectState { cursor: 0, scroll_offset: 0 },
|
||||
input_list: ListSelectState { cursor: 0, scroll_offset: 0 },
|
||||
restart_pending: false,
|
||||
error: None,
|
||||
}
|
||||
@@ -184,25 +227,7 @@ impl AudioSettings {
|
||||
};
|
||||
}
|
||||
|
||||
pub fn next_output_device(&mut self) {
|
||||
if self.output_devices.is_empty() {
|
||||
return;
|
||||
}
|
||||
let current_idx = self.current_output_device_index();
|
||||
let next_idx = (current_idx + 1) % self.output_devices.len();
|
||||
self.config.output_device = Some(self.output_devices[next_idx].name.clone());
|
||||
}
|
||||
|
||||
pub fn prev_output_device(&mut self) {
|
||||
if self.output_devices.is_empty() {
|
||||
return;
|
||||
}
|
||||
let current_idx = self.current_output_device_index();
|
||||
let prev_idx = (current_idx + self.output_devices.len() - 1) % self.output_devices.len();
|
||||
self.config.output_device = Some(self.output_devices[prev_idx].name.clone());
|
||||
}
|
||||
|
||||
fn current_output_device_index(&self) -> usize {
|
||||
pub fn current_output_device_index(&self) -> usize {
|
||||
match &self.config.output_device {
|
||||
Some(name) => self
|
||||
.output_devices
|
||||
@@ -217,25 +242,7 @@ impl AudioSettings {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next_input_device(&mut self) {
|
||||
if self.input_devices.is_empty() {
|
||||
return;
|
||||
}
|
||||
let current_idx = self.current_input_device_index();
|
||||
let next_idx = (current_idx + 1) % self.input_devices.len();
|
||||
self.config.input_device = Some(self.input_devices[next_idx].name.clone());
|
||||
}
|
||||
|
||||
pub fn prev_input_device(&mut self) {
|
||||
if self.input_devices.is_empty() {
|
||||
return;
|
||||
}
|
||||
let current_idx = self.current_input_device_index();
|
||||
let prev_idx = (current_idx + self.input_devices.len() - 1) % self.input_devices.len();
|
||||
self.config.input_device = Some(self.input_devices[prev_idx].name.clone());
|
||||
}
|
||||
|
||||
fn current_input_device_index(&self) -> usize {
|
||||
pub fn current_input_device_index(&self) -> usize {
|
||||
match &self.config.input_device {
|
||||
Some(name) => self
|
||||
.input_devices
|
||||
@@ -264,29 +271,6 @@ impl AudioSettings {
|
||||
self.config.refresh_rate = self.config.refresh_rate.toggle();
|
||||
}
|
||||
|
||||
pub fn current_output_device_name(&self) -> &str {
|
||||
match &self.config.output_device {
|
||||
Some(name) => name,
|
||||
None => self
|
||||
.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.config.input_device {
|
||||
Some(name) => name,
|
||||
None => self
|
||||
.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.config.sample_paths.contains(&path) {
|
||||
|
||||
266
src/state/file_browser.rs
Normal file
266
src/state/file_browser.rs
Normal file
@@ -0,0 +1,266 @@
|
||||
use std::cmp::Ordering;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub enum FileBrowserMode {
|
||||
Save,
|
||||
Load,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct DirEntry {
|
||||
pub name: String,
|
||||
pub is_dir: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct FileBrowserState {
|
||||
pub mode: FileBrowserMode,
|
||||
pub input: String,
|
||||
pub entries: Vec<DirEntry>,
|
||||
pub selected: usize,
|
||||
pub scroll_offset: usize,
|
||||
}
|
||||
|
||||
impl FileBrowserState {
|
||||
pub fn new_save(initial_path: String) -> Self {
|
||||
let mut state = Self {
|
||||
mode: FileBrowserMode::Save,
|
||||
input: initial_path,
|
||||
entries: Vec::new(),
|
||||
selected: 0,
|
||||
scroll_offset: 0,
|
||||
};
|
||||
state.refresh_entries();
|
||||
state
|
||||
}
|
||||
|
||||
pub fn new_load(initial_path: String) -> Self {
|
||||
let mut state = Self {
|
||||
mode: FileBrowserMode::Load,
|
||||
input: initial_path,
|
||||
entries: Vec::new(),
|
||||
selected: 0,
|
||||
scroll_offset: 0,
|
||||
};
|
||||
state.refresh_entries();
|
||||
state
|
||||
}
|
||||
|
||||
fn current_dir(&self) -> PathBuf {
|
||||
if self.input.is_empty() {
|
||||
return std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
|
||||
}
|
||||
let path = Path::new(&self.input);
|
||||
if self.input.ends_with('/') {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
match path.parent() {
|
||||
Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
|
||||
_ => std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn partial_name(&self) -> &str {
|
||||
if self.input.is_empty() || self.input.ends_with('/') {
|
||||
""
|
||||
} else if !self.input.contains('/') {
|
||||
&self.input
|
||||
} else {
|
||||
Path::new(&self.input)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn refresh_entries(&mut self) {
|
||||
let dir = self.current_dir();
|
||||
let prefix = self.partial_name().to_lowercase();
|
||||
|
||||
let mut entries = Vec::new();
|
||||
|
||||
if prefix.is_empty() && dir.parent().is_some() {
|
||||
entries.push(DirEntry {
|
||||
name: "..".to_string(),
|
||||
is_dir: true,
|
||||
});
|
||||
}
|
||||
|
||||
if let Ok(read_dir) = fs::read_dir(&dir) {
|
||||
for entry in read_dir.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
|
||||
if prefix.is_empty() || name.to_lowercase().starts_with(&prefix) {
|
||||
entries.push(DirEntry { name, is_dir });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entries.sort_by(|a, b| match (a.name.as_str(), b.name.as_str()) {
|
||||
("..", _) => Ordering::Less,
|
||||
(_, "..") => Ordering::Greater,
|
||||
_ => match (a.is_dir, b.is_dir) {
|
||||
(true, false) => Ordering::Less,
|
||||
(false, true) => Ordering::Greater,
|
||||
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
||||
},
|
||||
});
|
||||
|
||||
self.entries = entries;
|
||||
self.selected = 0;
|
||||
self.scroll_offset = 0;
|
||||
}
|
||||
|
||||
pub fn autocomplete(&mut self) {
|
||||
let real_entries: Vec<&DirEntry> =
|
||||
self.entries.iter().filter(|e| e.name != "..").collect();
|
||||
if real_entries.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let lcp = longest_common_prefix(&real_entries);
|
||||
if lcp.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let dir = self.current_dir();
|
||||
let mut new_input = dir.join(&lcp).display().to_string();
|
||||
|
||||
if real_entries.len() == 1 && real_entries[0].is_dir && !new_input.ends_with('/') {
|
||||
new_input.push('/');
|
||||
}
|
||||
|
||||
if new_input != self.input {
|
||||
self.input = new_input;
|
||||
self.refresh_entries();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_next(&mut self, visible_height: usize) {
|
||||
if self.selected + 1 < self.entries.len() {
|
||||
self.selected += 1;
|
||||
if self.selected >= self.scroll_offset + visible_height {
|
||||
self.scroll_offset = self.selected + 1 - visible_height;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_prev(&mut self, _visible_height: usize) {
|
||||
if self.selected > 0 {
|
||||
self.selected -= 1;
|
||||
if self.selected < self.scroll_offset {
|
||||
self.scroll_offset = self.selected;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn confirm(&mut self) -> Option<PathBuf> {
|
||||
if !self.entries.is_empty() {
|
||||
let entry = &self.entries[self.selected];
|
||||
if entry.is_dir {
|
||||
self.navigate_to_selected();
|
||||
return None;
|
||||
}
|
||||
let path = self.current_dir().join(&entry.name);
|
||||
if self.mode == FileBrowserMode::Save {
|
||||
ensure_parent_dirs(&path);
|
||||
}
|
||||
return Some(path);
|
||||
}
|
||||
|
||||
if self.mode == FileBrowserMode::Save && !self.input.is_empty() {
|
||||
let path = PathBuf::from(&self.input);
|
||||
ensure_parent_dirs(&path);
|
||||
return Some(path);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn enter_selected(&mut self) {
|
||||
if let Some(entry) = self.entries.get(self.selected) {
|
||||
if entry.is_dir {
|
||||
self.navigate_to_selected();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn go_up(&mut self) {
|
||||
let dir = self.current_dir();
|
||||
if let Some(parent) = dir.parent() {
|
||||
self.input = format_dir_path(parent);
|
||||
self.refresh_entries();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn backspace(&mut self) {
|
||||
if self.input.is_empty() {
|
||||
return;
|
||||
}
|
||||
if self.input.ends_with('/') && self.input.len() > 1 {
|
||||
let trimmed = &self.input[..self.input.len() - 1];
|
||||
match trimmed.rfind('/') {
|
||||
Some(pos) => self.input = trimmed[..=pos].to_string(),
|
||||
None => self.input.clear(),
|
||||
}
|
||||
} else {
|
||||
self.input.pop();
|
||||
}
|
||||
self.refresh_entries();
|
||||
}
|
||||
|
||||
fn navigate_to_selected(&mut self) {
|
||||
let entry = &self.entries[self.selected];
|
||||
if entry.name == ".." {
|
||||
self.go_up();
|
||||
} else {
|
||||
let dir = self.current_dir();
|
||||
self.input = format_dir_path(&dir.join(&entry.name));
|
||||
self.refresh_entries();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_dir_path(path: &Path) -> String {
|
||||
let mut s = path.display().to_string();
|
||||
if !s.ends_with('/') {
|
||||
s.push('/');
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn ensure_parent_dirs(path: &Path) {
|
||||
if let Some(parent) = path.parent() {
|
||||
if !parent.exists() {
|
||||
let _ = fs::create_dir_all(parent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn longest_common_prefix(entries: &[&DirEntry]) -> String {
|
||||
if entries.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let first = &entries[0].name;
|
||||
let mut len = first.len();
|
||||
for entry in &entries[1..] {
|
||||
len = first
|
||||
.chars()
|
||||
.zip(entry.name.chars())
|
||||
.take_while(|(a, b)| a.to_lowercase().eq(b.to_lowercase()))
|
||||
.count()
|
||||
.min(len);
|
||||
}
|
||||
first[..first
|
||||
.char_indices()
|
||||
.nth(len)
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(first.len())]
|
||||
.to_string()
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod audio;
|
||||
pub mod editor;
|
||||
pub mod file_browser;
|
||||
pub mod live_keys;
|
||||
pub mod modal;
|
||||
pub mod patterns_nav;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::state::editor::PatternField;
|
||||
use crate::state::file_browser::FileBrowserState;
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub enum Modal {
|
||||
@@ -21,8 +22,7 @@ pub enum Modal {
|
||||
bank: usize,
|
||||
selected: bool,
|
||||
},
|
||||
SaveAs(String),
|
||||
LoadFrom(String),
|
||||
FileBrowser(FileBrowserState),
|
||||
RenameBank {
|
||||
bank: usize,
|
||||
name: String,
|
||||
|
||||
Reference in New Issue
Block a user