~tsdh/swayr

486ff1e9038625e4d9dcfd7f7ee9109355f5d35f — Tassilo Horn 9 months ago 3e9a031
New for-each-window scripting command
6 files changed, 232 insertions(+), 140 deletions(-)

M Cargo.lock
M README.md
M swayr/NEWS.md
M swayr/src/bin/swayr.rs
M swayr/src/cmds.rs
M swayr/src/tree.rs
M Cargo.lock => Cargo.lock +43 -102
@@ 22,9 22,9 @@ dependencies = [

[[package]]
name = "anstream"
version = "0.3.1"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6342bd4f5a1205d7f41e94a41a901f5647c938cdfa96036338e8533c9d6c2450"
checksum = "0ca84f3628370c59db74ee214b3263d58f9aadd9b4fe7e711fd87dc452b7f163"
dependencies = [
 "anstyle",
 "anstyle-parse",


@@ 56,7 56,7 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca11d4be1bab0c8bc8734a9aa7bf4ee8316d462a08c6ac5052f888fef5b494b"
dependencies = [
 "windows-sys 0.48.0",
 "windows-sys",
]

[[package]]


@@ 66,7 66,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180abfa45703aebe0093f79badacc01b8fd4ea2e35118747e5811127f926e188"
dependencies = [
 "anstyle",
 "windows-sys 0.48.0",
 "windows-sys",
]

[[package]]


@@ 130,9 130,9 @@ dependencies = [

[[package]]
name = "clap"
version = "4.2.5"
version = "4.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a1f23fa97e1d1641371b51f35535cb26959b8e27ab50d167a8b996b5bada819"
checksum = "34d21f9bf1b425d2968943631ec91202fe5e837264063503708b83013f8fc938"
dependencies = [
 "clap_builder",
 "clap_derive",


@@ 141,9 141,9 @@ dependencies = [

[[package]]
name = "clap_builder"
version = "4.2.5"
version = "4.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fdc5d93c358224b4d6867ef1356d740de2303e9892edc06c5340daeccd96bab"
checksum = "914c8c79fb560f238ef6429439a30023c862f7a28e688c58f7203f12b29970bd"
dependencies = [
 "anstream",
 "anstyle",


@@ 297,22 297,23 @@ dependencies = [

[[package]]
name = "directories"
version = "5.0.0"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74be3be809c18e089de43bdc504652bb2bc473fca8756131f8689db8cf079ba9"
checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35"
dependencies = [
 "dirs-sys",
]

[[package]]
name = "dirs-sys"
version = "0.4.0"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04414300db88f70d74c5ff54e50f9e1d1737d9a5b90f53fcf2e95ca2a9ab554b"
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
dependencies = [
 "libc",
 "option-ext",
 "redox_users",
 "windows-sys 0.45.0",
 "windows-sys",
]

[[package]]


@@ 341,7 342,7 @@ checksum = "4bcfec3a70f97c962c307b2d2c56e358cf1d00b558d74262b5f929ee8cc7e73a"
dependencies = [
 "errno-dragonfly",
 "libc",
 "windows-sys 0.48.0",
 "windows-sys",
]

[[package]]


@@ 440,7 441,7 @@ checksum = "9c66c74d2ae7e79a5a8f7ac924adbe38ee42a859c6539ad869eb51f0b52dc220"
dependencies = [
 "hermit-abi 0.3.1",
 "libc",
 "windows-sys 0.48.0",
 "windows-sys",
]

[[package]]


@@ 452,7 453,7 @@ dependencies = [
 "hermit-abi 0.3.1",
 "io-lifetimes",
 "rustix",
 "windows-sys 0.48.0",
 "windows-sys",
]

[[package]]


@@ 499,9 500,9 @@ dependencies = [

[[package]]
name = "linux-raw-sys"
version = "0.3.6"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b64f40e5e03e0d54f03845c8197d0291253cdbedfb1cb46b13c2c117554a9f4c"
checksum = "ece97ea872ece730aed82664c424eb4c8291e1ff2480247ccf7409044bc6479f"

[[package]]
name = "log"


@@ 593,6 594,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3"

[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"

[[package]]
name = "peg"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 744,16 751,16 @@ dependencies = [

[[package]]
name = "rustix"
version = "0.37.15"
version = "0.37.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0661814f891c57c930a610266415528da53c4933e6dea5fb350cbfe048a9ece"
checksum = "acf8729d8542766f1b2cf77eb034d52f40d375bb8b615d0b147089946e16613d"
dependencies = [
 "bitflags",
 "errno",
 "io-lifetimes",
 "libc",
 "linux-raw-sys",
 "windows-sys 0.48.0",
 "windows-sys",
]

[[package]]


@@ 776,18 783,18 @@ checksum = "1792db035ce95be60c3f8853017b3999209281c24e2ba5bc8e59bf97a0c590c1"

[[package]]
name = "serde"
version = "1.0.160"
version = "1.0.162"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb2f3770c8bce3bcda7e149193a069a0f4365bda1fa5cd88e03bca26afc1216c"
checksum = "71b2f6e1ab5c2b98c05f0f35b236b22e8df7ead6ffbf51d7808da7f8817e7ab6"
dependencies = [
 "serde_derive",
]

[[package]]
name = "serde_derive"
version = "1.0.160"
version = "1.0.162"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291a097c63d8497e00160b166a967a4a79c64f3facdd01cbd7502231688d77df"
checksum = "a2a0814352fd64b58489904a44ea8d90cb1a91dcb6b4f5ebabc32c8318e93cb6"
dependencies = [
 "proc-macro2",
 "quote",


@@ 1123,16 1130,7 @@ version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f"
dependencies = [
 "windows-targets 0.48.0",
]

[[package]]
name = "windows-sys"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
dependencies = [
 "windows-targets 0.42.2",
 "windows-targets",
]

[[package]]


@@ 1141,22 1139,7 @@ version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
 "windows-targets 0.48.0",
]

[[package]]
name = "windows-targets"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
dependencies = [
 "windows_aarch64_gnullvm 0.42.2",
 "windows_aarch64_msvc 0.42.2",
 "windows_i686_gnu 0.42.2",
 "windows_i686_msvc 0.42.2",
 "windows_x86_64_gnu 0.42.2",
 "windows_x86_64_gnullvm 0.42.2",
 "windows_x86_64_msvc 0.42.2",
 "windows-targets",
]

[[package]]


@@ 1165,104 1148,62 @@ version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b1eb6f0cd7c80c79759c929114ef071b87354ce476d9d94271031c0497adfd5"
dependencies = [
 "windows_aarch64_gnullvm 0.48.0",
 "windows_aarch64_msvc 0.48.0",
 "windows_i686_gnu 0.48.0",
 "windows_i686_msvc 0.48.0",
 "windows_x86_64_gnu 0.48.0",
 "windows_x86_64_gnullvm 0.48.0",
 "windows_x86_64_msvc 0.48.0",
 "windows_aarch64_gnullvm",
 "windows_aarch64_msvc",
 "windows_i686_gnu",
 "windows_i686_msvc",
 "windows_x86_64_gnu",
 "windows_x86_64_gnullvm",
 "windows_x86_64_msvc",
]

[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"

[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc"

[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"

[[package]]
name = "windows_aarch64_msvc"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3"

[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"

[[package]]
name = "windows_i686_gnu"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241"

[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"

[[package]]
name = "windows_i686_msvc"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00"

[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"

[[package]]
name = "windows_x86_64_gnu"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1"

[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"

[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953"

[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"

[[package]]
name = "windows_x86_64_msvc"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a"

[[package]]
name = "winnow"
version = "0.4.3"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dd168d560b787538a8ab920d47c030a5c0d488b515dd20798d59ea279a5b1df"
checksum = "61de7bac303dc551fe038e2b3cef0f571087a47571ea6e79a87692ac99b99699"
dependencies = [
 "memchr",
]

M README.md => README.md +26 -11
@@ 212,6 212,30 @@ These commands change the layout of the current workspace.
  between a tabbed and tiled layout, i.e., it calls `shuffle-tile-workspace` if
  it is currently tabbed, and calls `shuffle-tile-workspace` if it is currently
  tiled.
  
#### Scripting commands

* `get-windows-as-json` returns a JSON containing all windows, possibly with
  scratchpad windows if `--include-scratchpad` is given.  Furthermore,
  `--matching <CRITERIA>` can be used to restrict the windows to those matching
  the given criteria query (see [the criteria
  section](#swayr-commands-criteria)).  Lastly, if `--error-if-no-match` is
  given and no windows exist or match the given criteria query, the command
  exits non-zero instead of printing a JSON array.  This makes it suitable for
  shell scripting.  Essentially, `swayr get-windows-as-json --matching
  <CRITERIA> --error-if-no-match` is like `swaymsg <CRITERIA> nop` except that
  it returns the windows as JSON and support's swayr's extended criteria
  queries instead of the simple ones supported by sway.
* `for-each-window <CRITERIA> <SHELL_COMMAND>` executes `<SHELL_COMMAND>` for
  each window matched by `<CRITERIA>` (see [the criteria
  section](#swayr-commands-criteria)).  In `<SHELL_COMMAND>` almost all
  placeholders defined in [the section about window
  formats](#swayr-window-placeholders) are replaced.  For example, `swayr
  for-each-window true echo "The app {app_name} has the PID {pid}."` tells the
  application name and the pid for each window.  The result of the command is a
  JSON array with objects containing the exit code, stdout, stderr, and a
  (system) error field.  If any command returns non-zero, so will
  `for-each-window`.

#### Miscellaneous commands



@@ 230,16 254,6 @@ These commands change the layout of the current workspace.
  frozen when the first cycling command is processed and remains so until a
  non-cycling command is received.  The `nop` command can conveniently serve to
  interrupt a sequence without having any other side effects.
* `get-windows-as-json` returns a JSON containing all windows, possibly with
  scratchpad windows if `--include-scratchpad` is given.  Furthermore,
  `--matching <CRITERIA>` can be used to restrict the windows to those matching
  the given criteria query.  Lastly, if `--error-if-no-match` is given and no
  windows exist or match the given criteria query, the command exits non-zero
  instead of printing a JSON array.  This makes it suitable for shell
  scripting.  Essentially, `swayr get-windows-as-json --matching <CRITERIA>
  --error-if-no-match` is like `swaymsg <CRITERIA> nop` except that it returns
  the windows as JSON and support's swayr's extended criteria queries instead
  of the simple ones supported by sway.

#### <a id="swayr-commands-criteria">Criteria</a>



@@ 466,7 480,7 @@ In the `[menu]` section, you can specify the menu program using the
passed.  If some argument contains the placeholder `{prompt}`, it is replaced
with a prompt such as "Switch to window" depending on context.

#### The format section
#### <a id="swayr-window-placeholders">The format section</a>

In the `[format]` section, format strings are specified defining how selection
choices are to be layed out.  `wofi` supports [pango


@@ 483,6 497,7 @@ right now.
    will be removed in a later version.
  * `{layout}` shows the workspace or container's layout.
  * `{id}` gets replaced by the sway-internal con id.
  * `{pid}` gets replaced by the PID.
  * `{indent}` gets replaced with N times the new `format.indent` value where N
    is the depth in the shown menu input.
  * `{app_name}` gets replaced with a window's application name.

M swayr/NEWS.md => swayr/NEWS.md +2 -0
@@ 4,6 4,8 @@ swayr v0.26.0
- Improved `get-windows-as-json` with new option `--matching <CRITERIA>` and
  new flag `--error-if-no-match` making it suitable as powerful `swaymsg
  <CRITERIA> nop` replacement in shell scripts.
* `for-each-window <CRITERIA> <SHELL_COMMAND>` executes `<SHELL_COMMAND>` for
  each window matched by `<CRITERIA>`.

swayr v0.25.0
=============

M swayr/src/bin/swayr.rs => swayr/src/bin/swayr.rs +4 -1
@@ 31,6 31,9 @@ fn main() -> Result<(), String> {
            println!("{val}");
            Ok(())
        }
        Err(err) => Err(err),
        Err(err) => {
            eprintln!("{err}");
            Err("Command failed".to_owned())
        }
    }
}

M swayr/src/cmds.rs => swayr/src/cmds.rs +134 -10
@@ 28,6 28,7 @@ use once_cell::sync::Lazy;
use rand::prelude::SliceRandom;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::io::Read;
use std::sync::Mutex;
use std::sync::MutexGuard;
use swayipc as s;


@@ 298,6 299,22 @@ pub enum SwayrCommand {
        )]
        error_if_no_match: bool,
    },
    ForEachWindow {
        #[clap(
            short,
            long,
            help = "Determines if windows on the scratchpad are to be included."
        )]
        include_scratchpad: bool,
        #[clap(
            short,
            long,
            help = "Return non-zero if no (matching) windows are found instead of just doing nothing."
        )]
        error_if_no_match: bool,
        criteria: String,
        shell_command: Vec<String>,
    },
}

impl SwayrCommand {


@@ 320,7 337,11 @@ impl SwayrCommand {
    }

    pub(crate) fn is_scripting_command(&self) -> bool {
        matches!(self, SwayrCommand::GetWindowsAsJson { .. })
        matches!(
            self,
            SwayrCommand::GetWindowsAsJson { .. }
                | SwayrCommand::ForEachWindow { .. }
        )
    }
}



@@ 608,6 629,18 @@ fn exec_swayr_cmd_1(
            criteria,
            *error_if_no_match,
        ),
        SwayrCommand::ForEachWindow {
            include_scratchpad,
            error_if_no_match,
            criteria,
            shell_command,
        } => for_each_window(
            fdata,
            *include_scratchpad,
            *error_if_no_match,
            criteria,
            shell_command,
        ),
        SwayrCommand::ExecuteSwaymsgCommand => exec_swaymsg_command(),
        SwayrCommand::ExecuteSwayrCommand => {
            let mut cmds = vec![


@@ 701,6 734,19 @@ fn init_switch_to_matching_data(
    switch_to_matching_data.skip_origin = skip_flags.skip_origin;
}

fn get_matching_windows<'a>(
    criteria: Option<&String>,
    wins: &'a [t::DisplayNode<'a>],
) -> Result<Vec<&'a t::DisplayNode<'a>>, String> {
    if let Some(criteria) = criteria {
        let c = criteria::parse_criteria(criteria)?;
        let pred = criteria::criterion_to_predicate(&c, wins);
        Ok(wins.iter().filter(|w| pred(w)).collect())
    } else {
        Ok(wins.iter().collect())
    }
}

fn get_windows_as_json(
    fdata: &FocusData,
    include_scratchpad: bool,


@@ 710,15 756,7 @@ fn get_windows_as_json(
    let root = ipc::get_root_node(include_scratchpad);
    let tree = t::get_tree(&root);
    let wins = tree.get_windows(fdata);
    let wins = if let Some(criteria) = criteria {
        let c = criteria::parse_criteria(criteria)?;
        let pred = criteria::criterion_to_predicate(&c, &wins);
        wins.iter()
            .filter(|w| pred(w))
            .collect::<Vec<&t::DisplayNode>>()
    } else {
        wins.iter().collect()
    };
    let wins = get_matching_windows(criteria.as_ref(), &wins)?;
    if error_if_no_match && wins.is_empty() {
        Err(String::from(if criteria.is_some() {
            "No matching windows"


@@ 731,6 769,92 @@ fn get_windows_as_json(
    }
}

#[derive(Serialize, Deserialize)]
struct ShellCommandResult {
    exit_code: i32,
    std_out: String,
    std_err: String,
    error: Option<String>,
}

fn for_each_window(
    fdata: &FocusData,
    include_scratchpad: bool,
    error_if_no_match: bool,
    criteria: &String,
    shell_command: &Vec<String>,
) -> Result<String, String> {
    if shell_command.is_empty() {
        return Err("No shell_command given".to_owned());
    }
    let root = ipc::get_root_node(include_scratchpad);
    let tree = t::get_tree(&root);
    let wins = tree.get_windows(fdata);
    let wins = get_matching_windows(Some(criteria), &wins)?;

    if error_if_no_match && wins.is_empty() {
        return Err(String::from("No matching windows"));
    }

    let mut results = vec![];
    for w in wins {
        let cmd: Vec<String> = shell_command
            .iter()
            .map(|arg| w.subst_node_placeholders(arg, false))
            .collect();
        let r = match std::process::Command::new(&cmd[0])
            .args(&cmd[1..])
            .stdout(std::process::Stdio::piped())
            .stderr(std::process::Stdio::piped())
            .spawn()
        {
            Ok(mut child) => {
                let mut out = String::new();
                let mut err = String::new();
                child
                    .stdout
                    .take()
                    .map(|mut co| co.read_to_string(&mut out));
                child
                    .stderr
                    .take()
                    .map(|mut ce| ce.read_to_string(&mut err));

                match child.wait() {
                    Ok(status) => ShellCommandResult {
                        exit_code: status.code().unwrap(),
                        std_out: out,
                        std_err: err,
                        error: None,
                    },
                    Err(err) => ShellCommandResult {
                        exit_code: err.raw_os_error().unwrap_or(998),
                        std_out: String::new(),
                        std_err: String::new(),
                        error: Some(err.to_string()),
                    },
                }
            }
            Err(err) => ShellCommandResult {
                exit_code: err.raw_os_error().unwrap_or(999),
                std_out: String::new(),
                std_err: String::new(),
                error: Some(err.to_string()),
            },
        };

        results.push(r);
    }

    let json =
        serde_json::to_string_pretty(&results).expect("Error generating JSON");
    if results.iter().all(|r| r.exit_code == 0) {
        Ok(json)
    } else {
        Err(json)
    }
}

fn steal_window_by_id(id: i64) -> Result<String, String> {
    run_sway_command(&[
        format!("[con_id={id}]").as_str(),

M swayr/src/tree.rs => swayr/src/tree.rs +23 -16
@@ 61,6 61,28 @@ pub struct DisplayNode<'a> {
    pub swayr_type: ipc::Type,
}

impl<'a> DisplayNode<'a> {
    pub fn subst_node_placeholders(&self, fmt: &str, html_escape: bool) -> String {
        subst_placeholders!(fmt, html_escape, {
            "id" => self.node.id,
            "pid" => self.node.pid
            .map_or("<no pid>".to_owned(), |pid| pid.to_string()),
            "app_name" => self.node.get_app_name(),
            "layout" => format!("{:?}", self.node.layout),
            "name" | "title" => self.node.get_name(),
            "output_name" => self
            .tree
            .get_parent_node_of_type(self.node.id, ipc::Type::Output)
            .map_or("<no_output>", |w| w.get_name()),
            "workspace_name" => self
            .tree
            .get_parent_node_of_type(self.node.id, ipc::Type::Workspace)
            .map_or("<no_workspace>", |w| w.get_name()),
            "marks" => format_marks(&self.node.marks),
        })
    }
}

impl<'a> Tree<'a> {
    fn get_node_by_id(&self, id: i64) -> &&s::Node {
        self.id_node


@@ 396,22 418,7 @@ impl DisplayFormat for DisplayNode<'_> {
                    .unwrap_or_else(String::new)
                    .as_str(),
            );

        subst_placeholders!(&fmt, html_escape, {
            "id" => self.node.id,
            "app_name" => self.node.get_app_name(),
            "layout" => format!("{:?}", self.node.layout),
            "name" | "title" => self.node.get_name(),
            "output_name" => self
                .tree
                .get_parent_node_of_type(self.node.id, ipc::Type::Output)
                .map_or("<no_output>", |w| w.get_name()),
            "workspace_name" => self
                .tree
                .get_parent_node_of_type(self.node.id, ipc::Type::Workspace)
                .map_or("<no_workspace>", |w| w.get_name()),
            "marks" => format_marks(&self.node.marks),
        })
        self.subst_node_placeholders(&fmt, html_escape)
    }

    fn get_indent_level(&self) -> usize {