Skip to content
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

feat(tui): initialize TUI with Ratatui #884

Draft
wants to merge 38 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
5546e78
feat(tui): initialize with Ratatui
orhun Aug 24, 2024
7c0ada8
feat(tui): add config list
orhun Aug 24, 2024
cece6c1
feat(tui): display changelog via executing git-cliff
orhun Aug 24, 2024
c7eb404
feat(tui): render markdown
orhun Aug 25, 2024
82b9e2d
refactor(tui): remove tui module
orhun Aug 25, 2024
e36f74d
chore(deps): use latest version of md-tui
orhun Aug 26, 2024
52a9a46
feat(tui): support autoloading config
orhun Aug 28, 2024
a03a00f
refactor(tui): use git-cliff as library for TUI
orhun Aug 29, 2024
ecae39b
feat(tui): support copying contents to clipboard
orhun Aug 31, 2024
f559aab
style(ui): update style
orhun Aug 31, 2024
0670e97
feat(tui): add toggle mode
orhun Sep 1, 2024
9925cc9
feat(tui): add progress indicator along with threaded generation
orhun Sep 2, 2024
4ba2617
style(tui): fit the items on the sidebar
orhun Sep 24, 2024
a8fb4e3
fix(tui): ignore send error
orhun Sep 24, 2024
3c3c707
style(tui): update progress indicator
orhun Sep 24, 2024
abd811c
style(tui): apply ui tweaks
orhun Sep 24, 2024
59a4df4
fix(ui): show the generating label
orhun Sep 24, 2024
d14731b
chore(typos): accept ratatui
orhun Sep 24, 2024
8e5b712
fix(tui): enable/disable mouse capture
orhun Oct 1, 2024
0ed2fbf
feat(tui): select the item on mouse click
orhun Oct 1, 2024
12b0f18
style(tui): add scrollbars
orhun Oct 1, 2024
ad48e58
fix(tui): fix the scrolling
orhun Oct 1, 2024
da2ff3c
feat(tui): support tweaking arguments
orhun Oct 2, 2024
30cd8a2
feat(tui): allow launching from CLI
orhun Oct 7, 2024
2a75eeb
fix(args): update the heading of tui flag
orhun Oct 7, 2024
91e02e3
style(tui): remove borders
orhun Oct 12, 2024
44e6501
refactor(tui): use built-in list and remove mouse events
orhun Dec 1, 2024
8bdb469
refactor(tui): separate the changelog generate/write logic
orhun Dec 1, 2024
9f6c46a
refactor: simplify changelog construction
orhun Dec 3, 2024
f592624
refactor: fix no-default-features
orhun Dec 9, 2024
df9c5da
refactor: use tui-markdown crate
orhun Dec 10, 2024
4fcc94f
Merge branch 'main' into feat/git-cliff-tui
orhun Dec 13, 2024
0819daa
refactor: simplify the code
orhun Dec 13, 2024
b5b6946
refactor: clean up a bit
orhun Dec 14, 2024
d0c2968
style(tui): add splash logo
orhun Dec 16, 2024
2abfe36
style(tui): add a border effect
orhun Dec 23, 2024
3d3a2dd
refactor(tui): update generation mechanism
orhun Dec 28, 2024
367d45a
style(tui): add generating animation
orhun Dec 28, 2024
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
1,199 changes: 1,043 additions & 156 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[workspace]
resolver = "2"
members = ["git-cliff-core", "git-cliff"]
members = ["git-cliff-core", "git-cliff", "git-cliff-tui"]

[workspace.dependencies]
regex = "1.11.1"
Expand Down
20 changes: 11 additions & 9 deletions git-cliff-core/src/changelog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,20 +28,21 @@ use std::time::{
};

/// Changelog generator.
#[derive(Debug)]
#[derive(Clone, Debug)]
pub struct Changelog<'a> {
/// Releases that the changelog will contain.
pub releases: Vec<Release<'a>>,
/// Configuration.
pub config: Config,
header_template: Option<Template>,
body_template: Template,
footer_template: Option<Template>,
config: &'a Config,
additional_context: HashMap<String, serde_json::Value>,
}

