Skip to content

Commit

Permalink
style(tui): add a border effect
Browse files Browse the repository at this point in the history
  • Loading branch information
orhun committed Dec 23, 2024
1 parent d0c2968 commit 2abfe36
Show file tree
Hide file tree
Showing 6 changed files with 226 additions and 110 deletions.
180 changes: 180 additions & 0 deletions git-cliff-tui/src/effect.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
use std::time::Instant;

use ratatui::{
layout::Rect,
style::Color,
};
use tachyonfx::{
fx,
Effect,
HslConvertable,

Check warning on line 10 in git-cliff-tui/src/effect.rs

View workflow job for this annotation

GitHub Actions / Typos

"Convertable" should be "Convertible".
};

use tachyonfx::Interpolatable;

pub trait IndexResolver<T: Clone> {
fn resolve(idx: usize, data: &[T]) -> &T;
}

#[derive(Clone, Debug)]
pub struct ColorCycle<T: IndexResolver<Color>> {
colors: Vec<Color>,
_marker: std::marker::PhantomData<T>,
}

#[derive(Clone, Debug)]
pub struct PingPongCycle;

impl IndexResolver<Color> for PingPongCycle {
fn resolve(idx: usize, data: &[Color]) -> &Color {
let dbl_idx = idx % (2 * data.len());
let final_index = if dbl_idx < data.len() {
dbl_idx
} else {
2 * data.len() - 1 - dbl_idx
};

data.get(final_index)
.expect("ColorCycle: index out of bounds")
}
}

pub type PingPongColorCycle = ColorCycle<PingPongCycle>;

#[derive(Clone, Debug)]
pub struct RepeatingCycle;

impl IndexResolver<Color> for RepeatingCycle {
fn resolve(idx: usize, data: &[Color]) -> &Color {
data.get(idx % data.len())
.expect("ColorCycle: index out of bounds")
}
}

pub type RepeatingColorCycle = ColorCycle<RepeatingCycle>;

impl<T> ColorCycle<T>
where
T: IndexResolver<Color>,
{
pub fn new(initial_color: Color, colors: &[(usize, Color)]) -> Self {
let mut gradient = vec![initial_color];
colors
.iter()
.fold((0, initial_color), |(_, prev_color), (len, color)| {
(0..=*len).for_each(|i| {
let color = prev_color.lerp(color, i as f32 / *len as f32);
gradient.push(color);
});
gradient.push(*color);
(*len, *color)
});

Self {
colors: gradient,
_marker: std::marker::PhantomData,
}
}

pub fn color_at(&self, idx: usize) -> &Color {
T::resolve(idx, &self.colors)
}
}

/// Creates a repeating color cycle based on a base color.
///
/// # Arguments
/// * `base_color` - Primary color to derive the cycle from
/// * `length_multiplier` - Factor to adjust the cycle length
///
/// # Returns
/// A ColorCycle instance with derived colors and adjusted steps.
fn create_color_cycle(
base_color: Color,
length_multiplier: usize,
) -> ColorCycle<RepeatingCycle> {
let color_step: usize = 7 * length_multiplier;

let (h, s, l) = base_color.to_hsl();

let color_l = Color::from_hsl(h, s, 80.0);
let color_d = Color::from_hsl(h, s, 40.0);

RepeatingColorCycle::new(base_color, &[
(4 * length_multiplier, color_d),
(2 * length_multiplier, color_l),
(
4 * length_multiplier,
Color::from_hsl((h - 25.0) % 360.0, s, (l + 10.0).min(100.0)),
),
(
color_step,
Color::from_hsl(h, (s - 20.0).max(0.0), (l + 10.0).min(100.0)),
),
(
color_step,
Color::from_hsl((h + 25.0) % 360.0, s, (l + 10.0).min(100.0)),
),
(
color_step,
Color::from_hsl(h, (s + 20.0).max(0.0), (l + 10.0).min(100.0)),
),
])
}

/// Creates an animated border effect using color cycling.
///
/// # Arguments
/// * `base_color` - The primary color to base the cycling effect on
/// * `area` - The rectangular area where the effect should be rendered
///
/// # Returns
///
/// An Effect that animates a border around the specified area using cycled
/// colors
pub fn create_border_effect(base_color: Color, area: Rect) -> Effect {
let color_cycle = create_color_cycle(base_color, 3);

let effect =
fx::effect_fn_buf(Instant::now(), u32::MAX, move |started_at, ctx, buf| {
let elapsed = started_at.elapsed().as_secs_f32();

// speed n cells/s
let idx = (elapsed * 30.0) as usize;

let area = ctx.area;

let mut update_cell = |(x, y): (u16, u16), idx: usize| {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_fg(*color_cycle.color_at(idx));
}
};

(area.x..area.right()).enumerate().for_each(|(i, x)| {
update_cell((x, area.y), idx + i);
});

let cell_idx_offset = area.width as usize;
(area.y + 1..area.bottom() - 1)
.enumerate()
.for_each(|(i, y)| {
update_cell((area.right() - 1, y), idx + i + cell_idx_offset);
});

let cell_idx_offset =
cell_idx_offset + area.height.saturating_sub(2) as usize;
(area.x..area.right()).rev().enumerate().for_each(|(i, x)| {
update_cell((x, area.bottom() - 1), idx + i + cell_idx_offset);
});

let cell_idx_offset = cell_idx_offset + area.width as usize;
(area.y + 1..area.bottom())
.rev()
.enumerate()
.for_each(|(i, y)| {
update_cell((area.x, y), idx + i + cell_idx_offset);
});
});

