use saudade::{
Color, Event, EventCtx, Key, MouseButton, NamedKey, Painter, Point, Rect, SCROLLBAR_THICKNESS,
ScrollBar, Theme, Widget,
};
use crate::thesaurus::{Entry, Pos, WordId};
const PAD_X: i32 = 6;
const PAD_Y: i32 = 4;
const INDENT_SENSE: i32 = 16;
const INDENT_DETAIL: i32 = 30;
const LINK: Color = Color::rgb(0x00, 0x00, 0xCC);
const LINK_HOVER: Color = Color::rgb(0x33, 0x66, 0xFF);
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum RunStyle {
Headword,
Pos,
SenseNum,
Definition,
Example,
Label,
Link,
}
#[derive(Clone, Debug)]
pub struct Span {
pub text: String,
pub style: RunStyle,
pub link: Option<WordId>,
}
impl Span {
pub fn text(text: impl Into<String>, style: RunStyle) -> Span {
Span {
text: text.into(),
style,
link: None,
}
}
pub fn link(text: impl Into<String>, word: WordId) -> Span {
Span {
text: text.into(),
style: RunStyle::Link,
link: Some(word),
}
}
}
#[derive(Clone, Debug, Default)]
pub struct Line {
pub indent: i32,
pub spans: Vec<Span>,
}
impl Line {
fn blank() -> Line {
Line::default()
}
}
struct Frag {
line: usize,
x: i32,
w: i32,
text: String,
style: RunStyle,
link: Option<WordId>,
}
pub struct DefinitionView {
rect: Rect,
doc: Vec<Line>,
frags: Vec<Frag>,
line_count: i32,
laid_width: i32,
dirty: bool,
font_size: f32,
v_scrollbar: ScrollBar,
focused: bool,
hovered: Option<usize>,
pending_nav: Option<WordId>,
placeholder: String,
}
impl DefinitionView {
pub fn new(rect: Rect) -> Self {
let mut me = Self {
rect,
doc: Vec::new(),
frags: Vec::new(),
line_count: 0,
laid_width: -1,
dirty: true,
font_size: 14.0,
v_scrollbar: ScrollBar::vertical(Rect::new(0, 0, 0, 0)),
focused: false,
hovered: None,
pending_nav: None,
placeholder: "Type a word in the search box above.".to_string(),
};
me.relayout_scrollbar();
me
}
pub fn set_document(&mut self, doc: Vec<Line>) {
self.doc = doc;
self.dirty = true;
self.hovered = None;
self.v_scrollbar.set_value(0);
}
pub fn clear(&mut self) {
self.set_document(Vec::new());
}
pub fn take_navigation(&mut self) -> Option<WordId> {
self.pending_nav.take()
}
fn line_height(&self) -> i32 {
(self.font_size as i32 + 8).max(10)
}
fn text_area(&self) -> Rect {
let (sb_w, overlap) = if self.v_scrollbar.rect().w > 0 {
(SCROLLBAR_THICKNESS, 1)
} else {
(0, 0)
};
Rect::new(
self.rect.x,
self.rect.y,
(self.rect.w - sb_w + overlap).max(0),
self.rect.h,
)
}
fn content_w(&self) -> i32 {
(self.text_area().w - PAD_X * 2).max(0)
}
fn visible_rows(&self) -> i32 {
((self.text_area().h - PAD_Y * 2) / self.line_height()).max(1)
}
fn scroll_top(&self) -> usize {
self.v_scrollbar.value().max(0) as usize
}
fn sync_scrollbar(&mut self) {
let visible = self.visible_rows();
let max_scroll = (self.line_count - visible).max(0);
self.v_scrollbar.set_range(visible, max_scroll);
self.v_scrollbar.set_line_step(1);
}
fn relayout_scrollbar(&mut self) {
let sb_rect = Rect::new(
self.rect.right() - SCROLLBAR_THICKNESS,
self.rect.y,
SCROLLBAR_THICKNESS,
self.rect.h,
);
self.v_scrollbar.set_rect(sb_rect);
self.sync_scrollbar();
}
fn scroll_by(&mut self, delta: i32) {
let v = self.v_scrollbar.value();
self.v_scrollbar.set_value(v + delta);
}
fn style_size(&self, style: RunStyle) -> f32 {
match style {
RunStyle::Headword => self.font_size + 4.0,
_ => self.font_size,
}
}
fn relayout(&mut self, painter: &Painter) {
self.frags.clear();
let content_w = self.content_w();
let space_w = painter.measure_text(" ", self.font_size).w.max(1);
let mut vline: usize = 0;
for line in &self.doc {
let x_start = line.indent;
let mut x = x_start;
let mut first = true;
for span in &line.spans {
let size = self.style_size(span.style);
let w = painter.measure_text(&span.text, size).w;
let space = if first { 0 } else { space_w };
if !first && x + space + w > content_w {
vline += 1;
x = x_start;
first = true;
}
if !first {
x += space;
}
self.frags.push(Frag {
line: vline,
x,
w,
text: span.text.clone(),
style: span.style,
link: span.link,
});
x += w;
first = false;
}
vline += 1;
}
self.line_count = vline as i32;
self.laid_width = content_w;
self.dirty = false;
self.sync_scrollbar();
}
#[doc(hidden)]
pub fn link_rect_for(&self, word: WordId) -> Option<Rect> {
let text = self.text_area();
let line_h = self.line_height();
let x0 = text.x + PAD_X;
let y0 = text.y + PAD_Y;
let scroll_top = self.scroll_top() as i32;
self.frags.iter().find(|f| f.link == Some(word)).map(|f| {
let row = f.line as i32 - scroll_top;
Rect::new(x0 + f.x, y0 + row * line_h, f.w, line_h)
})
}
fn link_at(&self, pos: Point) -> Option<usize> {
let text = self.text_area();
if !text.inset(1).contains(pos) {
return None;
}
let line_h = self.line_height();
let rel_y = pos.y - (text.y + PAD_Y);
if rel_y < 0 {
return None;
}
let line = self.scroll_top() + (rel_y / line_h) as usize;
let x0 = text.x + PAD_X;
self.frags.iter().position(|f| {
f.link.is_some() && f.line == line && pos.x >= x0 + f.x && pos.x < x0 + f.x + f.w
})
}
}
impl Widget for DefinitionView {
fn bounds(&self) -> Rect {
self.rect
}
fn paint(&mut self, painter: &mut Painter, theme: &Theme) {
if self.dirty || self.laid_width != self.content_w() {
self.relayout(painter);
}
self.sync_scrollbar();
let text = self.text_area();
painter.fill_rect(text, Color::WHITE);
painter.sunken_bevel(text, theme.highlight, theme.shadow);
painter.stroke_rect(text, theme.border);
let line_h = self.line_height();
let x0 = text.x + PAD_X;
let y0 = text.y + PAD_Y;
let visible = self.visible_rows() as usize;
let scroll_top = self.scroll_top();
let saved = painter.push_clip(text.inset(1));
if self.doc.is_empty() {
painter.text(x0, y0, &self.placeholder, self.font_size, Color::MID_GRAY);
} else {
for (idx, frag) in self.frags.iter().enumerate() {
if frag.line < scroll_top || frag.line >= scroll_top + visible {
continue;
}
let row = (frag.line - scroll_top) as i32;
let size = self.style_size(frag.style);
let y = y0 + row * line_h;
let hovered = self.hovered == Some(idx);
let color = color_for(frag.style, hovered);
let baseline = y + (line_h - size as i32) / 2;
painter.text(x0 + frag.x, baseline, &frag.text, size, color);
if frag.style == RunStyle::Link {
painter.h_line(x0 + frag.x, baseline + size as i32, frag.w, color);
}
}
}
painter.restore_clip(saved);
self.v_scrollbar.paint(painter, theme);
}
fn event(&mut self, event: &Event, ctx: &mut EventCtx) {
if self.v_scrollbar.captures_pointer() {
self.v_scrollbar.event(event, ctx);
return;
}
if let Some(pos) = event.position()
&& self.v_scrollbar.rect().contains(pos)
{
self.v_scrollbar.event(event, ctx);
return;
}
if let Event::Scroll { .. } = event {
self.v_scrollbar.event(event, ctx);
return;
}
match event {
Event::PointerDown {
pos,
button: MouseButton::Left,
} => {
ctx.request_focus();
if let Some(idx) = self.link_at(*pos) {
self.pending_nav = self.frags[idx].link;
}
ctx.request_paint();
}
Event::PointerMove { pos } => {
let hovered = self.link_at(*pos);
if hovered != self.hovered {
self.hovered = hovered;
ctx.request_paint();
}
}
Event::PointerLeave => {
if self.hovered.is_some() {
self.hovered = None;
ctx.request_paint();
}
}
Event::KeyDown { key, modifiers } if self.focused && !modifiers.has_command() => {
let page = (self.visible_rows() - 1).max(1);
let consumed = match key {
Key::Named(NamedKey::Up) => {
self.scroll_by(-1);
true
}
Key::Named(NamedKey::Down) => {
self.scroll_by(1);
true
}
Key::Named(NamedKey::PageUp) => {
self.scroll_by(-page);
true
}
Key::Named(NamedKey::PageDown) => {
self.scroll_by(page);
true
}
Key::Named(NamedKey::Home) => {
self.v_scrollbar.set_value(0);
true
}
Key::Named(NamedKey::End) => {
self.v_scrollbar.set_value(self.line_count);
true
}
_ => false,
};
if consumed {
ctx.request_paint();
}
}
_ => {}
}
}
fn captures_pointer(&self) -> bool {
self.v_scrollbar.captures_pointer()
}
fn focusable(&self) -> bool {
true
}
fn set_focused(&mut self, focused: bool) {
self.focused = focused;
}
fn layout(&mut self, bounds: Rect) {
self.rect = bounds;
self.relayout_scrollbar();
self.dirty = true;
}
}
fn color_for(style: RunStyle, hovered: bool) -> Color {
match style {
RunStyle::Headword => Color::BLACK,
RunStyle::Pos => Color::NAVY,
RunStyle::SenseNum => Color::DARK_GRAY,
RunStyle::Definition => Color::BLACK,
RunStyle::Example => Color::DARK_GRAY,
RunStyle::Label => Color::MID_GRAY,
RunStyle::Link => {
if hovered {
LINK_HOVER
} else {
LINK
}
}
}
}
pub fn build_document(entry: &Entry) -> Vec<Line> {
let mut lines = Vec::new();
lines.push(Line {
indent: 0,
spans: vec![Span::text(&entry.lemma, RunStyle::Headword)],
});
let mut last_pos: Option<Pos> = None;
let mut sense_num = 0;
for sense in &entry.senses {
if Some(sense.pos) != last_pos {
lines.push(Line::blank());
lines.push(Line {
indent: 0,
spans: vec![Span::text(
format!("\u{25B8} {}", sense.pos.label()),
RunStyle::Pos,
)],
});
last_pos = Some(sense.pos);
sense_num = 0;
}
sense_num += 1;
let mut spans = vec![Span::text(format!("{sense_num}."), RunStyle::SenseNum)];
for word in sense.definition.split_whitespace() {
spans.push(Span::text(word, RunStyle::Definition));
}
lines.push(Line {
indent: INDENT_SENSE,
spans,
});
for example in &sense.examples {
let quoted = format!("\u{201C}{example}\u{201D}");
let spans = quoted
.split_whitespace()
.map(|w| Span::text(w, RunStyle::Example))
.collect();
lines.push(Line {
indent: INDENT_DETAIL,
spans,
});
}
push_links(&mut lines, "synonyms:", &sense.synonyms);
push_links(&mut lines, "antonyms:", &sense.antonyms);
for group in &sense.related {
push_links(&mut lines, &format!("{}:", group.label), &group.links);
}
}
lines
}
fn push_links(lines: &mut Vec<Line>, label: &str, links: &[crate::thesaurus::Link]) {
if links.is_empty() {
return;
}
let mut spans = vec![Span::text(label, RunStyle::Label)];
let last = links.len() - 1;
for (i, link) in links.iter().enumerate() {
let text = if i < last {
format!("{},", link.lemma)
} else {
link.lemma.clone()
};
spans.push(Span::link(text, link.word));
}
lines.push(Line {
indent: INDENT_DETAIL,
spans,
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::thesaurus::{Fixture, Thesaurus};
#[test]
fn document_has_headword_pos_and_links() {
let fx = Fixture::sample();
let id = fx.lookup("abandon").unwrap();
let entry = fx.entry(id).unwrap();
let doc = build_document(&entry);
assert_eq!(doc[0].spans[0].style, RunStyle::Headword);
assert_eq!(doc[0].spans[0].text, "abandon");
assert!(doc.iter().any(|l| {
l.spans
.iter()
.any(|s| s.style == RunStyle::Pos && s.text.contains("verb"))
}));
let link = doc
.iter()
.flat_map(|l| &l.spans)
.find(|s| s.style == RunStyle::Link)
.expect("a link span");
assert!(link.link.is_some());
}
}