From aafde7e7e8db5ea692b5ba710873093011a65214 Mon Sep 17 00:00:00 2001 From: Georg Bauer Date: Tue, 4 Aug 2026 19:04:30 +0200 Subject: [PATCH] Highlight TUI content previews --- Cargo.lock | 306 +++++++++++++++++++++++++++++++++++++++++- TESTING.md | 13 +- crates/tui/Cargo.toml | 2 + crates/tui/src/app.rs | 6 +- crates/tui/src/ui.rs | 182 +++++++++++++++++++++++-- 5 files changed, 489 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index eda723d..c672c6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -157,13 +163,31 @@ dependencies = [ "serde", ] +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bit-set" version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" dependencies = [ - "bit-vec", + "bit-vec 0.6.3", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", ] [[package]] @@ -172,6 +196,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -370,6 +400,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "critical-section" version = "1.2.0" @@ -491,6 +530,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.10.7" @@ -564,10 +609,21 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" dependencies = [ - "bit-set", + "bit-set 0.5.3", "regex", ] +[[package]] +name = "fancy-regex" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" +dependencies = [ + "bit-set 0.8.0", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fast-srgb8" version = "1.0.0" @@ -609,6 +665,16 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -689,6 +755,12 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + [[package]] name = "futures-util" version = "0.3.33" @@ -715,6 +787,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -818,7 +899,9 @@ dependencies = [ "crossterm", "gotcha_gitea", "ratatui", + "syntect", "tokio", + "tui-markdown", ] [[package]] @@ -1121,6 +1204,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1241,6 +1333,12 @@ dependencies = [ "bitflags 2.13.1", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1342,6 +1440,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.2" @@ -1598,6 +1706,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "plist" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" +dependencies = [ + "base64", + "indexmap", + "quick-xml", + "serde", + "time", +] + [[package]] name = "portable-atomic" version = "1.14.0" @@ -1619,6 +1740,25 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -1628,6 +1768,34 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags 2.13.1", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.11" @@ -1773,7 +1941,7 @@ dependencies = [ "compact_str", "critical-section", "hashbrown 0.17.1", - "itertools", + "itertools 0.14.0", "kasuari", "lru", "palette", @@ -1838,7 +2006,7 @@ dependencies = [ "hashbrown 0.17.1", "indoc", "instability", - "itertools", + "itertools 0.14.0", "line-clipping", "ratatui-core", "serde", @@ -1886,6 +2054,12 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + [[package]] name = "reqwest" version = "0.13.4" @@ -1953,6 +2127,35 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rstest" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" +dependencies = [ + "futures-timer", + "futures-util", + "rstest_macros", +] + +[[package]] +name = "rstest_macros" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn 2.0.119", + "unicode-ident", +] + [[package]] name = "rtoolbox" version = "0.0.5" @@ -2291,6 +2494,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "simd_cesu8" version = "1.2.0" @@ -2439,6 +2648,27 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "syntect" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" +dependencies = [ + "bincode", + "fancy-regex 0.16.2", + "flate2", + "fnv", + "once_cell", + "plist", + "regex-syntax", + "serde", + "serde_derive", + "serde_json", + "thiserror 2.0.19", + "walkdir", + "yaml-rust", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -2495,7 +2725,7 @@ dependencies = [ "anyhow", "base64", "bitflags 2.13.1", - "fancy-regex", + "fancy-regex 0.11.0", "filedescriptor", "finl_unicode", "fixedbitset", @@ -2590,6 +2820,7 @@ dependencies = [ "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -2598,6 +2829,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -2697,6 +2938,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + [[package]] name = "toml_parser" version = "1.1.3+spec-1.1.0" @@ -2764,9 +3017,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -2782,6 +3047,20 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tui-markdown" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6779056a6ae2d46013edb3692be1eb55d2e1d3f0d1d454042163efe1b64baca" +dependencies = [ + "itertools 0.15.0", + "pretty_assertions", + "pulldown-cmark", + "ratatui-core", + "rstest", + "tracing", +] + [[package]] name = "typenum" version = "1.20.1" @@ -2818,7 +3097,7 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ - "itertools", + "itertools 0.14.0", "unicode-segmentation", "unicode-width", ] @@ -3373,6 +3652,21 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yoke" version = "0.8.3" diff --git a/TESTING.md b/TESTING.md index 3519a0a..2f8ad11 100644 --- a/TESTING.md +++ b/TESTING.md @@ -47,9 +47,10 @@ representative repositories. - [ ] The five numbered panes open Home, Issues, Repos, PRs, and Milestones; the selected pane and row remain visibly distinct at narrow and wide terminal sizes. -- [ ] `j`/`k`, arrows, `g`/`G`, Page Up/Down, Enter, Backspace, and `q` provide - mutt-style list and history navigation; `n`/`p` and scrolling past a - boundary traverse paginated API results without losing history. +- [ ] `j`/`k`, arrows, `g`/`G`, Page Up/Down, Enter, and Backspace provide + mutt-style list and history navigation; `q` exits immediately from every + non-editor depth. `n`/`p` and scrolling past a boundary traverse + paginated API results without losing history. - [ ] Clicking a pane switches it, clicking a list row selects it, double-clicking opens it, and the mouse wheel scrolls the list. - [ ] Home activity filters cycle with `v`; activity targets route to their @@ -57,6 +58,12 @@ representative repositories. - [ ] Repository favorites toggle with `*`, sort first per pane, and persist after relaunch. Commits switch branches with `b`; file trees, text and binary files, commit changes, pull changes, and per-file diffs open. +- [ ] Source files use grammar-based syntax colors selected by filename or + shebang, with unknown text remaining legible. Commit and pull diffs show + metadata, hunks, additions, and deletions in distinct syntax colors. +- [ ] Home activity content plus issue, pull request, milestone, and comment + Markdown pseudo-renders headings, emphasis, strong text, lists, links, + inline code, and fenced code while preserving metadata line breaks. - [ ] Issue and pull filters apply. Issues can be created, edited, closed/reopened, and deleted after confirmation; issue comments can be added and the current user's comments edited. diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 686b3ed..0be53ae 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -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 } diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 8a4ef86..ebb1860 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -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(), diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs index cfbd96d..6b74d3d 100644 --- a/crates/tui/src/ui.rs +++ b/crates/tui/src/ui.rs @@ -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 = LazyLock::new(SyntaxSet::load_defaults_newlines); +static THEMES: LazyLock = LazyLock::new(ThemeSet::load_defaults); +thread_local! { + static HIGHLIGHT_CACHE: RefCell)>> = 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::>(), + ) + } + Err(_) => Line::from(line.trim_end_matches(['\r', '\n']).to_owned()), + }) + .collect::>(); + 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::>(); + 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 + ); + } }