use colored::{Color, Colorize};
use perpl_sdk::{
abi::dex::Exchange::ExchangeEvents,
state::{Order, OrderHighlight, StateEvents},
types,
};
const INK: Color = Color::TrueColor { r: 16, g: 16, b: 20 };
const WARN_INK: Color = Color::TrueColor { r: 150, g: 0, b: 0 };
const TRACKED: Paint = Paint::new(255, 215, 0);
const MM_PALETTE: [Paint; 10] = [
Paint::new(126, 190, 255), Paint::new(147, 219, 141), Paint::new(240, 150, 210), Paint::new(255, 174, 122), Paint::new(160, 224, 220), Paint::new(197, 174, 255), Paint::new(226, 226, 140), Paint::new(255, 160, 155), Paint::new(170, 205, 170), Paint::new(205, 205, 215), ];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct Paint {
background: Color,
}
impl Paint {
const fn new(r: u8, g: u8, b: u8) -> Self { Self { background: Color::TrueColor { r, g, b } } }
pub(crate) fn apply(&self, text: &str) -> String { self.paint(text, INK) }
pub(crate) fn apply_warning(&self, text: &str) -> String { self.paint(text, WARN_INK) }
fn paint(&self, text: &str, ink: Color) -> String {
strip_ansi(text)
.on_color(self.background)
.color(ink)
.bold()
.to_string()
}
}
#[derive(Clone, Debug, Default)]
pub(crate) struct Highlights {
accounts: Vec<(types::AccountId, Paint)>,
}
impl Highlights {
pub(crate) fn track(&mut self, account_id: types::AccountId) {
if self.paint_of(account_id).is_none() {
self.accounts.push((account_id, TRACKED));
}
}
pub(crate) fn add(&mut self, account_id: types::AccountId) -> Paint {
if let Some(paint) = self.paint_of(account_id) {
return paint;
}
let paint = MM_PALETTE[self.accounts.len() % MM_PALETTE.len()];
self.accounts.push((account_id, paint));
paint
}
pub(crate) fn is_empty(&self) -> bool { self.accounts.is_empty() }
pub(crate) fn entries(&self) -> impl Iterator<Item = (types::AccountId, Paint)> + '_ {
self.accounts.iter().copied()
}
pub(crate) fn paint_of(&self, account_id: types::AccountId) -> Option<Paint> {
self.accounts
.iter()
.find(|(id, _)| *id == account_id)
.map(|(_, paint)| *paint)
}
pub(crate) fn raw_event(&self, event: &ExchangeEvents, line: String) -> String {
if self.is_empty() {
return line;
}
let debug = format!("{:?}", event);
match raw_event_accounts(event, &debug).find_map(|id| self.paint_of(id)) {
Some(paint) => paint.apply(&line),
None => line,
}
}
pub(crate) fn state_event(&self, event: &StateEvents, line: String) -> String {
if self.is_empty() {
return line;
}
match state_event_accounts(event)
.into_iter()
.find_map(|id| self.paint_of(id))
{
Some(paint) => paint.apply(&line),
None => line,
}
}
pub(crate) fn account(&self, account_id: types::AccountId, line: String) -> String {
match self.paint_of(account_id) {
Some(paint) => paint.apply(&line),
None => line,
}
}
}
impl OrderHighlight for Highlights {
fn highlight(&self, order: &Order, rendered: &str) -> Option<String> {
self.paint_of(order.account_id()).map(|paint| {
if order.is_expired() { paint.apply_warning(rendered) } else { paint.apply(rendered) }
})
}
}
fn state_event_accounts(event: &StateEvents) -> Vec<types::AccountId> {
match event {
StateEvents::Account(e) => vec![e.account_id],
StateEvents::Error(e) => vec![e.account_id],
StateEvents::Order(e) => vec![e.account_id],
StateEvents::Position(e) => vec![e.account_id],
StateEvents::Trade(trade) => std::iter::once(trade.taker_account_id)
.chain(trade.maker_fills.iter().map(|f| f.maker_account_id))
.collect(),
StateEvents::Exchange(_) | StateEvents::Perpetual(_) => vec![],
}
}
fn raw_event_accounts<'a>(
event: &ExchangeEvents,
debug: &'a str,
) -> impl Iterator<Item = types::AccountId> + 'a {
let created = match event {
ExchangeEvents::AccountCreated(e) => Some(e.id.to::<types::AccountId>()),
_ => None,
};
created.into_iter().chain(
ACCOUNT_ID_FIELDS
.iter()
.flat_map(move |field| debug.match_indices(field))
.filter_map(|(idx, field)| {
debug[idx + field.len()..]
.split(|c: char| !c.is_ascii_digit())
.next()
.filter(|digits| !digits.is_empty())
.and_then(|digits| digits.parse().ok())
}),
)
}
const ACCOUNT_ID_FIELDS: [&str; 2] = ["ccountId: ", "liquidatorId: "];
fn strip_ansi(text: &str) -> String {
let mut plain = String::with_capacity(text.len());
let mut chars = text.chars();
while let Some(ch) = chars.next() {
if ch != '\x1b' {
plain.push(ch);
continue;
}
if chars.next() == Some('[') {
for ch in chars.by_ref() {
if ('@'..='~').contains(&ch) {
break;
}
}
}
}
plain
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_nested_styling_before_painting() {
colored::control::set_override(true);
let styled = "resting".red().bold().to_string();
let painted = TRACKED.apply(&styled);
assert!(painted.contains("resting"));
assert_eq!(painted.matches("\u{1b}[0m").count(), 1);
assert!(painted.ends_with("\u{1b}[0m"));
}
#[test]
fn scans_debug_output_for_account_ids() {
let debug =
"MakerOrderFilledV2 { perpId: 3, accountId: 42, recyclerAccountId: 7, orderId: 421 }";
let found: Vec<_> = ACCOUNT_ID_FIELDS
.iter()
.flat_map(|field| debug.match_indices(field))
.filter_map(|(idx, field)| {
debug[idx + field.len()..]
.split(|c: char| !c.is_ascii_digit())
.next()
.and_then(|digits| digits.parse::<types::AccountId>().ok())
})
.collect();
assert_eq!(found, vec![42, 7]);
}
#[test]
fn hands_out_a_distinct_colour_per_account() {
let mut highlights = Highlights::default();
let first = highlights.add(1);
let second = highlights.add(2);
assert_ne!(first, second);
assert_eq!(highlights.add(1), first);
assert_eq!(highlights.paint_of(2), Some(second));
assert_eq!(highlights.paint_of(3), None);
}
}