effect.with_area(area)
}
79 changes: 1 addition & 78 deletions git-cliff-tui/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,99 +4,22 @@ use crate::state::{
};
use copypasta::ClipboardProvider;
use ratatui::crossterm::event::{
self,
Event as CrosstermEvent,
KeyCode,
KeyEvent,
KeyEventKind,
KeyModifiers,
MouseEvent,
};
use std::sync::mpsc;
use std::thread;
use std::time::{
Duration,
Instant,
};

/// Terminal events.
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum Event {
/// Terminal tick.
Tick,
/// Key press.
Key(KeyEvent),
/// Mouse click/scroll.
Mouse(MouseEvent),
/// Terminal resize.
Resize(u16, u16),
/// Generate changelog.
Generate(bool),
/// Quit the application.
Quit,
}

/// Terminal event handler.
#[allow(dead_code)]
#[derive(Debug)]
pub struct EventHandler {
/// Event sender channel.
pub sender: mpsc::Sender<Event>,
/// Event receiver channel.
pub receiver: mpsc::Receiver<Event>,
/// Event handler thread.
handler: thread::JoinHandle<()>,
}

impl EventHandler {
/// Constructs a new instance of [`EventHandler`].
pub fn new(tick_rate: u64) -> Self {
let tick_rate = Duration::from_millis(tick_rate);
let (sender, receiver) = mpsc::channel();
let handler = {
let sender = sender.clone();
thread::spawn(move || {
let mut last_tick = Instant::now();
loop {
let timeout = tick_rate
.checked_sub(last_tick.elapsed())
.unwrap_or(tick_rate);

if event::poll(timeout).expect("failed to poll new events") {
match event::read().expect("unable to read event") {
CrosstermEvent::Key(e) => {
if e.kind == KeyEventKind::Press {
sender.send(Event::Key(e))
} else {
Ok(())
}
}
CrosstermEvent::Mouse(e) => sender.send(Event::Mouse(e)),
CrosstermEvent::Resize(w, h) => {
sender.send(Event::Resize(w, h))
}
CrosstermEvent::FocusGained => Ok(()),
CrosstermEvent::FocusLost => Ok(()),
CrosstermEvent::Paste(_) => Ok(()),
}
.expect("failed to send terminal event")
}

if last_tick.elapsed() >= tick_rate {
let _ = sender.send(Event::Tick);
last_tick = Instant::now();
}
}
})
};
Self {
sender,
receiver,
handler,
}
}
}

/// Handles the key events and updates the state of [`State`].
pub fn handle_key_events(
key_event: KeyEvent,
Expand Down
1 change: 1 addition & 0 deletions git-cliff-tui/src/logo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ lazy_static! {
/// A logo widget
#[derive(Debug)]
pub struct Logo {
/// Whether the widget has been rendered
pub is_rendered: bool,
/// The height of the logo
pub height: u16,
Expand Down
46 changes: 22 additions & 24 deletions git-cliff-tui/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
use std::time::Duration;
use std::{
sync::mpsc,
time::Duration,
};

use crate::{
event::{
Event,
EventHandler,
},
event::Event,
state::{
Result,
State,
},
};
use ratatui::crossterm::event::Event as CrosstermEvent;

pub mod effect;
pub mod event;
pub mod logo;
pub mod state;
Expand All @@ -29,32 +31,28 @@ fn main() -> Result<()> {
let mut state = State::new(args.clone())?;

// Initialize the terminal user interface.
let events = EventHandler::new(250);
let (sender, receiver) = mpsc::channel::<Event>();
let mut terminal = ratatui::init();

// Start the main loop.
loop {
terminal.draw(|frame| ui::render(&mut state, frame))?;

if !state.logo.is_rendered {
std::thread::sleep(Duration::from_millis(16));
continue;
if let Ok(event) = receiver.try_recv() {
match event {
Event::Generate(update_data) => {
state.generate_changelog(update_data)?;
}
Event::Quit => break,
}
}

let event = events.receiver.recv()?;
match event {
Event::Tick => state.tick(),
Event::Key(key_event) => event::handle_key_events(
key_event,
events.sender.clone(),
&mut state,
)?,
Event::Mouse(_) => {}
Event::Resize(_, _) => {}
Event::Generate(update_data) => {
state.generate_changelog(update_data)?;
if ratatui::crossterm::event::poll(Duration::from_millis(16))? {
let event = ratatui::crossterm::event::read()?;
match event {
CrosstermEvent::Key(key_event) => {
event::handle_key_events(key_event, sender.clone(), &mut state)?;
}
_ => {}
}
Event::Quit => break,
}
}

Expand Down
4 changes: 4 additions & 0 deletions git-cliff-tui/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use git_cliff::core::changelog::Changelog;
use git_cliff::core::embed::BuiltinConfig;
use ratatui::widgets::ListState;
use std::error;
use tachyonfx::Effect;
use throbber_widgets_tui::ThrobberState;

use crate::logo::Logo;
Expand Down Expand Up @@ -33,6 +34,8 @@ pub struct State<'a> {
pub is_generating: bool,
/// Logo widget.
pub logo: Logo,
/// Border effect.
pub border_effect: Option<Effect>,
}

impl State<'_> {
Expand Down Expand Up @@ -60,6 +63,7 @@ impl State<'_> {
is_generating: false,
logo: Logo::default(),
args,
border_effect: None,
};
state.generate_changelog(false)?;
Ok(state)
Expand Down
Loading

0 comments on commit 2abfe36

Please sign in to comment.