feat(lib): port libghostty, Ghostty's embedded C API

Ports apprt/embedded.zig, main_c.zig and config/CApi.zig + c_get.zig as
crates/ghostty-lib (cdylib/staticlib/rlib `libghostty`), exporting every
function of the unchanged include/ghostty.h - the ABI Ghostty's Swift
macOS/iOS app links against. The embedded runtime implements the
runtime traits over the host's C callbacks, so the real core drives it.

- ghostty-core selects the renderer per target: Metal on Apple (surface
  NSView/UIView via RtSurface::platform_view), OpenGL elsewhere.
- ghostty_config_get follows Zig's c_get rules via a CValue type (143
  supported keys, checked against the Zig field types).
- The inspector C API is complete; its Metal backend replaces ImGui's
  Metal renderer and has not run.

Verification: generated C ABI probe (sizeof/alignof/offsetof of every
struct/union, 755 enum values, every function linked, nm exports, headers
byte-identical to upstream); a C host on Linux covering init, CLI
actions, config, app, keys, focus and color scheme; an opt-in
test-platform feature creating real surfaces on Linux with headless EGL
and a pty (13 tests). Apple targets are clippy-clean but not run; the
Swift app has not been built against it.

Test Plan:
- cargo test -p ghostty-lib (and --features test-platform)
- cargo clippy --target aarch64-apple-darwin -p ghostty-lib (cross env
  in docs/rewrite/notes/macos-M1.md)
This commit is contained in:
ddidderr committed 2026-10-12 06:38:33 +02:00
1 parent f2252bb165
commit 75bf604375
47 files changed
+10296 -34

No files matched your search

