3 Commits

Author SHA1 Message Date
e956346ae9 Feat: text selection using mouse
Some checks failed
Deploy Website / deploy (push) Failing after 30s
2026-02-25 23:20:42 +01:00
6892575a53 Fix: copy/paste multi-step 2026-02-25 22:35:43 +01:00
27b826ebaf Add indications for cross building 2026-02-25 22:08:08 +01:00
10 changed files with 318 additions and 9 deletions

91
BUILDING.md Normal file
View File

@@ -0,0 +1,91 @@
# Building Cagire
## Prerequisites
**Rust** (stable toolchain): https://rustup.rs
## System Dependencies
### macOS
```bash
brew install cmake
```
cmake is required by `rusty_link` (Ableton Link C++ bindings). Xcode Command Line Tools provide the C++ compiler. CoreAudio and CoreMIDI are built-in. The desktop build needs no additional dependencies on macOS (Cocoa/Metal are provided by the system).
### Linux (Debian/Ubuntu)
```bash
sudo apt install cmake g++ pkg-config libasound2-dev libjack-jackd2-dev
```
For the desktop build (egui/eframe), also install:
```bash
sudo apt install libgl-dev libxkbcommon-dev libx11-dev libxcursor-dev libxrandr-dev libxi-dev libwayland-dev
```
### Linux (Arch)
```bash
sudo pacman -S cmake gcc pkgconf alsa-lib jack2
```
For the desktop build:
```bash
sudo pacman -S libxkbcommon libx11 libxcursor libxrandr libxi wayland mesa
```
### Linux (Fedora)
```bash
sudo dnf install cmake gcc-c++ pkgconf-pkg-config alsa-lib-devel jack-audio-connection-kit-devel
```
For the desktop build:
```bash
sudo dnf install libxkbcommon-devel libX11-devel libXcursor-devel libXrandr-devel libXi-devel wayland-devel mesa-libGL-devel
```
### Windows
Install Visual Studio Build Tools (MSVC) and CMake. Everything else is provided by the Windows SDK.
## Build
Terminal (default):
```bash
cargo build --release
```
Desktop (egui window):
```bash
cargo build --release --features desktop --bin cagire-desktop
```
## Run
Terminal (default):
```bash
cargo run --release -- [OPTIONS]
```
Desktop (egui window):
```bash
cargo run --release --features desktop --bin cagire-desktop
```
| Flag | Description |
|------|-------------|
| `-s, --samples <path>` | Sample directory (repeatable) |
| `-o, --output <device>` | Output audio device |
| `-i, --input <device>` | Input audio device |
| `-c, --channels <n>` | Output channel count |
| `-b, --buffer <size>` | Audio buffer size |

View File

