Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
316ea68
Recording and the latency box time beats at the tempo playing
thacer13 Nov 7, 2026
cac08b0
Undoing a whole-project step puts back the mixer's slot colours and i…
thacer13 Nov 7, 2026
55a6760
A saved project opens again on the pattern shown when it was saved, w…
thacer13 Nov 7, 2026
3608ea5
A playlist solo is over once it no longer holds, as in the channel ra…
thacer13 Nov 7, 2026
c53dbd3
Tests: the latency box's beats at the tempo playing; the reopened pat…
thacer13 Nov 7, 2026
b71dfc5
A mixer track's equaliser is saved
thacer13 Nov 7, 2026
3e9356a
Loop mode with every channel following the pattern survives a save
thacer13 Nov 7, 2026
12abef8
Moving a channel with Shift+wheel is a Move channel step, as from the…
thacer13 Nov 7, 2026
223b790
Open audio editor's added Tesla is an undo step, and stereo separatio…
thacer13 Nov 8, 2026
bb17c66
Undo of a send level puts back no level record when the route had non…
thacer13 Nov 8, 2026
d2c16d9
The rack's loop lengths follow the pattern shown: each pattern keeps …
thacer13 Nov 8, 2026
9f8a45d
A piano roll tool's preview is put back in the pattern it previewed o…
thacer13 Nov 9, 2026
d6d5a17
Undo and redo of a pattern edit leave the pattern shown, as in FL
thacer13 Nov 9, 2026
21fa1fa
A drag in the channel rack's graph editor is one undo step, as in FL
thacer13 Nov 9, 2026
eb66bc3
Undo of a whole-project step puts the rack's loop mode and type back …
thacer13 Nov 9, 2026
5206d12
A song with no clips plays the pattern shown, not the pattern the pro…
thacer13 Nov 9, 2026
e16ff69
An edit no longer drops the knob step before it when the edit renumbe…
thacer13 Nov 9, 2026
2bcb2ee
A save hands what it writes into the project to playback
thacer13 Nov 9, 2026
7201450
A new audio or automation clip holds the offsets FL writes for it (fl…
thacer13 Nov 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 82 additions & 1 deletion crates/os-app/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2028,14 +2028,23 @@ impl App {
}
}
}
// What the save writes into the project from the interface (below) reaches playback like any edit: the engine
// holds the project as it is.
let before = self.state.project.clone();
self.store_builtin_states();
let s = &mut self.state;
s.project.tempo = Some(s.tempo);
s.project.song_mode = Some(s.song_mode);
// The pattern shown is saved, so the project opens on it again with the loops the rack stores into it (event
// 67, spec: flp/engine-events.md).
s.project.current_pattern = Some(s.pattern);
// Lock, zip, filter groups and loop lengths live in the rack UI (spec: ui/channel-rack.md, Saving).
crate::rack::to_model(s);
// The playlist's track height, zoom and scroll (event 242, spec: flp/events/playlist.md).
crate::playlist::store_view(s);
if s.project != before {
s.piano.dirty = true;
}
// A file Lemon Studio made before the project had a playlist has no arrangement block (FL's files always have
// one): build it again from the model, which holds everything that file stored.
let lacks_playlist = s.file.as_ref().and_then(|f| os_flp::project::load(f).ok()).is_some_and(|p| p.arrangements.is_empty())
Expand Down Expand Up @@ -4271,6 +4280,9 @@ impl App {
pub fn tick(&mut self) -> bool {
// Things the interface added get their ids before anything looks them up (`control::ids`).
let s = &mut self.state;
// The rack's loops follow the pattern shown: a file holds them per pattern (spec: flp/events/patterns.md "Loop
// lengths").
crate::rack::follow_pattern(s);
s.control.settle_tick(&mut s.project, s.piano.dirty);
self.control_tick();
self.plugin_tick();
Expand Down Expand Up @@ -5001,7 +5013,7 @@ impl App {
let start = s.playlist.pos;
let secs = s.playlist.wave(&path).map_or(0.0, |w| w.secs());
let p = &s.project;
let tempo = p.tick_tempo(p.tempo.unwrap_or(140.0).max(1.0));
let tempo = p.tick_tempo(s.tempo.max(1.0));
let len = (secs * tempo / 60.0 * f64::from(p.ppq.max(1))).ceil() as u32;
let track = crate::playlist::free_track(s, 0, start, start + len.max(1));
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
Expand Down Expand Up @@ -9396,6 +9408,7 @@ impl App {
Action::RackGraph => self.state.rack.graph_open = !self.state.rack.graph_open,
Action::RackLoopBox(i) => self.drag = Some(Drag::RackLoopLen { row: i, y0: mouse.1, v0: rk::loop_box_value(&self.state, i) }),
Action::RackGraphArea => {
crate::rack::graph_step(&mut self.state);
self.rack_graph_at(mouse);
self.drag = Some(Drag::RackGraph);
}
Expand Down Expand Up @@ -9433,6 +9446,7 @@ impl App {
return true;
}
Action::RackGraphArea => {
crate::rack::graph_step(&mut self.state);
self.rack_graph_at((x, y));
self.drag = Some(Drag::RackGraph);
return true;
Expand Down Expand Up @@ -13260,6 +13274,41 @@ mod tests {
assert_eq!(app.rows_get(Scroll::RackY), None);
}

/// A drag in the channel rack's graph editor is one undo step, "Graph editor change velocity" on the velocity tab;
/// a press that changes nothing leaves none (spec: ui/undo-steps.md; FL parity audit 2026-11-08 found it missing).
#[test]
fn a_graph_editor_drag_is_one_undo_step() {
let mut app = App::new(None);
let Ok(mut c) = Canvas::new(1920, 1080, 1.0) else { return };
app.state.raise(Win::ChannelRack);
app.state.rack.graph_open = true;
app.state.selected_channel = 0;
let ch = app.state.project.channels[0].index;
let n = app.state.pattern.max(1);
crate::patterns::pat_mut(&mut app.state, n).notes.push(os_model::Note::step(ch, 0));
frame(&mut app, &mut c, None);
let area = hit_rect(&app, &Action::RackGraphArea);
let x = hit_rect(&app, &Action::Step(0, 0)).cx();
let vel = |app: &App| app.state.project.pattern(n).map(|p| p.notes[0].velocity);
let (before, steps) = (vel(&app), app.state.history.done());
app.mouse_move(x, area.y + area.h * 0.9);
app.mouse_down();
app.mouse_move(x, area.y + area.h * 0.6);
app.mouse_move(x, area.y + area.h * 0.4);
app.mouse_up();
assert_ne!(vel(&app), before);
assert_eq!((app.state.history.done(), app.state.history.newest_done()), (steps + 1, Some("Graph editor change velocity")));
assert!(crate::history::undo(&mut app.state).is_some());
assert_eq!(vel(&app), before, "one step takes the whole drag back");
app.state.pattern = n + 1;
frame(&mut app, &mut c, None);
let steps = app.state.history.done();
app.mouse_move(x, area.y + area.h * 0.5);
app.mouse_down();
app.mouse_up();
assert_eq!((app.state.history.done(), vel(&app)), (steps, before), "a press in a pattern without steps changes nothing");
}

fn hit_rect(app: &App, a: &Action) -> Rect {
app.hits.iter().rev().find(|h| &h.action == a).map(|h| h.rect).unwrap_or_else(|| panic!("no hit for {a:?}"))
}
Expand Down Expand Up @@ -16842,6 +16891,10 @@ mod tests {
app.wheel(0.0, 1.0, true, false);
assert_eq!(order(&app)[..5], rows([0, 2, 1, 3, 4]), "the same channel, two rows up");
assert_eq!(app.state.rack.sel, std::collections::BTreeSet::from([1]));
// Each move is a "Move channel" step, as the menu's: undo puts them back.
assert_eq!(app.state.history.newest_done(), Some("Move channel"));
while crate::history::undo(&mut app.state).is_some() {}
assert_eq!(order(&app), b, "undone back to the start");
frame(&mut app, &mut c, None);
let moved = order(&app);
let name = hit_rect(&app, &Action::ChannelSelect(4));
Expand Down Expand Up @@ -19233,6 +19286,34 @@ mod tests {
}
frame(&mut app, &mut c, None);

// Another pattern shown while a dialog previews (New pattern, the pattern selector): the pattern previewed on
// gets its notes back on ×, on Accept and when the preview starts again on the new pattern, as no undo step
// holds the preview.
{
use crate::piano_dlg::Act as DAct;
let pat = app.state.pattern;
let notes_of = |app: &App, n: u16| app.state.project.patterns.iter().find(|p| p.number == n).map(|p| p.notes.clone()).unwrap_or_default();
put(&mut app, &[(0, 96, 60), (96, 48, 62)]);
let before = notes_of(&app, pat);
for close in [Action::CloseWin(Win::PianoDlg), Action::PianoDlg(DAct::Accept)] {
key(&mut app, "u", false, true, false);
assert_ne!(notes_of(&app, pat), before, "previewed");
app.state.pattern = pat + 1;
app.act(close.clone());
assert_eq!(notes_of(&app, pat), before, "{close:?} puts back the pattern previewed on");
assert!(!app.state.shown(Win::PianoDlg));
app.state.pattern = pat;
}
key(&mut app, "u", false, true, false);
app.state.pattern = pat + 1;
app.act(Action::PianoDlg(DAct::Group));
assert_eq!(notes_of(&app, pat), before, "a preview on the new pattern puts the old one back");
app.act(Action::CloseWin(Win::PianoDlg));
app.state.pattern = pat;
assert_eq!(notes_of(&app, pat), before);
}
frame(&mut app, &mut c, None);

// The toolbar's tools button opens the Tools menu.
let r = hit_rect(&app, &Action::PianoToolsMenu);
app.mouse_move(r.cx(), r.cy());
Expand Down
34 changes: 30 additions & 4 deletions crates/os-app/src/control/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,8 @@ impl Settings {
}
}

/// The whole project with the rack's own state of it: the tempo, groups, filter, zipped and locked rows and loops.
/// The whole project with the state of it the interface holds: the tempo, the rack's groups, filter, zipped and locked
/// rows and loops, and the mixer's slot names, colours and icons.
#[derive(Debug, Clone, PartialEq)]
pub struct Side {
pub project: Project,
Expand All @@ -379,6 +380,14 @@ pub struct Side {
pub loop_len: std::collections::BTreeMap<usize, u32>,
/// The mixer's slot names typed in this session (they follow tracks and slots as they move).
pub slot_names: std::collections::HashMap<(u16, u8), String>,
/// The slots' colours and the channels', slots' and tracks' icons, which follow them the same way.
pub slot_colors: std::collections::HashMap<(u16, u8), os_model::Rgb>,
pub icons: std::collections::HashMap<crate::playlist::IconOwner, crate::icons::Icon>,
/// Advanced looping's loop points by row, beside the loop lengths.
pub loop_unit: std::collections::BTreeMap<usize, crate::rack::LoopUnit>,
/// Loop mode and type, which say with the lengths what `pattern`'s loops are.
pub loop_mode: bool,
pub loop_type: u8,
}

impl Side {
Expand All @@ -395,6 +404,11 @@ impl Side {
locked: r.locked.clone(),
loop_len: r.loop_len.clone(),
slot_names: s.mixer.slot_names.clone(),
slot_colors: s.mixer.slot_colors.clone(),
icons: s.playlist.icons.clone(),
loop_unit: r.loop_unit.clone(),
loop_mode: r.loop_mode,
loop_type: r.loop_type,
}
}
}
Expand Down Expand Up @@ -1212,16 +1226,28 @@ fn restore(s: &mut State, side: Side) {
let sel_clips = selected_clips(s);
let new_row: std::collections::HashMap<u32, usize> = side.project.channels.iter().enumerate().map(|(r, c)| (c.id.0, r)).collect();
let map: Vec<Option<usize>> = old.iter().map(|id| new_row.get(id).copied()).collect();
let Side { project, tempo, pattern, groups, unsorted_at, filter, zipped, locked, loop_len, slot_names } = side;
s.mixer.slot_names = slot_names;
let Side { project, tempo, pattern, groups, unsorted_at, filter, zipped, locked, loop_len, slot_names, slot_colors, icons, loop_unit, loop_mode, loop_type } = side;
let shown = s.pattern;
let numbers = |p: &Project| p.patterns.iter().map(|q| q.number).collect::<std::collections::BTreeSet<_>>();
let patterns_before = numbers(&s.project);
(s.mixer.slot_names, s.mixer.slot_colors, s.playlist.icons) = (slot_names, slot_colors, icons);
let mutes = Mutes::of(&s.project);
s.project = project;
mutes.keep(&mut s.project);
s.tempo = tempo;
s.pattern = pattern;
crate::rack::remap_ui(s, &map);
let r = &mut s.rack;
(r.groups, r.unsorted_at, r.filter, r.zipped, r.locked, r.loop_len) = (groups, unsorted_at, filter, zipped, locked, loop_len);
(r.groups, r.unsorted_at, r.filter, r.zipped, r.locked, r.loop_len, r.loop_unit) = (groups, unsorted_at, filter, zipped, locked, loop_len, loop_unit);
(r.loop_mode, r.loop_type) = (loop_mode, loop_type);
// The loops put back are those of the pattern shown when the step was made. The pattern shown now stays shown, as
// in FL (an undo leaves the pattern shown), and gets its own loops; a step that made or removed patterns (a clone,
// a delete) shows the pattern it showed.
r.loops_of = s.pattern;
if numbers(&s.project) == patterns_before {
s.pattern = shown;
}
crate::rack::follow_pattern(s);
let tracks = crate::mixer::inserts(s);
s.selected_mixer = s.selected_mixer.min(tracks);
s.mixer.sel.retain(|&t| t <= tracks);
Expand Down
9 changes: 8 additions & 1 deletion crates/os-app/src/control/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,9 @@ pub fn apply(s: &mut State, by: &By, name: Option<&str>, commands: Vec<Command>)
/// `Command::Put`, as a command's own undo is.
pub fn edit<R>(s: &mut State, name: &'static str, region: command::Region, f: impl FnOnce(&mut State) -> R) -> R {
s.link_moves.clear();
// A pressed step that changed nothing goes before this edit, judged on the project as it is now: after the edit
// its region may name other things (tracks renumbered by a delete).
crate::history::settle(s);
let before = command::Taken::of(s, region);
let r = f(s);
if let Some(before) = before {
Expand Down Expand Up @@ -354,6 +357,9 @@ pub fn run_edit<R>(s: &mut State, name: &'static str, region: command::Region, r
return f(s);
}
s.link_moves.clear();
// A pressed step that changed nothing goes before this edit, judged on the project as it is now: after the edit
// its region may name other things (tracks renumbered by a delete).
crate::history::settle(s);
let before = command::Taken::of(s, region);
let r = f(s);
if let Some(before) = before {
Expand All @@ -368,7 +374,6 @@ pub fn run_edit<R>(s: &mut State, name: &'static str, region: command::Region, r
/// Records an interface edit's step. A step of the same press that changed nothing goes first (a text box's playlist
/// step when the box named a mixer track), as the press's release would have dropped it.
fn record_edit(s: &mut State, name: &'static str, before: command::Taken) {
crate::history::settle(s);
let mut undo = vec![Command::Put(Box::new(before))];
// The remote control links the edit moved with what they drive (`remote::follow`) go back with it.
let links: Vec<_> = std::mem::take(&mut s.link_moves).into_iter().map(|(from, to)| (to, from)).collect();
Expand All @@ -382,6 +387,7 @@ fn record_edit(s: &mut State, name: &'static str, before: command::Taken) {
/// `edit` for an edit that needs the app (a mixer menu command hosting or dropping plugins).
pub fn edit_app<R>(app: &mut crate::app::App, name: &'static str, region: command::Region, f: impl FnOnce(&mut crate::app::App) -> R) -> R {
app.state.link_moves.clear();
crate::history::settle(&mut app.state);
let before = command::Taken::of(&app.state, region);
let r = f(app);
let s = &mut app.state;
Expand Down Expand Up @@ -463,6 +469,7 @@ pub fn edit_run<R>(app: &mut crate::app::App, name: &'static str, region: comman
return f(app);
}
app.state.link_moves.clear();
crate::history::settle(&mut app.state);
let before = command::Taken::of(&app.state, region);
let r = f(app);
let s = &mut app.state;
Expand Down
Loading
Loading