+64
View File
@@ -0,0 +1,64 @@
//! The C representation of config values (Zig: `config/c_get.zig` together
//! with the `cval` functions of the config types).
//!
//! `ghostty_config_get` writes a value of a key-dependent C type into a
//! caller supplied pointer. This crate cannot use `unsafe`, so the typed
//! value is described by [`CValue`] and the C API crate (`ghostty-lib`) does
//! the actual write.
use ghostty_input::command::CommandC;
/// A size of the quick terminal along one axis in C form
/// (`ghostty_quick_terminal_size_s`).
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum QuickTerminalSizeC {
None,
Percentage(f32),
Pixels(u32),
}
/// The value of a config field in the form the C API exposes it.
#[derive(Debug, Clone, PartialEq)]
pub enum CValue {
/// The type of this field is not supported by the C API (Zig `getValue`
/// returns false).
Unsupported,
/// An optional field without a value (returns false).
Null,
/// An optional string without a value: `ghostty_config_get` returns true
/// and stores a null pointer.
NullString,
/// A string (`?[:0]const u8`): stored as a NUL terminated `const char*`.
String(String),
/// A boolean (`bool`).
Bool(bool),
/// `u8` / `u32`, stored as `c_uint`.
Uint(u32),
/// `i16`, stored as `c_short`.
Short(i16),
/// `f32`.
F32(f32),
/// `f64`.
F64(f64),
/// `usize` (`Duration.cval`).
Usize(usize),
/// An enum: the tag name, stored as a NUL terminated `const char*`.
EnumName(&'static str),
/// A packed struct of at most 32 bits: its bits stored as `c_uint`.
Packed(u32),
/// `ghostty_config_color_s`.
Color { r: u8, g: u8, b: u8 },
/// `ghostty_config_color_list_s`.
ColorList(Vec<[u8; 3]>),
/// `ghostty_config_palette_s`.
Palette(Box<[[u8; 3]; 256]>),
/// `ghostty_config_command_list_s`.
Commands(Vec<CommandC>),
/// `ghostty_config_quick_terminal_size_s`.
QuickTerminalSize {
primary: QuickTerminalSizeC,
secondary: QuickTerminalSizeC,
},
/// `ghostty_config_path_s`.
Path { path: String, optional: bool },
}
+67 -4
View File
@@ -15,6 +15,7 @@
use std::{fmt, io::BufRead};
use crate::{
c_value::CValue,
comma_splitter::CommaSplitter,
diagnostics::{Diagnostic, DiagnosticList, Location},
formatter::EntryFormatter,
@@ -113,6 +114,28 @@ pub trait ConfigField: Clone + PartialEq + Default + fmt::Debug {
/// Write this value in the `+show-config` format.
fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result;
/// The value in the form the C API exposes (Zig: `c_get.getValue`).
/// Types the C API doesn't support yet return [`CValue::Unsupported`].
fn c_value(&self) -> CValue {
CValue::Unsupported
}
/// [`ConfigField::c_value`] when the value is wrapped in an `Option`
/// (only `String` differs: `?[:0]const u8` is supported, `[]const u8`
/// is not).
fn c_value_optional(&self) -> CValue {
self.c_value()
}
/// The C value of an `Option<Self>` that is `None`.
fn c_value_none() -> CValue {
if matches!(Self::default().c_value(), CValue::Unsupported) {
CValue::Unsupported
} else {
CValue::Null
}
}
}
/// The destination of CLI parsing.
@@ -456,6 +479,10 @@ pub fn parse_int_base0(s: &str) -> Option<i128> {
impl ConfigField for bool {
const KIND: FieldKind = FieldKind::Bool;
fn c_value(&self) -> CValue {
CValue::Bool(*self)
}
fn parse_cli(&mut self, value: Option<&str>) -> Result<(), ArgError> {
*self = parse_bool(value.unwrap_or("t"))?;
Ok(())
@@ -467,8 +494,13 @@ impl ConfigField for bool {
}
macro_rules! impl_int_field {
($($t:ty),*) => {$(
($($t:ty => $cv:expr),*) => {$(
impl ConfigField for $t {
fn c_value(&self) -> CValue {
let f: fn($t) -> CValue = $cv;
f(*self)
}
fn parse_cli(&mut self, value: Option<&str>) -> Result<(), ArgError> {
let v = value.ok_or(ArgError::ValueRequired)?;
let n = parse_int_base0(v).ok_or(ArgError::InvalidValue)?;
@@ -482,11 +514,27 @@ macro_rules! impl_int_field {
}
)*};
}
impl_int_field!(u8, u16, u32, u64, usize, i8, i16, i32, i64, isize);
impl_int_field!(
u8 => |v| CValue::Uint(u32::from(v)),
u16 => |_| CValue::Unsupported,
u32 => CValue::Uint,
u64 => |_| CValue::Unsupported,
usize => |_| CValue::Unsupported,
i8 => |_| CValue::Unsupported,
i16 => CValue::Short,
i32 => |_| CValue::Unsupported,
i64 => |_| CValue::Unsupported,
isize => |_| CValue::Unsupported
);
macro_rules! impl_float_field {
($($t:ty),*) => {$(
($($t:ty => $cv:expr),*) => {$(
impl ConfigField for $t {
fn c_value(&self) -> CValue {
let f: fn($t) -> CValue = $cv;
f(*self)
}
fn parse_cli(&mut self, value: Option<&str>) -> Result<(), ArgError> {
let v = value.ok_or(ArgError::ValueRequired)?;
*self = v.parse::<$t>().map_err(|_| ArgError::InvalidValue)?;
@@ -499,9 +547,17 @@ macro_rules! impl_float_field {
}
)*};
}
impl_float_field!(f32, f64);
impl_float_field!(f32 => CValue::F32, f64 => CValue::F64);
impl ConfigField for String {
fn c_value_optional(&self) -> CValue {
CValue::String(self.clone())
}
fn c_value_none() -> CValue {
CValue::NullString
}
fn parse_cli(&mut self, value: Option<&str>) -> Result<(), ArgError> {
*self = value.ok_or(ArgError::ValueRequired)?.to_string();
Ok(())
@@ -519,6 +575,13 @@ impl<T: ConfigField> ConfigField for Option<T> {
_ => FieldKind::OptionalOther,
};
fn c_value(&self) -> CValue {
match self {
Some(v) => v.c_value_optional(),
None => T::c_value_none(),
}
}
fn parse_cli(&mut self, value: Option<&str>) -> Result<(), ArgError> {
// For optional fields, we just treat it as the child type.
if let Some(v) = self {
+91
View File
@@ -3810,6 +3810,16 @@ impl Default for Config {
}
impl Config {
/// The value of the config key in the form the C API exposes it (Zig:
/// `c_get.get`). `None` if there is no such key.
#[must_use]
pub fn c_value(&self, key: &str) -> Option<crate::c_value::CValue> {
<Config as crate::macros::ConfigStruct>::FIELDS
.iter()
.find(|f| f.name == key)
.map(|f| (f.c_value)(self))
}
/// Build the default configuration (Zig: `Config.default`).
#[must_use]
pub fn new_default() -> Config {
@@ -5412,4 +5422,85 @@ mod tests {
assert!(out.contains("\nfont-size = 12\n"));
assert!(out.contains("\nbackground = #282c34\n"));
}
/// The keys `ghostty_config_get` supports, derived from the Zig field
/// types and `c_get.getValue` (see `testdata/c_get_supported.txt`).
#[test]
fn c_value_supported_keys_match_zig() {
use crate::{c_value::CValue, macros::ConfigStruct};
let expected: std::collections::BTreeSet<&str> =
include_str!("testdata/c_get_supported.txt")
.lines()
.filter(|l| !l.is_empty())
.collect();
let cfg = Config::default();
let mut actual = std::collections::BTreeSet::new();
for f in <Config as ConfigStruct>::FIELDS {
if !matches!((f.c_value)(&cfg), CValue::Unsupported) {
actual.insert(f.name);
}
}
let missing: Vec<_> = expected.difference(&actual).collect();
let extra: Vec<_> = actual.difference(&expected).collect();
assert!(
missing.is_empty() && extra.is_empty(),
"missing={missing:?} extra={extra:?}"
);
}
#[test]
fn c_value_basic_types() {
use crate::c_value::CValue;
let mut cfg = Config::default();
cfg.maximize = true;
assert_eq!(cfg.c_value("maximize"), Some(CValue::Bool(true)));
cfg.window_theme = WindowTheme::Dark;
assert_eq!(cfg.c_value("window-theme"), Some(CValue::EnumName("dark")));
cfg.title = None;
assert_eq!(cfg.c_value("title"), Some(CValue::NullString));
cfg.unfocused_split_fill = None;
assert_eq!(cfg.c_value("unfocused-split-fill"), Some(CValue::Null));
cfg.background_opacity = 0.42;
assert_eq!(cfg.c_value("background-opacity"), Some(CValue::F64(0.42)));
cfg.background = crate::types_a::Color {
r: 12,
g: 34,
b: 56,
};
assert_eq!(
cfg.c_value("background"),
Some(CValue::Color {
r: 12,
g: 34,
b: 56
})
);
assert_eq!(cfg.c_value("not-a-real-key"), None);
assert_eq!(cfg.c_value("font-family"), Some(CValue::Unsupported));
}
#[test]
fn c_value_background_blur() {
use crate::{c_value::CValue, types_b::BackgroundBlur};
let mut cfg = Config::default();
for (v, expect) in [
(BackgroundBlur::False, 0),
(BackgroundBlur::True, 20),
(BackgroundBlur::Radius(42), 42),
(BackgroundBlur::MacosGlassRegular, -1),
(BackgroundBlur::MacosGlassClear, -2),
] {
cfg.background_blur = v;
assert_eq!(cfg.c_value("background-blur"), Some(CValue::Short(expect)));
}
}
#[test]
fn c_value_split_preserve_zoom() {
use crate::c_value::CValue;
let mut cfg = Config::default();
assert_eq!(cfg.c_value("split-preserve-zoom"), Some(CValue::Packed(0)));
cfg.split_preserve_zoom.navigation = true;
assert_eq!(cfg.c_value("split-preserve-zoom"), Some(CValue::Packed(1)));
}
}
+10
View File
@@ -6,6 +6,7 @@ use std::fmt;
use ghostty_input::{KeyRemapSet, OptionAsAlt};
use crate::{
c_value::CValue,
cli_args::{ArgError, ConfigField},
formatter::EntryFormatter,
};
@@ -30,6 +31,15 @@ impl ConfigField for KeyRemapSet {
impl ConfigField for OptionAsAlt {
const VALID_VALUES: Option<&'static [&'static str]> = None;
fn c_value(&self) -> CValue {
CValue::EnumName(match self {
OptionAsAlt::False => "false",
OptionAsAlt::True => "true",
OptionAsAlt::Left => "left",
OptionAsAlt::Right => "right",
})
}
fn parse_cli(&mut self, value: Option<&str>) -> Result<(), ArgError> {
let v = value.ok_or(ArgError::ValueRequired)?;
*self = match v {
+1
View File
@@ -13,6 +13,7 @@
)]
// Generic CLI parsing infrastructure.
pub mod c_value;
pub mod cli_args;
pub mod comma_splitter;
pub mod diagnostics;
+21
View File
@@ -37,6 +37,8 @@ pub struct FieldInfo<T> {
pub eq: fn(&T, &T) -> bool,
/// Format this field as in `+show-config`.
pub format: fn(&T, &str, &mut dyn fmt::Write) -> fmt::Result,
/// The value in the form the C API exposes it.
pub c_value: fn(&T) -> crate::c_value::CValue,
}
impl<T> FieldInfo<T> {
@@ -147,6 +149,7 @@ macro_rules! config_struct {
let mut f = $crate::formatter::EntryFormatter::new(name, w);
$crate::cli_args::ConfigField::format_entry(&a.$field, &mut f)
},
c_value: |a: &$name| $crate::cli_args::ConfigField::c_value(&a.$field),
},
)*
];
@@ -221,6 +224,10 @@ macro_rules! config_enum {
::core::result::Result::Ok(())
}
fn c_value(&self) -> $crate::c_value::CValue {
$crate::c_value::CValue::EnumName(self.as_str())
}
fn format_entry(
&self,
f: &mut $crate::formatter::EntryFormatter<'_>,
@@ -274,6 +281,20 @@ macro_rules! config_packed {
const KIND: $crate::cli_args::FieldKind =
$crate::cli_args::FieldKind::Packed(Self::NAMES);
fn c_value(&self) -> $crate::c_value::CValue {
// Packed bools: the first field is bit 0.
let mut bits = 0u32;
let mut _i = 0u32;
$(
if self.$field && _i < 32 { bits |= 1 << _i; }
_i += 1;
)*
if _i > 32 {
return $crate::c_value::CValue::Unsupported;
}
$crate::c_value::CValue::Packed(bits)
}
fn parse_cli(
&mut self,
value: ::core::option::Option<&str>,
+14
View File
@@ -6,6 +6,7 @@ use std::{
};
use crate::{
c_value::CValue,
cli_args::{ArgError, ConfigField},
diagnostics::{Diagnostic, DiagnosticList},
formatter::EntryFormatter,
@@ -171,6 +172,19 @@ impl Path {
}
impl ConfigField for Path {
fn c_value(&self) -> CValue {
match self {
Path::Optional(p) => CValue::Path {
path: p.clone(),
optional: true,
},
Path::Required(p) => CValue::Path {
path: p.clone(),
optional: false,
},
}
}
/// Parse CLI option. The value will be overwritten.
fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> {
let Some(item) = Path::parse(input)? else {
@@ -46,6 +46,14 @@ impl RepeatableStringMap {
.map(|(_, v)| v.as_str())
}
/// Insert or replace an entry (Zig: `map.put`).
pub fn put(&mut self, key: &str, value: &str) {
match self.map.iter_mut().find(|(k, _)| k == key) {
Some((_, v)) => value.clone_into(v),
None => self.map.push((key.to_owned(), value.to_owned())),
}
}
/// Iterator over the entries in the map (insertion order).
pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
self.map.iter().map(|(k, v)| (k.as_str(), v.as_str()))
+143
View File
@@ -0,0 +1,143 @@
language
font-synthetic-style
font-size
font-thicken
font-thicken-strength
font-shaping-break
alpha-blending
grapheme-width-method
freetype-load-flags
background
foreground
background-image
background-image-opacity
background-image-position
background-image-fit
background-image-repeat
selection-clear-on-typing
selection-clear-on-copy
minimum-contrast
palette
palette-generate
palette-harmonious
cursor-opacity
cursor-style
cursor-style-blink
cursor-click-to-move
mouse-hide-while-typing
scroll-to-bottom
mouse-shift-capture
mouse-reporting
background-opacity
background-opacity-cells
background-blur
unfocused-split-opacity
unfocused-split-fill
split-divider-color
split-preserve-zoom
notify-on-command-finish
notify-on-command-finish-action
notify-on-command-finish-after
wait-after-command
abnormal-command-exit-runtime
scrollbar
link-url
link-previews
maximize
fullscreen
title
class
x11-instance-name
window-padding-balance
window-padding-color
window-vsync
window-inherit-working-directory
tab-inherit-working-directory
split-inherit-working-directory
window-inherit-font-size
window-decoration
window-title-font-family
window-subtitle
window-theme
window-colorspace
window-height
window-width
window-position-x
window-position-y
window-save-state
window-step-resize
window-new-tab-position
window-show-tab-bar
window-titlebar-background
window-titlebar-foreground
resize-overlay
resize-overlay-position
resize-overlay-duration
focus-follows-mouse
clipboard-read
clipboard-write
clipboard-trim-trailing-spaces
clipboard-paste-protection
clipboard-paste-bracketed-safe
title-report
image-storage-limit
copy-on-select
right-click-action
click-repeat-interval
config-default-files
confirm-close-surface
quit-after-last-window-closed
quit-after-last-window-closed-delay
initial-window
undo-timeout
quick-terminal-position
quick-terminal-size
gtk-quick-terminal-layer
quick-terminal-screen
quick-terminal-animation-duration
quick-terminal-autohide
quick-terminal-space-behavior
quick-terminal-keyboard-interactivity
shell-integration
shell-integration-features
command-palette-entry
osc-color-report-format
vt-kam-allowed
custom-shader-animation
bell-features
bell-audio-path
bell-audio-volume
app-notifications
macos-non-native-fullscreen
macos-window-buttons
macos-titlebar-style
macos-titlebar-proxy-icon
macos-dock-drop-behavior
macos-option-as-alt
macos-window-shadow
macos-hidden
macos-auto-secure-input
macos-secure-input-indication
macos-applescript
macos-icon
macos-custom-icon
macos-icon-frame
macos-icon-ghost-color
macos-icon-screen-color
macos-shortcuts
linux-cgroup
linux-cgroup-hard-fail
gtk-opengl-debug
gtk-single-instance
gtk-titlebar
gtk-tabs-location
gtk-titlebar-hide-when-maximized
gtk-toolbar-style
gtk-titlebar-style
gtk-wide-tabs
desktop-notifications
progress-style
faint-opacity
async-backend
auto-update
auto-update-channel
+30
View File
@@ -7,6 +7,7 @@ use std::fmt;
use ghostty_vt::color::{DEFAULT as DEFAULT_PALETTE, Palette as TermPalette, PaletteMask, Rgb};
use crate::{
c_value::CValue,
cli_args::{ArgError, ConfigField, parse_int_base0, trim_ws},
clipboard_codepoint_map::{ClipboardCodepointMap, Entry as ClipboardEntry, Replacement},
config_enum,
@@ -35,6 +36,15 @@ impl ConfigField for CursorStyle {
const VALID_VALUES: Option<&'static [&'static str]> =
Some(&["bar", "block", "underline", "block_hollow"]);
fn c_value(&self) -> CValue {
CValue::EnumName(match self {
CursorStyle::Bar => "bar",
CursorStyle::Block => "block",
CursorStyle::Underline => "underline",
CursorStyle::BlockHollow => "block_hollow",
})
}
fn parse_cli(&mut self, value: Option<&str>) -> Result<(), ArgError> {
let v = value.ok_or(ArgError::ValueRequired)?;
*self = match v {
@@ -290,6 +300,14 @@ impl Color {
}
impl ConfigField for Color {
fn c_value(&self) -> CValue {
CValue::Color {
r: self.r,
g: self.g,
b: self.b,
}
}
fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> {
*self = Color::from_cli(input)?;
Ok(())
@@ -394,6 +412,10 @@ pub struct ColorList {
}
impl ConfigField for ColorList {
fn c_value(&self) -> CValue {
CValue::ColorList(self.colors.iter().map(|c| [c.r, c.g, c.b]).collect())
}
fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> {
let input = input.ok_or(ArgError::ValueRequired)?;
if input.is_empty() {
@@ -454,6 +476,14 @@ impl Default for Palette {
}
impl ConfigField for Palette {
fn c_value(&self) -> CValue {
let mut out = Box::new([[0u8; 3]; 256]);
for (dst, c) in out.iter_mut().zip(self.value.iter()) {
*dst = [c.r, c.g, c.b];
}
CValue::Palette(out)
}
fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> {
let value = input.ok_or(ArgError::ValueRequired)?;
let eql_idx = value.find('=').ok_or(ArgError::InvalidValue)?;
+82
View File
@@ -8,6 +8,7 @@ use std::fmt;
use ghostty_input::binding::Action;
use crate::{
c_value::{CValue, QuickTerminalSizeC},
cli_args::{ArgError, ConfigField, parse_auto_struct_with, parse_bool, trim_ws},
config_enum,
config_packed,
@@ -152,6 +153,15 @@ impl RepeatableCommand {
}
impl ConfigField for RepeatableCommand {
fn c_value(&self) -> CValue {
CValue::Commands(
self.value
.iter()
.map(ghostty_input::command::Command::cval)
.collect(),
)
}
fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> {
// Unset or empty input clears the list
let input = input.unwrap_or("");
@@ -604,6 +614,18 @@ impl QuickTerminalSize {
}
impl ConfigField for QuickTerminalSize {
fn c_value(&self) -> CValue {
let conv = |v: Option<QuickTerminalSizeValue>| match v {
None => QuickTerminalSizeC::None,
Some(QuickTerminalSizeValue::Percentage(p)) => QuickTerminalSizeC::Percentage(p),
Some(QuickTerminalSizeValue::Pixels(p)) => QuickTerminalSizeC::Pixels(p),
};
CValue::QuickTerminalSize {
primary: conv(self.primary),
secondary: conv(self.secondary),
}
}
fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> {
let input = input.ok_or(ArgError::ValueRequired)?;
let mut it = input.split(',');
@@ -786,6 +808,17 @@ impl BackgroundBlur {
}
impl ConfigField for BackgroundBlur {
fn c_value(&self) -> CValue {
// Sentinel values for the macOS glass styles, like Zig's `cval`.
CValue::Short(match self {
BackgroundBlur::False => 0,
BackgroundBlur::True => 20,
BackgroundBlur::Radius(v) => i16::from(*v),
BackgroundBlur::MacosGlassRegular => -1,
BackgroundBlur::MacosGlassClear => -2,
})
}
fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> {
let Some(input) = input else {
// Emulate behavior for bools
@@ -882,6 +915,10 @@ impl WindowDecoration {
impl ConfigField for WindowDecoration {
const VALID_VALUES: Option<&'static [&'static str]> = Some(Self::NAMES);
fn c_value(&self) -> CValue {
CValue::EnumName(self.as_str())
}
fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> {
*self = Self::parse(input)?;
Ok(())
@@ -1127,6 +1164,10 @@ impl fmt::Display for Duration {
}
impl ConfigField for Duration {
fn c_value(&self) -> CValue {
CValue::Usize(self.as_milliseconds() as usize)
}
fn parse_cli(&mut self, input: Option<&str>) -> Result<(), ArgError> {
*self = Duration::parse(input)?;
Ok(())
@@ -1267,6 +1308,47 @@ mod tests {
assert_eq!(list.value.len(), 0);
}
#[test]
fn repeatable_command_cval() {
let mut list = RepeatableCommand::default();
list.parse_cli(Some("title:Foo,action:ignore")).unwrap();
list.parse_cli(Some("title:Bar,description:bobr,action:text:ale bydle"))
.unwrap();
assert_eq!(list.value.len(), 2);
let CValue::Commands(cv) = list.c_value() else {
panic!("expected commands");
};
assert_eq!(cv.len(), 2);
// First entry
assert_eq!(cv[0].title, "Foo");
assert_eq!(cv[0].action_key, "ignore");
assert_eq!(cv[0].action, "ignore");
// Second entry
assert_eq!(cv[1].title, "Bar");
assert_eq!(cv[1].description, "bobr");
assert_eq!(cv[1].action_key, "text");
assert_eq!(cv[1].action, "text:ale bydle");
}
#[test]
fn repeatable_command_cval_cleared() {
let mut list = RepeatableCommand::default();
list.parse_cli(Some("title:Foo,action:ignore")).unwrap();
let CValue::Commands(cv) = list.c_value() else {
panic!("expected commands");
};
assert_eq!(cv.len(), 1);
list.parse_cli(Some("")).unwrap();
let CValue::Commands(cv) = list.c_value() else {
panic!("expected commands");
};
assert_eq!(cv.len(), 0);
}
#[test]
fn repeatable_command_format_config_empty() {
let list = RepeatableCommand::default();