@@ -4,6 +4,60 @@ All notable changes to this project will be documented in this file.
## [0.1.0]
### Forth Language
**Bracket syntax `[ ... ]`**
- `[ v1 v2 v3 ]` pushes all items plus their count. Sugar for `v1 v2 v3 3`.
**New words:**
- `index` — select item at explicit index (wraps with modulo).
- `pbounce` — ping-pong cycle keyed by pattern iteration (vs `bounce` which is step-keyed).
- `except` — inverse of `every`: run quotation on all iterations except every nth.
- `every+` / `except+``every`/`except` with a phase offset.
- `all` / `noall` — apply current params globally to all emitted sounds; clear global params.
- `linmap` / `expmap` — linear and exponential range mapping.
- `rec` / `overdub` (`dub`) — toggle recording/overdubbing master audio to a named sample.
- `orec` / `odub` — toggle recording/overdubbing a single orbit to a named sample.
**Harmony and voicing words:**
- `key!` — set tonal center for scale operations.
- `triad` / `seventh` — diatonic triad/seventh from scale degree (follows a scale word).
- `inv` / `dinv` — chord inversion / down inversion.
- `drop2` / `drop3` — drop-2 / drop-3 voicings.
- `tp` — transpose all ints on stack by N semitones.
**New chord types:**
- `pwr`, `augmaj7`, `7sus4`, `9sus4`, `maj69`, `min69`, `maj11`, `maj13`, `min13`, `dom7s11`.
**Ducking compressor params:**
- `comp`, `compattack`/`cattack`, `comprelease`/`crelease`, `comporbit`/`corbit`.
### Engine
- SF2 soundfont support: auto-scans sample directories for `.sf2` files and loads them.
- Audio stream errors surfaced as flash messages instead of printing to stderr.
### UI / Visualization
- Lissajous XY scope: stereo phase display using Braille characters, togglable via Options.
- Gain boost (1x16x) and normalize toggle for scope/lissajous/spectrum.
- Pattern description field: editable via `d` on Patterns page, shown in pattern row and properties.
- Mute/solo on main page now apply immediately (no staging).
- 10 bundled demo projects loaded on fresh startup (togglable in Options).
### Themes
- 5 new themes: Iceberg, Everforest, Fauve, Tropicalia, Jaipur.
### Desktop (egui)
- Fixed Alt/Option key on macOS (dead-key composition now works).
- Fixed multi-character text paste.
- Extended function key support (F13F20).
### Fixed
- CycleList + ArpList index collision: arp uses timing index, cycle uses polyphony slot.
- Scope widget not drawing completely in some terminal sizes.
### Documentation
- New tutorials: Recording (`docs/tutorials/recording.md`), Soundfonts (`docs/tutorials/soundfont.md`).
### UI / UX (breaking cosmetic changes)
- **Options page**: Each option now shows a short description line below when focused, replacing the static header box.
- **Dictionary page**: Removed the Forth description box at the top. The word list now uses the full page height.

5
Cross.toml Normal file
View File

@@ -0,0 +1,5 @@
[build]
volumes = ["/Users/bubo/doux:/Users/bubo/doux"]
[target.aarch64-unknown-linux-gnu]
dockerfile = "./cross/aarch64-linux.Dockerfile"

View File

