Skip to content

Commit 0fcfe38

Browse files
Merge pull request #29 from warpy-ai/28-featue-create-a-simple-menu-list
28 featue create a simple menu list
2 parents 0c3b0e7 + 4d8bc0c commit 0fcfe38

File tree

12 files changed

+330
-11
lines changed

12 files changed

+330
-11
lines changed

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "rustubble"
3-
version = "0.1.2"
3+
version = "0.1.3"
44
edition = "2021"
55
authors = ["Lucas Oliveira <jucas.oliveira@gmail.com>"] # List of crate authors.
66
description = "A brief description of what your crate does."

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ This project aims to provide a set of components that can be used in your termin
1717
- [Progress bar Component](#progress-bar-component)
1818
- [Timer Component](#timer-component)
1919
- [Stopwatch Component](#stopwatch-component)
20+
- [Viewport Component](#viewport-component)
21+
- [List Component](#list-component)
22+
- [MenuList Component](#menulist-component)
2023

2124
# TextInput Component
2225

@@ -167,6 +170,16 @@ A list component, build with ratatui.
167170

168171
- [Example Code](https://github.com/warpy-ai/rustubble/blob/main/examples/list_example.rs)
169172

173+
# MenuList Component
174+
175+
![menulist](https://github.com/warpy-ai/rustubble/blob/main/assets/menulist.gif)
176+
177+
A menu list component, build with ratatui.
178+
179+
## Usage
180+
181+
- [Example Code](https://github.com/warpy-ai/rustubble/blob/main/examples/menu_list_example.rs)
182+
170183
## Contribution
171184

172185
Contributions are welcome! If you have suggestions for improving the spinner or adding new styles, please open an issue or pull request on our GitHub repository.

assets/menulist.gif

209 KB
Loading

examples/list_example.rs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,31 @@ fn main() -> Result<(), io::Error> {
2727
Item {
2828
title: "Bicoin".to_string(),
2929
subtitle: "Cheap".to_string(),
30-
}, // Add more items
30+
},
31+
Item {
32+
title: "Coke".to_string(),
33+
subtitle: "Cheap".to_string(),
34+
},
35+
Item {
36+
title: "Sprite".to_string(),
37+
subtitle: "Cheap".to_string(),
38+
},
39+
Item {
40+
title: "Sprite".to_string(),
41+
subtitle: "Cheap".to_string(),
42+
},
43+
Item {
44+
title: "Sprite".to_string(),
45+
subtitle: "Cheap".to_string(),
46+
},
47+
Item {
48+
title: "Sprite".to_string(),
49+
subtitle: "Cheap".to_string(),
50+
},
51+
Item {
52+
title: "Sprite".to_string(),
53+
subtitle: "Cheap".to_string(),
54+
},
3155
],
3256
);
3357

examples/menu_list_example.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
2+
use rustubble::menu_list::{handle_menu_list, Menu};
3+
use std::io;
4+
5+
fn main() -> Result<(), io::Error> {
6+
enable_raw_mode()?;
7+
8+
let mut new_menu = Menu::new(
9+
"Main Menu".to_string(),
10+
"Select an option:".to_string(),
11+
vec![
12+
"Option 1".to_string(),
13+
"Option 2".to_string(),
14+
"Option 3".to_string(),
15+
"Option 4".to_string(),
16+
],
17+
);
18+
19+
let (x, y) = (5, 5);
20+
21+
let selected_menu = handle_menu_list(&mut new_menu, x, y);
22+
23+
println!("Selected Menu: {:?}", selected_menu);
24+
disable_raw_mode()
25+
}

src/command.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,10 @@ impl Command {
3939
Command::Delete => "del",
4040
Command::Help => "h",
4141
Command::ControlC => "cntrl+c",
42-
Command::Enter => "\u{2B90}",
42+
Command::Enter => "\u{2B90} ",
4343
Command::Filter => "/",
44-
Command::Up => "\u{2191}/h",
45-
Command::Down => "\u{2193}/l",
44+
Command::Up => "\u{2191}/k",
45+
Command::Down => "\u{2193}/j",
4646
// Additional commands
4747
}
4848
}

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ pub mod help;
44
pub mod helper;
55
pub mod input;
66
pub mod list;
7+
pub mod menu_list;
78
pub mod progress_bar;
89
pub mod spinner;
910
pub mod stopwatch;

src/list.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use ratatui::{
1111
};
1212

1313
use crate::{
14-
command::{self, CommandInfo},
14+
command::{CommandInfo},
1515
help::HelpComponent,
1616
};
1717

@@ -152,7 +152,7 @@ impl ItemList {
152152
.direction(Direction::Vertical)
153153
.constraints(
154154
[
155-
Constraint::Length(3),
155+
Constraint::Length(1),
156156
Constraint::Percentage(50),
157157
Constraint::Length(3),
158158
]
@@ -249,7 +249,7 @@ pub fn handle_list(list: &mut ItemList, x: u16, y: u16) -> Option<String> {
249249
KeyCode::Char('/') => {
250250
list.showing_filter = !list.showing_filter;
251251
}
252-
KeyCode::Esc => list.showing_filter = !list.showing_filter,
252+
KeyCode::Esc => list.showing_filter = false,
253253
KeyCode::Char('q') => return None,
254254
KeyCode::Down => list.next(),
255255
KeyCode::Up => list.previous(),

src/menu_list.rs

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
use std::io;
2+
3+
use crossterm::event::{read, Event, KeyCode, KeyEvent, KeyModifiers};
4+
use ratatui::{
5+
backend::{Backend, CrosstermBackend},
6+
layout::{Constraint, Direction, Layout, Rect},
7+
style::{Color, Modifier, Style, Stylize},
8+
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
9+
Terminal,
10+
};
11+
12+
use crate::{command::CommandInfo, help::HelpComponent};
13+
14+
#[derive(Clone, Debug)]
15+
struct MenuItem {
16+
name: String,
17+
selected: bool,
18+
}
19+
20+
#[derive(Clone, Debug)]
21+
pub struct Menu {
22+
title: String,
23+
subtitle: String,
24+
items: Vec<MenuItem>,
25+
selection_state: ListState,
26+
}
27+
28+
impl Menu {
29+
pub fn new(title: String, subtitle: String, items: Vec<String>) -> Self {
30+
let mut state = ListState::default();
31+
state.select(Some(0)); // Initialize the cursor at the first item
32+
33+
let menu_items = items
34+
.into_iter()
35+
.map(|item| MenuItem {
36+
name: item,
37+
selected: false,
38+
})
39+
.collect();
40+
41+
Self {
42+
title,
43+
subtitle,
44+
items: menu_items,
45+
selection_state: state,
46+
}
47+
}
48+
49+
pub fn render<B: Backend>(
50+
&self,
51+
terminal: &mut Terminal<B>,
52+
area: Rect,
53+
help_component: &mut HelpComponent,
54+
) {
55+
terminal
56+
.draw(|f| {
57+
let chunks = Layout::default()
58+
.direction(Direction::Vertical)
59+
.constraints(
60+
[
61+
Constraint::Length(1),
62+
Constraint::Length(2),
63+
Constraint::Max(10),
64+
Constraint::Length(3),
65+
]
66+
.as_ref(),
67+
)
68+
.split(area);
69+
70+
let title_widget = format!("{}", self.title);
71+
let title = Paragraph::new(title_widget.as_str())
72+
.style(Style::default().add_modifier(Modifier::BOLD))
73+
.fg(Color::LightMagenta)
74+
.block(Block::default().borders(Borders::NONE));
75+
f.render_widget(title, chunks[0]);
76+
77+
let subtitle_widget = format!("{}", self.subtitle);
78+
let subtitle = Paragraph::new(subtitle_widget.as_str())
79+
.style(
80+
Style::default()
81+
.add_modifier(Modifier::BOLD)
82+
.fg(Color::DarkGray),
83+
)
84+
.block(Block::default().borders(Borders::NONE));
85+
f.render_widget(subtitle, chunks[1]);
86+
87+
let items: Vec<ListItem> = self
88+
.items
89+
.iter()
90+
.map(|item| {
91+
let content = if item.selected {
92+
format!("✓ {}", item.name)
93+
} else {
94+
format!(" {}", item.name)
95+
};
96+
ListItem::new(content)
97+
})
98+
.collect();
99+
100+
//TODO: add color to symbol
101+
let symbol = "> ";
102+
let list = List::new(items)
103+
.block(Block::default().borders(Borders::NONE))
104+
.highlight_style(Style::default().add_modifier(Modifier::BOLD))
105+
.highlight_symbol(symbol)
106+
.scroll_padding(4);
107+
f.render_stateful_widget(list, chunks[2], &mut self.selection_state.clone());
108+
//TODO: calculate the area and render widget help_component under list
109+
f.render_widget(help_component.clone(), chunks[3]);
110+
})
111+
.unwrap();
112+
}
113+
114+
pub fn up(&mut self) {
115+
let i = match self.selection_state.selected() {
116+
Some(i) => {
117+
if i == 0 {
118+
self.items.len() - 1
119+
} else {
120+
i - 1
121+
}
122+
}
123+
None => 0,
124+
};
125+
self.selection_state.select(Some(i));
126+
}
127+
128+
pub fn down(&mut self) {
129+
let i = match self.selection_state.selected() {
130+
Some(i) => {
131+
if i >= self.items.len() - 1 {
132+
0
133+
} else {
134+
i + 1
135+
}
136+
}
137+
None => 0,
138+
};
139+
self.selection_state.select(Some(i));
140+
}
141+
142+
pub fn toggle_selection(&mut self) {
143+
if let Some(i) = self.selection_state.selected() {
144+
self.items[i].selected = !self.items[i].selected;
145+
}
146+
}
147+
148+
// Add methods to handle key inputs: up, down, toggle selection, etc.
149+
}
150+
151+
pub fn handle_menu_list(menu: &mut Menu, x: u16, y: u16) -> Option<String> {
152+
// Render the menu
153+
let stdout = io::stdout();
154+
let backend = CrosstermBackend::new(stdout);
155+
let mut terminal = Terminal::new(backend).unwrap();
156+
loop {
157+
terminal.clear().unwrap();
158+
159+
let commands = vec![
160+
CommandInfo::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
161+
CommandInfo::new(KeyCode::Char('q'), KeyModifiers::NONE),
162+
CommandInfo::new(KeyCode::Enter, KeyModifiers::NONE),
163+
CommandInfo::new(KeyCode::Down, KeyModifiers::NONE),
164+
CommandInfo::new(KeyCode::Up, KeyModifiers::NONE),
165+
];
166+
167+
let mut help_component = HelpComponent::new(commands, vec![]);
168+
169+
menu.render(&mut terminal, Rect::new(x, y, 40, 50), &mut help_component);
170+
171+
match read().unwrap() {
172+
Event::Key(KeyEvent {
173+
code: KeyCode::Char(c),
174+
modifiers,
175+
..
176+
}) => {
177+
if c == 'j' {
178+
menu.down();
179+
}
180+
if c == 'k' {
181+
menu.up();
182+
}
183+
if c == 'q' {
184+
return None;
185+
}
186+
if modifiers.contains(KeyModifiers::CONTROL) && c == 't' {
187+
menu.toggle_selection();
188+
}
189+
if modifiers.contains(KeyModifiers::CONTROL) && c == 'c' {
190+
return None;
191+
}
192+
}
193+
Event::Key(KeyEvent {
194+
code: KeyCode::Up, ..
195+
}) => menu.up(),
196+
Event::Key(KeyEvent {
197+
code: KeyCode::Down,
198+
..
199+
}) => menu.down(),
200+
Event::Key(KeyEvent {
201+
code: KeyCode::Enter,
202+
..
203+
}) => {
204+
if let Some(i) = menu.selection_state.selected() {
205+
return Some(menu.items[i].name.clone());
206+
}
207+
}
208+
209+
_ => {}
210+
}
211+
}
212+
}
213+
214+
#[cfg(test)]
215+
mod tests {
216+
use super::*;
217+
218+
#[test]
219+
fn initializes_correctly() {
220+
let menu = Menu::new("Title".to_string(), "Subtitle".to_string(), vec![]);
221+
assert_eq!(menu.selection_state.selected(), Some(0));
222+
}
223+
224+
#[test]
225+
fn navigates_correctly() {
226+
let mut menu = Menu::new(
227+
"Title".to_string(),
228+
"Subtitle".to_string(),
229+
vec![
230+
"Option 1".to_string(),
231+
"Option 2".to_string(),
232+
"Option 3".to_string(),
233+
],
234+
);
235+
menu.up();
236+
assert_eq!(menu.selection_state.selected(), Some(2));
237+
238+
menu.down();
239+
assert_eq!(menu.selection_state.selected(), Some(0));
240+
241+
menu.down();
242+
assert_eq!(menu.selection_state.selected(), Some(1));
243+
}
244+
245+
#[test]
246+
fn selects_item_correctly() {
247+
let mut menu = Menu::new(
248+
"Title".to_string(),
249+
"Subtitle".to_string(),
250+
vec!["Option 1".to_string()],
251+
);
252+
menu.toggle_selection();
253+
254+
assert_eq!(menu.items[0].selected, true);
255+
}
256+
}

0 commit comments

Comments
 (0)