Highlight TUI content previews
This commit is contained in:
@@ -14,4 +14,6 @@ path = "src/main.rs"
|
||||
crossterm = "0.29"
|
||||
gotcha_gitea = { path = "../gitea" }
|
||||
ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color"] }
|
||||
syntect = { version = "5.3", default-features = false, features = ["default-fancy"] }
|
||||
tokio.workspace = true
|
||||
tui-markdown = { version = "0.3", default-features = false }
|
||||
|
||||
@@ -1324,7 +1324,7 @@ impl App {
|
||||
),
|
||||
);
|
||||
screen.detail = format!(
|
||||
"State: {state}\nAuthor: {}\nUpdated: {}\n\n{}\n\n[e] edit [c] comment [x] state [d] delete",
|
||||
"State: {state} \nAuthor: {} \nUpdated: {}\n\n{}\n\n[e] edit [c] comment [x] state [d] delete",
|
||||
issue
|
||||
.user
|
||||
.as_ref()
|
||||
@@ -1545,7 +1545,7 @@ impl App {
|
||||
screen.page = page;
|
||||
screen.has_more = details.has_more;
|
||||
screen.detail = format!(
|
||||
"State: {}\n{} → {}\nFiles: {} +{} -{}\n\n{}",
|
||||
"State: {} \n{} → {} \nFiles: {} +{} -{}\n\n{}",
|
||||
gotcha_gitea::pull_state(&pull),
|
||||
pull.head
|
||||
.as_ref()
|
||||
@@ -1642,7 +1642,7 @@ impl App {
|
||||
screen.page = page;
|
||||
screen.has_more = details.has_more;
|
||||
screen.detail = format!(
|
||||
"State: {}\nDue: {}\nOpen: {} Closed: {}\n\n{}\n\n[e] edit [x] state [d] delete",
|
||||
"State: {} \nDue: {} \nOpen: {} Closed: {}\n\n{}\n\n[e] edit [x] state [d] delete",
|
||||
milestone.state.as_deref().unwrap_or("unknown"),
|
||||
milestone.due_on.as_deref().unwrap_or("none"),
|
||||
milestone.open_issues.unwrap_or_default(),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::{cell::RefCell, sync::LazyLock};
|
||||
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
@@ -5,13 +7,24 @@ use ratatui::{
|
||||
text::{Line, Span, Text},
|
||||
widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Tabs, Wrap},
|
||||
};
|
||||
use syntect::{
|
||||
easy::HighlightLines,
|
||||
highlighting::{FontStyle, ThemeSet},
|
||||
parsing::SyntaxSet,
|
||||
util::LinesWithEndings,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
app::{App, Tab},
|
||||
app::{App, ScreenKind, Tab},
|
||||
editor::Editor,
|
||||
};
|
||||
|
||||
const ACCENT: Color = Color::Cyan;
|
||||
static SYNTAXES: LazyLock<SyntaxSet> = LazyLock::new(SyntaxSet::load_defaults_newlines);
|
||||
static THEMES: LazyLock<ThemeSet> = LazyLock::new(ThemeSet::load_defaults);
|
||||
thread_local! {
|
||||
static HIGHLIGHT_CACHE: RefCell<Option<(String, String, Text<'static>)>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
pub fn draw(frame: &mut Frame<'_>, app: &mut App) {
|
||||
let area = frame.area();
|
||||
@@ -167,25 +180,127 @@ fn lane_color(lane: usize) -> Color {
|
||||
}
|
||||
|
||||
fn draw_detail(frame: &mut Frame<'_>, app: &App, area: Rect) {
|
||||
let mut detail = app.screen.detail.clone();
|
||||
let mut detail = render_screen_detail(&app.screen.kind, &app.screen.detail);
|
||||
if let Some(item) = app.screen.selected_item()
|
||||
&& !item.detail.is_empty()
|
||||
{
|
||||
if !detail.is_empty() {
|
||||
detail.push_str("\n\n────\n\n");
|
||||
if !detail.lines.is_empty() {
|
||||
detail.lines.extend([
|
||||
Line::default(),
|
||||
Line::styled("────", Style::default().fg(Color::DarkGray)),
|
||||
Line::default(),
|
||||
]);
|
||||
}
|
||||
detail.push_str(&item.detail);
|
||||
detail
|
||||
.lines
|
||||
.extend(render_item_detail(&app.screen.kind, &item.detail).lines);
|
||||
}
|
||||
if detail.is_empty() {
|
||||
detail = "Select an item to see details.".into();
|
||||
if detail.lines.is_empty() {
|
||||
detail = Text::from("Select an item to see details.");
|
||||
}
|
||||
let paragraph = Paragraph::new(Text::from(detail))
|
||||
let paragraph = Paragraph::new(detail)
|
||||
.block(Block::default().borders(Borders::ALL).title(" Preview "))
|
||||
.wrap(Wrap { trim: false })
|
||||
.scroll((app.screen.detail_scroll, 0));
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
fn render_screen_detail<'a>(kind: &ScreenKind, detail: &'a str) -> Text<'a> {
|
||||
match kind {
|
||||
ScreenKind::File(_, path) => highlight(detail, path),
|
||||
ScreenKind::Text(_, _) if detail.starts_with("diff --git ") => {
|
||||
highlight(detail, "change.diff")
|
||||
}
|
||||
ScreenKind::Text(title, _) => highlight(detail, title),
|
||||
ScreenKind::Issue(_, _) | ScreenKind::Pull(_, _) | ScreenKind::Milestone(_, _) => {
|
||||
render_markdown(detail)
|
||||
}
|
||||
_ => Text::from(detail),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_item_detail<'a>(kind: &ScreenKind, detail: &'a str) -> Text<'a> {
|
||||
if detail.starts_with("diff --git ") {
|
||||
highlight(detail, "change.diff")
|
||||
} else if matches!(
|
||||
kind,
|
||||
ScreenKind::Home
|
||||
| ScreenKind::Issue(_, _)
|
||||
| ScreenKind::Pull(_, _)
|
||||
| ScreenKind::Milestone(_, _)
|
||||
) {
|
||||
render_markdown(detail)
|
||||
} else {
|
||||
Text::from(detail)
|
||||
}
|
||||
}
|
||||
|
||||
fn render_markdown(markdown: &str) -> Text<'_> {
|
||||
tui_markdown::from_str(markdown)
|
||||
}
|
||||
|
||||
fn highlight(source: &str, path: &str) -> Text<'static> {
|
||||
HIGHLIGHT_CACHE.with_borrow_mut(|cache| {
|
||||
if let Some((cached_path, cached_source, text)) = cache.as_ref()
|
||||
&& cached_path == path
|
||||
&& cached_source == source
|
||||
{
|
||||
return text.clone();
|
||||
}
|
||||
let text = highlight_uncached(source, path);
|
||||
*cache = Some((path.to_owned(), source.to_owned(), text.clone()));
|
||||
text
|
||||
})
|
||||
}
|
||||
|
||||
fn highlight_uncached(source: &str, path: &str) -> Text<'static> {
|
||||
let syntax = SYNTAXES
|
||||
.find_syntax_for_file(path)
|
||||
.ok()
|
||||
.flatten()
|
||||
.or_else(|| SYNTAXES.find_syntax_by_first_line(source))
|
||||
.unwrap_or_else(|| SYNTAXES.find_syntax_plain_text());
|
||||
let theme = &THEMES.themes["base16-ocean.dark"];
|
||||
let mut highlighter = HighlightLines::new(syntax, theme);
|
||||
let lines = LinesWithEndings::from(source)
|
||||
.map(|line| match highlighter.highlight_line(line, &SYNTAXES) {
|
||||
Ok(ranges) => {
|
||||
let last = ranges.len().saturating_sub(1);
|
||||
Line::from(
|
||||
ranges
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, (style, text))| {
|
||||
let text = if index == last {
|
||||
text.trim_end_matches(['\r', '\n'])
|
||||
} else {
|
||||
text
|
||||
};
|
||||
let mut terminal_style = Style::default().fg(Color::Rgb(
|
||||
style.foreground.r,
|
||||
style.foreground.g,
|
||||
style.foreground.b,
|
||||
));
|
||||
if style.font_style.contains(FontStyle::BOLD) {
|
||||
terminal_style = terminal_style.add_modifier(Modifier::BOLD);
|
||||
}
|
||||
if style.font_style.contains(FontStyle::ITALIC) {
|
||||
terminal_style = terminal_style.add_modifier(Modifier::ITALIC);
|
||||
}
|
||||
if style.font_style.contains(FontStyle::UNDERLINE) {
|
||||
terminal_style = terminal_style.add_modifier(Modifier::UNDERLINED);
|
||||
}
|
||||
Span::styled(text.to_owned(), terminal_style)
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
Err(_) => Line::from(line.trim_end_matches(['\r', '\n']).to_owned()),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Text::from(lines)
|
||||
}
|
||||
|
||||
fn draw_footer(frame: &mut Frame<'_>, app: &App, area: Rect) {
|
||||
let status = if let Some(status) = app.visible_status() {
|
||||
format!(
|
||||
@@ -357,4 +472,55 @@ mod tests {
|
||||
assert_eq!(buffer[(4, 0)].fg, lane_color(1));
|
||||
assert_eq!(buffer[(6, 0)].fg, lane_color(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previews_render_markdown_source_and_diffs_with_styles() {
|
||||
let repository = gotcha_gitea::RepositoryId {
|
||||
owner: "owner".into(),
|
||||
repository: "project".into(),
|
||||
};
|
||||
let markdown = render_item_detail(
|
||||
&ScreenKind::Home,
|
||||
"# Title\n\nSome *emphasis* and **strong text**.",
|
||||
);
|
||||
assert!(
|
||||
markdown.lines[0]
|
||||
.style
|
||||
.add_modifier
|
||||
.contains(Modifier::BOLD)
|
||||
);
|
||||
assert!(
|
||||
markdown
|
||||
.lines
|
||||
.iter()
|
||||
.flat_map(|line| &line.spans)
|
||||
.any(|span| { span.style.add_modifier.contains(Modifier::ITALIC) })
|
||||
);
|
||||
|
||||
let source = render_screen_detail(
|
||||
&ScreenKind::File(repository, "main.rs".into()),
|
||||
"fn main() { println!(\"hello\"); }\n",
|
||||
);
|
||||
let source_colors = source
|
||||
.lines
|
||||
.iter()
|
||||
.flat_map(|line| &line.spans)
|
||||
.filter_map(|span| span.style.fg)
|
||||
.collect::<Vec<_>>();
|
||||
assert!(
|
||||
source_colors
|
||||
.iter()
|
||||
.any(|color| Some(color) != source_colors.first())
|
||||
);
|
||||
|
||||
let diff_source = "diff --git a/main.rs b/main.rs\n@@ -1 +1 @@\n-old\n+new\n";
|
||||
let diff = render_screen_detail(
|
||||
&ScreenKind::Text("main.rs".into(), diff_source.into()),
|
||||
diff_source,
|
||||
);
|
||||
assert_ne!(
|
||||
diff.lines[2].spans[0].style.fg,
|
||||
diff.lines[3].spans[0].style.fg
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user