@@ -99,6 +99,14 @@ impl Editor {
self.text.is_selecting()
}
pub fn move_cursor_to(&mut self, row: u16, col: u16) {
self.text.move_cursor(tui_textarea::CursorMove::Jump(row, col));
}
pub fn scroll_offset(&self) -> u16 {
self.scroll_offset.get()
}
pub fn copy(&mut self) {
self.text.copy();
}
@@ -111,6 +119,14 @@ impl Editor {
self.text.paste()
}
pub fn yank_text(&self) -> String {
self.text.yank_text()
}
pub fn set_yank_text(&mut self, text: impl Into<String>) {
self.text.set_yank_text(text);
}
pub fn select_all(&mut self) {
self.text.select_all();
}
@@ -138,7 +154,11 @@ impl Editor {
}
pub fn set_content(&mut self, lines: Vec<String>) {
let yank = self.text.yank_text();
self.text = TextArea::new(lines);
if !yank.is_empty() {
self.text.set_yank_text(yank);
}
self.completion.active = false;
self.sample_finder.active = false;
self.search.query.clear();

View File

@@ -0,0 +1,15 @@
FROM ghcr.io/cross-rs/aarch64-unknown-linux-gnu:main
RUN dpkg --add-architecture arm64 && \
apt-get update && \
apt-get install -y --no-install-recommends \
cmake \
libclang-dev \
libasound2-dev:arm64 \
libjack-dev:arm64 \
libxcb-render0-dev:arm64 \
libxcb-shape0-dev:arm64 \
libxcb-xfixes0-dev:arm64 \
libxkbcommon-dev:arm64 \
libgl1-mesa-dev:arm64 \
&& rm -rf /var/lib/apt/lists/*

View File

@@ -22,7 +22,7 @@ use cagire::engine::{
};
use cagire::init::{init, InitArgs};
use cagire::input::{handle_key, handle_mouse, InputContext, InputResult};
use cagire::input_egui::{convert_egui_events, convert_egui_mouse};
use cagire::input_egui::{convert_egui_events, convert_egui_mouse, EguiMouseState};
use cagire::settings::Settings;
use cagire::views;
use crossbeam_channel::Receiver;
@@ -168,6 +168,7 @@ struct CagireDesktop {
mouse_y: Arc<AtomicU32>,
mouse_down: Arc<AtomicU32>,
last_frame: std::time::Instant,
egui_mouse: EguiMouseState,
}
impl CagireDesktop {
@@ -212,6 +213,7 @@ impl CagireDesktop {
mouse_y: b.mouse_y,
mouse_down: b.mouse_down,
last_frame: std::time::Instant::now(),
egui_mouse: EguiMouseState::default(),
}
}
@@ -322,7 +324,7 @@ impl CagireDesktop {
let term = self.terminal.get_frame().area();
let widget_rect = ctx.content_rect();
for mouse in convert_egui_mouse(ctx, widget_rect, term) {
for mouse in convert_egui_mouse(ctx, widget_rect, term, &mut self.egui_mouse) {
let mut input_ctx = InputContext {
app: &mut self.app,
link: &self.link,

View File

@@ -343,13 +343,26 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
editor.select_all();
}
KeyCode::Char('c') if ctrl => {
editor.copy();
ctx.app.editor_ctx.editor.copy();
let text = ctx.app.editor_ctx.editor.yank_text();
if let Some(clip) = &mut ctx.app.clipboard {
let _ = clip.set_text(text);
}
}
KeyCode::Char('x') if ctrl => {
editor.cut();
ctx.app.editor_ctx.editor.cut();
let text = ctx.app.editor_ctx.editor.yank_text();
if let Some(clip) = &mut ctx.app.clipboard {
let _ = clip.set_text(text);
}
}
KeyCode::Char('v') if ctrl => {
editor.paste();
if let Some(clip) = &mut ctx.app.clipboard {
if let Ok(text) = clip.get_text() {
ctx.app.editor_ctx.editor.set_yank_text(text);
}
}
ctx.app.editor_ctx.editor.paste();
}
KeyCode::Left | KeyCode::Right | KeyCode::Up | KeyCode::Down if shift => {
if !editor.is_selecting() {

View File

@@ -26,6 +26,12 @@ pub fn handle_mouse(ctx: &mut InputContext, mouse: MouseEvent, term: Rect) {
match kind {
MouseEventKind::Down(MouseButton::Left) => handle_click(ctx, col, row, term),
MouseEventKind::Drag(MouseButton::Left) | MouseEventKind::Moved => {
handle_editor_drag(ctx, col, row, term);
}
MouseEventKind::Up(MouseButton::Left) => {
ctx.app.editor_ctx.mouse_selecting = false;
}
MouseEventKind::ScrollUp => handle_scroll(ctx, col, row, term, true),
MouseEventKind::ScrollDown => handle_scroll(ctx, col, row, term, false),
_ => {}
@@ -59,6 +65,55 @@ fn contains(area: Rect, col: u16, row: u16) -> bool {
col >= area.x && col < area.x + area.width && row >= area.y && row < area.y + area.height
}
fn handle_editor_drag(ctx: &mut InputContext, col: u16, row: u16, term: Rect) {
if ctx.app.editor_ctx.mouse_selecting {
handle_editor_mouse(ctx, col, row, term, true);
}
}
fn handle_editor_mouse(ctx: &mut InputContext, col: u16, row: u16, term: Rect, dragging: bool) {
// Reconstruct editor area (mirrors render_modal_editor / ModalFrame::render_centered)
let width = (term.width * 80 / 100).max(40);
let height = (term.height * 60 / 100).max(10);
let modal_w = width.min(term.width.saturating_sub(4));
let modal_h = height.min(term.height.saturating_sub(4));
let mx = term.x + (term.width.saturating_sub(modal_w)) / 2;
let my = term.y + (term.height.saturating_sub(modal_h)) / 2;
// inner = area inside 1-cell border
let inner_x = mx + 1;
let inner_y = my + 1;
let inner_w = modal_w.saturating_sub(2);
let inner_h = modal_h.saturating_sub(2);
let show_search = ctx.app.editor_ctx.editor.search_active()
|| !ctx.app.editor_ctx.editor.search_query().is_empty();
let reserved = 1 + if show_search { 1 } else { 0 };
let editor_y = inner_y + if show_search { 1 } else { 0 };
let editor_h = inner_h.saturating_sub(reserved);
if col < inner_x || col >= inner_x + inner_w || row < editor_y || row >= editor_y + editor_h {
return;
}
let scroll = ctx.app.editor_ctx.editor.scroll_offset();
let text_row = (row - editor_y) + scroll;
let text_col = col - inner_x;
if dragging {
if !ctx.app.editor_ctx.editor.is_selecting() {
ctx.app.editor_ctx.editor.start_selection();
}
} else {
ctx.app.editor_ctx.mouse_selecting = true;
ctx.app.editor_ctx.editor.cancel_selection();
}
ctx.app
.editor_ctx
.editor
.move_cursor_to(text_row, text_col);
}
fn handle_click(ctx: &mut InputContext, col: u16, row: u16, term: Rect) {
// Sticky minimap intercepts all clicks
if matches!(ctx.app.ui.minimap, MinimapMode::Sticky) {
@@ -745,8 +800,11 @@ fn handle_engine_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect) {
fn handle_modal_click(ctx: &mut InputContext, col: u16, row: u16, term: Rect) {
match &ctx.app.ui.modal {
Modal::Editor | Modal::Preview => {
// Don't dismiss editor/preview on click
Modal::Editor => {
handle_editor_mouse(ctx, col, row, term, false);
}
Modal::Preview => {
// Don't dismiss preview on click
}
Modal::Confirm { .. } => {
handle_confirm_click(ctx, col, row, term);

View File

@@ -1,13 +1,25 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use ratatui::layout::Rect;
#[derive(Default)]
pub struct EguiMouseState {
prev_down: bool,
prev_col: u16,
prev_row: u16,
}
pub fn convert_egui_mouse(
ctx: &egui::Context,
widget_rect: egui::Rect,
term: Rect,
state: &mut EguiMouseState,
) -> Vec<MouseEvent> {
let mut events = Vec::new();
if widget_rect.width() < 1.0 || widget_rect.height() < 1.0 || term.width == 0 || term.height == 0 {
if widget_rect.width() < 1.0
|| widget_rect.height() < 1.0
|| term.width == 0
|| term.height == 0
{
return events;
}
@@ -16,6 +28,15 @@ pub fn convert_egui_mouse(
return;
};
if !widget_rect.contains(pos) {
if state.prev_down && !i.pointer.primary_down() {
events.push(MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column: state.prev_col,
row: state.prev_row,
modifiers: KeyModifiers::empty(),
});
state.prev_down = false;
}
return;
}
@@ -26,15 +47,43 @@ pub fn convert_egui_mouse(
let col = col.min(term.width.saturating_sub(1));
let row = row.min(term.height.saturating_sub(1));
if i.pointer.button_clicked(egui::PointerButton::Primary) {
let is_down = i.pointer.primary_down();
let moved = col != state.prev_col || row != state.prev_row;
if !state.prev_down && is_down {
events.push(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: col,
row,
modifiers: KeyModifiers::empty(),
});
} else if state.prev_down && is_down && moved {
events.push(MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: col,
row,
modifiers: KeyModifiers::empty(),
});
} else if state.prev_down && !is_down {
events.push(MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column: col,
row,
modifiers: KeyModifiers::empty(),
});
} else if !is_down && moved {
events.push(MouseEvent {
kind: MouseEventKind::Moved,
column: col,
row,
modifiers: KeyModifiers::empty(),
});
}
state.prev_down = is_down;
state.prev_col = col;
state.prev_row = row;
let scroll = i.raw_scroll_delta.y;
if scroll > 1.0 {
events.push(MouseEvent {

View File

@@ -99,6 +99,7 @@ pub struct EditorContext {
pub stack_cache: RefCell<Option<StackCache>>,
pub target: EditorTarget,
pub steps_per_page: Cell<usize>,
pub mouse_selecting: bool,
}
#[derive(Clone)]
@@ -150,6 +151,7 @@ impl Default for EditorContext {
stack_cache: RefCell::new(None),
target: EditorTarget::default(),
steps_per_page: Cell::new(32),
mouse_selecting: false,
}
}
}