use gtk::prelude::*;
use gtk4 as gtk;
use similar::{ChangeTag, TextDiff};
use sourceview5 as sv;
use crate::diff_engine::{DiffItem, DiffKind, DiffResult};
const TAG_LINE_ADDED: &str = "rustdiff-line-added";
const TAG_LINE_REMOVED: &str = "rustdiff-line-removed";
const TAG_ADDED: &str = "rustdiff-added";
const TAG_REMOVED: &str = "rustdiff-removed";
const TAG_CHANGED: &str = "rustdiff-changed";
const COLOR_LINE_ADDED: &str = "rgba(46, 160, 67, 0.22)";
const COLOR_LINE_REMOVED: &str = "rgba(248, 81, 73, 0.22)";
const COLOR_INLINE_ADDED: &str = "rgba(46, 160, 67, 0.45)";
const COLOR_INLINE_REMOVED: &str = "rgba(248, 81, 73, 0.45)";
const COLOR_INLINE_CHANGED: &str = "rgba(210, 153, 34, 0.50)";
pub fn apply_highlights(
left_view: &sv::View,
right_view: &sv::View,
left_text: &str,
right_text: &str,
diff_result: &DiffResult,
) {
let left_buf = left_view.buffer();
let right_buf = right_view.buffer();
clear_highlights(&left_buf);
clear_highlights(&right_buf);
ensure_tags(&left_buf);
ensure_tags(&right_buf);
apply_line_diff(&left_buf, &right_buf, left_text, right_text);
apply_semantic_highlights(&left_buf, left_text, diff_result, Side::Left);
apply_semantic_highlights(&right_buf, right_text, diff_result, Side::Right);
}
pub fn clear_highlights(buffer: >k::TextBuffer) {
let start = buffer.start_iter();
let end = buffer.end_iter();
for tag in [
TAG_LINE_ADDED,
TAG_LINE_REMOVED,
TAG_ADDED,
TAG_REMOVED,
TAG_CHANGED,
] {
buffer.remove_tag_by_name(tag, &start, &end);
}
}
pub fn scroll_to_text(view: &sv::View, search_text: &str) -> bool {
let buffer = view.buffer();
let start = buffer.start_iter();
if let Some((match_start, match_end)) =
start.forward_search(search_text, gtk::TextSearchFlags::CASE_INSENSITIVE, None)
{
buffer.place_cursor(&match_start);
buffer.select_range(&match_start, &match_end);
view.scroll_to_iter(&mut match_start.clone(), 0.1, false, 0.0, 0.0);
true
} else {
false
}
}
pub fn highlight_and_scroll_to_item(left_view: &sv::View, right_view: &sv::View, item: &DiffItem) {
if let Some(ref left_val) = item.left {
let search = clean_search_value(left_val);
scroll_to_text(left_view, &search);
}
if let Some(ref right_val) = item.right {
let search = clean_search_value(right_val);
scroll_to_text(right_view, &search);
}
if item.left.is_none() && item.right.is_none() {
let key = extract_key_from_path(&item.path);
scroll_to_text(left_view, &key);
scroll_to_text(right_view, &key);
}
}
#[derive(Clone, Copy)]
enum Side {
Left,
Right,
}
fn ensure_tags(buffer: >k::TextBuffer) {
let table = buffer.tag_table();
if table.lookup(TAG_LINE_ADDED).is_none() {
let tag = gtk::TextTag::builder()
.name(TAG_LINE_ADDED)
.paragraph_background(COLOR_LINE_ADDED)
.build();
table.add(&tag);
}
if table.lookup(TAG_LINE_REMOVED).is_none() {
let tag = gtk::TextTag::builder()
.name(TAG_LINE_REMOVED)
.paragraph_background(COLOR_LINE_REMOVED)
.build();
table.add(&tag);
}
if table.lookup(TAG_ADDED).is_none() {
let tag = gtk::TextTag::builder()
.name(TAG_ADDED)
.background(COLOR_INLINE_ADDED)
.build();
table.add(&tag);
}
if table.lookup(TAG_REMOVED).is_none() {
let tag = gtk::TextTag::builder()
.name(TAG_REMOVED)
.background(COLOR_INLINE_REMOVED)
.build();
table.add(&tag);
}
if table.lookup(TAG_CHANGED).is_none() {
let tag = gtk::TextTag::builder()
.name(TAG_CHANGED)
.background(COLOR_INLINE_CHANGED)
.build();
table.add(&tag);
}
}
fn apply_line_diff(
left_buf: >k::TextBuffer,
right_buf: >k::TextBuffer,
left_text: &str,
right_text: &str,
) {
let diff = TextDiff::from_lines(left_text, right_text);
let mut left_line: i32 = 0;
let mut right_line: i32 = 0;
for change in diff.iter_all_changes() {
match change.tag() {
ChangeTag::Equal => {
left_line += 1;
right_line += 1;
}
ChangeTag::Delete => {
tag_line(left_buf, left_line, TAG_LINE_REMOVED);
left_line += 1;
}
ChangeTag::Insert => {
tag_line(right_buf, right_line, TAG_LINE_ADDED);
right_line += 1;
}
}
}
}
fn tag_line(buffer: >k::TextBuffer, line: i32, tag_name: &str) {
let total_lines = buffer.line_count();
if line >= total_lines {
return;
}
let start = buffer.iter_at_line(line);
let end = buffer.iter_at_line(line);
if let (Some(ref mut s), Some(ref mut e)) = (start, end) {
e.forward_to_line_end();
buffer.apply_tag_by_name(tag_name, s, e);
}
}
fn apply_semantic_highlights(
buffer: >k::TextBuffer,
_text: &str,
diff_result: &DiffResult,
side: Side,
) {
let items = match side {
Side::Left => {
diff_result
.removed
.iter()
.chain(diff_result.changed.iter())
.collect::<Vec<_>>()
}
Side::Right => {
diff_result
.added
.iter()
.chain(diff_result.changed.iter())
.collect::<Vec<_>>()
}
};
for item in items {
let (search_value, tag_name) = match side {
Side::Left => {
let val = item.left.as_deref().unwrap_or_default();
let tag = match item.kind {
DiffKind::Removed => TAG_REMOVED,
DiffKind::Changed => TAG_CHANGED,
_ => continue,
};
(val, tag)
}
Side::Right => {
let val = item.right.as_deref().unwrap_or_default();
let tag = match item.kind {
DiffKind::Added => TAG_ADDED,
DiffKind::Changed => TAG_CHANGED,
_ => continue,
};
(val, tag)
}
};
if search_value.is_empty() {
continue;
}
let clean = clean_search_value(search_value);
if clean.is_empty() {
continue;
}
highlight_first_occurrence(buffer, &clean, tag_name);
let key = extract_key_from_path(&item.path);
if !key.is_empty() {
let json_pattern = format!("\"{key}\"");
highlight_first_occurrence(buffer, &json_pattern, tag_name);
}
}
}
fn highlight_first_occurrence(buffer: >k::TextBuffer, needle: &str, tag_name: &str) {
let start_iter = buffer.start_iter();
if let Some((match_start, match_end)) =
start_iter.forward_search(needle, gtk::TextSearchFlags::CASE_INSENSITIVE, None)
{
buffer.apply_tag_by_name(tag_name, &match_start, &match_end);
}
}
fn clean_search_value(value: &str) -> String {
let trimmed = value.trim();
if trimmed.starts_with('"') && trimmed.ends_with('"') && trimmed.len() >= 2 {
trimmed[1..trimmed.len() - 1].to_string()
} else {
trimmed.to_string()
}
}
fn extract_key_from_path(path: &str) -> String {
if let Some(start) = path.rfind("[@") {
if let Some(end) = path[start..].find(']') {
return path[start + 2..start + end].to_string();
}
}
if path.ends_with("[text]") {
let without_text = path.trim_end_matches(".[text]");
return extract_last_segment(without_text);
}
let last = path.rsplit('.').next().unwrap_or(path);
if let Some(bracket) = last.rfind('[') {
let name = &last[..bracket];
if !name.is_empty() {
return name.to_string();
}
}
last.to_string()
}
fn extract_last_segment(path: &str) -> String {
path.rsplit('.').next().unwrap_or(path).to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clean_search_value_quita_comillas() {
assert_eq!(clean_search_value("\"hola\""), "hola");
assert_eq!(clean_search_value("42"), "42");
assert_eq!(clean_search_value(" \"test\" "), "test");
assert_eq!(clean_search_value("null"), "null");
}
#[test]
fn extract_key_ruta_json() {
assert_eq!(extract_key_from_path("$.usuario.perfil.ciudad"), "ciudad");
assert_eq!(extract_key_from_path("$.data"), "data");
assert_eq!(extract_key_from_path("$"), "$");
}
#[test]
fn extract_key_ruta_con_indice() {
assert_eq!(extract_key_from_path("$.users[0].nombre"), "nombre");
assert_eq!(extract_key_from_path("$.items[2]"), "items");
}
#[test]
fn extract_key_ruta_xml_atributo() {
assert_eq!(extract_key_from_path("server[@version]"), "version");
assert_eq!(extract_key_from_path("config.db[@host]"), "host");
}
#[test]
fn extract_key_ruta_xml_texto() {
assert_eq!(extract_key_from_path("config.db.host.[text]"), "host");
assert_eq!(extract_key_from_path("root.[text]"), "root");
}
}