mod parse;
use ratatui::style::Color;
use std::fmt::Display;
use std::str::FromStr;
use strum::EnumMessage;
use news_flash::models::{Enclosure, Url};
pub mod prelude {
pub use super::parse::CommandParseError;
pub use super::{ActionScope, Command, CommandSequence, EnclosureType, Panel, PastePosition};
}
use crate::prelude::*;
#[derive(
Clone,
Copy,
Debug,
Default,
Eq,
PartialEq,
strum::EnumString,
strum::EnumMessage,
strum::EnumIter,
strum::AsRefStr,
)]
#[strum(
parse_err_fn = CommandParseError::panel_expected,
parse_err_ty = CommandParseError)]
pub enum Panel {
#[default]
#[strum(serialize = "feeds")]
#[strum(
message = "feeds",
detailed_message = "panel with tree of feeds, categories, tags, etc."
)]
FeedList,
#[strum(serialize = "articles")]
#[strum(
message = "article list",
detailed_message = "panel with the list of articles"
)]
ArticleList,
#[strum(serialize = "content")]
#[strum(
message = "article content",
detailed_message = "content of the selected article"
)]
ArticleContent,
}
impl Display for Panel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.get_message().unwrap().fmt(f) }
}
impl CommandParseError {
fn panel_expected(_: &str) -> CommandParseError {
CommandParseError::PanelExpected
}
}
#[derive(Clone, Debug, Default, strum::EnumIter, strum::EnumMessage, strum::AsRefStr)]
pub enum ActionScope {
#[default]
#[strum(
serialize = "current",
message = "current",
detailed_message = "currently selected item"
)]
Current,
#[strum(
serialize = "above",
message = "above",
detailed_message = "currently selected item and all above"
)]
Above,
#[strum(
serialize = "below",
message = "below",
detailed_message = "currently selected item and all below"
)]
Below,
#[strum(serialize = "all", message = "all", detailed_message = "all items")]
All,
#[strum(
message = "query",
detailed_message = "all articles defined by a query"
)]
Query(ArticleQuery),
}
impl FromStr for ActionScope {
type Err = CommandParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
use ActionScope as S;
match s {
"." | "current" => Ok(S::Current),
"%" | "all" => Ok(S::All),
"above" => Ok(S::Above),
"below" => Ok(S::Below),
_ => Ok(S::Query(ArticleQuery::from_str(s)?)),
}
}
}
impl ActionScope {
fn from_option_string(s: Option<&str>) -> Result<ActionScope, CommandParseError> {
match s {
Some(s) => Ok(ActionScope::from_str(s)?),
None => Ok(ActionScope::Current),
}
}
}
impl Display for ActionScope {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
use ActionScope as S;
match self {
S::Current => write!(f, "current article")?,
S::All => write!(f, "all articles")?,
S::Below => write!(f, "current article and all below")?,
S::Above => write!(f, "current article and all above")?,
S::Query(query) => write!(f, "all articles matching {}", query.query_string())?,
};
Ok(())
}
}
#[derive(
Debug,
Clone,
Copy,
strum::EnumString,
strum::EnumIter,
strum::EnumMessage,
strum::AsRefStr,
PartialEq,
Eq,
)]
#[strum(serialize_all = "lowercase")]
pub enum EnclosureType {
#[strum(message = "audio", detailed_message = "audio enclosure")]
Audio,
#[strum(message = "video", detailed_message = "video enclosure")]
Video,
#[strum(message = "image", detailed_message = "image enclosure")]
Image,
}
impl Display for EnclosureType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.get_message().unwrap().fmt(f) }
}
impl From<&Enclosure> for EnclosureType {
fn from(enclosure: &Enclosure) -> Self {
if enclosure.is_video() {
EnclosureType::Video
} else if enclosure.is_audio() {
EnclosureType::Audio
} else {
EnclosureType::Image
}
}
}
#[derive(Debug, Clone, Copy, Default, strum::EnumString, strum::EnumIter, strum::EnumMessage)]
#[strum(serialize_all = "lowercase")]
pub enum PastePosition {
#[default]
#[strum(
message = "after",
detailed_message = "position after the current element"
)]
After,
#[strum(
message = "before",
detailed_message = "position before the current element"
)]
Before,
}
impl Display for PastePosition {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.get_message().unwrap().fmt(f) }
}
#[derive(
Clone, Debug, Default, strum::AsRefStr, strum::EnumString, strum::EnumIter, strum::EnumMessage,
)]
pub enum Command {
#[default]
#[strum(
serialize = "nop",
message = "nop",
detailed_message = "no operation (for unmapping key bindings)"
)]
NoOperation,
#[strum(
serialize = "up",
message = "up",
detailed_message = "nagivates up in the current context (all)"
)]
NavigateUp,
#[strum(
serialize = "down",
message = "down",
detailed_message = "nagivates down in the current context (all)"
)]
NavigateDown,
#[strum(
serialize = "pageup",
message = "pageup",
detailed_message = "navigates up by several items (all)"
)]
NavigatePageUp,
#[strum(
serialize = "pagedown",
message = "pagedown",
detailed_message = "navigates down by several items (all)"
)]
NavigatePageDown,
#[strum(
serialize = "gotofirst",
message = "gotofirst",
detailed_message = "navigate to first element (all)"
)]
NavigateFirst,
#[strum(
serialize = "gotolast",
message = "gotolast",
detailed_message = "navigate to last element (all)"
)]
NavigateLast,
#[strum(
serialize = "left",
message = "left",
detailed_message = "nagivate left in the current context (all)"
)]
NavigateLeft,
#[strum(
serialize = "right",
message = "right",
detailed_message = "nagivate right in the current context (all)"
)]
NavigateRight,
#[strum(
serialize = "nextunread",
message = "nextunread",
detailed_message = "select next unread item (feed list, article list)"
)]
SelectNextUnread,
#[strum(
serialize = "_search",
message = "_search",
detailed_message = "open prompt to search"
)]
InputSearch,
#[strum(
serialize = "_abort",
message = "abort",
detailed_message = "abort current dialog"
)]
InputAbort,
#[strum(
serialize = "_submit",
message = "submit",
detailed_message = "submit current input"
)]
InputSubmit,
#[strum(
serialize = "_clear",
message = "clear",
detailed_message = "clear current input"
)]
InputClear,
#[strum(
serialize = "next",
message = "next",
detailed_message = "focus next panel until article content (all)"
)]
PanelFocusNext,
#[strum(
serialize = "prev",
message = "prev",
detailed_message = "focus previous panel until feed list (all)"
)]
PanelFocusPrevious,
#[strum(
serialize = "nextc",
message = "nextc",
detailed_message = "focus next panel, cycling back to feed list (all)"
)]
PanelFocusNextCyclic,
#[strum(
serialize = "prevc",
message = "prevc",
detailed_message = "focus previous panel, cycling back to article content (all)"
)]
PanelFocusPreviousCyclic,
#[strum(
serialize = "focus",
message = "focus <panel>",
detailed_message = "focuses the given panel (all)"
)]
PanelFocus(Panel),
#[strum(
serialize = "zen",
message = "zen",
detailed_message = "toggle distraction-free mode (article content)"
)]
ToggleDistractionFreeMode,
#[strum(
serialize = "sync",
message = "sync",
detailed_message = "sync all feeds (feed list)"
)]
FeedListSync,
#[strum(
serialize = "categoryadd",
message = "categoryadd <category name>",
detailed_message = "add a new category with the given name (feed list)"
)]
FeedListCategoryAdd(String),
#[strum(
serialize = "feedadd",
message = "feedadd <feed URL> [<name>]",
detailed_message = "add a new feed with the given URL and optional name (feed list)"
)]
FeedListFeedAdd(Option<Url>, Option<String>),
#[strum(
serialize = "tagchangecolor",
message = "tagchangecolor <color>",
detailed_message = "change the color of the selected tag (feed list)"
)]
FeedListTagChangeColor(Color),
#[strum(
serialize = "rename",
message = "rename <new name>",
detailed_message = "rename the selected item (feed list)"
)]
FeedListRenameEntity(String),
#[strum(
serialize = "remove",
message = "remove",
detailed_message = "remove the selected childless item (feed list)"
)]
FeedListRemoveEntity,
#[strum(
serialize = "removeall",
message = "removeall",
detailed_message = "remove the selected item with children (feed list)"
)]
FeedListRemoveEntityWithChildren,
#[strum(
serialize = "feedchangeurl",
message = "feedchangeurl",
detailed_message = "change URL of the selected feed (feed list)"
)]
FeedListFeedChangeUrl(Option<Url>),
#[strum(
serialize = "yank",
message = "yank",
detailed_message = "yank the selected item (feed or category) for moving (feed list)"
)]
FeedListYankFeedOrCategory,
#[strum(
serialize = "paste",
message = "paste <paste position>",
detailed_message = "paste the yanked item before/after selected item (feed list)"
)]
FeedListPasteFeedOrCategory(PastePosition),
#[strum(
serialize = "toggle",
message = "toggle",
detailed_message = "toggle selected item open/closed (feed list)"
)]
FeedListToggleExpand,
#[strum(
serialize = "expandcategories",
message = "expandcategories <scope>",
detailed_message = "expands current selected item or all categories belonging to scope (feed list)"
)]
FeedListExpandCategories(ArticleScope),
#[strum(
serialize = "expand",
message = "expand",
detailed_message = "expands current selected item (feed list)"
)]
FeedListExpand,
#[strum(
serialize = "collapse",
message = "collapse",
detailed_message = "collapses the currently selected item (feed list)"
)]
FeedListCollapse,
#[strum(
serialize = "collapseall",
message = "collapseall",
detailed_message = "collapses all items (feed list)"
)]
FeedListCollapseAll,
#[strum(
serialize = "read",
message = "read [<scope>]",
detailed_message = "set all articles matching the scope in the target to read (feed list, article list)"
)]
ActionSetRead(ActionScope),
#[strum(
serialize = "unread",
message = "unread <scope>",
detailed_message = "set all articles matching the scope to unread (feed list, article list)"
)]
ActionSetUnread(ActionScope),
#[strum(
serialize = "mark",
message = "mark <scope>",
detailed_message = "marks all articles matching the scope (article list)"
)]
ActionSetMarked(ActionScope),
#[strum(
serialize = "unmark",
message = "unmark <scope>",
detailed_message = "unmarks all articles matching the scope (article list)"
)]
ActionSetUnmarked(ActionScope),
#[strum(
serialize = "flag",
message = "flag <scope>",
detailed_message = "flags all articles matching the scope (article list)"
)]
ActionSetFlagged(ActionScope),
#[strum(
serialize = "unflag",
message = "unflag <scope>",
detailed_message = "unflags all articles matching the scope (article list)"
)]
ActionSetUnflagged(ActionScope),
#[strum(
serialize = "flaginvert",
message = "flaginvert <scope>",
detailed_message = "inverts flagged articles (article list)"
)]
ActionFlagInvert(ActionScope),
#[strum(
serialize = "open",
message = "open",
detailed_message = "opens all articles matching the scope in the webbrowser (article list)"
)]
ActionOpenInBrowser(ActionScope),
#[strum(
serialize = "tag",
message = "tag <tag name> [<scope>]",
detailed_message = "adds the tag to all articles matching the scope (article list)"
)]
ActionTagArticles(ActionScope, String),
#[strum(
serialize = "untag",
message = "untag <tag name> [<scope>]",
detailed_message = "removes the tag from all articles matching the scope (article list)"
)]
ActionUntagArticles(ActionScope, String),
#[strum(
serialize = "tagadd",
message = "tagadd <tag name> [<color>]",
detailed_message = "adds a new tag with the given name and optional color (feed list)"
)]
TagAdd(String, Option<Color>),
#[strum(
serialize = "show",
message = "show <article scope>",
detailed_message = "show only articles in the article scope (feed list, article list)"
)]
Show(ArticleScope),
#[strum(
serialize = "openenclosure",
message = "openenclosure [<enclosure type>]",
detailed_message = "open the default enclosure or the enclosure of the given type (article content)"
)]
ArticleOpenEnclosure(Option<EnclosureType>),
#[strum(
serialize = "scrape",
message = "scrape",
detailed_message = "scrape the current article (article list, article content)"
)]
ArticleCurrentScrape,
#[strum(
serialize = "searcharticles",
message = "searcharticles <article query>",
detailed_message = "search for articles matching the query (article list)"
)]
ArticleListSearch(ArticleQuery),
#[strum(
serialize = "search",
message = "search <search term>",
detailed_message = "search item by search term (feed list)"
)]
Search(Option<SearchTerm>),
#[strum(
serialize = "searchnext",
message = "searchnext",
detailed_message = "search next matching item (feed list, article list)"
)]
SearchNext,
#[strum(
serialize = "searchprev",
message = "searchprev",
detailed_message = "search previous matching item (feed list, article list)"
)]
SearchPrevious,
#[strum(
serialize = "filter",
message = "filter <article query>",
detailed_message = "filter articles by query (article list)"
)]
ArticleListFilterSet(ArticleQuery),
#[strum(
serialize = "filtersticky",
message = "filtersticky <article query>",
detailed_message = "filter articles by query sticky, i.e., always apply filter (article list)"
)]
ArticleListFilterSetSticky(ArticleQuery),
#[strum(
serialize = "filterapply",
message = "filterapply",
detailed_message = "apply current filter (article list)"
)]
ArticleListFilterApply,
#[strum(
serialize = "filterclear",
message = "filterclear",
detailed_message = "clear current filter (article list)"
)]
ArticleListFilterClear,
#[strum(
serialize = "sort",
message = "sort <sort order>",
detailed_message = "sort articles according to sort order (article list)"
)]
ArticleListSort(SortOrder),
#[strum(
serialize = "hintfollow",
message = "hintfollow <url hint>",
detailed_message = "opens the URL of the given hint (article content)"
)]
ContentFollowHint(String),
#[strum(
serialize = "hintshare",
message = "hintshare <share> <url hint>",
detailed_message = "shares the URL of the given hint (article content)"
)]
ContentShareHint(String, String),
#[strum(
serialize = "sortfeeds",
message = "sortfeeds",
detailed_message = "sorts the feeds alphabetically (cannot be undone)"
)]
FeedListSort,
#[strum(
serialize = "sortreverse",
message = "sortreverse",
detailed_message = "reverse the current sort order (article list)"
)]
ArticleListSortReverse,
#[strum(
serialize = "sortclear",
message = "sortclear",
detailed_message = "clear the current sort order (article list)"
)]
ArticleListSortClear,
#[strum(
serialize = "query",
message = "query <article query>",
detailed_message = "executes a query on all articles (article list)"
)]
ArticleListQuery(ArticleQuery),
#[strum(
serialize = "share",
message = "share <target>",
detailed_message = "shares title and url with target (article list, article content)"
)]
ArticleShare(String),
#[strum(
serialize = "importopml",
message = "importopml <path>",
detailed_message = "imports an OPML file from the given path (all)"
)]
ImportOpml(String),
#[strum(
serialize = "exportopml",
message = "exportopml <path>",
detailed_message = "exports an OPML file to the given path (all)"
)]
ExportOpml(String),
#[strum(
serialize = "quit",
message = "quit",
detailed_message = "quit eilmeldung (all)"
)]
ApplicationQuit,
#[strum(
serialize = "cmd",
message = "cmd [<command line content>]",
detailed_message = "open command line with optional content (all)"
)]
#[allow(clippy::enum_variant_names)]
CommandLineOpen(Option<String>),
#[strum(
serialize = "LOGOUT",
message = "LOGOUT <confirmation>",
detailed_message = "logout, NOTE: this will remove ALL LOCAL DATA! Pass `NOW` as parameter to confirm."
)]
Logout(String),
#[strum(
serialize = "helpinput",
message = "helpinput",
detailed_message = "show help on in put mappings (all)"
)]
HelpInput,
#[strum(
serialize = "confirm",
message = "confirm <command>",
detailed_message = "ask user for confirmation to execute command and, if positive, execute command (all)"
)]
#[allow(clippy::enum_variant_names)]
CommandConfirm(Box<Command>),
#[strum(
serialize = "in",
message = "in <panel> <command>",
detailed_message = "execute command in the panel"
)]
In(Panel, Box<Command>),
#[strum(
serialize = "redraw",
message = "redraw",
detailed_message = "redraw screen (all)"
)]
Redraw,
#[strum(
serialize = "refresh",
message = "refresh",
detailed_message = "refreshes contents of all panels according to selections (all)"
)]
Refresh,
}
impl Command {
pub fn unwrap_in(&self, panel: Panel) -> Option<&Command> {
if let Command::In(target_panel, command) = self {
return (*target_panel == panel).then_some(command);
}
Some(self)
}
}
impl Display for Command {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
use Command::*;
match self.clone() {
NoOperation => write!(f, "no operation"),
NavigateUp => write!(f, "up"),
NavigateDown => write!(f, "down"),
NavigatePageUp => write!(f, "page up"),
NavigatePageDown => write!(f, "page down"),
NavigateFirst => write!(f, "to first"),
NavigateLast => write!(f, "to last"),
NavigateLeft => write!(f, "left"),
NavigateRight => write!(f, "right"),
InputSearch => write!(f, "open find prompt"),
InputAbort => write!(f, "abort current input"),
InputSubmit => write!(f, "submit current input"),
InputClear => write!(f, "clear current input"),
PanelFocusNext => write!(f, "focus next"),
PanelFocus(panel) => write!(f, "focus {}", panel),
PanelFocusPrevious => write!(f, "focus previous"),
PanelFocusNextCyclic => write!(f, "focus previous (wrapping)"),
PanelFocusPreviousCyclic => write!(f, "focus next (wrapping)"),
ToggleDistractionFreeMode => write!(f, "distraction free mode"),
FeedListToggleExpand => write!(f, "toggle selected node"),
FeedListExpand => write!(f, "expand the selected node"),
FeedListCollapse => write!(f, "collapse the selected node"),
FeedListCollapseAll => write!(f, "collapse the selected node"),
FeedListExpandCategories(ArticleScope::All) => write!(f, "expand all nodes"),
FeedListExpandCategories(ArticleScope::Unread) => {
write!(f, "expand all categories with unread items")
}
FeedListExpandCategories(ArticleScope::Marked) => {
write!(f, "expand all categories with marked items")
}
FeedListCategoryAdd(name) => write!(f, "add category {name}"),
FeedListFeedAdd(Some(url), Some(name)) => write!(f, "add feed {name} with url {url}"),
FeedListFeedAdd(Some(url), None) => write!(f, "add feed with url {url}"),
FeedListFeedAdd(None, _) => unreachable!(),
FeedListRenameEntity(name) => write!(f, "rename selected to {name}"),
FeedListRemoveEntity => write!(f, "remove selected"),
FeedListRemoveEntityWithChildren => write!(f, "remove selected and its children"),
FeedListFeedChangeUrl(Some(url)) => write!(f, "change url of selected feed to {url}"),
FeedListFeedChangeUrl(None) => unreachable!(),
FeedListYankFeedOrCategory => write!(f, "yank selected feed or category"),
FeedListPasteFeedOrCategory(position) => {
write!(
f,
"paste yanked feed or category {position} selected element"
)
}
FeedListTagChangeColor(color) => {
write!(f, "change color of tag to {}", color)
}
FeedListSort => write!(f, "sort feed list alphabetically"),
SelectNextUnread => write!(f, "select next unread"),
Show(ArticleScope::Marked) => write!(f, "show only marked"),
Show(ArticleScope::Unread) => write!(f, "show only unread"),
Show(ArticleScope::All) => write!(f, "show all"),
ArticleOpenEnclosure(None) => write!(f, "open default enclosure"),
ArticleOpenEnclosure(Some(enclosure_type)) => {
write!(f, "open {enclosure_type} enclosure")
}
ArticleShare(share_target) => write!(f, "share article to {share_target}"),
ArticleCurrentScrape => write!(f, "scrape content"),
ImportOpml(path) => write!(f, "import OPML file from {path}"),
ExportOpml(path) => write!(f, "export OPML file to {path}"),
ApplicationQuit => write!(f, "quit application"),
Redraw => write!(f, "redraw UI"),
Refresh => write!(f, "refresh UI"),
CommandLineOpen(input) => write!(f, ":{}", input.unwrap_or_default()),
ArticleListSearch(query) => {
write!(f, "search article by query: {}", query.query_string())
}
SearchNext => write!(f, "article search next"),
SearchPrevious => write!(f, "article search previous"),
Search(Some(search_term)) => write!(f, "search for {search_term}"),
Search(None) => write!(f, "clear search"),
ArticleListFilterSet(query) => {
write!(
f,
"filter current article list by query: {}",
query.query_string()
)
}
ArticleListFilterSetSticky(query) => {
write!(
f,
"always filter article list by query: {}",
query.query_string()
)
}
ArticleListQuery(query) => {
write!(f, "query all articles by: {}", query.query_string())
}
ArticleListFilterApply => write!(f, "apply current article filter"),
ArticleListFilterClear => write!(f, "clear article filter"),
ArticleListSort(sort_order) => write!(f, "sort article list by {}", sort_order),
ArticleListSortReverse => write!(f, "reverse current sort order"),
ArticleListSortClear => write!(f, "clear current sort order"),
ContentFollowHint(hint) => write!(f, "open URL behind {hint}"),
ContentShareHint(target, hint) => write!(f, "share URL behind {hint} to {target}"),
FeedListSync => write!(f, "sync all"),
ActionSetRead(action_scope) => {
write!(f, "mark {action_scope} as read",)
}
ActionSetUnread(action_scope) => write!(f, "mark {} as unread", action_scope),
ActionSetMarked(action_scope) => write!(f, "mark {}", action_scope),
ActionSetUnmarked(action_scope) => write!(f, "unmark {}", action_scope),
ActionSetFlagged(action_scope) => write!(f, "flag {}", action_scope),
ActionSetUnflagged(action_scope) => write!(f, "unflag {}", action_scope),
ActionFlagInvert(action_scope) => write!(f, "invert flags {}", action_scope),
ActionOpenInBrowser(action_scope) => write!(f, "open {} in browser", action_scope),
ActionTagArticles(action_scope, tag) => {
write!(f, "add #{} to {}", tag, &action_scope)
}
ActionUntagArticles(action_scope, tag) => {
write!(f, "remove #{} from {}", tag, &action_scope)
}
Logout(_) => write!(
f,
"logout from provider, NOTE: this will remove ALL LOCAL DATA!"
),
HelpInput => write!(f, "show help on input mappings"),
TagAdd(tag_title, _) => {
write!(f, "add tag #{}", tag_title)
}
CommandConfirm(command) => write!(f, "{}?", command),
In(panel, command) => write!(f, "{command} in {panel}"),
}
}
}
impl Command {
pub(crate) fn is_async(&self) -> bool {
use Command as C;
match self {
C::FeedListSync
| C::FeedListCategoryAdd(..)
| C::FeedListFeedAdd(..)
| C::FeedListTagChangeColor(..)
| C::FeedListRenameEntity(..)
| C::FeedListRemoveEntity
| C::FeedListRemoveEntityWithChildren
| C::FeedListFeedChangeUrl(..)
| C::FeedListPasteFeedOrCategory(..)
| C::TagAdd(..)
| C::ImportOpml(..)
| C::ExportOpml(..)
| C::Logout(..) => true,
C::CommandConfirm(command) => command.is_async(),
C::In(_, command) => command.is_async(),
_ => false,
}
}
}
#[derive(Clone, Debug, serde::Deserialize, Default)]
#[serde(transparent)]
pub struct CommandSequence {
pub commands: Vec<Command>,
}
impl From<Command> for CommandSequence {
fn from(single_command: Command) -> Self {
Self {
commands: vec![single_command],
}
}
}
impl From<Vec<Command>> for CommandSequence {
fn from(commands: Vec<Command>) -> Self {
Self { commands }
}
}
impl<const N: usize> From<[Command; N]> for CommandSequence {
fn from(value: [Command; N]) -> Self {
value.into_iter().collect::<Vec<Command>>().into()
}
}
impl<const N: usize> From<[&str; N]> for CommandSequence {
fn from(value: [&str; N]) -> Self {
value
.iter()
.map(|s| Command::from_str(s).unwrap()) .collect::<Vec<Command>>()
.into()
}
}
impl From<&str> for CommandSequence {
fn from(value: &str) -> Self {
Command::from_str(value).unwrap().into() }
}
impl Display for CommandSequence {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut first = true;
for command in self.commands.iter() {
if !first {
f.write_str(",")?;
}
command.fmt(f)?;
first = false;
}
Ok(())
}
}