ua-client 1.2.0

Native OPC UA browser/inspector GUI built on async-opcua and egui
Documentation
use cursive::Cursive;
use cursive::direction::Orientation;
use cursive::event::Key;
use cursive::view::{Nameable, Resizable, Scrollable};
use cursive::views::{
    BoxedView, Dialog, DialogFocus, DummyView, EditView, HideableView, LinearLayout, OnEventView,
    Panel, RadioGroup, SelectView, TextView,
};

use crate::messages::UiAction;
use crate::model::AppModel;
use crate::types::{AuthMode, EndpointInfo, SecurityMode};

use super::{TuiState, dispatch_action};

const ID_ENDPOINTS: &str = "dlg_endpoints";
const ID_AUTH_USER_ROW: &str = "dlg_auth_user_row";
const ID_AUTH_CERT_ROW: &str = "dlg_auth_cert_row";
const ID_AUTH_USERNAME: &str = "dlg_auth_username";
const ID_AUTH_PASSWORD: &str = "dlg_auth_password";
const ID_AUTH_CERT_PATH: &str = "dlg_auth_cert_path";
const ID_AUTH_KEY_PATH: &str = "dlg_auth_key_path";
const ID_STATUS: &str = "dlg_status";

pub fn show(siv: &mut Cursive) {
    let snap = match siv.user_data::<TuiState>() {
        Some(st) => InitialState::from(&st.engine.model),
        None => return,
    };
    let mut dialog = build(snap);
    let _ = dialog.set_focus(DialogFocus::Button(2));
    let wrapped = OnEventView::new(dialog).on_pre_event(Key::Esc, |s| {
        dispatch_action(s, UiAction::CloseEndpointPicker);
    });
    siv.add_layer(wrapped);
    if let Some(st) = siv.user_data::<TuiState>() {
        st.dialog_open = true;
    }
    refresh(siv);
}

pub fn close(siv: &mut Cursive) {
    siv.pop_layer();
    if let Some(st) = siv.user_data::<TuiState>() {
        st.dialog_open = false;
    }
}

pub fn refresh(siv: &mut Cursive) {
    let Some(snap) = siv
        .user_data::<TuiState>()
        .map(|st| ListSnap::from(&st.engine.model))
    else {
        return;
    };
    siv.call_on_name(ID_STATUS, |v: &mut TextView| {
        v.set_content(snap.status_text.clone());
    });
    siv.call_on_name(ID_ENDPOINTS, |v: &mut SelectView<EndpointInfo>| {
        let preserved = v.selection().map(|arc| (*arc).clone());
        v.clear();
        for ep in &snap.endpoints {
            v.add_item(format_endpoint(ep), ep.clone());
        }
        if let Some(ref p) = preserved
            && let Some(idx) = snap.endpoints.iter().position(|e| endpoint_eq(e, p))
        {
            v.set_selection(idx);
        }
    });
}

struct InitialState {
    auth_mode: AuthMode,
    endpoint_mode_filter: SecurityMode,
    auth_username: String,
    auth_password: String,
    auth_cert_path: String,
    auth_key_path: String,
}

impl InitialState {
    fn from(model: &AppModel) -> Self {
        Self {
            auth_mode: model.auth_mode,
            endpoint_mode_filter: model.endpoint_mode_filter,
            auth_username: model.auth_username.clone(),
            auth_password: model.auth_password.clone(),
            auth_cert_path: model.auth_cert_path.clone(),
            auth_key_path: model.auth_key_path.clone(),
        }
    }
}

struct ListSnap {
    status_text: String,
    endpoints: Vec<EndpointInfo>,
}

