use std::rc::Rc;
use std::time::Duration;
use rich_art::FigletFont;
use rich_art::animate::{AnimatedBanner, mix};
use rich_intuituive::banner::{Play, banner};
use rich_intuituive::interact::keymap::Keymap;
use rich_intuituive::pixels::{PixelSprite, Rgba};
use rich_intuituive::prelude::*;
use rich_intuituive::sprite::{Placed, Sprite, StageOptions, stage_with};
use crate::actions::keys_label;
use crate::bookmarks::Bookmarks;
pub const TAGLINE: &str = "a web browser for the terminal";
pub const TITLE: &str = "NOCTILUCA";
pub const FISH_NAME: &str = "About Noctiluca";
pub const LANTERNFISH: &str = include_str!("assets/lanternfish-pixel.txt");
pub const FISH_CELLS: (u16, u16) = (32, 16);
const FISH_GAP: u16 = 2;
const BELOW_TITLE: u16 = 4 + MIN_LIST;
const MIN_LIST: u16 = 3;
const TAG_ROWS: u16 = 1;
pub fn leaves(command: &crate::tab::Command) -> bool {
use crate::tab::Command;
match command {
Command::Open(url) => url != crate::about::ADDRESS,
Command::History(_) => true,
_ => false,
}
}
pub type Opener = Rc<dyn Fn(String, &mut Ctx)>;
#[derive(Clone)]
pub struct StartPage {
pub keymap: Keymap,
pub bookmarks: Signal<Bookmarks>,
pub open: Opener,
pub about: Rc<dyn Fn(&mut Ctx)>,
pub chrome_rows: u16,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum FishPlace {
Beside,
Above,
None,
}
pub fn fish_place(width: u16, height: u16, title: (u16, u16)) -> FishPlace {
let (fish_w, fish_h) = FISH_CELLS;
let (title_w, title_h) = title;
if width >= title_w + FISH_GAP + fish_w && height >= title_h.max(fish_h) + BELOW_TITLE {
FishPlace::Beside
} else if width >= fish_w && height >= fish_h + title_h + BELOW_TITLE {
FishPlace::Above
} else {
FishPlace::None
}
}
fn palette() -> Vec<(char, [u8; 3])> {
LANTERNFISH
.lines()
.filter_map(|line| line.strip_prefix("# "))
.filter_map(|rest| {
let (key, hex) = rest.split_once(' ')?;
let key = key.chars().next().filter(|_| key.len() == 1)?;
let hex = hex.trim().strip_prefix('#')?;
if hex.len() != 6 {
return None;
}
let byte = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok();
Some((key, [byte(0)?, byte(2)?, byte(4)?]))
})
.collect()
}
fn rows() -> Vec<&'static str> {
LANTERNFISH
.lines()
.filter(|line| !line.starts_with('#') && !line.is_empty())
.collect()
}
pub fn lanternfish(lure: f64) -> PixelSprite {
let colours = palette();
let colour = |c: char| colours.iter().find(|(k, _)| *k == c).map(|(_, rgb)| *rgb);
let dark = colour('K').unwrap_or([0, 0, 0]);
let rgba = |[r, g, b]: [u8; 3]| -> Rgba { [r, g, b, 255] };
let palette: Vec<(char, Rgba)> = colours.iter().map(|(k, rgb)| (*k, rgba(*rgb))).collect();
let rows = rows();
let drawn = PixelSprite::from_art(&rows, &palette);
let width = drawn.width() as usize;
let mut pixels = drawn.frame(0).to_vec();
for (y, row) in rows.iter().enumerate().take(10) {
for (x, c) in row.chars().enumerate().take(10) {
if let Some(rgb) = colour(c).filter(|_| c == 'G' || c == 'W') {
pixels[y * width + x] = rgba(mix(dark, rgb, 0.2 + 0.8 * lure));
}
}
}
PixelSprite::new(drawn.width(), drawn.height(), pixels)
}
pub fn page(start: StartPage) -> Node {
let font = FigletFont::standard();
let art = AnimatedBanner::new(TITLE, &font)
.wave(true)
.specks(true)
.seed(7);
let (art_w, art_h) = art.size();
let title = (art_w as u16, art_h as u16 + TAG_ROWS);
let play = Play::new(art.duration_of());
let port = viewport();
let chrome = start.chrome_rows;
let fish_play = play.clone();
let fish_art = art.clone();
let about = start.about.clone();
let place = move || {
let (w, h) = port.get();
fish_place(w, h.saturating_sub(chrome), title)
};
let banner_play = play.clone();
let title_rows = title.1;
let top = keyed(place, move |at| {
let title = title_block(art.clone(), banner_play.clone());
let fish = || fish(fish_art.clone(), fish_play.clone(), about.clone(), at);
match at {
FishPlace::Beside => row([title, fish().flex(1)])
.gap(FISH_GAP)
.fixed(FISH_CELLS.1.max(title_rows)),
FishPlace::Above => column([fish().fixed(FISH_CELLS.1), title]).auto(),
FishPlace::None => title,
}
})
.auto();
let page = column([
top,
hints(&start.keymap).fixed(1),
bookmarks(start.clone()).flex(1),
])
.gap(1)
.padding(1, 2);
play.skip_on_key(page)
}
fn title_block(art: AnimatedBanner, play: Play) -> Node {
let (art_w, _) = art.size();
let port = viewport();
let tagline = label(format!(
"[b]{TAGLINE}[/] [dim]· Noctiluca {}[/]",
env!("CARGO_PKG_VERSION")
))
.fixed(1);
let big = keyed(
move || usize::from(port.get().0) >= art_w + 4,
move |fits| {
if fits {
banner(art.clone(), play.clone()).label(TITLE)
} else {
label(format!("[b]{TITLE}[/]")).fixed(1)
}
},
)
.auto();
column([big, tagline]).auto()
}
fn fish(art: AnimatedBanner, play: Play, about: Rc<dyn Fn(&mut Ctx)>, at: FishPlace) -> Node {
let port = viewport();
let fish_w = FISH_CELLS.0;
let sprites = move || {
let t = play.elapsed();
let p = art.progress(t);
let lure = art.pulse(t);
let width = f64::from(port.get().0);
let rest = match at {
FishPlace::Above => ((width - f64::from(fish_w)) / 2.0).max(0.0) - 2.0,
_ => 0.0,
};
let eased = 1.0 - (1.0 - p).powi(3);
let x = width + (rest - width) * eased;
vec![Placed::new(0u8, Sprite::from_pixels(&lanternfish(lure))).at(x.max(rest), 0.0)]
};
let click = about.clone();
stage_with(
sprites,
StageOptions::new().on_sprite_click(move |_, cx| click(cx)),
)
.label(FISH_NAME)
.role(rich_intuituive::a11y::Role::Button)
.focusable()
.on_key("enter", move |cx| about(cx))
}
fn hints(keymap: &Keymap) -> Node {
let parts: Vec<String> = [
("open", "open an address"),
("bookmarks", "bookmarks"),
("actions", "all actions"),
("about", "about"),
("quit", "quit"),
]
.iter()
.filter_map(|(id, what)| {
let keys = keys_label(keymap, id);
(!keys.is_empty()).then(|| format!("[b]{}[/] {what}", crate::clean::markup(&keys)))
})
.collect();
label(format!(
"{} [dim]· Tab: the lanternfish[/]",
parts.join(" · ")
))
}
fn bookmarks(start: StartPage) -> Node {
let marks = start.bookmarks;
let selected = signal(0usize);
let items = move || {
marks.with(|m| {
m.items
.iter()
.map(|bm| {
if bm.title.is_empty() {
bm.url.clone()
} else {
format!("{} {}", bm.title, bm.url)
}
})
.collect::<Vec<_>>()
})
};
let open = start.open.clone();
let list = list(items, selected)
.symbol("› ")
.label("Bookmarks")
.autofocus()
.on_key("enter", move |cx| {
let url = marks
.with_untracked(|m| m.items.get(selected.get_untracked()).map(|b| b.url.clone()));
if let Some(url) = url {
open(url, cx);
}
})
.flex(1);
let add = keys_label(&start.keymap, "bookmark");
let empty = label(format!(
"[dim]No bookmarks yet: on a page, {} keeps it.[/]",
crate::clean::markup(&add)
))
.fixed(1);
let has = move || marks.with(|m| !m.items.is_empty());
column([
label("[b]Bookmarks[/]").fixed(1),
list.shown_when(has),
empty.hidden_when(has),
])
}
pub fn duration() -> Duration {
rich_art::animate::DEFAULT_DURATION
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_fish_is_32_by_16_cells_with_its_palette() {
let fish = lanternfish(1.0);
assert_eq!((fish.width(), fish.height()), (32, 32));
assert_eq!(palette().len(), 6);
let w = |s: &PixelSprite| s.frame(0)[5 * 32 + 4];
assert_eq!(w(&fish), [0xea, 0xff, 0xfd, 255]);
assert!(w(&lanternfish(0.0))[0] < 0x60);
let belly = |s: &PixelSprite| s.frame(0)[26 * 32 + 8];
assert_eq!(belly(&lanternfish(0.0)), belly(&fish));
}
#[test]
fn opening_a_page_leaves_it_and_the_about_box_does_not() {
use crate::engine::HistoryStep;
use crate::tab::Command;
assert!(leaves(&Command::Open("https://example.org/".into())));
assert!(leaves(&Command::History(HistoryStep::Back)));
assert!(!leaves(&Command::Open(crate::about::ADDRESS.into())));
assert!(!leaves(&Command::Resize(80, 24)));
}
#[test]
fn the_fish_never_squeezes_the_title_or_the_list() {
let title = (64, 9);
assert_eq!(fish_place(120, 40, title), FishPlace::Beside);
assert_eq!(fish_place(80, 40, title), FishPlace::Above);
assert_eq!(fish_place(80, 24, title), FishPlace::None);
assert_eq!(fish_place(30, 60, title), FishPlace::None);
assert_eq!(fish_place(120, 20, title), FishPlace::None);
}
}