From 1130bff975ca965a362acfc88447f9f427fd83da Mon Sep 17 00:00:00 2001 From: Bastien Dejean Date: Tue, 22 Oct 2019 16:23:51 +0200 Subject: [PATCH] Add support for dictd dictionaries --- .gitignore | 3 +- Cargo.lock | 9 + Cargo.toml | 3 + css/dictionary.css | 11 + dist.sh | 1 + doc/GUIDE.md | 2 +- doc/MANUAL.md | 14 + doc/NAVIGATION.md | 2 +- doc/TODO.md | 1 - src/app.rs | 44 ++- src/dictionary/dictreader.rs | 303 +++++++++++++++ src/dictionary/errors.rs | 100 +++++ src/dictionary/indexing.rs | 164 ++++++++ src/dictionary/mod.rs | 111 ++++++ src/document/epub/mod.rs | 2 +- src/document/html/engine.rs | 2 +- src/document/html/mod.rs | 18 +- src/emulator.rs | 17 +- src/main.rs | 1 + src/metadata.rs | 4 - src/settings/mod.rs | 33 +- src/view/calculator/bottom_bar.rs | 4 +- src/view/calculator/input_bar.rs | 5 +- src/view/common.rs | 10 +- src/view/dictionary/bottom_bar.rs | 146 +++++++ src/view/dictionary/mod.rs | 614 ++++++++++++++++++++++++++++++ src/view/home/mod.rs | 23 +- src/view/icon.rs | 3 - src/view/image.rs | 77 ++++ src/view/input_field.rs | 11 +- src/view/label.rs | 24 +- src/view/labeled_icon.rs | 2 +- src/view/mod.rs | 24 +- src/view/named_input.rs | 4 +- src/view/reader/bottom_bar.rs | 2 +- src/view/reader/mod.rs | 47 ++- src/view/reader/tool_bar.rs | 6 +- src/view/search_bar.rs | 11 +- src/view/top_bar.rs | 2 +- 39 files changed, 1762 insertions(+), 98 deletions(-) create mode 100644 css/dictionary.css create mode 100644 src/dictionary/dictreader.rs create mode 100644 src/dictionary/errors.rs create mode 100644 src/dictionary/indexing.rs create mode 100644 src/dictionary/mod.rs create mode 100644 src/view/dictionary/bottom_bar.rs create mode 100644 src/view/dictionary/mod.rs create mode 100644 src/view/image.rs diff --git a/.gitignore b/.gitignore index 3599f6ba..40f907b4 100644 --- a/.gitignore +++ b/.gitignore @@ -6,8 +6,9 @@ /libs /bin /Settings.toml -/user.css +/css/*-user.css /hyphenation-patterns +/dictionaries /src/wrapper/*.so /src/wrapper/*.dylib /dist diff --git a/Cargo.lock b/Cargo.lock index 52287c92..741d9256 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -590,6 +590,11 @@ name = "lazy_static" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" +[[package]] +name = "levenshtein" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" + [[package]] name = "libc" version = "0.2.65" @@ -792,6 +797,7 @@ name = "plato" version = "0.7.9" dependencies = [ "bitflags 1.2.1 (registry+https://github.com/rust-lang/crates.io-index)", + "byteorder 1.3.2 (registry+https://github.com/rust-lang/crates.io-index)", "chrono 0.4.9 (registry+https://github.com/rust-lang/crates.io-index)", "crockford 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)", "crossbeam-channel 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)", @@ -799,11 +805,13 @@ dependencies = [ "entities 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)", "failure 0.1.6 (registry+https://github.com/rust-lang/crates.io-index)", "failure_derive 0.1.6 (registry+https://github.com/rust-lang/crates.io-index)", + "flate2 1.0.12 (registry+https://github.com/rust-lang/crates.io-index)", "fnv 1.0.6 (registry+https://github.com/rust-lang/crates.io-index)", "getopts 0.2.21 (registry+https://github.com/rust-lang/crates.io-index)", "glob 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)", "kl-hyphenate 0.7.2 (registry+https://github.com/rust-lang/crates.io-index)", "lazy_static 1.4.0 (registry+https://github.com/rust-lang/crates.io-index)", + "levenshtein 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)", "libc 0.2.65 (registry+https://github.com/rust-lang/crates.io-index)", "paragraph-breaker 0.4.3 (registry+https://github.com/rust-lang/crates.io-index)", "png 0.15.0 (registry+https://github.com/rust-lang/crates.io-index)", @@ -1823,6 +1831,7 @@ dependencies = [ "checksum kl-hyphenate 0.7.2 (registry+https://github.com/rust-lang/crates.io-index)" = "d5cfb8648ef69fffc3bf55206322bd62f236a1e1145516e3b4d5e5d474819ee8" "checksum lazy_static 0.2.11 (registry+https://github.com/rust-lang/crates.io-index)" = "76f033c7ad61445c5b347c7382dd1237847eb1bce590fe50365dcb33d546be73" "checksum lazy_static 1.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +"checksum levenshtein 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)" = "66189c12161c65c0023ceb53e2fccc0013311bcb36a7cbd0f9c5e938b408ac96" "checksum libc 0.2.65 (registry+https://github.com/rust-lang/crates.io-index)" = "1a31a0627fdf1f6a39ec0dd577e101440b7db22672c0901fe00a9a6fbb5c24e8" "checksum lock_api 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)" = "f8912e782533a93a167888781b836336a6ca5da6175c05944c86cf28c31104dc" "checksum log 0.4.8 (registry+https://github.com/rust-lang/crates.io-index)" = "14b6052be84e6b71ab17edffc2eeabf5c2c3ae1fdb464aae35ac50c67a44e1f7" diff --git a/Cargo.toml b/Cargo.toml index 41d8f6bb..8062718a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,9 @@ paragraph-breaker = "0.4.3" rand_xorshift = "0.2.0" xi-unicode = "0.2.0" septem = "1.0.1" +byteorder = "1.3.2" +flate2 = "1.0.12" +levenshtein = "1.0.4" [dependencies.getopts] version = "0.2.21" diff --git a/css/dictionary.css b/css/dictionary.css new file mode 100644 index 00000000..68b44fd4 --- /dev/null +++ b/css/dictionary.css @@ -0,0 +1,11 @@ +.dictname { + margin-top: 1.5em; + text-align: center; + font-feature-settings: "smcp" "c2sc" "onum"; + letter-spacing: 0.07em; +} + +.headword { + margin-top: 1.0em; + font-weight: bold; +} diff --git a/dist.sh b/dist.sh index 0ad9fd80..a2a346bb 100755 --- a/dist.sh +++ b/dist.sh @@ -28,6 +28,7 @@ cp -R scripts dist cp -R icons dist cp -R fonts dist cp -R css dist +find dist/css -name '*-user.css' -delete cp target/arm-unknown-linux-gnueabihf/release/plato dist/ patchelf --remove-rpath dist/libs/* diff --git a/doc/GUIDE.md b/doc/GUIDE.md index 19483c17..2d472a26 100644 --- a/doc/GUIDE.md +++ b/doc/GUIDE.md @@ -29,4 +29,4 @@ The default library path is `/mnt/onboard` on devices without an external SD car library-path = "LIBRARY_PATH" ``` -If there's a `user.css` in same directory as the program's binary, it will be used for all the reflowable formats. +The default ePUB stylesheet, `css/epub.css`, can be overriden via `css/epub-user.css`. diff --git a/doc/MANUAL.md b/doc/MANUAL.md index 9f1a50eb..8b21646a 100644 --- a/doc/MANUAL.md +++ b/doc/MANUAL.md @@ -99,6 +99,20 @@ The *CMB* (combine) key can be used to enter special characters, e.g.: `CMB o e` A tap and hold on the delete or motion keys will act on words instead of characters. +# Applications + +Applications can be launched from the *Applications* submenu of the main menu. + +You can go back to the previous view by tapping the top-left *back arrow*. + +## Dictionary + +*Dictionary* can be launched from the *Reader* view by tapping and holding a word or by making a text selection and tapping *Define* in the selection menu. + +Dictionaries will be searched recursively in the `dictionaries` directory. The supported format is *dictd*: `.dict.dz` (or `.dict`) and `.index`. The dictionary definitions can be styled by creating a stylesheet at `css/dictionary-user.css`. The definitions that aren't formatted with XML are wrapped inside a *pre* tag. The font size and margin width can be changed in the `[dictionary]` section of `Settings.toml`. + +You can select the search target by tapping the label in the bottom bar. You can set the input languages of a dictionary by tapping and holding the target's label. You can then provide a comma-separated list of IETF language tags (e.g.: *en, en-US, en-GB*). + # Annex ## Combination Sequences diff --git a/doc/NAVIGATION.md b/doc/NAVIGATION.md index 1296f6b0..6bac9635 100644 --- a/doc/NAVIGATION.md +++ b/doc/NAVIGATION.md @@ -4,7 +4,7 @@ To name a page, hold the current page indicator and select the *Name* entry. A p Once a page is named, you can jump to any page above it in the same category. For example if you've defined page 15 as *vi*, by entering *'ix* (or *"ix*), in the *Go to page* input field, you'll jump to page 18. -You can also select a page name in the book's text and jump to it by taping *Go To* in the selection menu. This can be particularly useful within a book's index. +You can also select a page name in the book's text and jump to it by tapping *Go To* in the selection menu. This can be particularly useful within a book's index. ## Overriding the TOC diff --git a/doc/TODO.md b/doc/TODO.md index e65671cc..0d3c98eb 100644 --- a/doc/TODO.md +++ b/doc/TODO.md @@ -1,4 +1,3 @@ -- Dictionary. - ePUB renderer: RTL. - Metadata view. - Complex/fuzzy search queries? diff --git a/src/app.rs b/src/app.rs index 574f6091..9c8bf061 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3,19 +3,22 @@ use std::fs; use std::path::{Path, PathBuf}; use std::sync::mpsc::{self, Receiver, Sender}; use std::process::Command; -use std::collections::VecDeque; +use std::collections::{BTreeMap, VecDeque}; use std::time::{Duration, Instant}; use failure::{Error, ResultExt}; use fnv::FnvHashMap; use chrono::Local; +use glob::glob; +use crate::dictionary::{Dictionary, load_dictionary_from_file}; use crate::framebuffer::{Framebuffer, KoboFramebuffer, Display, UpdateMode}; -use crate::view::{View, Event, EntryId, EntryKind, ViewId, AppId}; +use crate::view::{View, Event, EntryId, EntryKind, ViewId, AppCmd}; use crate::view::{render, render_region, render_no_wait, render_no_wait_region, handle_event, expose}; use crate::view::common::{locate, locate_by_id, transfer_notifications, overlapping_rectangle}; use crate::view::frontlight::FrontlightWindow; use crate::view::menu::{Menu, MenuKind}; -use crate::view::sketch::Sketch; +use crate::view::dictionary::Dictionary as DictionaryApp; use crate::view::calculator::Calculator; +use crate::view::sketch::Sketch; use crate::input::{DeviceEvent, PowerSource, ButtonCode, ButtonStatus}; use crate::input::{raw_events, device_events, usb_events, display_rotate_event}; use crate::gesture::{GestureEvent, gesture_events}; @@ -49,6 +52,7 @@ pub struct Context { pub metadata: Metadata, pub filename: PathBuf, pub fonts: Fonts, + pub dictionaries: BTreeMap, pub frontlight: Box, pub battery: Box, pub lightsensor: Box, @@ -67,10 +71,31 @@ impl Context { let dims = fb.dims(); let rotation = CURRENT_DEVICE.transformed_rotation(fb.rotation()); Context { fb, display: Display { dims, rotation }, - settings, metadata, filename, fonts, battery, + settings, metadata, filename, fonts, dictionaries: BTreeMap::new(), battery, frontlight, lightsensor, notification_index: 0, kb_rect: Rectangle::default(), plugged: false, covered: false, shared: false, online: false } } + + pub fn load_dictionaries(&mut self) { + if let Ok(entries) = glob("dictionaries/**/*.index") { + for entry in entries.into_iter().filter_map(|e| e.ok()) { + let index_path = entry; + let mut content_path = index_path.clone(); + content_path.set_extension("dict.dz"); + if !content_path.exists() { + content_path.set_extension(""); + } + if let Ok(mut dict) = load_dictionary_from_file(&content_path, &index_path) { + let name = dict.short_name().ok().unwrap_or_else(|| { + index_path.file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default() + }); + self.dictionaries.insert(name, dict); + } + } + } + } } struct Task { @@ -746,15 +771,16 @@ pub fn run() -> Result<(), Error> { }); view = next_view; }, - Event::Select(EntryId::Launch(app_id)) => { + Event::Select(EntryId::Launch(app_cmd)) => { view.children_mut().retain(|child| !child.is::()); let monochrome = context.fb.monochrome(); - let mut next_view: Box = match app_id { - AppId::Sketch => { + let mut next_view: Box = match app_cmd { + AppCmd::Sketch => { context.fb.set_monochrome(true); Box::new(Sketch::new(context.fb.rect(), &tx, &mut context)) }, - AppId::Calculator => Box::new(Calculator::new(context.fb.rect(), &tx, &mut context)?), + AppCmd::Calculator => Box::new(Calculator::new(context.fb.rect(), &tx, &mut context)?), + AppCmd::Dictionary { ref query, ref language } => Box::new(DictionaryApp::new(context.fb.rect(), query, language, &tx, &mut context)), }; transfer_notifications(view.as_mut(), next_view.as_mut(), &mut context); history.push(HistoryItem { @@ -779,6 +805,8 @@ pub fn run() -> Result<(), Error> { } } view.handle_event(&Event::Reseed, &tx, &mut bus, &mut context); + } else { + break; } }, Event::TogglePresetMenu(rect, index) => { diff --git a/src/dictionary/dictreader.rs b/src/dictionary/dictreader.rs new file mode 100644 index 00000000..52fb29f0 --- /dev/null +++ b/src/dictionary/dictreader.rs @@ -0,0 +1,303 @@ +//! Open and read .dict or .dict.dz files. +//! +//! This module contains traits and structs to work with uncompressed .dict and compressed .dict.dz +//! files. These files contain the actual dictionary content. While these readers return the +//! definitions, they do not do any post-processing. Definitions are normally plain text, but they +//! could be HTML, or anything else, in theory (although plain text is the de facto default). +//! +//! To understand some of the constants defined in this module or to understand the internals of +//! the DictReaderDz struct, it is advisable to have a brief look at +//! [the GZip standard](https://tools.ietf.org/html/rfc1952). + +use std::io; +use std::fs::File; +use std::ffi::OsStr; +use std::path::Path; +use std::io::{BufReader, BufRead, Read, Seek, SeekFrom}; + +use byteorder::*; +use super::errors::DictError; + +/// Limit size of a word buffer, so that malicious index files cannot request too much memory for a +/// translation. +pub static MAX_BYTES_FOR_BUFFER: u64 = 1_048_576; // No headword definition is larger than 1M. + +/// Byte mask to query for existence of FEXTRA field in the flags byte of a `.dz` file. +pub static GZ_FEXTRA: u8 = 0b0000_0100; +/// Byte mask to query for the existence of a file name in a `.dz` file. +pub static GZ_FNAME: u8 = 0b0000_1000; // Indicates whether a file name is contained in the archive. +/// Byte mask to query for the existence of a comment in a `.dz` file. +pub static GZ_COMMENT: u8 = 0b0001_0000; // Indicates, whether a comment is present. +/// Byte mask to detect that a comment is contained in a `.dz` file. +pub static GZ_FHCRC: u8 = 0b0000_0010; + + +/// A dictionary (content) reader. +/// +/// This type abstracts from the underlying seek operations required for lookup +/// of headwords and provides easy methods to search for a word given a certain +/// offset and length. Users of a type which implements this trait don't need to care about compression +/// of the dictionary. +pub trait DictReader { + /// Fetch the definition from the dictionary at offset and length. + fn fetch_definition(&mut self, start_offset: u64, length: u64) -> Result; +} + +/// Raw Dict reader. +/// +/// This reader can read uncompressed .dict files. +pub struct DictReaderRaw { + dict_data: B, + total_length: u64, +} + +impl DictReaderRaw { + /// Get a new DictReader from a Reader. + pub fn new(mut dict_data: B) -> Result, DictError> { + let end = dict_data.seek(SeekFrom::End(0))?; + Ok(DictReaderRaw { dict_data, total_length: end }) + } +} + +impl DictReader for DictReaderRaw { + /// Fetch definition from dictionary. + fn fetch_definition(&mut self, start_offset: u64, length: u64) -> Result { + if length > MAX_BYTES_FOR_BUFFER { + return Err(DictError::MemoryError); + } + + if (start_offset + length) > self.total_length { + return Err(DictError::IoError(io::Error::new(io::ErrorKind::UnexpectedEof, "a \ + seek beyond the end of uncompressed data was requested"))); + } + + self.dict_data.seek(SeekFrom::Start(start_offset))?; + let mut read_data = vec![0; length as usize]; + let bytes_read = self.dict_data.read(read_data.as_mut_slice())? as u64; + if bytes_read != length { // reading from end of file? + return Err(DictError::IoError(io::Error::new( + io::ErrorKind::UnexpectedEof, "seek beyond end of file"))); + } + Ok(String::from_utf8(read_data)?) + } +} + +/// Load a `DictReader` from file. +/// +/// This function loads a `Dictreader` from a file and transparently selects +/// the correct reader using the file type extension, so the callee doesn't need to care about +/// compression (`.dz`). +/// +/// # Errors +/// +/// The function can return a `DictError`, which can either occur if a I/O error occurs, or when +/// the GZ compressed file is invalid. +pub fn load_dict>(path: P) -> Result, DictError> { + if path.as_ref().extension() == Some(OsStr::new("dz")) { + let reader = File::open(path)?; + Ok(Box::new(DictReaderDz::new(reader)?)) + } else { + let reader = BufReader::new(File::open(path)?); + Ok(Box::new(DictReaderRaw::new(reader)?)) + } +} + + +/// Gzip Dict reader +/// +/// This reader can read compressed .dict files with the file name suffix .dz. +/// This format is documented in RFC 1952 and in `man dictzip`. An example implementation can be +/// found in the dict daemon (dictd) in `data.c`. +pub struct DictReaderDz { + /// Compressed DZ dictionary. + dzdict: B, + /// Length of an uncompressed chunk. + uchunk_length: usize, + /// End of compressed data. + end_compressed_data: usize, + /// Offsets in file where a new compressed chunk starts. + chunk_offsets: Vec, + /// Total size of uncompressed file. + ufile_length: u64, // Has u64 to be quicker in comparing to offsets. +} + +#[derive(Debug)] +// A (GZ) chunk, representing length and offset withing the compressed file. +struct Chunk { + offset: usize, + length: usize, +} + +impl DictReaderDz { + /// Get a new DictReader from a Reader. + pub fn new(dzdict: B) -> Result, DictError> { + let mut buffered_dzdict = BufReader::new(dzdict); + let mut header = vec![0u8; 12]; + buffered_dzdict.read_exact(&mut header)?; + if header[0..2] != [0x1F, 0x8B] { + return Err(DictError::InvalidFileFormat("Not in gzip format".into(), None)); + } + + let flags = &header[3]; // Bitmap of gzip attributes. + if (flags & GZ_FEXTRA) == 0 { // Check whether FLG.FEXTRA is set. + return Err(DictError::InvalidFileFormat("Extra flag (FLG.FEXTRA) \ + not set, not in gzip + dzip format".into(), None)); + } + + // Read XLEN, length of extra FEXTRA field. + let xlen = LittleEndian::read_u16(&header[10..12]); + + // Read FEXTRA data. + let mut fextra = vec![0u8; xlen as usize]; + buffered_dzdict.read_exact(&mut fextra)?; + + if fextra[0..2] != [b'R', b'A'] { + return Err(DictError::InvalidFileFormat("No dictzip info found in FEXTRA \ + header (behind XLEN, in SI1SI2 fields)".into(), None)); + } + + let length_subfield = LittleEndian::read_u16(&fextra[2..4]); + assert_eq!(length_subfield, xlen - 4, "the length of the subfield \ + should be the same as the fextra field, ignoring the \ + additional length information and the file format identification"); + let subf_version = LittleEndian::read_u16(&fextra[4..6]); + if subf_version != 1 { + return Err(DictError::InvalidFileFormat("Unimplemented dictzip \ + version, only ver 1 supported".into(), None)); + } + + // Before compression, the file is split into evenly-sized chunks and the size information + // is put right after the version information: + let uchunk_length = LittleEndian::read_u16(&fextra[6..8]); + // Number of chunks in the file. + let chunk_count = LittleEndian::read_u16(&fextra[8..10]); + if chunk_count == 0 { + return Err(DictError::InvalidFileFormat("No compressed chunks in \ + file or broken header information".into(), None)); + } + + // Compute number of possible chunks which would fit into the FEXTRA field; used for + // validity check. The first 10 bytes of FEXTRA are header information, the rest are 2-byte, + // little-endian numbers. + let numbers_chunks_which_would_fit = ((fextra.len() - 10) / 2) as u16; // each chunk represented by u16 == 2 bytes + // Check that number of claimed chunks fits within given size for subfield. + if numbers_chunks_which_would_fit != chunk_count { + return Err(DictError::InvalidFileFormat(format!("Expected {} chunks \ + according to dictzip header, but the FEXTRA field can \ + accomodate {}; possibly broken file", chunk_count, + numbers_chunks_which_would_fit), None)); + } + + // If file name bit set, seek beyond the 0-terminated file name, we don't care. + if (flags & GZ_FNAME) != 0 { + let mut tmp = Vec::new(); + buffered_dzdict.read_until(b'\0', &mut tmp)?; + } + + // Seek past comment, if any. + if (flags & GZ_COMMENT) != 0 { + let mut tmp = Vec::new(); + buffered_dzdict.read_until(b'\0', &mut tmp)?; + } + + // Skip CRC stuff, 2 bytes. + if (flags & GZ_FHCRC) != 0 { + buffered_dzdict.seek(SeekFrom::Current(2))?; + } + + // Save length of each compressed chunk. + let mut chunk_offsets = Vec::with_capacity(chunk_count as usize); + // Save position of last compressed byte (this is NOT EOF, could be followed by CRC checksum). + let mut end_compressed_data = buffered_dzdict.seek(SeekFrom::Current(0))? as usize; + // After the various header bytes parsed above, the list of chunk lengths can be found (slice for easier indexing). + let chunks_from_header = &fextra[10usize..(10 + chunk_count * 2) as usize]; + + // Iterate over each 2nd byte, parse u16. + for index in (0..chunks_from_header.len()).filter(|i| (i%2)==0) { + let index = index as usize; + let compressed_len = LittleEndian::read_u16(&chunks_from_header[index..(index + 2)]) as usize; + chunk_offsets.push(end_compressed_data); + end_compressed_data += compressed_len; + } + assert_eq!(chunk_offsets.len() as u16, chunk_count, "The read number of compressed chunks in \ + the .dz file must be equivalent to the number of chunks actually found in the file.\n"); + + // Read uncompressed file length. + buffered_dzdict.seek(SeekFrom::Start(end_compressed_data as u64))?; + let uncompressed = buffered_dzdict.read_i32::()?; + + Ok(DictReaderDz { dzdict: buffered_dzdict.into_inner(), + chunk_offsets, + end_compressed_data, + uchunk_length: uchunk_length as usize, + ufile_length: uncompressed as u64 }) + } + + fn get_chunks_for(&self, start_offset: u64, length: u64) -> Result, DictError> { + let mut chunks = Vec::new(); + let start_chunk = start_offset as usize / self.uchunk_length; + let end_chunk = (start_offset + length) as usize / self.uchunk_length; + for id in start_chunk..=end_chunk { + let chunk_length = match self.chunk_offsets.get(id+1) { + Some(next) => next - self.chunk_offsets[id], + None => self.end_compressed_data - self.chunk_offsets[id], + }; + chunks.push(Chunk { offset: self.chunk_offsets[id], length: chunk_length }); + } + + Ok(chunks) + } + + // Inflate a dictdz chunk. + fn inflate(&self, data: Vec) -> Result, DictError> { + let mut decoder = flate2::Decompress::new(false); + let mut decoded = vec![0u8; self.uchunk_length]; + decoder.decompress(data.as_slice(), decoded.as_mut_slice(), flate2::FlushDecompress::None)?; + Ok(decoded) + } +} + +impl DictReader for DictReaderDz { + // Fetch definition from the dictionary. + fn fetch_definition(&mut self, start_offset: u64, length: u64) -> Result { + if length > MAX_BYTES_FOR_BUFFER { + return Err(DictError::MemoryError); + } + if (start_offset + length) > self.ufile_length { + return Err(DictError::IoError(io::Error::new(io::ErrorKind::UnexpectedEof, "a \ + seek beyond the end of uncompressed data was requested"))); + } + let mut data = Vec::new(); + for chunk in self.get_chunks_for(start_offset, length)? { + let pos = self.dzdict.seek(SeekFrom::Start(chunk.offset as u64))?; + if pos != (chunk.offset as u64) { + return Err(DictError::IoError(io::Error::new(io::ErrorKind::Other, format!( + "attempted to seek to {} but new position is {}", + chunk.offset, pos)))); + } + let mut definition = vec![0u8; chunk.length]; + self.dzdict.read_exact(&mut definition)?; + data.push(self.inflate(definition)?); + }; + + // Cut definition, convert to string. + let cut_front = start_offset as usize % self.uchunk_length; + // Join the chunks to one vector, only keeping the content of the definition. + let data = match data.len() { + 0 => panic!(), + 1 => data[0][cut_front .. cut_front + length as usize].to_vec(), + n => { + let mut tmp = data[0][cut_front..].to_vec(); + // First vec has been inserted into tmp, therefore skip first and last chunk, too. + for text in data.iter().skip(1).take(n-2) { + tmp.extend_from_slice(text); + } + // Add last chunk to tmp, omitting stuff after word definition end. + let remaining_bytes = (length as usize + cut_front) % self.uchunk_length; + tmp.extend_from_slice(&data[n-1][..remaining_bytes]); + tmp + }, + }; + Ok(String::from_utf8(data)?) + } +} diff --git a/src/dictionary/errors.rs b/src/dictionary/errors.rs new file mode 100644 index 00000000..551661b6 --- /dev/null +++ b/src/dictionary/errors.rs @@ -0,0 +1,100 @@ +//! Errors for the Dict dictionary crate. +use std::error; + +/// Error type, representing the errors which can be returned by the libdict library. +/// +/// This enum represents a handful of custom errors and wraps `io:::Error` and +/// `string::FromUtf8Error`. +#[derive(Debug)] +pub enum DictError { + /// Invalid character, e.g. within the index file; the error contains the erroneous character, + /// and optionally line and position. + InvalidCharacter(char, Option, Option), + /// Occurs whenever a line in an index file misses a column. + MissingColumnInIndex(usize), + /// Invalid file format, contains an explanation an optional path to the + /// file with the invalid file format. + InvalidFileFormat(String, Option), + /// This reports a malicious / malformed index file, which requests a buffer which is too large. + MemoryError, + /// This reports words which are not present in the dictionary. + WordNotFound(String), + /// A wrapped io::Error. + IoError(::std::io::Error), + /// A wrapped Utf8Error. + Utf8Error(::std::string::FromUtf8Error), + /// Errors thrown by the flate2 crate - not really descriptive errors, though. + DeflateError(flate2::DecompressError), +} + +impl ::std::fmt::Display for DictError { + fn fmt(&self, f: &mut ::std::fmt::Formatter) -> ::std::fmt::Result { + match *self { + DictError::IoError(ref e) => e.fmt(f), + DictError::Utf8Error(ref e) => e.fmt(f), + DictError::DeflateError(ref err) => write!(f, "Error while using \ + the flate2 crate: {:?}", err), + DictError::MemoryError => write!(f, "not enough memory available"), + DictError::WordNotFound(ref word) => write!(f, "Word not found: {}", word), + DictError::InvalidCharacter(ref ch, ref line, ref pos) => { + let mut ret = write!(f, "Invalid character {}", ch); + if let Some(ln) = *line { + ret = write!(f, " on line {}", ln); + } + if let Some(pos) = *pos { + ret = write!(f, " at position {}", pos); + } + ret + }, + DictError::MissingColumnInIndex(ref lnum) => write!(f, "line {}: not \ + enough -separated columns found, expected at least 3", lnum), + DictError::InvalidFileFormat(ref explanation, ref path) => + write!(f, "{}{}", path.clone().unwrap_or_else(String::new), explanation) + } + } +} + +impl error::Error for DictError { + fn description(&self) -> &str { + match *self { + DictError::InvalidCharacter(_, _, _) => "invalid character", + DictError::MemoryError => "not enough memory available", + DictError::WordNotFound(_) => "word not found", + DictError::MissingColumnInIndex(_) => + "not enough -separated columns given", + DictError::InvalidFileFormat(ref _explanation, ref _path) => "could not \ + determine file format", + DictError::IoError(ref err) => err.description(), + DictError::DeflateError(_) => "invalid data, couldn't inflate", + DictError::Utf8Error(ref err) => err.description(), + } + } + + fn source(&self) -> Option<&(dyn error::Error + 'static)> { + match *self { + DictError::IoError(ref err) => err.source(), + DictError::Utf8Error(ref err) => err.source(), + _ => None, + } + } +} + +// Allow seamless coercion from::Error. +impl From<::std::io::Error> for DictError { + fn from(err: ::std::io::Error) -> DictError { + DictError::IoError(err) + } +} + +impl From<::std::string::FromUtf8Error> for DictError { + fn from(err: ::std::string::FromUtf8Error) -> DictError { + DictError::Utf8Error(err) + } +} + +impl From for DictError { + fn from(err: flate2::DecompressError) -> DictError { + DictError::DeflateError(err) + } +} + diff --git a/src/dictionary/indexing.rs b/src/dictionary/indexing.rs new file mode 100644 index 00000000..2abebd4a --- /dev/null +++ b/src/dictionary/indexing.rs @@ -0,0 +1,164 @@ +//! Parse and decode `*.index` files. +//! +//! Each dictionary file (`*.dict.dz)`) is accompanied by a `*.index` file containing a list of +//! words, together with its (byte) position in the dict file and its (byte) length. This module +//! provides functions to parse this index file. +//! +//! The position and the length of a definition is given in a semi-base64 encoding. It uses all +//! Latin letters (upper and lower case), all digits and additionally, `+` and `/`: +//! +//! `ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/` +//! +//! The calculation works as follows: `sum += x * 64^i` +//! +//! - `i` is the position within the string to calculate the number from and counts from right to +//! left, starting at 0. +//! - `x` is the index within the array given above, i.e. `'a' == 26`. +//! +//! The sum makes up the index. + +use std::fs::File; +use std::path::Path; +use std::io::{BufRead, BufReader}; + +use levenshtein::levenshtein; + +use super::errors::DictError; +use super::errors::DictError::*; + +/// The index is partially loaded if `state` isn't `None`. +pub struct Index { + pub words: Vec, + pub state: Option, +} + +#[derive(Debug, Clone)] +pub struct Entry { + pub word: String, + pub offset: u64, + pub size: u64, + pub original: Option, +} + +pub trait IndexReader { + fn load_and_find(&mut self, word: &str, fuzzy: bool) -> Vec; + fn find(&self, word: &str, fuzzy: bool) -> Vec; +} + +impl IndexReader for Index { + fn load_and_find(&mut self, word: &str, fuzzy: bool) -> Vec { + if let Some(br) = self.state.take() { + if let Ok(mut index) = parse_index(br, false) { + self.words.append(&mut index.words); + } + } + self.find(word, fuzzy) + } + + fn find(&self, word: &str, fuzzy: bool) -> Vec { + if fuzzy { + self.words.iter().filter(|entry| levenshtein(word, &entry.word) <= 1).cloned().collect() + } else { + if let Ok(i) = self.words.binary_search_by_key(&word, |entry| &entry.word) { + vec![self.words[i].clone()] + } else { + Vec::new() + } + } + } +} + +/// Get the assigned number for a character +/// If the character was unknown, an empty Err(()) is returned. +#[inline] +fn get_base(input: char) -> Option { + match input { + 'A' ..= 'Z' => Some((input as u64) - 65), // 'A' should become 0 + 'a' ..= 'z' => Some((input as u64) - 71), // 'a' should become 26, ... + '0' ..= '9' => Some((input as u64) + 4), // 0 should become 52 + '+' => Some(62), + '/' => Some(63), + _ => None, + } +} + +/// Decode a number from a given String. +/// +/// This function decodes a number from the format described in the module documentation. If +/// unknown characters/bytes are encountered, a `DictError` is returned. +pub fn decode_number(word: &str) -> Result { + let mut index = 0u64; + for (i, character) in word.chars().rev().enumerate() { + index += match get_base(character) { + Some(x) => x * 64u64.pow(i as u32), + None => return Err(InvalidCharacter(character, None, Some(i))), + }; + } + Ok(index) +} + +/// Parse a single line from the index file. +fn parse_line(line: &str, line_number: usize) -> Result<(&str, u64, u64, Option<&str>), DictError> { + // First column: headword. + let mut split = line.split('\t'); + let word = split.next().ok_or(MissingColumnInIndex(line_number))?; + + // Second column: offset into file. + let offset = split.next().ok_or(MissingColumnInIndex(line_number))?; + let offset = decode_number(offset)?; + + // Third column: entry size. + let size = split.next().ok_or(MissingColumnInIndex(line_number))?; + let size = decode_number(size)?; + + // Fourth column: optional original headword. + let original = split.next(); + + Ok((word, offset, size, original)) +} + +/// Parse the index for a dictionary from a given BufRead compatible object. +/// When `lazy` is `true`, the loop stops once all the metadata entries are parsed. +pub fn parse_index(mut br: B, lazy: bool) -> Result, DictError> { + let mut info = false; + let mut words = Vec::new(); + let mut line_number = 0; + let mut line = String::new(); + + while let Ok(nb) = br.read_line(&mut line) { + if nb == 0 { + break; + } + let (word, offset, size, original) = parse_line(line.trim_end(), line_number)?; + if lazy { + if !info && (word.starts_with("00-database-") || word.starts_with("00database")) { + info = true; + } else if info && !word.starts_with("00-database-") && !word.starts_with("00database") { + break; + } + } + words.push(Entry { + word: word.to_string(), + offset, + size, + original: original.map(String::from), + }); + line_number += 1; + line.clear(); + } + + let state = if lazy { + Some(br) + } else { + None + }; + + Ok(Index { words, state }) +} + +/// Parse the index for a dictionary from a given path. +pub fn parse_index_from_file>(path: P, lazy: bool) -> Result>, DictError> { + let file = File::open(path)?; + let reader = BufReader::new(file); + parse_index(reader, lazy) +} diff --git a/src/dictionary/mod.rs b/src/dictionary/mod.rs new file mode 100644 index 00000000..2f8f19ca --- /dev/null +++ b/src/dictionary/mod.rs @@ -0,0 +1,111 @@ +//! A dict format (`*.dict`) reader crate. +//! +//! This crate can read dictionaries in the dict format, as used by dictd. It supports both +//! uncompressed and compressed dictionaries. + +mod dictreader; +mod errors; +mod indexing; + +use std::path::Path; + +use self::dictreader::DictReader; +use self::indexing::IndexReader; + +/// A dictionary wrapper. +/// +/// A dictionary is made up of a `*.dict` or `*.dict.dz` file with the actual content and a +/// `*.index` file with a list of all headwords and with positions in the dict file + length +/// information. It provides a convenience function to look up headwords directly, without caring +/// about the details of the index and the underlying dict format. +pub struct Dictionary { + content: Box, + index: Box, + all_chars: bool, + case_sensitive: bool, +} + +impl Dictionary { + /// Look up a word in a dictionary. + /// + /// Words are looked up in the index and then retrieved from the dict file. If no word was + /// found, the returned vector is empty. Errors result from the parsing of the underlying files. + pub fn lookup(&mut self, word: &str, fuzzy: bool) -> Result, errors::DictError> { + let mut query = word.to_string(); + if !self.case_sensitive { + query = query.to_lowercase(); + } + if !self.all_chars { + query = query.chars().filter(|c| c.is_alphanumeric() || c.is_whitespace()).collect(); + } + let entries = self.index.load_and_find(&query, fuzzy); + let mut results = Vec::new(); + for entry in entries.into_iter() { + results.push([entry.original.unwrap_or(entry.word), + self.content.fetch_definition(entry.offset, entry.size)?]); + } + Ok(results) + } + + /// Retreive metadata from the dictionaries. + /// + /// The metadata headwords start with `00-database-` or `00database`. + pub fn metadata(&mut self, name: &str) -> Result { + let mut query = format!("00-database-{}", name); + if !self.all_chars { + query = query.replace(|c: char| !c.is_alphanumeric(), ""); + } + let entries = self.index.find(&query, false); + let entry = entries.get(0).ok_or_else(|| errors::DictError::WordNotFound(name.into()))?; + self.content.fetch_definition(entry.offset, entry.size) + .map(|def| { + let start = def.find('\n') + .filter(|pos| *pos < def.len() - 1) + .unwrap_or(0); + def[start..].trim().to_string() + }) + } + + /// Get the short name. + /// + /// This returns the short name of a dictionary. This corresponds to the + /// value passed to the `-s` option of `dictfmt`. + pub fn short_name(&mut self) -> Result { + self.metadata("short") + } + + /// Get the URL. + /// + /// This returns the URL of a dictionary. This corresponds to the + /// value passed to the `-u` option of `dictfmt`. + pub fn url(&mut self) -> Result { + self.metadata("url") + } +} + +/// Load dictionary from given paths +/// +/// A dictionary is made of an index and a dictionary (data) file, both are opened from the given +/// input file names. Gzipped files with the suffix `.dz` will be handled automatically. +pub fn load_dictionary_from_file>(content_path: P, index_path: P) -> Result { + let content = dictreader::load_dict(content_path)?; + let index = Box::new(indexing::parse_index_from_file(index_path, true)?); + Ok(load_dictionary(content, index)) +} + +/// Load dictionary from given `DictReader` and `Index`. +/// +/// A dictionary is made of an index and a dictionary (data). Both are required for look up. This +/// function allows abstraction from the underlying source by only requiring a +/// `dictReader` as trait object. This way, dictionaries from RAM or similar can be +/// implemented. +pub fn load_dictionary(content: Box, index: Box) -> Dictionary { + let all_chars = !index.find("00-database-allchars", false).is_empty(); + let word = if all_chars { + "00-database-case-sensitive" + } else { + "00databasecasesensitive" + }; + let case_sensitive = !index.find(word, false).is_empty(); + Dictionary { content, index, all_chars, case_sensitive } +} diff --git a/src/document/epub/mod.rs b/src/document/epub/mod.rs index 7ab29450..d040b37b 100644 --- a/src/document/epub/mod.rs +++ b/src/document/epub/mod.rs @@ -19,7 +19,7 @@ use super::html::css::{CssParser, RuleKind}; use super::html::xml::XmlParser; const VIEWER_STYLESHEET: &str = "css/epub.css"; -const USER_STYLESHEET: &str = "user.css"; +const USER_STYLESHEET: &str = "css/epub-user.css"; type UriCache = HashMap; diff --git a/src/document/html/engine.rs b/src/document/html/engine.rs index 0e5e5b8c..77cc2b4f 100644 --- a/src/document/html/engine.rs +++ b/src/document/html/engine.rs @@ -897,7 +897,7 @@ impl Engine { items.push(ParagraphItem::Box { width: 0, data: ParagraphElement::Nothing }); } - let is_unbreakable = c == '\u{00A0}' || c == '\u{202F}'; + let is_unbreakable = c == '\u{00A0}' || c == '\u{202F}' || c == '\u{2007}'; if is_unbreakable { items.push(ParagraphItem::Penalty { width: 0, penalty: INFINITE_PENALTY, flagged: false }); diff --git a/src/document/html/mod.rs b/src/document/html/mod.rs index ad89f1b3..822dd717 100644 --- a/src/document/html/mod.rs +++ b/src/document/html/mod.rs @@ -24,7 +24,7 @@ use self::css::{CssParser, RuleKind}; use self::xml::XmlParser; const VIEWER_STYLESHEET: &str = "css/html.css"; -const USER_STYLESHEET: &str = "user.css"; +const USER_STYLESHEET: &str = "css/html-user.css"; type UriCache = HashMap; @@ -35,6 +35,7 @@ pub struct HtmlDocument { parent: PathBuf, size: usize, viewer_stylesheet: PathBuf, + user_stylesheet: PathBuf, ignore_document_css: bool, } @@ -67,6 +68,7 @@ impl HtmlDocument { parent: parent.to_path_buf(), size, viewer_stylesheet: PathBuf::from(VIEWER_STYLESHEET), + user_stylesheet: PathBuf::from(USER_STYLESHEET), ignore_document_css: false, }) } @@ -83,10 +85,18 @@ impl HtmlDocument { parent: PathBuf::from(""), size, viewer_stylesheet: PathBuf::from(VIEWER_STYLESHEET), + user_stylesheet: PathBuf::from(USER_STYLESHEET), ignore_document_css: false, } } + pub fn update(&mut self, content: &str) { + self.size = content.len(); + self.content = XmlParser::new(content).parse(); + self.content.wrap_lost_inlines(); + self.pages.clear(); + } + pub fn set_margin(&mut self, margin: &Edge) { self.engine.set_margin(margin); self.pages.clear(); @@ -102,8 +112,8 @@ impl HtmlDocument { self.pages.clear(); } - pub fn set_ignore_document_css(&mut self, value: bool) { - self.ignore_document_css = value; + pub fn set_user_stylesheet>(&mut self, path: P) { + self.user_stylesheet = path.as_ref().to_path_buf(); self.pages.clear(); } @@ -167,7 +177,7 @@ impl HtmlDocument { stylesheet.append(&mut css); } - if let Ok(text) = fs::read_to_string(USER_STYLESHEET) { + if let Ok(text) = fs::read_to_string(&self.user_stylesheet) { let (mut css, _) = CssParser::new(&text).parse(RuleKind::User); stylesheet.append(&mut css); } diff --git a/src/emulator.rs b/src/emulator.rs index c7ce6b08..4a825974 100644 --- a/src/emulator.rs +++ b/src/emulator.rs @@ -9,6 +9,7 @@ mod battery; mod device; mod font; mod helpers; +mod dictionary; mod document; mod metadata; mod settings; @@ -37,7 +38,7 @@ use sdl2::rect::Point as SdlPoint; use sdl2::rect::Rect as SdlRect; use crate::framebuffer::{Framebuffer, UpdateMode}; use crate::input::{DeviceEvent, FingerStatus}; -use crate::view::{View, Event, ViewId, EntryId, AppId, EntryKind}; +use crate::view::{View, Event, ViewId, EntryId, AppCmd, EntryKind}; use crate::view::{render, render_region, render_no_wait, render_no_wait_region, handle_event, expose}; use crate::view::home::Home; use crate::view::reader::Reader; @@ -45,8 +46,9 @@ use crate::view::notification::Notification; use crate::view::frontlight::FrontlightWindow; use crate::view::keyboard::Keyboard; use crate::view::menu::{Menu, MenuKind}; -use crate::view::sketch::Sketch; +use crate::view::dictionary::Dictionary; use crate::view::calculator::Calculator; +use crate::view::sketch::Sketch; use crate::view::common::{locate, locate_by_id, transfer_notifications, overlapping_rectangle}; use crate::helpers::{load_json, save_json, load_toml, save_toml}; use crate::metadata::{Metadata, METADATA_FILENAME, auto_import}; @@ -390,15 +392,18 @@ pub fn run() -> Result<(), Error> { history.push(view as Box); view = next_view; }, - Event::Select(EntryId::Launch(app_id)) => { + Event::Select(EntryId::Launch(app_cmd)) => { view.children_mut().retain(|child| !child.is::()); - let mut next_view: Box = match app_id { - AppId::Sketch => { + let mut next_view: Box = match app_cmd { + AppCmd::Sketch => { Box::new(Sketch::new(context.fb.rect(), &tx, &mut context)) }, - AppId::Calculator => { + AppCmd::Calculator => { Box::new(Calculator::new(context.fb.rect(), &tx, &mut context)?) }, + AppCmd::Dictionary { ref query, ref language } => { + Box::new(Dictionary::new(context.fb.rect(), query, language, &tx, &mut context)) + }, }; transfer_notifications(view.as_mut(), next_view.as_mut(), &mut context); history.push(view as Box); diff --git a/src/main.rs b/src/main.rs index a985744a..63c5a03b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ mod battery; mod input; mod gesture; mod helpers; +mod dictionary; mod document; mod metadata; mod symbolic_path; diff --git a/src/metadata.rs b/src/metadata.rs index da2ecc88..f9f993a0 100644 --- a/src/metadata.rs +++ b/src/metadata.rs @@ -19,7 +19,6 @@ use crate::symbolic_path; pub const METADATA_FILENAME: &str = ".metadata.json"; pub const IMPORTED_MD_FILENAME: &str = ".metadata-imported.json"; -pub const MATCHES_MD_FILENAME: &str = ".metadata-matches-%Y%m%d_%H%M%S.json"; pub const TRASH_NAME: &str = ".trash"; pub const DEFAULT_CONTRAST_EXPONENT: f32 = 1.0; @@ -631,9 +630,6 @@ pub fn extract_metadata_from_epub(dir: &Path, metadata: &mut Metadata, settings: info.number = doc.series_index().unwrap_or_default(); } info.language = doc.language().unwrap_or_default(); - if info.language == "en" || info.language == "en-US" { - info.language.clear(); - } if subjects_as_categories { info.categories.append(&mut doc.categories()); } diff --git a/src/settings/mod.rs b/src/settings/mod.rs index 05882a98..16e4fd22 100644 --- a/src/settings/mod.rs +++ b/src/settings/mod.rs @@ -1,7 +1,7 @@ mod preset; use std::path::PathBuf; -use fnv::{FnvHashSet, FnvHashMap}; +use std::collections::{HashSet, HashMap, BTreeMap}; use serde::{Serialize, Deserialize}; use crate::metadata::{SortMethod, TextAlign}; use crate::frontlight::LightLevels; @@ -51,13 +51,14 @@ pub struct Settings { pub rotation_lock: Option, pub button_scheme: ButtonScheme, pub auto_suspend: u8, - #[serde(skip_serializing_if = "FnvHashMap::is_empty")] - pub intermission_images: FnvHashMap, + #[serde(skip_serializing_if = "HashMap::is_empty")] + pub intermission_images: HashMap, #[serde(skip_serializing_if = "Vec::is_empty")] pub frontlight_presets: Vec, pub home: HomeSettings, pub reader: ReaderSettings, pub import: ImportSettings, + pub dictionary: DictionarySettings, pub sketch: SketchSettings, pub calculator: CalculatorSettings, pub battery: BatterySettings, @@ -87,8 +88,27 @@ pub struct ImportSettings { pub unshare_trigger: bool, pub startup_trigger: bool, pub traverse_hidden: bool, - pub allowed_kinds: FnvHashSet, - pub category_providers: FnvHashSet, + pub allowed_kinds: HashSet, + pub category_providers: HashSet, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, rename_all = "kebab-case")] +pub struct DictionarySettings { + pub margin_width: i32, + pub font_size: f32, + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub languages: BTreeMap>, +} + +impl Default for DictionarySettings { + fn default() -> Self { + DictionarySettings { + font_size: 11.0, + margin_width: 4, + languages: BTreeMap::new(), + } + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -269,10 +289,11 @@ impl Default for Settings { rotation_lock: None, button_scheme: ButtonScheme::Natural, auto_suspend: 15, - intermission_images: FnvHashMap::default(), + intermission_images: HashMap::new(), home: HomeSettings::default(), reader: ReaderSettings::default(), import: ImportSettings::default(), + dictionary: DictionarySettings::default(), sketch: SketchSettings::default(), calculator: CalculatorSettings::default(), battery: BatterySettings::default(), diff --git a/src/view/calculator/bottom_bar.rs b/src/view/calculator/bottom_bar.rs index 097c733c..cc037919 100644 --- a/src/view/calculator/bottom_bar.rs +++ b/src/view/calculator/bottom_bar.rs @@ -65,13 +65,13 @@ impl BottomBar { pub fn update_font_size(&mut self, font_size: f32, hub: &Hub) { if let Some(labeled_icon) = self.children[3].downcast_mut::() { - labeled_icon.update(format!("{:.1} pt", font_size), hub); + labeled_icon.update(&format!("{:.1} pt", font_size), hub); } } pub fn update_margin_width(&mut self, margin_width: i32, hub: &Hub) { if let Some(labeled_icon) = self.children[1].downcast_mut::() { - labeled_icon.update(format!("{} mm", margin_width), hub); + labeled_icon.update(&format!("{} mm", margin_width), hub); } } } diff --git a/src/view/calculator/input_bar.rs b/src/view/calculator/input_bar.rs index cd90bfe4..9de9a242 100644 --- a/src/view/calculator/input_bar.rs +++ b/src/view/calculator/input_bar.rs @@ -1,4 +1,4 @@ -use crate::framebuffer::{Framebuffer, UpdateMode}; +use crate::framebuffer::{Framebuffer}; use crate::device::CURRENT_DEVICE; use crate::view::{View, Event, Hub, Bus, ViewId, THICKNESS_MEDIUM}; use crate::view::icon::Icon; @@ -64,8 +64,7 @@ impl InputBar { pub fn set_text(&mut self, text: &str, move_cursor: bool, hub: &Hub) { if let Some(input_field) = self.children[2].downcast_mut::() { - input_field.set_text(text, move_cursor); - hub.send(Event::Render(*input_field.rect(), UpdateMode::Gui)).unwrap(); + input_field.set_text(text, move_cursor, hub); } } diff --git a/src/view/common.rs b/src/view/common.rs index fd0ddb75..8bb81820 100644 --- a/src/view/common.rs +++ b/src/view/common.rs @@ -5,7 +5,7 @@ use crate::device::CURRENT_DEVICE; use crate::settings::RotationLock; use crate::framebuffer::UpdateMode; use crate::geom::{Point, Rectangle}; -use super::{View, Event, Hub, ViewId, AppId, EntryId, EntryKind}; +use super::{View, Event, Hub, ViewId, AppCmd, EntryId, EntryKind}; use super::menu::{Menu, MenuKind}; use super::notification::Notification; use crate::app::Context; @@ -79,10 +79,12 @@ pub fn toggle_main_menu(view: &mut dyn View, rect: Rectangle, enable: Option>(); - let apps = vec![EntryKind::Command("Sketch".to_string(), - EntryId::Launch(AppId::Sketch)), + let apps = vec![EntryKind::Command("Dictionary".to_string(), + EntryId::Launch(AppCmd::Dictionary { query: "".to_string(), language: "".to_string() })), EntryKind::Command("Calculator".to_string(), - EntryId::Launch(AppId::Calculator))]; + EntryId::Launch(AppCmd::Calculator)), + EntryKind::Command("Sketch".to_string(), + EntryId::Launch(AppCmd::Sketch))]; let mut entries = vec![EntryKind::CheckBox("Invert Colors".to_string(), EntryId::ToggleInverted, diff --git a/src/view/dictionary/bottom_bar.rs b/src/view/dictionary/bottom_bar.rs new file mode 100644 index 00000000..8ab1c17b --- /dev/null +++ b/src/view/dictionary/bottom_bar.rs @@ -0,0 +1,146 @@ +use crate::framebuffer::{Framebuffer, UpdateMode}; +use crate::view::{View, Event, Hub, Bus, ViewId, Align}; +use crate::view::icon::Icon; +use crate::view::filler::Filler; +use crate::view::label::Label; +use crate::gesture::GestureEvent; +use crate::input::DeviceEvent; +use crate::geom::{Rectangle, CycleDir}; +use crate::color::WHITE; +use crate::font::Fonts; +use crate::app::Context; + +#[derive(Debug)] +pub struct BottomBar { + rect: Rectangle, + children: Vec>, + has_prev: bool, + has_next: bool, +} + +impl BottomBar { + pub fn new(rect: Rectangle, name: &str, has_prev: bool, has_next: bool) -> BottomBar { + let mut children = Vec::new(); + let side = rect.height() as i32; + + let prev_rect = rect![rect.min, rect.min + side]; + + if has_prev { + let prev_icon = Icon::new("arrow-left", + prev_rect, + Event::Page(CycleDir::Previous)); + children.push(Box::new(prev_icon) as Box); + } else { + let prev_filler = Filler::new(prev_rect, WHITE); + children.push(Box::new(prev_filler) as Box); + } + + let name_rect = rect![pt!(rect.min.x + side, rect.min.y), + pt!(rect.max.x - side, rect.max.y)]; + let name_label = Label::new(name_rect, name.to_string(), Align::Center) + .event(Some(Event::ToggleNear(ViewId::SearchTargetMenu, name_rect))) + .hold_event(Some(Event::EditLanguages)); + children.push(Box::new(name_label) as Box); + + let next_rect = rect![rect.max - side, rect.max]; + + if has_next { + let next_icon = Icon::new("arrow-right", + rect![rect.max - side, rect.max], + Event::Page(CycleDir::Next)); + children.push(Box::new(next_icon) as Box); + } else { + let next_filler = Filler::new(next_rect, WHITE); + children.push(Box::new(next_filler) as Box); + } + + BottomBar { + rect, + children, + has_prev, + has_next, + } + } + + pub fn update_icons(&mut self, has_prev: bool, has_next: bool, hub: &Hub) { + if self.has_prev != has_prev { + let index = 0; + let prev_rect = *self.child(index).rect(); + if has_prev { + let prev_icon = Icon::new("arrow-left", + prev_rect, + Event::Page(CycleDir::Previous)); + self.children[index] = Box::new(prev_icon) as Box; + } else { + let prev_filler = Filler::new(prev_rect, WHITE); + self.children[index] = Box::new(prev_filler) as Box; + } + self.has_prev = has_prev; + hub.send(Event::Render(prev_rect, UpdateMode::Gui)).unwrap(); + } + + if self.has_next != has_next { + let index = self.len() - 1; + let next_rect = *self.child(index).rect(); + if has_next { + let next_icon = Icon::new("arrow-right", + next_rect, + Event::Page(CycleDir::Next)); + self.children[index] = Box::new(next_icon) as Box; + } else { + let next_filler = Filler::new(next_rect, WHITE); + self.children[index] = Box::new(next_filler) as Box; + } + self.has_next = has_next; + hub.send(Event::Render(next_rect, UpdateMode::Gui)).unwrap(); + } + } + + pub fn update_name(&mut self, text: &str, hub: &Hub) { + let name_label = self.child_mut(1).downcast_mut::