atuinsh / atuin

✨ Magical shell history
https://atuin.sh
MIT License
18.67k stars 523 forks source link

Syntax highlighting #763

Open ellie opened 1 year ago

ellie commented 1 year ago

It would be awesome if we could syntax highlight shell history in the interactive UI

Something like this might work: https://github.com/trishume/syntect

digitallyserviced commented 1 year ago

so taking your idea of syntect and just doing a copypasta'd poc...

the syntax is defined statically, the theme... but fairly simple to integrate without much consideration about anything else lol...

the changed file so you can mess with...

image

use std::time::Duration;

use crate::tui::{
    buffer::Buffer,
    layout::Rect,
    style::{Color, Modifier, Style},
    widgets::{Block, StatefulWidget, Widget},
};
use atuin_client::history::History;
// use crossterm::style::Color;
use syntect::easy::HighlightLines;
use syntect::parsing::SyntaxSet;
use syntect::highlighting::{ThemeSet, Style as SynStyle};
use syntect::util::{as_24_bit_terminal_escaped, LinesWithEndings};

use super::format_duration;

pub struct HistoryList<'a> {
    history: &'a [History],
    block: Option<Block<'a>>,
}

#[derive(Default)]
pub struct ListState {
    offset: usize,
    selected: usize,
    max_entries: usize,
}

impl ListState {
    pub fn selected(&self) -> usize {
        self.selected
    }

    pub fn max_entries(&self) -> usize {
        self.max_entries
    }

    pub fn select(&mut self, index: usize) {
        self.selected = index;
    }
}

impl<'a> StatefulWidget for HistoryList<'a> {
    type State = ListState;

    fn render(mut self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
        let list_area = self.block.take().map_or(area, |b| {
            let inner_area = b.inner(area);
            b.render(area, buf);
            inner_area
        });

        if list_area.width < 1 || list_area.height < 1 || self.history.is_empty() {
            return;
        }
        let list_height = list_area.height as usize;

        let (start, end) = self.get_items_bounds(state.selected, state.offset, list_height);
        state.offset = start;
        state.max_entries = end - start;

        let mut s = DrawState {
            buf,
            list_area,
            x: 0,
            y: 0,
            state,
        };

        for item in self.history.iter().skip(state.offset).take(end - start) {
            s.index();
            s.duration(item);
            s.time(item);
            s.command(item);

            // reset line
            s.y += 1;
            s.x = 0;
        }
    }
}

impl<'a> HistoryList<'a> {
    pub fn new(history: &'a [History]) -> Self {
        Self {
            history,
            block: None,
        }
    }

    pub fn block(mut self, block: Block<'a>) -> Self {
        self.block = Some(block);
        self
    }

    fn get_items_bounds(&self, selected: usize, offset: usize, height: usize) -> (usize, usize) {
        let offset = offset.min(self.history.len().saturating_sub(1));

        let max_scroll_space = height.min(10);
        if offset + height < selected + max_scroll_space {
            let end = selected + max_scroll_space;
            (end - height, end)
        } else if selected < offset {
            (selected, selected + height)
        } else {
            (offset, offset + height)
        }
    }
}

struct DrawState<'a> {
    buf: &'a mut Buffer,
    list_area: Rect,
    x: u16,
    y: u16,
    state: &'a ListState,
}

// longest line prefix I could come up with
#[allow(clippy::cast_possible_truncation)] // we know that this is <65536 length
pub const PREFIX_LENGTH: u16 = " > 123ms 59s ago".len() as u16;

impl DrawState<'_> {
    fn index(&mut self) {
        // these encode the slices of `" > "`, `" {n} "`, or `"   "` in a compact form.
        // Yes, this is a hack, but it makes me feel happy
        static SLICES: &str = " > 1 2 3 4 5 6 7 8 9   ";

        let i = self.y as usize + self.state.offset;
        let i = i.checked_sub(self.state.selected);
        let i = i.unwrap_or(10).min(10) * 2;
        self.draw(&SLICES[i..i + 3], Style::default());
    }

    fn duration(&mut self, h: &History) {
        let status = Style::default().fg(if h.success() {
            Color::Green
        } else {
            Color::Red
        });
        let duration = Duration::from_nanos(u64::try_from(h.duration).unwrap_or(0));
        self.draw(&format_duration(duration), status);
    }

    #[allow(clippy::cast_possible_truncation)] // we know that time.len() will be <6
    fn time(&mut self, h: &History) {
        let style = Style::default().fg(Color::Blue);

        // Account for the chance that h.timestamp is "in the future"
        // This would mean that "since" is negative, and the unwrap here
        // would fail.
        // If the timestamp would otherwise be in the future, display
        // the time since as 0.
        let since = chrono::Utc::now() - h.timestamp;
        let time = format_duration(since.to_std().unwrap_or_default());

        // pad the time a little bit before we write. this aligns things nicely
        self.x = PREFIX_LENGTH - 4 - time.len() as u16;

        self.draw(&time, style);
        self.draw(" ago", style);
    }

    fn command(&mut self, h: &History) {
        let mut style = Style::default();
        if self.y as usize + self.state.offset == self.state.selected {
            style = style.fg(Color::Red).add_modifier(Modifier::BOLD);
        }

// Load these once at the start of your program
let ps = SyntaxSet::load_defaults_newlines();
let ts = ThemeSet::load_defaults();

let syntax = ps.find_syntax_by_extension("zsh").unwrap();
let mut hi = HighlightLines::new(syntax, &ts.themes["base16-ocean.dark"]);
for line in LinesWithEndings::from(&h.command.as_str()) {
    let ranges: Vec<(SynStyle, &str)> = hi.highlight_line(line, &ps).unwrap();
self.x += 1;
            for sec in ranges {
                // Color::
                let fg = Color::Rgb { 0: sec.0.foreground.r, 1: sec.0.foreground.g, 2: sec.0.foreground.b };
                let bg = Color::Rgb { 0: sec.0.background.r, 1: sec.0.background.g, 2: sec.0.background.b };
                // style.fg(fg).bg(bg); // .add_modifier(sec.0.font_style);
            self.draw(sec.1, style.fg(fg)); // .bg(bg)
            }
    // let escaped = as_24_bit_terminal_escaped(&ranges[..], true);
            // self.draw(escaped.as_str(), Style::default());
            // break
}
        // for section in h.command.split_ascii_whitespace() {
        //     self.x += 1;
        //     if self.x > self.list_area.width {
        //         // Avoid attempting to draw a command section beyond the width
        //         // of the list
        //         return;
        //     }
        //     self.draw(section, style);
        // }
    }

    fn draw(&mut self, s: &str, style: Style) {
        let cx = self.list_area.left() + self.x;
        let cy = self.list_area.bottom() - self.y - 1;
        let w = (self.list_area.width - self.x) as usize;
        self.x += self.buf.set_stringn(cx, cy, s, w, style).0 - cx;
    }
}
lukekarrys commented 1 year ago

