1
1
mirror of https://github.com/wez/wezterm.git synced 2024-11-25 21:07:39 +03:00

char selector: track recently selected emoji and use frecency to show it

refs: https://github.com/wez/wezterm/issues/2163
This commit is contained in:
Wez Furlong 2022-08-31 22:56:58 -07:00
parent 699d720a71
commit 7670c4f0ae
5 changed files with 308 additions and 10 deletions

82
Cargo.lock generated
View File

@ -992,6 +992,41 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b365fabc795046672053e29c954733ec3b05e4be654ab130fe8f1f94d7051f35"
[[package]]
name = "darling"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4529658bdda7fd6769b8614be250cdcfc3aeb0ee72fe66f9e41e5e5eb73eac02"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "649c91bc01e8b1eac09fb91e8dbc7d517684ca6be8ebc75bb9cafc894f9fdb6f"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn",
]
[[package]]
name = "darling_macro"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddfc69c5bfcbd2fc09a0f38451d2daf0e372e367986a83906d1b0dbc88134fb5"
dependencies = [
"darling_core",
"quote",
"syn",
]
[[package]]
name = "dashmap"
version = "5.3.4"
@ -1434,6 +1469,16 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "frecency"
version = "0.1.0"
dependencies = [
"chrono",
"serde",
"serde_json",
"serde_with",
]
[[package]]
name = "freetype"
version = "0.1.0"
@ -1943,6 +1988,12 @@ dependencies = [
"winapi",
]
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "idna"
version = "0.2.3"
@ -1999,6 +2050,7 @@ checksum = "10a35a97730320ffe8e2d410b5d3b69279b98d2c14bdb8b70ea89ecf7888d41e"
dependencies = [
"autocfg",
"hashbrown 0.12.3",
"serde",
]
[[package]]
@ -4039,6 +4091,34 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_with"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89df7a26519371a3cce44fbb914c2819c84d9b897890987fa3ab096491cc0ea8"
dependencies = [
"base64",
"chrono",
"hex",
"indexmap",
"serde",
"serde_json",
"serde_with_macros",
"time 0.3.14",
]
[[package]]
name = "serde_with_macros"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de337f322382fcdfbb21a014f7c224ee041a23785651db67b9827403178f698f"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_yaml"
version = "0.9.10"
@ -4690,6 +4770,7 @@ dependencies = [
"itoa 1.0.3",
"libc",
"num_threads",
"serde",
]
[[package]]
@ -5435,6 +5516,7 @@ dependencies = [
"fastrand",
"filedescriptor",
"fnv",
"frecency",
"futures",
"fuzzy-matcher",
"hdrhistogram",

14
frecency/Cargo.toml Normal file
View File

@ -0,0 +1,14 @@
[package]
name = "frecency"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
chrono = "0.4"
serde = {version="1.0", features = ["derive"]}
serde_with = {version="2.0", features = ["chrono_0_4"]}
[dev-dependencies]
serde_json = "1.0"

134
frecency/src/lib.rs Normal file
View File

@ -0,0 +1,134 @@
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
/// Frecency tracks stats around when an item was accessed,
/// and provides a score that is a combination of frequency
/// and recency that is useful when presenting the user
/// with a list of previously access items.
#[serde_with::serde_as]
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Frecency {
/// The frecency score decays to half its value when
/// half_life has elapsed since its previous access.
#[serde_as(as = "serde_with::DurationSeconds<i64>")]
half_life: Duration,
#[serde_as(as = "serde_with::TimestampSeconds<i64>")]
last_accessed: DateTime<Utc>,
frecency: f64,
num_accesses: u64,
}
impl Default for Frecency {
fn default() -> Self {
Self::new()
}
}
impl Frecency {
/// Creates a new Frecency that initially has no accesses
pub fn new() -> Self {
Self::new_at_time(Utc::now())
}
/// Creates a new Frecency that initially has no accesses.
/// `now` is the current time, if you happen to already know it.
pub fn new_at_time(now: DateTime<Utc>) -> Self {
Self {
half_life: Duration::days(3),
frecency: 0.0,
last_accessed: now,
num_accesses: 0,
}
}
/// Record an access; updates internal stats accordingly
pub fn register_access(&mut self) {
self.register_access_at_time(Utc::now());
}
/// Record an access at a given time; updates internal stats accordingly
pub fn register_access_at_time(&mut self, now: DateTime<Utc>) {
let prior = self.score_at_time(now);
self.last_accessed = now;
self.set_frecency_at_time(1.0 + prior, now);
self.num_accesses += 1;
}
/// Returns the number of accesses
pub fn num_accesses(&self) -> u64 {
self.num_accesses
}
/// Returns the time when the item was last accessed
pub fn last_accessed(&self) -> &DateTime<Utc> {
&self.last_accessed
}
/// Compute the frecency score
pub fn score(&self) -> f64 {
self.score_at_time(Utc::now())
}
/// Compute the frecency score at a particular time
pub fn score_at_time(&self, now: DateTime<Utc>) -> f64 {
let elapsed = duration_secs_f64(now - self.last_accessed);
self.frecency / 2.0_f64.powf(elapsed / duration_secs_f64(self.half_life))
}
fn set_frecency_at_time(&mut self, value: f64, now: DateTime<Utc>) {
let elapsed = duration_secs_f64(now - self.last_accessed);
self.frecency = value * 2.0_f64.powf(elapsed / duration_secs_f64(self.half_life));
}
}
fn duration_secs_f64(dur: Duration) -> f64 {
dur.num_milliseconds() as f64 / 1000.
}
#[cfg(test)]
mod tests {
use super::*;
fn approx_eq(a: f64, b: f64) -> bool {
a == b || (a - b).abs() <= f64::EPSILON
}
fn assert_approx_eq(a: f64, b: f64) {
if !approx_eq(a, b) {
panic!("expected {a} to be approx. {b}");
}
}
#[test]
fn it_works() {
let now = Utc::now();
let mut f = Frecency::new_at_time(now);
assert_eq!(f.score_at_time(now), 0.);
f.register_access_at_time(now);
assert_eq!(f.score_at_time(now), 1.0);
assert_approx_eq(f.score_at_time(now + Duration::days(1)), 0.7937005259840997);
// After 3 days (the half life), we expect the frecency to decay to half
assert_approx_eq(f.score_at_time(now + Duration::days(3)), 0.5);
// An access adds 1 to the score
f.register_access_at_time(now + Duration::days(3));
assert_approx_eq(f.score_at_time(now + Duration::days(3)), 1.5);
assert_approx_eq(f.score_at_time(now + Duration::days(30)), 0.0029296875);
assert_approx_eq(
f.score_at_time(now + Duration::days(300)),
0.0000000000000000000000000000023665827156630354,
);
assert_eq!(f.num_accesses(), 2);
}
#[test]
fn serialize() {
use chrono::TimeZone;
let now = Utc.ymd(2022, 08, 31).and_hms(22, 16, 0);
let f = Frecency::new_at_time(now);
assert_eq!(serde_json::to_string(&f).unwrap(), "{\"half_life\":259200,\"last_accessed\":1661984160,\"frecency\":0.0,\"num_accesses\":0}");
}
}

View File

@ -37,6 +37,7 @@ euclid = "0.22"
fastrand = "1.6"
filedescriptor = { version="0.8", path = "../filedescriptor" }
fnv = "1.0"
frecency = { path = "../frecency" }
futures = "0.3"
fuzzy-matcher = "0.3"
hdrhistogram = "7.1"

View File

@ -12,10 +12,13 @@ use config::keyassignment::{
};
use config::Dimension;
use emojis::{Emoji, Group};
use frecency::Frecency;
use fuzzy_matcher::skim::SkimMatcherV2;
use fuzzy_matcher::FuzzyMatcher;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use std::cell::{Ref, RefCell};
use std::path::PathBuf;
use wezterm_term::{KeyCode, KeyModifiers, MouseEvent};
use window::color::LinearRgba;
@ -46,7 +49,7 @@ enum Character {
struct Alias {
name: Cow<'static, str>,
character: Character,
group: CharSelectGroup,
group: Option<CharSelectGroup>,
}
impl Alias {
@ -73,6 +76,47 @@ impl Alias {
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
struct Recent {
glyph: String,
name: String,
frecency: Frecency,
}
fn recent_file_name() -> PathBuf {
config::RUNTIME_DIR.join("recent-emoji.json")
}
fn load_recents() -> anyhow::Result<Vec<Recent>> {
let file_name = recent_file_name();
let f = std::fs::File::open(&file_name)?;
let mut recents: Vec<Recent> = serde_json::from_reader(f)?;
recents.sort_by(|a, b| b.frecency.score().partial_cmp(&a.frecency.score()).unwrap());
Ok(recents)
}
fn save_recent(alias: &Alias) -> anyhow::Result<()> {
let mut recents = load_recents().unwrap_or_else(|_| vec![]);
let glyph = alias.glyph();
if let Some(recent_idx) = recents.iter().position(|r| r.glyph == glyph) {
let recent = recents.get_mut(recent_idx).unwrap();
recent.frecency.register_access();
} else {
let mut frecency = Frecency::new();
frecency.register_access();
recents.push(Recent {
glyph,
name: alias.name().to_string(),
frecency,
});
}
let json = serde_json::to_string(&recents)?;
let file_name = recent_file_name();
std::fs::write(&file_name, json)?;
Ok(())
}
fn build_aliases() -> Vec<Alias> {
let mut aliases = vec![];
let start = std::time::Instant::now();
@ -81,6 +125,25 @@ fn build_aliases() -> Vec<Alias> {
aliases.push(alias);
}
if let Ok(recents) = load_recents() {
for r in recents {
let character = if let Some(emoji) = emojis::get(&r.glyph) {
Character::Emoji(emoji)
} else {
Character::Unicode {
name: "",
value: r.glyph.chars().next().unwrap(),
}
};
aliases.push(Alias {
name: Cow::Owned(r.name.clone()),
character,
group: None,
});
}
}
for emoji in emojis::iter() {
let group = match emoji.group() {
Group::SmileysAndEmotion => CharSelectGroup::SmileysAndEmotion,
@ -98,7 +161,7 @@ fn build_aliases() -> Vec<Alias> {
Alias {
name: Cow::Borrowed(emoji.name()),
character: Character::Emoji(emoji),
group,
group: Some(group),
},
);
if let Some(short) = emoji.shortcode() {
@ -108,7 +171,7 @@ fn build_aliases() -> Vec<Alias> {
Alias {
name: Cow::Borrowed(short),
character: Character::Emoji(emoji),
group,
group: Some(group),
},
);
}
@ -124,7 +187,7 @@ fn build_aliases() -> Vec<Alias> {
name,
value: char::from_u32(*value).unwrap(),
},
group: CharSelectGroup::UnicodeNames,
group: Some(CharSelectGroup::UnicodeNames),
},
);
}
@ -138,7 +201,7 @@ fn build_aliases() -> Vec<Alias> {
name,
value: *value,
},
group: CharSelectGroup::NerdFonts,
group: Some(CharSelectGroup::NerdFonts),
},
);
}
@ -179,7 +242,10 @@ fn compute_matches(selection: &str, aliases: &[Alias], group: CharSelectGroup) -
aliases
.iter()
.enumerate()
.filter(|(_idx, a)| a.group == group)
.filter(|(_idx, a)| match a.group {
Some(g) => g == group,
None => true,
})
.map(|(idx, _a)| idx)
.collect()
} else {
@ -418,8 +484,6 @@ impl CharSelector {
if *row < *top_row {
*top_row = *row;
}
log::info!("selected_row={} top_row={}", *row, *top_row);
}
fn move_down(&self) {
@ -437,7 +501,6 @@ impl CharSelector {
if *row + *top_row > max_rows_on_screen - 1 {
*top_row = row.saturating_sub(max_rows_on_screen - 1);
}
log::info!("selected_row={} top_row={}", *row, *top_row);
}
}
@ -503,7 +566,11 @@ impl Modal for CharSelector {
.borrow()
.as_ref()
.map_or(selected_idx, |m| m.matches[selected_idx]);
let glyph = self.aliases[alias_idx].glyph();
let item = &self.aliases[alias_idx];
if let Err(err) = save_recent(item) {
log::error!("Error while saving recents: {err:#}");
}
let glyph = item.glyph();
log::trace!("selected: {glyph}");
term_window.copy_to_clipboard(
ClipboardCopyDestination::ClipboardAndPrimarySelection,