operad 8.0.1

A cross-platform GUI library for Rust.
Documentation
//! Command registry diagnostics widgets.

use taffy::prelude::{
    Dimension, Display, FlexDirection, LengthPercentage, Size as TaffySize, Style,
};

use crate::commands::{ShortcutBinding, ShortcutConflict};
use crate::tooltips::ShortcutFormatter;
use crate::{
    ColorRgba, Command, CommandId, CommandRegistry, CommandScope, LayoutStyle, TextStyle,
    UiDocument, UiNode, UiNodeId, UiNodeStyle,
};

use super::data::PropertyValueKind;
use super::property_inspector::{
    property_inspector_grid, PropertyGridRow, PropertyInspectorOptions,
};

#[derive(Debug, Clone)]
pub struct CommandDiagnosticsPanelOptions {
    pub layout: LayoutStyle,
    pub label_width: f32,
    pub row_height: f32,
    pub max_command_rows: usize,
    pub max_conflict_rows: usize,
    pub action_prefix: Option<String>,
    pub title_style: TextStyle,
}

impl Default for CommandDiagnosticsPanelOptions {
    fn default() -> Self {
        Self {
            layout: LayoutStyle::from_taffy_style(Style {
                display: Display::Flex,
                flex_direction: FlexDirection::Column,
                size: TaffySize {
                    width: Dimension::percent(1.0),
                    height: Dimension::auto(),
                },
                gap: taffy::geometry::Size {
                    width: LengthPercentage::length(8.0),
                    height: LengthPercentage::length(8.0),
                },
                ..Default::default()
            }),
            label_width: 190.0,
            row_height: 26.0,
            max_command_rows: 32,
            max_conflict_rows: 8,
            action_prefix: None,
            title_style: TextStyle {
                font_size: 14.0,
                line_height: 20.0,
                color: ColorRgba::new(238, 243, 248, 255),
                ..Default::default()
            },
        }
    }
}