impl<'a> Changelog<'a> {
/// Constructs a new instance.
pub fn new(releases: Vec<Release<'a>>, config: &'a Config) -> Result<Self> {
pub fn new(releases: Vec<Release<'a>>, config: Config) -> Result<Self> {
let mut changelog = Changelog::build(releases, config)?;
changelog.add_remote_data()?;
changelog.process_commits();
Expand All @@ -50,7 +51,7 @@ impl<'a> Changelog<'a> {
}

/// Builds a changelog from releases and config.
fn build(releases: Vec<Release<'a>>, config: &'a Config) -> Result<Self> {
fn build(releases: Vec<Release<'a>>, config: Config) -> Result<Self> {
let trim = config.changelog.trim.unwrap_or(true);
Ok(Self {
releases,
Expand All @@ -60,7 +61,7 @@ impl<'a> Changelog<'a> {
}
None => None,
},
body_template: get_body_template(config, trim)?,
body_template: get_body_template(&config, trim)?,
footer_template: match &config.changelog.footer {
Some(footer) => {
Some(Template::new("footer", footer.to_string(), trim)?)
Expand All @@ -73,7 +74,7 @@ impl<'a> Changelog<'a> {
}

/// Constructs an instance from a serialized context object.
pub fn from_context<R: Read>(input: &mut R, config: &'a Config) -> Result<Self> {
pub fn from_context<R: Read>(input: &mut R, config: Config) -> Result<Self> {
Changelog::build(serde_json::from_reader(input)?, config)
}

Expand Down Expand Up @@ -431,6 +432,7 @@ impl<'a> Changelog<'a> {

/// Adds information about the remote to the template context.
pub fn add_remote_context(&mut self) -> Result<()> {
debug!("Adding remote context...");
self.additional_context.insert(
"remote".to_string(),
serde_json::to_value(self.config.remote.clone())?,
Expand Down Expand Up @@ -1024,7 +1026,7 @@ mod test {
#[test]
fn changelog_generator() -> Result<()> {
let (config, releases) = get_test_data();
let mut changelog = Changelog::new(releases, &config)?;
let mut changelog = Changelog::new(releases, config)?;
changelog.bump_version()?;
changelog.releases[0].timestamp = 0;
let mut out = Vec::new();
Expand Down Expand Up @@ -1143,7 +1145,7 @@ chore(deps): fix broken deps
",
),
));
let changelog = Changelog::new(releases, &config)?;
let changelog = Changelog::new(releases, config)?;
let mut out = Vec::new();
changelog.generate(&mut out)?;
assert_eq!(
Expand Down Expand Up @@ -1247,7 +1249,7 @@ chore(deps): fix broken deps
{% endfor %}{% endfor %}"#
.to_string(),
);
let mut changelog = Changelog::new(releases, &config)?;
let mut changelog = Changelog::new(releases, config)?;
changelog.add_context("custom_field", "Hello")?;
let mut out = Vec::new();
changelog.generate(&mut out)?;
Expand Down
2 changes: 1 addition & 1 deletion git-cliff-core/src/template.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ use tera::{
};

/// Wrapper for [`Tera`].
#[derive(Debug)]
#[derive(Clone, Debug)]
pub struct Template {
/// Template name.
name: String,
Expand Down
21 changes: 21 additions & 0 deletions git-cliff-tui/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
[package]
name = "git-cliff-tui"
version = "2.5.0" # managed by release.sh
authors = ["Orhun Parmaksız <[email protected]>"]
license = "MIT"
edition = "2021"

[dependencies]
ratatui = { version = "0.29.0", features = ["unstable-widget-ref"] }
copypasta = "0.10.1"
tui-markdown = "0.3.0"
ansi-to-tui = "7.0.0"
unicode-width = "0.2.0"
tachyonfx = "0.10.1"
lazy_static.workspace = true

[dependencies.git-cliff]
version = "2.5.0" # managed by release.sh
path = "../git-cliff"
default-features = false
features = ["github", "gitlab", "gitea", "bitbucket"]
185 changes: 185 additions & 0 deletions git-cliff-tui/src/effect.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
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,
speed: f32,
length: usize,
area: Rect,
) -> Effect {
let color_cycle = create_color_cycle(base_color, length);

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 * speed) 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)
}
77 changes: 77 additions & 0 deletions git-cliff-tui/src/event.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
use crate::state::{
Config,
Result,
State,
};
use copypasta::ClipboardProvider;
use ratatui::crossterm::event::{
KeyCode,
KeyEvent,
KeyModifiers,
};
use std::sync::mpsc;

/// Terminal events.
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum Event {
/// Generate changelog.
Generate,
/// Update the changelog data.
UpdateChangelog(Vec<Config>),
/// Quit the application.
Quit,
}

/// Handles the key events and updates the state of [`State`].
pub fn handle_key_events(
key_event: KeyEvent,
sender: mpsc::Sender<Event>,
state: &mut State,
) -> Result<()> {
match key_event.code {
KeyCode::Esc | KeyCode::Char('q') => {
sender.send(Event::Quit)?;
}
KeyCode::Char('c') | KeyCode::Char('C') => {
if key_event.modifiers == KeyModifiers::CONTROL {
sender.send(Event::Quit)?;
} else {
if let Some(clipboard) = &mut state.clipboard {
let contents = state
.list_state
.selected()
.map(|i| state.configs[i].contents.clone())
.unwrap_or_default();
if let Err(e) = clipboard.set_contents(contents) {
return Err(format!(
"Failed to set clipboard contents: {e}"
)
.into());
}
}
}
}
KeyCode::Char('k') | KeyCode::Char('K') | KeyCode::Up => {
state.list_state.select_previous();
}
KeyCode::Char('j') | KeyCode::Char('J') | KeyCode::Down => {
state.list_state.select_next();
}
KeyCode::Char('h') | KeyCode::Char('H') | KeyCode::Left => {
state.scroll_index = state.scroll_index.saturating_sub(1);
}
KeyCode::Char('l') | KeyCode::Char('L') | KeyCode::Right => {
state.scroll_index = state.scroll_index.saturating_add(1);
// state.args.latest = !state.args.latest;
// sender.send(Event::Generate(true))?;
}
KeyCode::Enter => sender.send(Event::Generate)?,
KeyCode::Char('u') | KeyCode::Char('U') => {
state.args.unreleased = !state.args.unreleased;
sender.send(Event::Generate)?;
}
_ => {}
}
Ok(())
}
Loading
Loading