ok
This commit is contained in:
55
docs/welcome.md
Normal file
55
docs/welcome.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Welcome to Cagire
|
||||
|
||||
Cagire is a terminal-based step sequencer for live music. Each step in a pattern contains a Forth script that controls sound synthesis and effects.
|
||||
|
||||
## Pages
|
||||
|
||||
Navigate between pages with **Ctrl+Left/Right**:
|
||||
|
||||
- **Sequencer**: Edit patterns, write Forth scripts, manage playback slots
|
||||
- **Patterns**: Browse and select across 16 banks x 16 patterns
|
||||
- **Engine**: Audio device selection, sample loading, CPU and voice monitoring
|
||||
- **Options**: Application settings
|
||||
- **Dict**: Forth word reference, organized by category
|
||||
- **Help**: Documentation (you are here)
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Press **Space** to start the transport
|
||||
2. Use **arrow keys** to navigate steps
|
||||
3. Press **Tab** to focus the editor
|
||||
4. Write a Forth script (e.g. `/sound/sine`)
|
||||
5. Press **Ctrl+E** to compile
|
||||
6. Press **Tab** to return to the sequencer
|
||||
7. Press **Enter** to activate the step
|
||||
8. Press **g** to queue the pattern to a slot
|
||||
|
||||
## Example Script
|
||||
|
||||
A step that plays a filtered saw with reverb:
|
||||
|
||||
```
|
||||
"saw" s c4 note 0.5 dur 0.3 decay
|
||||
0.6 gain 2000 lpf 0.4 lpq
|
||||
0.3 verb 2.0 verbdecay .
|
||||
```
|
||||
|
||||
A rhythmic pattern using subdivisions and probability:
|
||||
|
||||
```
|
||||
"kick" s 0.8 gain .
|
||||
div
|
||||
"hat" s < 0.3 0.5 0.7 > gain . .
|
||||
~
|
||||
{ "snare" s 0.6 gain . } often
|
||||
```
|
||||
|
||||
## Concepts
|
||||
|
||||
**Banks and Patterns**: 16 banks of 16 patterns each. Each pattern has up to 32 steps with configurable length and speed.
|
||||
|
||||
**Slots**: 8 concurrent playback slots. Queue patterns into slots with **g**. Slot changes are quantized to bar boundaries.
|
||||
|
||||
**Ableton Link**: Cagire syncs tempo and phase with other Link-enabled applications on your network.
|
||||
|
||||
**Forth**: The scripting language used in each step. Open the **Dict** page to browse all available words.
|
||||
35
src/app.rs
35
src/app.rs
@@ -939,18 +939,45 @@ impl App {
|
||||
// Help navigation
|
||||
AppCommand::HelpNextTopic => {
|
||||
self.ui.help_topic = (self.ui.help_topic + 1) % help_view::topic_count();
|
||||
self.ui.help_scroll = 0;
|
||||
}
|
||||
AppCommand::HelpPrevTopic => {
|
||||
let count = help_view::topic_count();
|
||||
self.ui.help_topic = (self.ui.help_topic + count - 1) % count;
|
||||
self.ui.help_scroll = 0;
|
||||
}
|
||||
AppCommand::HelpScrollDown(n) => {
|
||||
self.ui.help_scroll = self.ui.help_scroll.saturating_add(n);
|
||||
let s = self.ui.help_scroll_mut();
|
||||
*s = s.saturating_add(n);
|
||||
}
|
||||
AppCommand::HelpScrollUp(n) => {
|
||||
self.ui.help_scroll = self.ui.help_scroll.saturating_sub(n);
|
||||
let s = self.ui.help_scroll_mut();
|
||||
*s = s.saturating_sub(n);
|
||||
}
|
||||
AppCommand::HelpActivateSearch => {
|
||||
self.ui.help_search_active = true;
|
||||
}
|
||||
AppCommand::HelpClearSearch => {
|
||||
self.ui.help_search_query.clear();
|
||||
self.ui.help_search_active = false;
|
||||
}
|
||||
AppCommand::HelpSearchInput(c) => {
|
||||
self.ui.help_search_query.push(c);
|
||||
if let Some((topic, line)) = help_view::find_match(&self.ui.help_search_query) {
|
||||
self.ui.help_topic = topic;
|
||||
self.ui.help_scrolls[topic] = line;
|
||||
}
|
||||
}
|
||||
AppCommand::HelpSearchBackspace => {
|
||||
self.ui.help_search_query.pop();
|
||||
if self.ui.help_search_query.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some((topic, line)) = help_view::find_match(&self.ui.help_search_query) {
|
||||
self.ui.help_topic = topic;
|
||||
self.ui.help_scrolls[topic] = line;
|
||||
}
|
||||
}
|
||||
AppCommand::HelpSearchConfirm => {
|
||||
self.ui.help_search_active = false;
|
||||
}
|
||||
|
||||
// Dictionary navigation
|
||||
|
||||
@@ -130,6 +130,11 @@ pub enum AppCommand {
|
||||
HelpPrevTopic,
|
||||
HelpScrollDown(usize),
|
||||
HelpScrollUp(usize),
|
||||
HelpActivateSearch,
|
||||
HelpClearSearch,
|
||||
HelpSearchInput(char),
|
||||
HelpSearchBackspace,
|
||||
HelpSearchConfirm,
|
||||
|
||||
// Dictionary navigation
|
||||
DictToggleFocus,
|
||||
|
||||
45
src/input.rs
45
src/input.rs
@@ -361,7 +361,11 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
}
|
||||
} else {
|
||||
let dir = state.current_dir();
|
||||
if dir.is_dir() { Some(dir) } else { None }
|
||||
if dir.is_dir() {
|
||||
Some(dir)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(path) = sample_path {
|
||||
let index = doux::loader::scan_samples_dir(&path);
|
||||
@@ -573,8 +577,8 @@ fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
}
|
||||
|
||||
fn handle_panel_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
use cagire_ratatui::TreeLineKind;
|
||||
use crate::engine::AudioCommand;
|
||||
use cagire_ratatui::TreeLineKind;
|
||||
|
||||
let state = match &mut ctx.app.panel.side {
|
||||
Some(SidePanel::SampleBrowser(s)) => s,
|
||||
@@ -625,8 +629,7 @@ fn handle_panel_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
TreeLineKind::File => {
|
||||
let folder = &entry.folder;
|
||||
let idx = entry.index;
|
||||
let cmd =
|
||||
format!("/sound/{folder}/n/{idx}/gain/0.5/dur/1");
|
||||
let cmd = format!("/sound/{folder}/n/{idx}/gain/0.5/dur/1");
|
||||
let _ = ctx.audio_tx.load().send(AudioCommand::Evaluate(cmd));
|
||||
}
|
||||
_ => state.toggle_expand(),
|
||||
@@ -979,10 +982,9 @@ fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
}
|
||||
KeyCode::Char('r') => ctx.app.metrics.peak_voices = 0,
|
||||
KeyCode::Char('t') => {
|
||||
let _ = ctx
|
||||
.audio_tx
|
||||
.load()
|
||||
.send(AudioCommand::Evaluate("/sound/sine/dur/0.5/decay/0.2".into()));
|
||||
let _ = ctx.audio_tx.load().send(AudioCommand::Evaluate(
|
||||
"/sound/sine/dur/0.5/decay/0.2".into(),
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -1035,7 +1037,22 @@ fn handle_options_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
}
|
||||
|
||||
fn handle_help_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
if ctx.app.ui.help_search_active {
|
||||
match key.code {
|
||||
KeyCode::Esc => ctx.dispatch(AppCommand::HelpClearSearch),
|
||||
KeyCode::Enter => ctx.dispatch(AppCommand::HelpSearchConfirm),
|
||||
KeyCode::Backspace => ctx.dispatch(AppCommand::HelpSearchBackspace),
|
||||
KeyCode::Char(c) => ctx.dispatch(AppCommand::HelpSearchInput(c)),
|
||||
_ => {}
|
||||
}
|
||||
return InputResult::Continue;
|
||||
}
|
||||
|
||||
match key.code {
|
||||
KeyCode::Char('/') => ctx.dispatch(AppCommand::HelpActivateSearch),
|
||||
KeyCode::Esc if !ctx.app.ui.help_search_query.is_empty() => {
|
||||
ctx.dispatch(AppCommand::HelpClearSearch);
|
||||
}
|
||||
KeyCode::Char('j') | KeyCode::Down => ctx.dispatch(AppCommand::HelpScrollDown(1)),
|
||||
KeyCode::Char('k') | KeyCode::Up => ctx.dispatch(AppCommand::HelpScrollUp(1)),
|
||||
KeyCode::Tab => ctx.dispatch(AppCommand::HelpNextTopic),
|
||||
@@ -1077,18 +1094,14 @@ fn handle_dict_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
ctx.dispatch(AppCommand::DictClearSearch);
|
||||
}
|
||||
KeyCode::Tab => ctx.dispatch(AppCommand::DictToggleFocus),
|
||||
KeyCode::Char('j') | KeyCode::Down => {
|
||||
match ctx.app.ui.dict_focus {
|
||||
KeyCode::Char('j') | KeyCode::Down => match ctx.app.ui.dict_focus {
|
||||
DictFocus::Categories => ctx.dispatch(AppCommand::DictNextCategory),
|
||||
DictFocus::Words => ctx.dispatch(AppCommand::DictScrollDown(1)),
|
||||
}
|
||||
}
|
||||
KeyCode::Char('k') | KeyCode::Up => {
|
||||
match ctx.app.ui.dict_focus {
|
||||
},
|
||||
KeyCode::Char('k') | KeyCode::Up => match ctx.app.ui.dict_focus {
|
||||
DictFocus::Categories => ctx.dispatch(AppCommand::DictPrevCategory),
|
||||
DictFocus::Words => ctx.dispatch(AppCommand::DictScrollUp(1)),
|
||||
}
|
||||
}
|
||||
},
|
||||
KeyCode::PageDown => ctx.dispatch(AppCommand::DictScrollDown(10)),
|
||||
KeyCode::PageUp => ctx.dispatch(AppCommand::DictScrollUp(10)),
|
||||
KeyCode::Char('q') => {
|
||||
|
||||
@@ -26,7 +26,9 @@ pub struct UiState {
|
||||
pub flash_kind: FlashKind,
|
||||
pub modal: Modal,
|
||||
pub help_topic: usize,
|
||||
pub help_scroll: usize,
|
||||
pub help_scrolls: Vec<usize>,
|
||||
pub help_search_active: bool,
|
||||
pub help_search_query: String,
|
||||
pub dict_focus: DictFocus,
|
||||
pub dict_category: usize,
|
||||
pub dict_scroll: usize,
|
||||
@@ -47,7 +49,9 @@ impl Default for UiState {
|
||||
flash_kind: FlashKind::Success,
|
||||
modal: Modal::None,
|
||||
help_topic: 0,
|
||||
help_scroll: 0,
|
||||
help_scrolls: vec![0; crate::views::help_view::topic_count()],
|
||||
help_search_active: false,
|
||||
help_search_query: String::new(),
|
||||
dict_focus: DictFocus::default(),
|
||||
dict_category: 0,
|
||||
dict_scroll: 0,
|
||||
@@ -62,6 +66,14 @@ impl Default for UiState {
|
||||
}
|
||||
|
||||
impl UiState {
|
||||
pub fn help_scroll(&self) -> usize {
|
||||
self.help_scrolls[self.help_topic]
|
||||
}
|
||||
|
||||
pub fn help_scroll_mut(&mut self) -> &mut usize {
|
||||
&mut self.help_scrolls[self.help_topic]
|
||||
}
|
||||
|
||||
pub fn flash(&mut self, msg: &str, duration_ms: u64, kind: FlashKind) {
|
||||
self.status_message = Some(msg.to_string());
|
||||
self.flash_until = Some(Instant::now() + Duration::from_millis(duration_ms));
|
||||
@@ -69,7 +81,11 @@ impl UiState {
|
||||
}
|
||||
|
||||
pub fn flash_kind(&self) -> Option<FlashKind> {
|
||||
if self.is_flashing() { Some(self.flash_kind) } else { None }
|
||||
if self.is_flashing() {
|
||||
Some(self.flash_kind)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_status(&mut self, msg: String) {
|
||||
|
||||
@@ -2,39 +2,43 @@ use minimad::{Composite, CompositeStyle, Compound, Line};
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line as RLine, Span};
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem, Padding, Paragraph, Wrap};
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::views::highlight;
|
||||
|
||||
const STATIC_DOCS: &[(&str, &str)] = &[
|
||||
// To add a new help topic: drop a .md file in docs/ and add one line here.
|
||||
const DOCS: &[(&str, &str)] = &[
|
||||
("Welcome", include_str!("../../docs/welcome.md")),
|
||||
("Keybindings", include_str!("../../docs/keybindings.md")),
|
||||
("Sequencer", include_str!("../../docs/sequencer.md")),
|
||||
];
|
||||
|
||||
const TOPICS: &[&str] = &["Keybindings", "Sequencer"];
|
||||
pub fn topic_count() -> usize {
|
||||
DOCS.len()
|
||||
}
|
||||
|
||||
pub fn render(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let [topics_area, content_area] =
|
||||
Layout::horizontal([Constraint::Length(18), Constraint::Fill(1)]).areas(area);
|
||||
|
||||
render_topics(frame, app, topics_area);
|
||||
|
||||
let topic = TOPICS[app.ui.help_topic];
|
||||
render_markdown_content(frame, app, content_area, topic);
|
||||
render_content(frame, app, content_area);
|
||||
}
|
||||
|
||||
fn render_topics(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let items: Vec<ListItem> = TOPICS
|
||||
let items: Vec<ListItem> = DOCS
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, name)| {
|
||||
let style = if i == app.ui.help_topic {
|
||||
.map(|(i, (name, _))| {
|
||||
let selected = i == app.ui.help_topic;
|
||||
let style = if selected {
|
||||
Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::new().fg(Color::White)
|
||||
};
|
||||
let prefix = if i == app.ui.help_topic { "> " } else { " " };
|
||||
let prefix = if selected { "> " } else { " " };
|
||||
ListItem::new(format!("{prefix}{name}")).style(style)
|
||||
})
|
||||
.collect();
|
||||
@@ -43,39 +47,144 @@ fn render_topics(frame: &mut Frame, app: &App, area: Rect) {
|
||||
frame.render_widget(list, area);
|
||||
}
|
||||
|
||||
fn render_markdown_content(frame: &mut Frame, app: &App, area: Rect, topic: &str) {
|
||||
let md = STATIC_DOCS
|
||||
.iter()
|
||||
.find(|(name, _)| *name == topic)
|
||||
.map(|(_, content)| *content)
|
||||
.unwrap_or("");
|
||||
fn render_content(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let (name, md) = DOCS[app.ui.help_topic];
|
||||
let query = &app.ui.help_search_query;
|
||||
let has_query = !query.is_empty();
|
||||
let query_lower = query.to_lowercase();
|
||||
|
||||
let lines = parse_markdown(md);
|
||||
|
||||
let visible_height = area.height.saturating_sub(2) as usize;
|
||||
let total_lines = lines.len();
|
||||
let max_scroll = total_lines.saturating_sub(visible_height);
|
||||
let scroll = app.ui.help_scroll.min(max_scroll);
|
||||
let has_search_bar = app.ui.help_search_active || has_query;
|
||||
let search_bar_height: u16 = u16::from(has_search_bar);
|
||||
let visible_height = area.height.saturating_sub(6 + search_bar_height) as usize;
|
||||
let max_scroll = lines.len().saturating_sub(visible_height);
|
||||
let scroll = app.ui.help_scroll().min(max_scroll);
|
||||
|
||||
let visible: Vec<RLine> = lines
|
||||
.into_iter()
|
||||
.skip(scroll)
|
||||
.take(visible_height)
|
||||
.map(|line| {
|
||||
if has_query {
|
||||
highlight_line(line, &query_lower)
|
||||
} else {
|
||||
line
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let para = Paragraph::new(visible).block(Block::default().borders(Borders::ALL).title(topic));
|
||||
frame.render_widget(para, area);
|
||||
let content_area = if has_search_bar {
|
||||
let [content, search] =
|
||||
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area);
|
||||
render_search_bar(frame, app, search);
|
||||
content
|
||||
} else {
|
||||
area
|
||||
};
|
||||
|
||||
let para = Paragraph::new(visible)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(name)
|
||||
.padding(Padding::new(2, 2, 2, 2)),
|
||||
)
|
||||
.wrap(Wrap { trim: false });
|
||||
frame.render_widget(para, content_area);
|
||||
}
|
||||
|
||||
fn render_search_bar(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let style = if app.ui.help_search_active {
|
||||
Style::new().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::new().fg(Color::DarkGray)
|
||||
};
|
||||
let cursor = if app.ui.help_search_active { "█" } else { "" };
|
||||
let text = format!(" /{}{cursor}", app.ui.help_search_query);
|
||||
frame.render_widget(Paragraph::new(text).style(style), area);
|
||||
}
|
||||
|
||||
fn highlight_line<'a>(line: RLine<'a>, query: &str) -> RLine<'a> {
|
||||
let mut result: Vec<Span<'a>> = Vec::new();
|
||||
for span in line.spans {
|
||||
let lower = span.content.to_lowercase();
|
||||
if !lower.contains(query) {
|
||||
result.push(span);
|
||||
continue;
|
||||
}
|
||||
let content = span.content.to_string();
|
||||
let base_style = span.style;
|
||||
let hl_style = base_style.bg(Color::Yellow).fg(Color::Black);
|
||||
let mut start = 0;
|
||||
let lower_bytes = lower.as_bytes();
|
||||
let query_bytes = query.as_bytes();
|
||||
while let Some(pos) = find_bytes(&lower_bytes[start..], query_bytes) {
|
||||
let abs = start + pos;
|
||||
if abs > start {
|
||||
result.push(Span::styled(content[start..abs].to_string(), base_style));
|
||||
}
|
||||
result.push(Span::styled(
|
||||
content[abs..abs + query.len()].to_string(),
|
||||
hl_style,
|
||||
));
|
||||
start = abs + query.len();
|
||||
}
|
||||
if start < content.len() {
|
||||
result.push(Span::styled(content[start..].to_string(), base_style));
|
||||
}
|
||||
}
|
||||
RLine::from(result)
|
||||
}
|
||||
|
||||
fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
haystack.windows(needle.len()).position(|w| w == needle)
|
||||
}
|
||||
|
||||
/// Find first line matching query across all topics. Returns (topic_index, line_index).
|
||||
pub fn find_match(query: &str) -> Option<(usize, usize)> {
|
||||
let query = query.to_lowercase();
|
||||
for (topic_idx, (_, content)) in DOCS.iter().enumerate() {
|
||||
for (line_idx, line) in content.lines().enumerate() {
|
||||
if line.to_lowercase().contains(&query) {
|
||||
return Some((topic_idx, line_idx));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn code_border_style() -> Style {
|
||||
Style::new().fg(Color::Rgb(60, 60, 70))
|
||||
}
|
||||
|
||||
fn parse_markdown(md: &str) -> Vec<RLine<'static>> {
|
||||
let text = minimad::Text::from(md);
|
||||
let mut lines = Vec::new();
|
||||
|
||||
let mut code_line_nr: usize = 0;
|
||||
|
||||
for line in text.lines {
|
||||
match line {
|
||||
Line::Normal(composite) if composite.style == CompositeStyle::Code => {
|
||||
code_line_nr += 1;
|
||||
let raw: String = composite.compounds.iter().map(|c| &*c.src).collect();
|
||||
let mut spans = vec![
|
||||
Span::styled(format!(" {code_line_nr:>2} "), code_border_style()),
|
||||
Span::styled("│ ", code_border_style()),
|
||||
];
|
||||
spans.extend(
|
||||
highlight::highlight_line(&raw)
|
||||
.into_iter()
|
||||
.map(|(style, text)| Span::styled(text, style)),
|
||||
);
|
||||
lines.push(RLine::from(spans));
|
||||
}
|
||||
Line::Normal(composite) => {
|
||||
code_line_nr = 0;
|
||||
lines.push(composite_to_line(composite));
|
||||
}
|
||||
Line::TableRow(_) | Line::HorizontalRule | Line::CodeFence(_) | Line::TableRule(_) => {
|
||||
_ => {
|
||||
lines.push(RLine::from(""));
|
||||
}
|
||||
}
|
||||
@@ -133,7 +242,3 @@ fn compound_to_span(compound: Compound, base: Style) -> Span<'static> {
|
||||
|
||||
Span::styled(compound.src.to_string(), style)
|
||||
}
|
||||
|
||||
pub fn topic_count() -> usize {
|
||||
TOPICS.len()
|
||||
}
|
||||
|
||||
@@ -12,12 +12,22 @@ use crate::model::SourceSpan;
|
||||
use crate::page::Page;
|
||||
use crate::state::{FlashKind, Modal, PanelFocus, PatternField, SidePanel};
|
||||
use crate::views::highlight::{self, highlight_line, highlight_line_with_runtime};
|
||||
use crate::widgets::{ConfirmModal, ModalFrame, NavMinimap, NavTile, SampleBrowser, TextInputModal};
|
||||
use crate::widgets::{
|
||||
ConfirmModal, ModalFrame, NavMinimap, NavTile, SampleBrowser, TextInputModal,
|
||||
};
|
||||
|
||||
use super::{dict_view, engine_view, help_view, main_view, options_view, patterns_view, title_view};
|
||||
use super::{
|
||||
dict_view, engine_view, help_view, main_view, options_view, patterns_view, title_view,
|
||||
};
|
||||
|
||||
fn adjust_spans_for_line(spans: &[SourceSpan], line_start: usize, line_len: usize) -> Vec<SourceSpan> {
|
||||
spans.iter().filter_map(|s| {
|
||||
fn adjust_spans_for_line(
|
||||
spans: &[SourceSpan],
|
||||
line_start: usize,
|
||||
line_len: usize,
|
||||
) -> Vec<SourceSpan> {
|
||||
spans
|
||||
.iter()
|
||||
.filter_map(|s| {
|
||||
if s.end <= line_start || s.start >= line_start + line_len {
|
||||
return None;
|
||||
}
|
||||
@@ -25,7 +35,8 @@ fn adjust_spans_for_line(spans: &[SourceSpan], line_start: usize, line_len: usiz
|
||||
start: s.start.max(line_start) - line_start,
|
||||
end: (s.end.min(line_start + line_len)) - line_start,
|
||||
})
|
||||
}).collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn render(frame: &mut Frame, app: &App, link: &LinkState, snapshot: &SequencerSnapshot) {
|
||||
@@ -60,18 +71,14 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, snapshot: &Sequenc
|
||||
let (page_area, panel_area) = if app.panel.visible && app.panel.side.is_some() {
|
||||
if body_area.width >= 120 {
|
||||
let panel_width = body_area.width * 35 / 100;
|
||||
let [main, side] = Layout::horizontal([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(panel_width),
|
||||
])
|
||||
let [main, side] =
|
||||
Layout::horizontal([Constraint::Fill(1), Constraint::Length(panel_width)])
|
||||
.areas(body_area);
|
||||
(main, Some(side))
|
||||
} else {
|
||||
let panel_height = body_area.height * 40 / 100;
|
||||
let [main, side] = Layout::vertical([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(panel_height),
|
||||
])
|
||||
let [main, side] =
|
||||
Layout::vertical([Constraint::Fill(1), Constraint::Length(panel_height)])
|
||||
.areas(body_area);
|
||||
(main, Some(side))
|
||||
}
|
||||
@@ -106,7 +113,11 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, snapshot: &Sequenc
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let (col, row) = p.grid_pos();
|
||||
NavTile { col, row, name: p.name() }
|
||||
NavTile {
|
||||
col,
|
||||
row,
|
||||
name: p.name(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let selected = app.page.grid_pos();
|
||||
@@ -170,9 +181,13 @@ fn render_header(
|
||||
// Fill indicator
|
||||
let fill = app.live_keys.fill();
|
||||
let fill_style = if fill {
|
||||
Style::new().bg(Color::Rgb(30, 30, 35)).fg(Color::Rgb(100, 220, 100))
|
||||
Style::new()
|
||||
.bg(Color::Rgb(30, 30, 35))
|
||||
.fg(Color::Rgb(100, 220, 100))
|
||||
} else {
|
||||
Style::new().bg(Color::Rgb(30, 30, 35)).fg(Color::Rgb(60, 60, 70))
|
||||
Style::new()
|
||||
.bg(Color::Rgb(30, 30, 35))
|
||||
.fg(Color::Rgb(60, 60, 70))
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(if fill { "F" } else { "·" })
|
||||
@@ -303,21 +318,14 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
|
||||
("Enter", "Select"),
|
||||
("A", "Add path"),
|
||||
],
|
||||
Page::Options => vec![
|
||||
("Tab", "Next"),
|
||||
("←→", "Toggle"),
|
||||
("Space", "Play"),
|
||||
],
|
||||
Page::Options => vec![("Tab", "Next"), ("←→", "Toggle"), ("Space", "Play")],
|
||||
Page::Help => vec![
|
||||
("↑↓", "Scroll"),
|
||||
("Tab", "Topic"),
|
||||
("PgUp/Dn", "Page"),
|
||||
],
|
||||
Page::Dict => vec![
|
||||
("Tab", "Focus"),
|
||||
("↑↓", "Navigate"),
|
||||
("/", "Search"),
|
||||
],
|
||||
Page::Dict => vec![("Tab", "Focus"), ("↑↓", "Navigate"), ("/", "Search")],
|
||||
};
|
||||
|
||||
let page_width = page_indicator.chars().count();
|
||||
@@ -505,8 +513,16 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
||||
.lines()
|
||||
.map(|line_str| {
|
||||
let tokens = if let Some(t) = trace {
|
||||
let exec = adjust_spans_for_line(&t.executed_spans, line_start, line_str.len());
|
||||
let sel = adjust_spans_for_line(&t.selected_spans, line_start, line_str.len());
|
||||
let exec = adjust_spans_for_line(
|
||||
&t.executed_spans,
|
||||
line_start,
|
||||
line_str.len(),
|
||||
);
|
||||
let sel = adjust_spans_for_line(
|
||||
&t.selected_spans,
|
||||
line_start,
|
||||
line_str.len(),
|
||||
);
|
||||
highlight_line_with_runtime(line_str, &exec, &sel)
|
||||
} else {
|
||||
highlight_line(line_str)
|
||||
@@ -544,7 +560,9 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
||||
.render_centered(frame, term);
|
||||
|
||||
let trace = if app.ui.runtime_highlight && app.playback.playing {
|
||||
let source = app.current_edit_pattern().resolve_source(app.editor_ctx.step);
|
||||
let source = app
|
||||
.current_edit_pattern()
|
||||
.resolve_source(app.editor_ctx.step);
|
||||
snapshot.get_trace(app.editor_ctx.bank, app.editor_ctx.pattern, source)
|
||||
} else {
|
||||
None
|
||||
@@ -575,11 +593,22 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
||||
|
||||
let (search_area, editor_area, hint_area) = if show_search {
|
||||
let search_area = Rect::new(inner.x, inner.y, inner.width, 1);
|
||||
let editor_area = Rect::new(inner.x, inner.y + 1, inner.width, inner.height.saturating_sub(2));
|
||||
let hint_area = Rect::new(inner.x, inner.y + 1 + editor_area.height, inner.width, 1);
|
||||
let editor_area = Rect::new(
|
||||
inner.x,
|
||||
inner.y + 1,
|
||||
inner.width,
|
||||
inner.height.saturating_sub(2),
|
||||
);
|
||||
let hint_area =
|
||||
Rect::new(inner.x, inner.y + 1 + editor_area.height, inner.width, 1);
|
||||
(Some(search_area), editor_area, hint_area)
|
||||
} else {
|
||||
let editor_area = Rect::new(inner.x, inner.y, inner.width, inner.height.saturating_sub(1));
|
||||
let editor_area = Rect::new(
|
||||
inner.x,
|
||||
inner.y,
|
||||
inner.width,
|
||||
inner.height.saturating_sub(1),
|
||||
);
|
||||
let hint_area = Rect::new(inner.x, inner.y + editor_area.height, inner.width, 1);
|
||||
(None, editor_area, hint_area)
|
||||
};
|
||||
@@ -590,7 +619,11 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
let cursor = if app.editor_ctx.editor.search_active() { "_" } else { "" };
|
||||
let cursor = if app.editor_ctx.editor.search_active() {
|
||||
"_"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let text = format!("/{}{}", app.editor_ctx.editor.search_query(), cursor);
|
||||
frame.render_widget(Paragraph::new(Line::from(Span::styled(text, style))), sa);
|
||||
}
|
||||
@@ -604,24 +637,35 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
||||
let flash_block = Block::default().style(Style::default().bg(bg));
|
||||
frame.render_widget(flash_block, editor_area);
|
||||
}
|
||||
app.editor_ctx.editor.render(frame, editor_area, &highlighter);
|
||||
app.editor_ctx
|
||||
.editor
|
||||
.render(frame, editor_area, &highlighter);
|
||||
|
||||
let dim = Style::default().fg(Color::DarkGray);
|
||||
let key = Style::default().fg(Color::Yellow);
|
||||
let hint = if app.editor_ctx.editor.search_active() {
|
||||
Line::from(vec![
|
||||
Span::styled("Enter", key), Span::styled(" confirm ", dim),
|
||||
Span::styled("Esc", key), Span::styled(" cancel", dim),
|
||||
Span::styled("Enter", key),
|
||||
Span::styled(" confirm ", dim),
|
||||
Span::styled("Esc", key),
|
||||
Span::styled(" cancel", dim),
|
||||
])
|
||||
} else {
|
||||
Line::from(vec![
|
||||
Span::styled("Esc", key), Span::styled(" save ", dim),
|
||||
Span::styled("C-e", key), Span::styled(" eval ", dim),
|
||||
Span::styled("C-f", key), Span::styled(" find ", dim),
|
||||
Span::styled("C-n", key), Span::styled("/", dim),
|
||||
Span::styled("C-p", key), Span::styled(" next/prev ", dim),
|
||||
Span::styled("C-u", key), Span::styled("/", dim),
|
||||
Span::styled("C-r", key), Span::styled(" undo/redo", dim),
|
||||
Span::styled("Esc", key),
|
||||
Span::styled(" save ", dim),
|
||||
Span::styled("C-e", key),
|
||||
Span::styled(" eval ", dim),
|
||||
Span::styled("C-f", key),
|
||||
Span::styled(" find ", dim),
|
||||
Span::styled("C-n", key),
|
||||
Span::styled("/", dim),
|
||||
Span::styled("C-p", key),
|
||||
Span::styled(" next/prev ", dim),
|
||||
Span::styled("C-u", key),
|
||||
Span::styled("/", dim),
|
||||
Span::styled("C-r", key),
|
||||
Span::styled(" undo/redo", dim),
|
||||
])
|
||||
};
|
||||
frame.render_widget(Paragraph::new(hint).alignment(Alignment::Right), hint_area);
|
||||
@@ -654,7 +698,11 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
||||
|
||||
let fields = [
|
||||
("Name", name.as_str(), *field == PatternPropsField::Name),
|
||||
("Length", length.as_str(), *field == PatternPropsField::Length),
|
||||
(
|
||||
"Length",
|
||||
length.as_str(),
|
||||
*field == PatternPropsField::Length,
|
||||
),
|
||||
("Speed", speed.label(), *field == PatternPropsField::Speed),
|
||||
(
|
||||
"Quantization",
|
||||
@@ -676,7 +724,9 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
||||
|
||||
let (label_style, value_style) = if *selected {
|
||||
(
|
||||
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
Style::default().fg(Color::White).bg(Color::DarkGray),
|
||||
)
|
||||
} else {
|
||||
@@ -693,10 +743,7 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
||||
Paragraph::new(format!("{label}:")).style(label_style),
|
||||
label_area,
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new(*value).style(value_style),
|
||||
value_area,
|
||||
);
|
||||
frame.render_widget(Paragraph::new(*value).style(value_style), value_area);
|
||||
}
|
||||
|
||||
let hint_area = Rect::new(inner.x, inner.y + inner.height - 1, inner.width, 1);
|
||||
|
||||
Reference in New Issue
Block a user