impl ListSnap {
    fn from(model: &AppModel) -> Self {
        let status_text = if model.endpoints_loading {
            "Querying endpoints…".to_string()
        } else {
            match model.discovered_endpoints.as_ref() {
                None => "Click Refresh to query endpoints.".to_string(),
                Some(list) => {
                    let filtered = list
                        .iter()
                        .filter(|e| e.security_mode == model.endpoint_mode_filter)
                        .count();
                    if list.is_empty() {
                        "No endpoints returned by the server.".to_string()
                    } else if filtered == 0 {
                        format!(
                            "{} endpoint(s) returned; none match mode '{}'.",
                            list.len(),
                            model.endpoint_mode_filter.label()
                        )
                    } else {
                        format!(
                            "{} endpoint(s) matching '{}'.",
                            filtered,
                            model.endpoint_mode_filter.label()
                        )
                    }
                }
            }
        };
        let endpoints = model
            .discovered_endpoints
            .as_ref()
            .map(|eps| {
                let mut filtered: Vec<EndpointInfo> = eps
                    .iter()
                    .filter(|e| e.security_mode == model.endpoint_mode_filter)
                    .cloned()
                    .collect();
                filtered.sort_by(|a, b| b.security_level.cmp(&a.security_level));
                filtered
            })
            .unwrap_or_default();
        Self {
            status_text,
            endpoints,
        }
    }
}

fn build(snap: InitialState) -> Dialog {
    let mode_row = build_mode_row(snap.endpoint_mode_filter);
    let auth_row = build_auth_row(snap.auth_mode);
    let user_hide = build_user_fields(&snap);
    let cert_hide = build_cert_fields(&snap);
    let endpoints_panel = build_endpoints_panel();
    let status = TextView::new("").with_name(ID_STATUS);

    let content = LinearLayout::new(Orientation::Vertical)
        .child(mode_row)
        .child(DummyView.fixed_height(1))
        .child(auth_row)
        .child(user_hide)
        .child(cert_hide)
        .child(DummyView.fixed_height(1))
        .child(endpoints_panel)
        .child(DummyView.fixed_height(1))
        .child(status);

    Dialog::around(content.min_size((78, 18)))
        .title("Connect to OPC UA server")
        .button("Refresh", |s| {
            dispatch_action(s, UiAction::ForceRefreshEndpoints)
        })
        .button("Cancel", |s| dispatch_action(s, UiAction::CloseEndpointPicker))
        .button("Connect", |s| dispatch_action(s, UiAction::ConfirmConnect))
}

fn build_mode_row(current: SecurityMode) -> LinearLayout {
    let mut group: RadioGroup<SecurityMode> = RadioGroup::new();
    group.set_on_change(|s, m: &SecurityMode| {
        dispatch_action(s, UiAction::SetEndpointModeFilter(*m));
    });
    let none_btn = group.button(SecurityMode::None, "None");
    let sign_btn = group.button(SecurityMode::Sign, "Sign");
    let se_btn = group.button(SecurityMode::SignAndEncrypt, "SignAndEncrypt");
    let (none_btn, sign_btn, se_btn) = match current {
        SecurityMode::None => (none_btn.selected(), sign_btn, se_btn),
        SecurityMode::Sign => (none_btn, sign_btn.selected(), se_btn),
        SecurityMode::SignAndEncrypt => (none_btn, sign_btn, se_btn.selected()),
    };
    LinearLayout::new(Orientation::Horizontal)
        .child(TextView::new("Mode: "))
        .child(none_btn)
        .child(DummyView.fixed_width(2))
        .child(sign_btn)
        .child(DummyView.fixed_width(2))
        .child(se_btn)
}

fn build_auth_row(current: AuthMode) -> LinearLayout {
    let mut group: RadioGroup<AuthMode> = RadioGroup::new();
    group.set_on_change(|s, m: &AuthMode| {
        dispatch_action(s, UiAction::SetAuthMode(*m));
        update_auth_visibility(s, *m);
    });
    let anon = group.button(AuthMode::Anonymous, "Anonymous");
    let user = group.button(AuthMode::UserName, "UserName");
    let cert = group.button(AuthMode::Certificate, "Certificate");
    let (anon, user, cert) = match current {
        AuthMode::Anonymous => (anon.selected(), user, cert),
        AuthMode::UserName => (anon, user.selected(), cert),
        AuthMode::Certificate => (anon, user, cert.selected()),
    };
    LinearLayout::new(Orientation::Horizontal)
        .child(TextView::new("Auth: "))
        .child(anon)
        .child(DummyView.fixed_width(2))
        .child(user)
        .child(DummyView.fixed_width(2))
        .child(cert)
}

