Highlight TUI content previews

This commit is contained in:
Georg Bauer
2026-08-04 19:04:30 +02:00
parent ccaa9204f0
commit aafde7e7e8
5 changed files with 489 additions and 20 deletions

View File

@@ -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(),

View File

@@ -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
);
}
}