Skip to content

feat: Implement interactive popup dragging and resizing #59

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
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
4 changes: 4 additions & 0 deletions tui-popup/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,7 @@ required-features = ["crossterm"]
[[example]]
name = "state"
required-features = ["crossterm"]

[[example]]
name = "interactive"
required-features = ["crossterm"]
2 changes: 1 addition & 1 deletion tui-popup/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ frame.render_widget_ref(popup, area);
- [x] move the popup (using state)
- [x] handle mouse events for dragging
- [x] move to position
- [ ] resize
- [x] resize
- [ ] set border set / style
- [ ] add close button
- [ ] add nicer styling of header etc.
Expand Down
223 changes: 223 additions & 0 deletions tui-popup/examples/interactive.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
use color_eyre::Result;
use ratatui::{
crossterm::{
event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind,
KeyModifiers,
},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
},
layout::Size,
prelude::{Constraint, CrosstermBackend, Frame, Layout, Rect, Style, Stylize},
symbols::border,
text::{Line, Span, Text},
widgets::{Block, Paragraph, Wrap},
DefaultTerminal, Terminal,
};
use std::io::{self, stdout};
use tui_popup::{KnownSizeWrapper, Popup, PopupState};

fn set_panic_hook() {
let hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
restore();
hook(info);
}));
}

pub fn try_init() -> io::Result<DefaultTerminal> {
set_panic_hook();
enable_raw_mode()?;
execute!(stdout(), EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout());
Terminal::new(backend)
}

pub fn init() -> DefaultTerminal {
try_init().expect("failed to initialize terminal")
}

pub fn try_restore() -> io::Result<()> {
disable_raw_mode()?;
execute!(stdout(), LeaveAlternateScreen, DisableMouseCapture)?;
Ok(())
}

pub fn restore() {
if let Err(err) = try_restore() {
eprintln!("Failed to restore terminal: {err}");
}
}

fn main() -> Result<()> {
color_eyre::install()?;
let terminal = init();
let result = run(terminal);
restore();
result
}

fn run(mut terminal: DefaultTerminal) -> Result<()> {
let Size { width, height } = terminal.size()?;
let mut state = PopupState::new(Rect::new(0, 0, width, height));
let mut show_help = false;
let mut exit = false;

while !exit {
terminal.draw(|frame| draw(frame, &mut state, show_help))?;
handle_events(&mut state, &mut show_help, &mut exit)?;
}
Ok(())
}

fn draw(frame: &mut Frame, state: &mut PopupState, show_help: bool) {
let layout = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]);
let [main_area, status_area] = layout.areas(frame.area());

render_background(frame, main_area);
render_main_popup(frame, main_area, state);
if show_help {
render_help_popup(frame, main_area);
}
render_enhanced_status_bar(frame, status_area, state, show_help);
}

fn render_background(frame: &mut Frame, area: Rect) {
let stars: String = (0..area.area())
.map(|i| if i % 7 == 0 { '✧' } else { ' ' })
.collect();

let styled_background = Paragraph::new(stars)
.style(Style::default().dark_gray())
.wrap(Wrap { trim: false });

frame.render_widget(styled_background, area);
}

fn render_main_popup(frame: &mut Frame, area: Rect, state: &mut PopupState) {
let content = Text::from(vec![
Line::from(vec![
Span::styled("Welcome to ", Style::default().blue()),
Span::styled("Interactive Popup!", Style::default().blue().bold()),
]),
Line::raw(""),
Line::styled("Mouse Controls:", Style::default().yellow()),
Line::raw("• Click and drag title bar to move"),
Line::raw("• Drag ⟋ handle to resize"),
Line::raw("• Click [✕] to close"),
Line::raw(""),
Line::styled("Press '?' for keyboard shortcuts", Style::default().dim()),
]);

let wrapped_content =
KnownSizeWrapper::new(Paragraph::new(content).wrap(Wrap { trim: true }), 40, 10);

let popup = Popup::new(wrapped_content)
.title("Interactive Demo")
.border_set(border::ROUNDED)
.border_style(Style::default().blue().bold())
.style(Style::default().white());

frame.render_stateful_widget(popup, area, state);
}

fn render_help_popup(frame: &mut Frame, area: Rect) {
let mut help_state = PopupState::new(area);
help_state.open(area.x + 5, area.y + 2, 35, 12);

let shortcuts = Text::from(vec![
Line::styled("Keyboard Shortcuts", Style::default().bold()),
Line::raw(""),
Line::raw("Movement:"),
Line::raw("h/←, j/↓, k/↑, l/→: Move popup"),
Line::raw("Ctrl + Arrow: Jump to edge"),
Line::raw(""),
Line::raw("Other:"),
Line::raw("?: Toggle this help"),
Line::raw("r: Reset position"),
Line::raw("q: Quit"),
]);

let help_popup = Popup::new(shortcuts)
.title("Help")
.border_set(border::DOUBLE)
.border_style(Style::default().yellow())
.style(Style::default().white());

frame.render_stateful_widget(help_popup, area, &mut help_state);
}