impl CommandDiagnosticsPanelOptions {
    pub fn with_action_prefix(mut self, prefix: impl Into<String>) -> Self {
        self.action_prefix = Some(prefix.into());
        self
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CommandDiagnosticsPanelNodes {
    pub root: UiNodeId,
    pub command_grid: UiNodeId,
    pub conflict_grid: UiNodeId,
}

pub fn command_diagnostics_panel(
    document: &mut UiDocument,
    parent: UiNodeId,
    name: impl Into<String>,
    registry: &CommandRegistry,
    active_scopes: &[CommandScope],
    formatter: &ShortcutFormatter,
    options: CommandDiagnosticsPanelOptions,
) -> CommandDiagnosticsPanelNodes {
    let name = name.into();
    let root = document.add_child(
        parent,
        UiNode::container(
            name.clone(),
            UiNodeStyle {
                layout: options.layout.style.clone(),
                ..Default::default()
            },
        ),
    );

    document.add_child(
        root,
        UiNode::text(
            format!("{name}.commands.title"),
            "Commands",
            options.title_style.clone(),
            LayoutStyle::new().with_width_percent(1.0).with_height(24.0),
        ),
    );
    let command_grid = property_inspector_grid(
        document,
        root,
        format!("{name}.commands"),
        &command_rows(registry, active_scopes, formatter, options.max_command_rows),
        grid_options(
            options.label_width,
            options.row_height,
            options
                .action_prefix
                .as_deref()
                .map(|prefix| format!("{prefix}.command")),
            "Command registry",
        ),
    );

    document.add_child(
        root,
        UiNode::text(
            format!("{name}.conflicts.title"),
            "Shortcut conflicts",
            TextStyle {
                color: ColorRgba::new(171, 183, 201, 255),
                ..options.title_style
            },
            LayoutStyle::new().with_width_percent(1.0).with_height(24.0),
        ),
    );
    let conflict_grid = property_inspector_grid(
        document,
        root,
        format!("{name}.conflicts"),
        &conflict_rows(registry, formatter, options.max_conflict_rows),
        grid_options(
            options.label_width,
            options.row_height,
            options
                .action_prefix
                .as_deref()
                .map(|prefix| format!("{prefix}.conflict")),
            "Shortcut conflicts",
        ),
    );

    CommandDiagnosticsPanelNodes {
        root,
        command_grid,
        conflict_grid,
    }
}

fn grid_options(
    label_width: f32,
    row_height: f32,
    action_prefix: Option<String>,
    accessibility_label: impl Into<String>,
) -> PropertyInspectorOptions {
    PropertyInspectorOptions {
        label_width,
        row_height,
        action_prefix,
        accessibility_label: Some(accessibility_label.into()),
        ..Default::default()
    }
}

fn command_rows(
    registry: &CommandRegistry,
    active_scopes: &[CommandScope],
    formatter: &ShortcutFormatter,
    limit: usize,
) -> Vec<PropertyGridRow> {
    let mut commands = registry.commands().collect::<Vec<_>>();
    commands.sort_by(|left, right| left.meta.id.cmp(&right.meta.id));
    let rows = commands
        .into_iter()
        .take(limit)
        .map(|command| command_row(registry, command, active_scopes, formatter))
        .collect::<Vec<_>>();

    if rows.is_empty() {
        vec![PropertyGridRow::new("empty", "Commands", "No commands").read_only()]
    } else {
        rows
    }
}

fn command_row(
    registry: &CommandRegistry,
    command: &Command,
    active_scopes: &[CommandScope],
    formatter: &ShortcutFormatter,
) -> PropertyGridRow {
    let shortcut = command_shortcut(registry, &command.meta.id, active_scopes, formatter)
        .unwrap_or_else(|| "unbound".to_owned());
    let mut value = vec![shortcut];
    if let Some(category) = &command.meta.category {
        value.push(format!("category={category}"));
    }
    if let Some(description) = &command.meta.description {
        value.push(description.clone());
    }
    if !command.enabled {
        value.push(
            command
                .disabled_reason
                .clone()
                .unwrap_or_else(|| "disabled".to_owned()),
        );
    }

    let row = PropertyGridRow::new(
        sanitize_row_id(command.meta.id.as_str()),
        command.meta.label.clone(),
        compact_value(value.join("; "), 40),
    )
    .with_kind(PropertyValueKind::Custom);

    if command.enabled {
        row
    } else {
        row.disabled()
    }
}

fn conflict_rows(
    registry: &CommandRegistry,
    formatter: &ShortcutFormatter,
    limit: usize,
) -> Vec<PropertyGridRow> {
    let rows = registry
        .conflicts()
        .iter()
        .take(limit)
        .map(|conflict| conflict_row(conflict, formatter))
        .collect::<Vec<_>>();

    if rows.is_empty() {
        vec![PropertyGridRow::new("none", "Conflicts", "No shortcut conflicts").read_only()]
    } else {
        rows
    }
}

fn conflict_row(conflict: &ShortcutConflict, formatter: &ShortcutFormatter) -> PropertyGridRow {
    PropertyGridRow::new(
        sanitize_row_id(format!(
            "{:?}.{}",
            conflict.scope,
            formatter.format(conflict.shortcut)
        )),
        format!(
            "{:?} {}",
            conflict.scope,
            formatter.format(conflict.shortcut)
        ),
        conflict
            .commands
            .iter()
            .map(CommandId::as_str)
            .collect::<Vec<_>>()
            .join(", "),
    )
    .warning("Multiple commands use this shortcut in the same scope")
    .read_only()
}

fn command_shortcut(
    registry: &CommandRegistry,
    command: &CommandId,
    active_scopes: &[CommandScope],
    formatter: &ShortcutFormatter,
) -> Option<String> {
    registry
        .bindings()
        .iter()
        .filter(|binding| &binding.command == command)
        .min_by_key(|binding| shortcut_rank(binding, active_scopes))
        .map(|binding| formatter.format(binding.shortcut))
}

fn shortcut_rank(binding: &ShortcutBinding, active_scopes: &[CommandScope]) -> (u8, usize) {
    let active_index = active_scopes
        .iter()
        .position(|scope| scope == &binding.scope)
        .unwrap_or(usize::MAX);
    (binding.scope.hierarchy_rank(), active_index)
}

fn sanitize_row_id(value: impl AsRef<str>) -> String {
    let mut out = String::new();
    for character in value.as_ref().chars() {
        if character.is_ascii_alphanumeric() || character == '_' || character == '-' {
            out.push(character);
        } else {
            out.push('_');
        }
    }
    if out.is_empty() {
        "row".to_owned()
    } else {
        out
    }
}

fn compact_value(value: String, max_chars: usize) -> String {
    if value.chars().count() <= max_chars {
        return value;
    }
    let mut out = value
        .chars()
        .take(max_chars.saturating_sub(3))
        .collect::<String>();
    out.push_str("...");
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{root_style, ApproxTextMeasurer, CommandMeta, KeyModifiers, Shortcut, UiSize};

    #[test]
    fn command_diagnostics_panel_lists_commands_shortcuts_and_empty_conflicts() {
        let mut registry = CommandRegistry::new();
        registry
            .register(
                CommandMeta::new("file.open", "Open")
                    .description("Open a file")
                    .category("File"),
            )
            .unwrap();
        registry
            .register(CommandMeta::new("file.close", "Close"))
            .unwrap();
        registry
            .bind_shortcut(
                CommandScope::Global,
                Shortcut::character(
                    'o',
                    KeyModifiers {
                        ctrl: true,
                        ..KeyModifiers::NONE
                    },
                ),
                "file.open",
            )
            .unwrap();

        let mut doc = UiDocument::new(root_style(500.0, 280.0));
        let root = doc.root;
        let nodes = command_diagnostics_panel(
            &mut doc,
            root,
            "commands.debug",
            &registry,
            &[CommandScope::Global],
            &ShortcutFormatter::default(),
            CommandDiagnosticsPanelOptions {
                action_prefix: Some("commands.inspect".to_owned()),
                ..Default::default()
            },
        );
        doc.compute_layout(UiSize::new(500.0, 280.0), &mut ApproxTextMeasurer)
            .expect("layout");

        assert_eq!(doc.node(nodes.command_grid).children.len(), 2);
        assert_eq!(doc.node(nodes.conflict_grid).children.len(), 1);
        let open_row = doc.node(doc.node(nodes.command_grid).children[1]);
        assert_eq!(
            open_row
                .action
                .as_ref()
                .and_then(|action| action.action_id())
                .map(|id| id.as_str()),
            Some("commands.inspect.command.row.file_open")
        );
    }
}