feat: configure command and search popup anchors

This commit is contained in:
Valdemar Lorenzen
2026-10-12 13:41:45 +02:00
parent 406f45c6fb
commit 6eb99a792a
11 changed files with 440 additions and 10 deletions
+134
View File
@@ -1,6 +1,86 @@
use serde::{Deserialize, Deserializer};
use std::collections::BTreeMap;
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum PopupAnchor {
TopLeft,
TopCenter,
TopRight,
CenterLeft,
Center,
CenterRight,
#[default]
BottomLeft,
BottomCenter,
BottomRight,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct PopupOptions {
pub anchor: PopupAnchor,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct PopupConfig {
pub command: PopupOptions,
pub search: PopupOptions,
}
impl<'de> Deserialize<'de> for PopupOptions {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Options {
#[serde(default)]
anchor: PopupAnchor,
}
let options: Options = deserialize_object(deserializer)?;
Ok(Self {
anchor: options.anchor,
})
}
}
impl<'de> Deserialize<'de> for PopupConfig {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Options {
#[serde(default)]
command: PopupOptions,
#[serde(default)]
search: PopupOptions,
}
let options: Options = deserialize_object(deserializer)?;
Ok(Self {
command: options.command,
search: options.search,
})
}
}
// Struct derives also accept sequences; popup configuration accepts only objects.
fn deserialize_object<'de, D: Deserializer<'de>, T: Deserialize<'de>>(
deserializer: D,
) -> Result<T, D::Error> {
struct ObjectVisitor<T>(std::marker::PhantomData<T>);
impl<'de, T: Deserialize<'de>> serde::de::Visitor<'de> for ObjectVisitor<T> {
type Value = T;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a popup configuration object")
}
fn visit_map<M: serde::de::MapAccess<'de>>(self, map: M) -> Result<T, M::Error> {
T::deserialize(serde::de::value::MapAccessDeserializer::new(map))
}
}
deserializer.deserialize_map(ObjectVisitor(std::marker::PhantomData))
}
const DEFAULT_BOTTOM_SEGMENTS: &[&str] = &[
"mode",
"pending",
@@ -260,6 +340,60 @@ pub fn labels<'de, D: Deserializer<'de>>(
mod tests {
use super::*;
#[test]
fn popup_partial_options_keep_command_and_search_independent() {
let popup: PopupConfig = serde_json::from_value(serde_json::json!({
"command": {"anchor": "top_right"}
}))
.unwrap();
assert_eq!(popup.command.anchor, PopupAnchor::TopRight);
assert_eq!(popup.search.anchor, PopupAnchor::BottomLeft);
let popup: PopupConfig = serde_json::from_value(serde_json::json!({
"command": {}, "search": {"anchor": "center_left"}
}))
.unwrap();
assert_eq!(popup.command.anchor, PopupAnchor::BottomLeft);
assert_eq!(popup.search.anchor, PopupAnchor::CenterLeft);
}
#[test]
fn malformed_popup_options_are_rejected_at_the_boundary() {
for value in [
serde_json::json!(null),
serde_json::json!([]),
serde_json::json!(7),
serde_json::json!({"selector": {}}),
] {
assert!(
serde_json::from_value::<PopupConfig>(value.clone()).is_err(),
"{value}"
);
}
for options in [
serde_json::json!(null),
serde_json::json!([]),
serde_json::json!("center"),
serde_json::json!({"anchor": null}),
serde_json::json!({"anchor": 7}),
serde_json::json!({"anchor": true}),
serde_json::json!({"anchor": []}),
serde_json::json!({"anchor": {}}),
serde_json::json!({"anchor": "middle"}),
serde_json::json!({"anchor": "TopLeft"}),
serde_json::json!({"width": 72}),
serde_json::json!({"anchor": "center", "callback": "run"}),
] {
for mode in ["command", "search"] {
let mut popup = serde_json::json!({});
popup[mode] = options.clone();
assert!(
serde_json::from_value::<PopupConfig>(popup).is_err(),
"{mode}: {options}"
);
}
}
}
#[derive(Deserialize)]
struct Lines {
#[serde(deserialize_with = "top")]