fn render_enhanced_status_bar(frame: &mut Frame, area: Rect, state: &PopupState, show_help: bool) {
let pos = state.area().map_or("Hidden".to_string(), |area| {
format!("x:{} y:{} {}x{}", area.x, area.y, area.width, area.height)
});

let mode = if show_help { "Help" } else { "Normal" };
let status = format!("Position: {} | Mode: {} | Press '?' for help", pos, mode);

let status_bar = Paragraph::new(status)
.style(Style::default().black().on_blue())
.block(Block::default());

frame.render_widget(status_bar, area);
}

fn handle_events(state: &mut PopupState, show_help: &mut bool, exit: &mut bool) -> Result<()> {
if let Ok(event) = event::read() {
match event {
Event::Key(key) if key.kind == KeyEventKind::Press => {
handle_key_event(key, state, show_help, exit);
}
Event::Mouse(event) => {
state.handle_mouse_event(event);
}
_ => {}
}
}
Ok(())
}

fn handle_key_event(
event: KeyEvent,
state: &mut PopupState,
show_help: &mut bool,
exit: &mut bool,
) {
let is_ctrl = event.modifiers.contains(KeyModifiers::CONTROL);

match event.code {
KeyCode::Char('q') | KeyCode::Esc => *exit = true,
KeyCode::Char('?') => *show_help = !*show_help,
KeyCode::Char('r') => state.reset_position(),
KeyCode::Char('j') | KeyCode::Down => {
if is_ctrl {
state.move_to_bottom();
} else {
state.move_down(1);
}
}
KeyCode::Char('k') | KeyCode::Up => {
if is_ctrl {
state.move_to_top();
} else {
state.move_up(1);
}
}
KeyCode::Char('h') | KeyCode::Left => {
if is_ctrl {
state.move_to_leftmost();
} else {
state.move_left(1);
}
}
KeyCode::Char('l') | KeyCode::Right => {
if is_ctrl {
state.move_to_rightmost();
} else {
state.move_right(1);
}
}
_ => {}
}
}
43 changes: 37 additions & 6 deletions tui-popup/examples/state.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
use color_eyre::Result;
use lipsum::lipsum;
use ratatui::{
crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind},
crossterm::{
event::{self, Event, KeyCode, KeyEvent, KeyEventKind},
terminal,
},
prelude::{Constraint, Frame, Layout, Rect, Style, Stylize, Text},
widgets::{Paragraph, Wrap},
DefaultTerminal,
Expand All @@ -17,8 +20,11 @@ fn main() -> Result<()> {
}

fn run(mut terminal: DefaultTerminal) -> Result<()> {
let mut state = PopupState::default();
// Initialize state with terminal size and open state
let (width, height) = terminal::size()?;
let mut state = PopupState::new(Rect::new(0, 0, width, height));
let mut exit = false;

while !exit {
terminal.draw(|frame| draw(frame, &mut state))?;
handle_events(&mut state, &mut exit)?;
Expand Down Expand Up @@ -80,13 +86,38 @@ fn handle_events(popup: &mut PopupState, exit: &mut bool) -> Result<()> {
}

fn handle_key_event(event: KeyEvent, popup: &mut PopupState, exit: &mut bool) {
let is_ctrl = event.modifiers.contains(event::KeyModifiers::CONTROL);
match event.code {
KeyCode::Char('q') | KeyCode::Esc => *exit = true,
KeyCode::Char('r') => *popup = PopupState::default(),
KeyCode::Char('j') | KeyCode::Down => popup.move_down(1),
KeyCode::Char('k') | KeyCode::Up => popup.move_up(1),
KeyCode::Char('h') | KeyCode::Left => popup.move_left(1),
KeyCode::Char('l') | KeyCode::Right => popup.move_right(1),
KeyCode::Char('j') | KeyCode::Down => {
if is_ctrl {
popup.move_to_bottom();
} else {
popup.move_down(1);
}
}
KeyCode::Char('k') | KeyCode::Up => {
if is_ctrl {
popup.move_to_top();
} else {
popup.move_up(1);
}
}
KeyCode::Char('h') | KeyCode::Left => {
if is_ctrl {
popup.move_to_leftmost();
} else {
popup.move_left(1);
}
}
KeyCode::Char('l') | KeyCode::Right => {
if is_ctrl {
popup.move_to_rightmost();
} else {
popup.move_right(1);
}
}
_ => {}
}
}
2 changes: 1 addition & 1 deletion tui-popup/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,5 @@ pub use crate::{
known_size::KnownSize,
known_size_wrapper::KnownSizeWrapper,
popup::Popup,
popup_state::{DragState, PopupState},
popup_state::{InteractionState, PopupState},
};
Loading