Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
27 changes: 27 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,33 @@ Each group sorted alphabetically, blank lines between groups.
- CGEventTap callback signals CFRunLoopSource for immediate processing
- Default mode: `"normal"` (always exists, implicit)

### Command Sequencing
Multiple `bind` commands for the same hotkey automatically create a command sequence:

```sh
yashiki bind alt-x window-close
yashiki bind alt-x tag-view 2
yashiki bind alt-x retile
# alt-x now executes all three commands in order
```

**Sequence execution:**
- Commands execute in bind order
- Each command sees state changes from previous commands
- Sequence stops on first error (fail-fast)
- `unbind` removes entire sequence

**Implementation:**
- `Command::Sequence { commands: Vec<Command> }` variant in yashiki-ipc
- `HotkeyManager::bind()` checks for existing binding, creates/appends to sequence
- `process_command()` recursively executes sequence commands, accumulating effects
- No CLI changes required (backward compatible)

**Related code:**
- `yashiki-ipc/src/command.rs`: `Command::Sequence`
- `macos/hotkey.rs`: `HotkeyManager::bind()` - sequence building
- `app/command.rs`: `process_command()` - sequence execution

### Focus
- `next`/`prev`: Stack-based (sorted by window ID)
- `left`/`right`/`up`/`down`: Geometry-based (Manhattan distance)
Expand Down
5 changes: 5 additions & 0 deletions yashiki-ipc/src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -742,6 +742,11 @@ pub enum Command {
},
GetLogLevel,

// Sequencing
Sequence {
commands: Vec<Command>,
},

// Control
Quit,
}
Expand Down
29 changes: 29 additions & 0 deletions yashiki/src/app/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,35 @@ pub fn process_command(
// Log level - handled in ipc_source_callback (needs RunLoopContext)
Command::SetLogLevel { .. } | Command::GetLogLevel => CommandResult::ok(),

// Sequencing
Command::Sequence { commands } => {
tracing::debug!("Executing sequence with {} commands", commands.len());

let mut all_effects = Vec::new();
let mut executed_count = 0;

for command in commands {
tracing::debug!("Executing command: {:?}", command);
executed_count += 1;

let result = process_command(state, hotkey_manager, command);

// If any command fails, stop sequence and return error
if let Response::Error { message } = result.response {
tracing::info!("Sequence failed after {} commands", executed_count);
return CommandResult::error(message);
}

all_effects.extend(result.effects);
}

tracing::info!(
"Sequence completed successfully ({} commands)",
executed_count
);
CommandResult::ok_with_effects(all_effects)
}

// Control
Command::Quit => {
tracing::info!("Quit command received");
Expand Down
111 changes: 111 additions & 0 deletions yashiki/src/app/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -256,4 +256,115 @@ mod tests {
assert!(matches!(response, Response::Ok));
assert_eq!(state.borrow().visible_tags().mask(), 0b10);
}

#[test]
fn test_dispatch_command_sequence() {
let (
state,
layout_manager,
hotkey_manager,
ws,
manipulator,
event_emitter,
observer_manager,
) = setup_test_context();

// Create a sequence: switch to tag 2, then close focused window
let sequence = Command::Sequence {
commands: vec![
Command::TagView {
tags: 0b10,
output: None,
},
Command::WindowClose,
],
};

let response = dispatch_command(
&sequence,
&state,
&layout_manager,
&hotkey_manager,
&ws,
&manipulator,
&event_emitter,
&observer_manager,
);

assert!(matches!(response, Response::Ok));
// Verify tag switched to 2
assert_eq!(state.borrow().visible_tags().mask(), 0b10);
// Verify close command was executed (would be in manipulator operations)
// Note: actual close verification would require checking manipulator operations
}

#[test]
fn test_dispatch_command_sequence_stops_on_error() {
use crate::platform::mock::MockWindowSystem;

let ws = MockWindowSystem::new()
.with_displays(vec![create_test_display(1, 0.0, 0.0, 1920.0, 1080.0)])
.with_windows(vec![create_test_window(
100, 1000, "Safari", 0.0, 0.0, 960.0, 1080.0,
)]);

let mut state = State::new();
state.sync_all(&ws);
let state = RefCell::new(state);

let layout_engine_manager = RefCell::new(LayoutEngineManager::new());

let (tx, _rx) = std_mpsc::channel();
let dummy_source = Arc::new(AtomicPtr::new(std::ptr::null_mut()));
let hotkey_manager = RefCell::new(HotkeyManager::new(tx, dummy_source));

let (event_tx, _event_rx) = std_mpsc::channel();
let event_emitter = EventEmitter::new(event_tx);

let manipulator = MockWindowManipulator::new();

let (observer_event_tx, _observer_event_rx) = std_mpsc::channel::<Event>();
let observer_source_ptr = Arc::new(AtomicPtr::new(ptr::null_mut()));
let observer_manager =
RefCell::new(ObserverManager::new(observer_event_tx, observer_source_ptr));

// Sequence with invalid command in the middle
let sequence = Command::Sequence {
commands: vec![
Command::TagView {
tags: 0b10,
output: None,
},
// This should fail - undeclared mode
Command::EnterMode {
name: "nonexistent".to_string(),
},
// This should not execute
Command::TagView {
tags: 0b100,
output: None,
},
],
};

let response = dispatch_command(
&sequence,
&state,
&layout_engine_manager,
&hotkey_manager,
&ws,
&manipulator,
&event_emitter,
&observer_manager,
);

// Should return error
assert!(matches!(response, Response::Error { .. }));

// First command should have executed (tag view to 0b10)
assert_eq!(state.borrow().visible_tags().mask(), 0b10);

// Third command should NOT have executed (still at 0b10, not 0b100)
assert_ne!(state.borrow().visible_tags().mask(), 0b100);
}
}
Loading