use hjkl_engine::Host;
use hjkl_engine_tui::EditorRatatuiExt;
use std::path::Path;
use hjkl_bonsai::{CommentMarkerPass, Highlighter, Theme};
use hjkl_engine::types::{Attrs, Color as EngineColor, Style as EngineStyle};
use hjkl_picker::PreviewSpans;
use hjkl_app::git::{GitChange, GitChangeKind};
use hjkl_app::git_worker::GitJob;
use hjkl_buffer_tui::Sign;
use hjkl_lang::GrammarRequest;
use ratatui::style::{Color, Style};
use super::App;
fn change_to_sign(c: GitChange) -> Sign {
let (ch, style) = match c.kind {
GitChangeKind::Add => ('+', Style::default().fg(Color::Green)),
GitChangeKind::Modify => ('~', Style::default().fg(Color::Yellow)),
GitChangeKind::Delete => ('_', Style::default().fg(Color::Red)),
};
Sign {
row: c.row,
ch,
style,
priority: 50,
}
}
impl App {
pub(crate) fn refresh_git_signs(&mut self) {
self.refresh_git_signs_inner(false);
}
pub(crate) fn refresh_git_signs_force(&mut self) {
self.refresh_git_signs_inner(true);
}
pub(crate) fn refresh_git_signs_inner(&mut self, force: bool) {
use std::time::{Duration, Instant};
const REFRESH_MIN_INTERVAL: Duration = Duration::from_millis(250);
let path = match self.active().filename.as_deref() {
Some(p) => p.to_path_buf(),
None => {
let slot = self.active_mut();
slot.git_signs.clear();
slot.last_git_dirty_gen = None;
return;
}
};
let dg = self.active().editor.buffer().dirty_gen();
if !force && self.active().last_git_dirty_gen == Some(dg) {
return;
}
let now = Instant::now();
if !force && now.duration_since(self.active().last_git_refresh_at) < REFRESH_MIN_INTERVAL {
return;
}
let rope = self.active().editor.buffer().rope();
let buffer_id = self.active().buffer_id;
self.active_mut().last_git_refresh_at = now;
self.git_worker.submit(GitJob {
buffer_id,
path,
rope,
dirty_gen: dg,
});
}
pub(crate) fn poll_git_signs(&mut self) -> bool {
let mut redraw = false;
while let Some(result) = self.git_worker.try_recv() {
if let Some(slot) = self
.slots
.iter_mut()
.find(|s| s.buffer_id == result.buffer_id)
&& slot
.last_git_dirty_gen
.is_none_or(|dg| dg <= result.dirty_gen)
{
slot.git_signs = result.changes.into_iter().map(change_to_sign).collect();
slot.is_untracked = result.is_untracked;
slot.last_git_dirty_gen = Some(result.dirty_gen);
redraw = true;
}
}
redraw
}
pub(crate) fn poll_grammar_loads(&mut self) -> bool {
let events = self.syntax.poll_pending_loads();
if events.is_empty() {
return false;
}
for event in &events {
use crate::syntax::LoadEventKind;
hjkl_syntax::SyntaxLayer::dispatch_load_event(event, |kind| match kind {
LoadEventKind::Ready { id, name } => {
tracing::debug!("grammar load complete: {name} (buffer {id})");
if let Some(slot) = self.slots.iter().find(|s| s.buffer_id == id)
&& let Some(ref p) = slot.filename.clone()
{
let _ = self.syntax.set_language_for_path(id, p);
}
}
LoadEventKind::Failed { id, name, error } => {
tracing::debug!("grammar load failed: {name} (buffer {id}): {error}");
self.bus.error(format!("grammar {name}: {error}"));
}
});
}
true
}
pub(crate) fn poll_anvil_jobs(&mut self) -> bool {
use hjkl_anvil::InstallStatus;
let mut redraw = false;
let mut to_remove: Vec<String> = Vec::new();
for (name, handle) in self.anvil_handles.iter() {
while let Some(status) = handle.try_recv() {
redraw = true;
let log_line = format_anvil_status(&status);
self.anvil_log
.entry(name.clone())
.or_default()
.push(log_line);
match &status {
InstallStatus::Done { .. } => {
self.bus.info(format!("anvil: installed {name}"));
to_remove.push(name.clone());
}
InstallStatus::Failed(reason) => {
self.bus
.error(format!("anvil: {name} failed \u{2014} {reason}"));
to_remove.push(name.clone());
}
InstallStatus::Downloading {
bytes_downloaded,
total,
} => {
let pct = match total {
Some(t) if *t > 0 => {
format!("{}%", (bytes_downloaded * 100) / t)
}
_ => format!("{bytes_downloaded} bytes"),
};
self.bus.info(format!("anvil: {name} downloading {pct}"));
}
InstallStatus::Verifying => {
self.bus.info(format!("anvil: {name} verifying"));
}
InstallStatus::Extracting => {
self.bus.info(format!("anvil: {name} extracting"));
}
InstallStatus::Installing => {
self.bus.info(format!("anvil: {name} installing"));
}
InstallStatus::Queued => {}
InstallStatus::TofuRecorded { triple, sha256 } => {
self.bus
.info(format!("anvil: {name} TOFU hash recorded for {triple}"));
let _ = sha256;
}
}
}
}
for name in to_remove {
self.anvil_handles.remove(&name);
}
redraw
}
pub(crate) fn handle_active_content_reset(&mut self, buffer_id: crate::syntax::BufferId) {
self.syntax.reset(buffer_id);
let active_idx = self.focused_slot_idx();
self.slots[active_idx]
.editor
.install_ratatui_syntax_spans(Vec::new());
}
pub(crate) fn recompute_and_install(&mut self) {
if !self.syntax_enabled {
return;
}
let buffer_id = self.active().buffer_id;
let (top, height) = {
let focused_slot = self.focused_slot_idx();
let (focused_top, focused_height) = {
let vp = self.active().editor.host().viewport();
(vp.top_row, vp.height as usize)
};
let mut union_top = focused_top;
let mut union_bot = focused_top + focused_height;
for w in self.windows.iter().flatten() {
if w.slot == focused_slot
&& let Some(rect) = w.last_rect
{
union_top = union_top.min(w.top_row);
union_bot = union_bot.max(w.top_row + rect.h as usize);
}
}
(union_top, union_bot - union_top)
};
let active_idx = self.focused_slot_idx();
let buf = self.slots[active_idx].editor.buffer();
let out = self.syntax.render_viewport(buffer_id, buf, top, height);
if let Some(out) = out {
let start = out.key.1;
let end = start + out.spans.len();
self.slots[active_idx]
.editor
.patch_ratatui_syntax_spans_range(start..end, &out.spans);
self.slots[active_idx].diag_signs = out.signs;
} else {
self.slots[active_idx]
.editor
.install_ratatui_syntax_spans(Vec::new());
}
self.refresh_git_signs();
}
pub fn preview_spans_for(&self, path: &Path, bytes: &[u8]) -> PreviewSpans {
self.preview_spans_for_range(path, bytes, 0..bytes.len())
}
pub fn preview_spans_for_range(
&self,
path: &Path,
bytes: &[u8],
byte_range: std::ops::Range<usize>,
) -> PreviewSpans {
let grammar = match self.directory.request_for_path(path) {
GrammarRequest::Cached(g) => g,
GrammarRequest::Loading { .. } | GrammarRequest::Unknown | _ => {
return PreviewSpans::default();
}
};
let name = grammar.name().to_string();
let mut cache = match self.preview_highlighters.lock() {
Ok(c) => c,
Err(_) => return PreviewSpans::default(),
};
let h = match cache.entry(name) {
std::collections::hash_map::Entry::Occupied(o) => o.into_mut(),
std::collections::hash_map::Entry::Vacant(v) => match Highlighter::new(grammar) {
Ok(h) => v.insert(h),
Err(_) => return PreviewSpans::default(),
},
};
h.reset();
h.parse_initial(bytes);
let directory = std::sync::Arc::clone(&self.directory);
let resolve = move |name: &str| match directory.request_by_name(name) {
GrammarRequest::Cached(g) => Some(g),
GrammarRequest::Loading { .. } | GrammarRequest::Unknown | _ => None,
};
let mut flat = h.highlight_range_with_injections(bytes, byte_range, resolve);
drop(cache);
CommentMarkerPass::new().apply(&mut flat, bytes);
let theme = self.theme.syntax.clone();
let ranges: Vec<(std::ops::Range<usize>, EngineStyle)> = flat
.into_iter()
.filter_map(|span| {
theme.style(span.capture()).map(|s| {
let fg = s.fg.map(|c| EngineColor(c.r, c.g, c.b));
let bg = s.bg.map(|c| EngineColor(c.r, c.g, c.b));
let mut attrs = Attrs::empty();
if s.modifiers.bold {
attrs |= Attrs::BOLD;
}
if s.modifiers.italic {
attrs |= Attrs::ITALIC;
}
if s.modifiers.underline {
attrs |= Attrs::UNDERLINE;
}
if s.modifiers.reverse {
attrs |= Attrs::REVERSE;
}
if s.modifiers.strikethrough {
attrs |= Attrs::STRIKE;
}
(span.byte_range.clone(), EngineStyle { fg, bg, attrs })
})
})
.collect();
PreviewSpans::from_byte_ranges(&ranges, bytes)
}
pub(crate) fn set_syntax_enabled(&mut self, enabled: bool) {
if self.syntax_enabled == enabled {
return;
}
self.syntax_enabled = enabled;
if !enabled {
for slot in &mut self.slots {
slot.editor.install_ratatui_syntax_spans(Vec::new());
slot.diag_signs.clear();
}
} else {
for i in 0..self.slots.len() {
let buffer_id = self.slots[i].buffer_id;
if let Some(p) = self.slots[i].filename.clone() {
let _ = self.syntax.set_language_for_path(buffer_id, &p);
}
}
self.recompute_and_install();
}
}
}
const VIEWPORT_SLACK_ROWS: usize = 50;
fn byte_offset_of_row(bytes: &[u8], target_row: usize) -> usize {
if target_row == 0 {
return 0;
}
let mut row = 0usize;
for (i, b) in bytes.iter().enumerate() {
if *b == b'\n' {
row += 1;
if row == target_row {
return i + 1;
}
}
}
bytes.len()
}
impl hjkl_picker::PreviewHighlighter for App {
fn spans_for(&self, path: &Path, bytes: &[u8]) -> PreviewSpans {
self.preview_spans_for(path, bytes)
}
fn spans_for_viewport(
&self,
path: &Path,
bytes: &[u8],
top_row: usize,
height: usize,
) -> PreviewSpans {
let start_row = top_row.saturating_sub(VIEWPORT_SLACK_ROWS);
let end_row = top_row
.saturating_add(height)
.saturating_add(VIEWPORT_SLACK_ROWS);
let start = byte_offset_of_row(bytes, start_row);
let end = byte_offset_of_row(bytes, end_row);
self.preview_spans_for_range(path, bytes, start..end)
}
}
fn format_anvil_status(status: &hjkl_anvil::InstallStatus) -> String {
use hjkl_anvil::InstallStatus;
match status {
InstallStatus::Queued => "queued".into(),
InstallStatus::Downloading {
bytes_downloaded,
total,
} => match total {
Some(t) if *t > 0 => format!(
"downloading {}% ({bytes_downloaded}/{t} bytes)",
(bytes_downloaded * 100) / t
),
_ => format!("downloading {bytes_downloaded} bytes"),
},
InstallStatus::Verifying => "verifying checksum".into(),
InstallStatus::Extracting => "extracting archive".into(),
InstallStatus::Installing => "installing binary".into(),
InstallStatus::Done { bin_path } => format!("done → {}", bin_path.display()),
InstallStatus::Failed(reason) => format!("failed: {reason}"),
InstallStatus::TofuRecorded { triple, sha256 } => {
format!("tofu recorded for {triple}: {}", &sha256[..8])
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
#[ignore = "network + compiler: fetches markdown + rust grammars"]
fn preview_spans_for_markdown_includes_rust_injection() {
let app = App::new(None, false, None, None).unwrap();
assert!(
app.directory.by_name("markdown").is_some(),
"markdown grammar should resolve"
);
assert!(
app.directory.by_name("rust").is_some(),
"rust grammar should resolve"
);
let source = b"# Title\n\n```rust\nfn main() {}\n```\n";
let path = PathBuf::from("test.md");
let spans = app.preview_spans_for(&path, source);
const RUST_ROW: usize = 3;
assert!(
spans.by_row.len() > RUST_ROW,
"expected at least {} rows, got {}",
RUST_ROW + 1,
spans.by_row.len()
);
let rust_row = &spans.by_row[RUST_ROW];
assert!(
rust_row.len() >= 3,
"expected ≥3 styled spans on the rust row (keyword/function/punct from injection); \
got {} spans: {:?}",
rust_row.len(),
rust_row
);
}
}