A cool addition to this would be the ability to highlight where in the filtered commands a search token was found. I'm coming from zsh-history-substring-search which enabled this with the HISTORY_SUBSTRING_SEARCH_HIGHLIGHT_FOUND setting: https://github.com/zsh-users/zsh-history-substring-search#configuration

digitallyserviced commented 1 year ago

@lukekarrys @ellie just as something that maybe can be put into wiki or contrib scripts somewhere...

This is FZF searching atuin with the preview being highlighted by bat. image

#!/bin/zsh
emulate zsh

FZF_COLORS="
--color=fg:regular:green:dim,fg+:regular:bright-green:dim
--color=hl:regular:red:dim:bold,hl+:regular:yellow:dim:bold
--color=bg:,bg+:
"
export FZF_DEFAULT_OPTS="
--prompt '❱ '
--pointer '➤'
--marker '┃'
--cycle
$FZF_COLORS
--reverse --min-height 14 --ansi
--bind=ctrl-s:toggle-sort
--bind=alt-p:preview-up,alt-n:preview-down
--bind=ctrl-k:preview-up,ctrl-j:preview-down
--bind=ctrl-u:half-page-up,ctrl-d:half-page-down
--bind=alt-,:first,alt-.:last,change:first"

ZERODIR=$(dirname $0)
0="${ZERO:-${${0:#$ZSH_ARGZERO}:-${(%):-%N}}}"
0="${${(M)0:#/*}:-$PWD/$0}"
ARGZERO=$0

if (( ZDEBUG )); then
  emulate -L zsh -o err_return -o no_unset -o xtrace
  print -rn -- $'\e]0;ZDEBUG\a' >$TTY
fi

q=""

typeset -a search=("${ARGZERO}" "searcher")

accept(){
  while read datte time dir cmd; do
    echo $cmd;
  done
}
fzfs(){
  (){
    cat - | sort | uniq | sort 
  } < <(${(z)search[@]} ) > >(fzf_list)
}
local atuin="atuin"
local LIMIT=100
searcher(){
  typeset -a clipcatsearch=(${atuin} "search" --limit ${LIMIT} --format '{time}\t\t{directory}\t\t{command}')
  local q=${1:-}
  clipcatsearch+=(${q})
  typeset -a lines=()
  while read line; do # id ts ts2 size ctype context; do 
    lines+=($line)

  done < <(${clipcatsearch[@]})
  print ${(j.\n.)lines[@]}
}

typeset timer=""

previewer(){
  {
    echo "# TIME=\"${(z)1}\"\tDIR=\"${2}\"\n${3}\n"
  }
} > >(bat -f -n -l zsh --wrap character --theme Dracula --terminal-width -2 --file-name "${2}") # --style "snip"
typeset -a binds=("enter:accept-non-empty" "change:reload:(${search} {q})" "ctrl-space:reload:(${search} {q})")
typeset -a previewer=("$ARGZERO" "previewer"  \"{1}\" "{2}" "{3}" ) 
typeset -a bind=('--bind')

fzf_list(){
  print -v HEADER -f "Atuin Search"
  local -a bindsa=(${bind:^^binds})
  local WHFZF="fzf" # "
  {
    {cat /dev/stdin} | \
      ${WHFZF} --header "${HEADER}"\
      --nth 1.. --with-nth 3.. \
      --tabstop=1 --sync \
      --info=inline\
      --no-sort\
      --tac\
      --delimiter="\t\t" \
      ${bindsa} \
      --preview-window="down,38%,border-rounded,nofollow,nohidden,~3,+3/2"\
      --preview="${previewer}" 
  } > >(accept)
}
{
  typeset -a cmd=()
  LISTER=${1:-fzfs}
  case "$LISTER" in
    accept) echo ${@:2};
    ;;
  searcher) searcher ${@:2};
    ;;
  previewer) previewer ${@:2};
    ;;
    *)   fzfs ""
    ;;
  esac
}
theesm commented 1 year ago

Maybe something like Tree-sitter (I've found tree-sitter-highlight - crates.io: Rust Package Registry during a quick search) could be a viable option to evaluate for syntax highlighting as well; the difference is, that this would be a concrete syntax tree based approach to syntax highlighting instead of a regex based one (it may have less performance costs because of this).

There's also a bash grammar availabe for tree-sitter as a crate: tree-sitter-bash - crates.io: Rust Package Registry.

atuin-bot commented 5 months ago

This issue has been mentioned on Atuin Community. There might be relevant details there:

https://forum.atuin.sh/t/handling-control-sequences-in-commands/70/2