Skip to content

Commit a17daa2

Browse files
committed
feat: add participant box grouping with color support
1 parent 51f62c0 commit a17daa2

9 files changed

Lines changed: 737 additions & 11 deletions

File tree

src/app.rs

Lines changed: 299 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use crate::{
2-
core::{Event, NotePosition, SequenceDiagram},
2+
core::{BoxColor, Event, NotePosition, SequenceDiagram},
33
render::render_sequence,
44
theme::Theme,
55
ui::{
@@ -23,6 +23,7 @@ pub const CONFIRM: WidgetId = WidgetId("Confirm");
2323
pub const TEXT_INPUT: WidgetId = WidgetId("TextInput");
2424
pub const SELECT_PARTICIPANT: WidgetId = WidgetId("SelectParticipant");
2525
pub const SELECT_POSITION: WidgetId = WidgetId("SelectPosition");
26+
pub const SELECT_BOX_COLOR: WidgetId = WidgetId("SelectBoxColor");
2627

2728
#[derive(Default)]
2829
pub struct AppState {
@@ -42,6 +43,7 @@ pub fn setup_world(world: &mut World, diagram: SequenceDiagram) {
4243
select_position_keybindings(world);
4344
text_input_keybindings(world);
4445
confirm_keybindings(world);
46+
select_box_color_keybindings(world);
4547
}
4648

4749
fn normal_keybindings(world: &mut World) {
@@ -158,6 +160,51 @@ fn normal_keybindings(world: &mut World) {
158160
}
159161
});
160162

163+
kb.bind(NORMAL, 'B', "Remove box", |world| {
164+
let selection = world.get::<EditorState>().selection;
165+
if let Selection::Participant(idx) = selection {
166+
let box_idx = world
167+
.get::<SequenceDiagram>()
168+
.boxes
169+
.iter()
170+
.position(|b| b.start <= idx && idx <= b.end);
171+
if let Some(box_idx) = box_idx {
172+
world.get_mut::<SequenceDiagram>().remove_box_at(box_idx);
173+
} else {
174+
world.get_mut::<EditorState>().set_status("No box here");
175+
}
176+
}
177+
});
178+
179+
kb.bind(NORMAL, 'd', "Delete selected", |world| {
180+
let selection = world.get::<EditorState>().selection;
181+
match selection {
182+
Selection::Participant(idx) => {
183+
world.get_mut::<SequenceDiagram>().remove_participant(idx);
184+
let new_count = world.get::<SequenceDiagram>().participant_count();
185+
let editor = world.get_mut::<EditorState>();
186+
if new_count == 0 {
187+
editor.clear_selection();
188+
} else {
189+
editor.selection = Selection::Participant(idx.min(new_count - 1));
190+
}
191+
}
192+
Selection::Event(idx) => {
193+
world.get_mut::<SequenceDiagram>().remove_event(idx);
194+
let new_count = world.get::<SequenceDiagram>().event_count();
195+
let editor = world.get_mut::<EditorState>();
196+
if new_count == 0 {
197+
editor.clear_selection();
198+
} else {
199+
editor.selection = Selection::Event(idx.min(new_count - 1));
200+
}
201+
}
202+
Selection::None => {
203+
world.get_mut::<SequenceDiagram>().events.pop();
204+
}
205+
}
206+
});
207+
161208
kb.bind_many(
162209
NORMAL,
163210
keys!['l', KeyCode::Right],
@@ -388,6 +435,21 @@ fn normal_keybindings(world: &mut World) {
388435
}
389436
});
390437

438+
kb.bind(NORMAL, 'b', "Add box", |world| {
439+
let participant_count = world.get::<SequenceDiagram>().participant_count();
440+
if participant_count >= 1 {
441+
let selection = world.get::<EditorState>().selection;
442+
let editor = world.get_mut::<EditorState>();
443+
editor.mode = EditorMode::SelectBoxStart;
444+
editor.selected_index = match selection {
445+
Selection::Participant(idx) => idx,
446+
_ => 0,
447+
};
448+
editor.box_start = None;
449+
editor.box_end = None;
450+
}
451+
});
452+
391453
kb.bind(NORMAL, 'C', "Clear diagram", |world| {
392454
let diagram = world.get::<SequenceDiagram>();
393455
if !diagram.participants.is_empty() || !diagram.events.is_empty() {
@@ -557,6 +619,7 @@ fn confirm_keybindings(world: &mut World) {
557619
let diagram = world.get_mut::<SequenceDiagram>();
558620
diagram.participants.clear();
559621
diagram.events.clear();
622+
diagram.boxes.clear();
560623
world.get_mut::<EditorState>().reset();
561624
});
562625

@@ -565,6 +628,46 @@ fn confirm_keybindings(world: &mut World) {
565628
});
566629
}
567630

631+
fn select_box_color_keybindings(world: &mut World) {
632+
let kb = world.get_mut::<Keybindings>();
633+
634+
kb.bind(
635+
SELECT_BOX_COLOR,
636+
KeyBinding::key(KeyCode::Enter),
637+
"Confirm",
638+
handle_input_confirm,
639+
);
640+
641+
kb.bind(
642+
SELECT_BOX_COLOR,
643+
KeyBinding::key(KeyCode::Esc),
644+
"Cancel",
645+
|world| {
646+
world.get_mut::<EditorState>().reset();
647+
},
648+
);
649+
650+
kb.bind_many(
651+
SELECT_BOX_COLOR,
652+
keys!['h', 'k', KeyCode::Left, KeyCode::Up],
653+
"Previous",
654+
|world| {
655+
let editor = world.get_mut::<EditorState>();
656+
editor.box_color = editor.box_color.prev();
657+
},
658+
);
659+
660+
kb.bind_many(
661+
SELECT_BOX_COLOR,
662+
keys!['j', 'l', KeyCode::Right, KeyCode::Down],
663+
"Next",
664+
|world| {
665+
let editor = world.get_mut::<EditorState>();
666+
editor.box_color = editor.box_color.next();
667+
},
668+
);
669+
}
670+
568671
fn handle_input_confirm(world: &mut World) {
569672
let mode = world.get::<EditorState>().mode.clone();
570673
match mode {
@@ -734,6 +837,47 @@ fn handle_input_confirm(world: &mut World) {
734837
EditorMode::EditNoteText => {
735838
save_note_changes(world);
736839
}
840+
EditorMode::SelectBoxStart => {
841+
let selected = world.get::<EditorState>().selected_index;
842+
let editor = world.get_mut::<EditorState>();
843+
editor.box_start = Some(selected);
844+
editor.mode = EditorMode::SelectBoxEnd;
845+
editor.selected_index = selected;
846+
}
847+
EditorMode::SelectBoxEnd => {
848+
let selected = world.get::<EditorState>().selected_index;
849+
let editor = world.get_mut::<EditorState>();
850+
editor.box_end = Some(selected);
851+
editor.box_color = BoxColor::default();
852+
editor.mode = EditorMode::SelectBoxColor;
853+
}
854+
EditorMode::SelectBoxColor => {
855+
let editor = world.get_mut::<EditorState>();
856+
editor.mode = EditorMode::InputBoxLabel;
857+
editor.input_buffer.clear();
858+
}
859+
EditorMode::InputBoxLabel => {
860+
let editor_state = world.get::<EditorState>().clone();
861+
let label = editor_state.input_buffer.trim().to_string();
862+
if let (Some(mut start), Some(mut end)) = (editor_state.box_start, editor_state.box_end)
863+
{
864+
if start > end {
865+
std::mem::swap(&mut start, &mut end);
866+
}
867+
let ok = world.get_mut::<SequenceDiagram>().add_box(
868+
label,
869+
editor_state.box_color,
870+
start,
871+
end,
872+
);
873+
if !ok {
874+
world
875+
.get_mut::<EditorState>()
876+
.set_status("Boxes cannot overlap");
877+
}
878+
}
879+
world.get_mut::<EditorState>().reset();
880+
}
737881
_ => {}
738882
}
739883
}
@@ -805,6 +949,7 @@ pub fn active_widgets(world: &World) -> Vec<WidgetId> {
805949
EditorMode::Normal | EditorMode::Help => vec![NORMAL],
806950
EditorMode::ConfirmClear => vec![CONFIRM],
807951
EditorMode::SelectNotePosition | EditorMode::EditNotePosition => vec![SELECT_POSITION],
952+
EditorMode::SelectBoxColor => vec![SELECT_BOX_COLOR],
808953
m if m.is_selecting_participant() => vec![SELECT_PARTICIPANT],
809954
m if m.is_text_input() => vec![TEXT_INPUT],
810955
_ => vec![],
@@ -838,7 +983,8 @@ pub fn render(frame: &mut Frame, world: &mut World) {
838983
| EditorMode::EditMessage
839984
| EditorMode::RenameParticipant
840985
| EditorMode::InputNoteText
841-
| EditorMode::EditNoteText => {
986+
| EditorMode::EditNoteText
987+
| EditorMode::InputBoxLabel => {
842988
render_input_popup(frame, world);
843989
}
844990
EditorMode::SelectFrom
@@ -863,6 +1009,12 @@ pub fn render(frame: &mut Frame, world: &mut World) {
8631009
EditorMode::ConfirmClear => {
8641010
render_confirm_dialog(frame, world);
8651011
}
1012+
EditorMode::SelectBoxStart | EditorMode::SelectBoxEnd => {
1013+
render_box_participant_selector(frame, area, world);
1014+
}
1015+
EditorMode::SelectBoxColor => {
1016+
render_box_color_selector(frame, area, world);
1017+
}
8661018
EditorMode::Normal => {}
8671019
}
8681020
}
@@ -1125,6 +1277,151 @@ fn render_note_position_selector(frame: &mut Frame, area: Rect, world: &World) {
11251277
}
11261278
}
11271279

1280+
fn render_box_participant_selector(frame: &mut Frame, area: Rect, world: &World) {
1281+
let editor = world.get::<EditorState>();
1282+
let diagram = world.get::<SequenceDiagram>();
1283+
let theme = world.get::<Theme>();
1284+
1285+
let participants = &diagram.participants;
1286+
let cursor = editor.selected_index;
1287+
let is_selecting_end = matches!(editor.mode, EditorMode::SelectBoxEnd);
1288+
let box_start = editor.box_start;
1289+
1290+
let popup_width = 40.min(area.width.saturating_sub(4));
1291+
let popup_height = (participants.len() as u16 + 4).min(area.height.saturating_sub(4));
1292+
1293+
let popup_area = centered_rect(popup_width, popup_height, area);
1294+
1295+
frame.render_widget(ratatui::widgets::Clear, popup_area);
1296+
1297+
let title = if is_selecting_end {
1298+
" Box: End Participant "
1299+
} else {
1300+
" Box: Start Participant "
1301+
};
1302+
let block = Block::default()
1303+
.title(title)
1304+
.borders(Borders::ALL)
1305+
.border_style(theme.border);
1306+
1307+
let inner = block.inner(popup_area);
1308+
frame.render_widget(block, popup_area);
1309+
1310+
for (i, name) in participants.iter().enumerate() {
1311+
if i as u16 >= inner.height {
1312+
break;
1313+
}
1314+
1315+
let y = inner.y + i as u16;
1316+
let is_cursor = cursor == i;
1317+
let is_start_marker = is_selecting_end && box_start == Some(i);
1318+
1319+
let prefix = if is_cursor { "\u{25b6} " } else { " " };
1320+
let style = if is_cursor {
1321+
theme.selected
1322+
} else if is_start_marker {
1323+
theme.accent
1324+
} else {
1325+
theme.text
1326+
};
1327+
1328+
frame.render_widget(
1329+
Paragraph::new(format!("{prefix}{name}")).style(style),
1330+
Rect {
1331+
x: inner.x,
1332+
y,
1333+
width: inner.width,
1334+
height: 1,
1335+
},
1336+
);
1337+
}
1338+
}
1339+
1340+
fn render_box_color_selector(frame: &mut Frame, area: Rect, world: &World) {
1341+
use ratatui::style::Style;
1342+
let editor = world.get::<EditorState>();
1343+
let theme = world.get::<Theme>();
1344+
1345+
let current_color = editor.box_color;
1346+
let colors = BoxColor::all();
1347+
1348+
let popup_width = 30.min(area.width.saturating_sub(4));
1349+
let popup_height = (colors.len() as u16 + 4).min(area.height.saturating_sub(4));
1350+
1351+
let popup_area = centered_rect(popup_width, popup_height, area);
1352+
1353+
frame.render_widget(ratatui::widgets::Clear, popup_area);
1354+
1355+
let block = Block::default()
1356+
.title(" Box Color ")
1357+
.borders(Borders::ALL)
1358+
.border_style(theme.border);
1359+
1360+
let inner = block.inner(popup_area);
1361+
frame.render_widget(block, popup_area);
1362+
1363+
for (i, color) in colors.iter().enumerate() {
1364+
let y = inner.y + i as u16;
1365+
if y >= inner.y + inner.height {
1366+
break;
1367+
}
1368+
1369+
let is_selected = *color == current_color;
1370+
let swatch_color = box_swatch_color(*color);
1371+
let prefix = if is_selected { "\u{25b6} " } else { " " };
1372+
let name_style = if is_selected {
1373+
theme.selected
1374+
} else {
1375+
theme.text
1376+
};
1377+
1378+
let line = Line::from(vec![
1379+
Span::raw(prefix),
1380+
Span::styled("\u{25a0} ", Style::default().fg(swatch_color)),
1381+
Span::styled(color.as_mermaid_str(), name_style),
1382+
]);
1383+
1384+
frame.render_widget(
1385+
Paragraph::new(line),
1386+
Rect {
1387+
x: inner.x,
1388+
y,
1389+
width: inner.width,
1390+
height: 1,
1391+
},
1392+
);
1393+
}
1394+
1395+
if inner.height > colors.len() as u16 {
1396+
let hint_y = inner.y + inner.height - 1;
1397+
frame.render_widget(
1398+
Paragraph::new("Enter: confirm | Esc: cancel")
1399+
.style(theme.muted)
1400+
.alignment(Alignment::Right),
1401+
Rect {
1402+
x: inner.x,
1403+
y: hint_y,
1404+
width: inner.width,
1405+
height: 1,
1406+
},
1407+
);
1408+
}
1409+
}
1410+
1411+
fn box_swatch_color(color: BoxColor) -> ratatui::style::Color {
1412+
use ratatui::style::Color;
1413+
match color {
1414+
BoxColor::Blue => Color::Rgb(100, 150, 220),
1415+
BoxColor::Green => Color::Rgb(80, 180, 100),
1416+
BoxColor::Red => Color::Rgb(220, 80, 80),
1417+
BoxColor::Yellow => Color::Rgb(200, 180, 50),
1418+
BoxColor::Orange => Color::Rgb(220, 130, 50),
1419+
BoxColor::Purple => Color::Rgb(170, 80, 200),
1420+
BoxColor::Aqua => Color::Rgb(60, 190, 200),
1421+
BoxColor::Gray => Color::Rgb(150, 150, 150),
1422+
}
1423+
}
1424+
11281425
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
11291426
let [area] = Layout::vertical([Constraint::Length(height)])
11301427
.flex(Flex::Center)

src/core/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
mod models;
22
mod sequence;
33

4-
pub use models::{Event, NotePosition};
4+
pub use models::{BoxColor, Event, NotePosition};
55
pub use sequence::SequenceDiagram;

0 commit comments

Comments
 (0)