fn build_user_fields(snap: &InitialState) -> cursive::views::NamedView<HideableView<BoxedView>> {
    let user_edit = EditView::new()
        .content(snap.auth_username.clone())
        .on_edit(|s, c, _| dispatch_action(s, UiAction::AuthUsernameEdited(c.to_owned())))
        .with_name(ID_AUTH_USERNAME)
        .min_width(20);
    let pass_edit = EditView::new()
        .secret()
        .content(snap.auth_password.clone())
        .on_edit(|s, c, _| dispatch_action(s, UiAction::AuthPasswordEdited(c.to_owned())))
        .with_name(ID_AUTH_PASSWORD)
        .min_width(20);
    let row = LinearLayout::new(Orientation::Horizontal)
        .child(TextView::new("User: "))
        .child(user_edit)
        .child(DummyView.fixed_width(2))
        .child(TextView::new("Pass: "))
        .child(pass_edit);
    HideableView::new(BoxedView::boxed(row))
        .visible(matches!(snap.auth_mode, AuthMode::UserName))
        .with_name(ID_AUTH_USER_ROW)
}

fn build_cert_fields(snap: &InitialState) -> cursive::views::NamedView<HideableView<BoxedView>> {
    let cert_edit = EditView::new()
        .content(snap.auth_cert_path.clone())
        .on_edit(|s, c, _| dispatch_action(s, UiAction::AuthCertPathEdited(c.to_owned())))
        .with_name(ID_AUTH_CERT_PATH)
        .min_width(40);
    let key_edit = EditView::new()
        .content(snap.auth_key_path.clone())
        .on_edit(|s, c, _| dispatch_action(s, UiAction::AuthKeyPathEdited(c.to_owned())))
        .with_name(ID_AUTH_KEY_PATH)
        .min_width(40);
    let row = LinearLayout::new(Orientation::Vertical)
        .child(
            LinearLayout::new(Orientation::Horizontal)
                .child(TextView::new("Cert: "))
                .child(cert_edit),
        )
        .child(
            LinearLayout::new(Orientation::Horizontal)
                .child(TextView::new("Key:  "))
                .child(key_edit),
        );
    HideableView::new(BoxedView::boxed(row))
        .visible(matches!(snap.auth_mode, AuthMode::Certificate))
        .with_name(ID_AUTH_CERT_ROW)
}

fn build_endpoints_panel() -> Panel<BoxedView> {
    let mut select = SelectView::<EndpointInfo>::new();
    select.set_on_submit(|s, ep: &EndpointInfo| {
        dispatch_action(s, UiAction::SelectEndpoint(ep.clone()));
    });
    let scrollable = select.with_name(ID_ENDPOINTS).scrollable();
    Panel::new(BoxedView::boxed(scrollable.fixed_height(8))).title("Endpoints")
}

fn update_auth_visibility(siv: &mut Cursive, mode: AuthMode) {
    siv.call_on_name(ID_AUTH_USER_ROW, |v: &mut HideableView<BoxedView>| {
        v.set_visible(matches!(mode, AuthMode::UserName));
    });
    siv.call_on_name(ID_AUTH_CERT_ROW, |v: &mut HideableView<BoxedView>| {
        v.set_visible(matches!(mode, AuthMode::Certificate));
    });
}

fn format_endpoint(ep: &EndpointInfo) -> String {
    format!(
        "{} L{} [{}]   {}",
        ep.security_policy,
        ep.security_level,
        token_label(ep),
        ep.endpoint_url
    )
}

fn token_label(ep: &EndpointInfo) -> String {
    let mut parts = Vec::new();
    if ep.supports_anonymous {
        parts.push("Anon");
    }
    if ep.supports_username {
        parts.push("User");
    }
    if ep.supports_certificate {
        parts.push("Cert");
    }
    if parts.is_empty() {
        "-".to_string()
    } else {
        parts.join(", ")
    }
}

fn endpoint_eq(a: &EndpointInfo, b: &EndpointInfo) -> bool {
    a.endpoint_url == b.endpoint_url
        && a.security_policy_uri == b.security_policy_uri
        && a.security_mode == b.security_mode
}