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:
parent
699d720a71
commit
7670c4f0ae
82
Cargo.lock
generated
82
Cargo.lock
generated
@ -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
14
frecency/Cargo.toml
Normal 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
134
frecency/src/lib.rs
Normal 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}");
|
||||
}
|
||||
}
|
@ -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"
|
||||
|
@ -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,
|
||||
|
Loading…
Reference in New Issue
Block a user