kr580 1.0.0

Desktop KR580VM80 / Intel 8080 emulator.
Documentation
use std::fmt::Write;

use crate::backend::{ConnectionState, DeviceStatus, NetworkMode, NetworkState, decode_oem_text};
use iced::widget::{Space, column, container, mouse_area, opaque, row, scrollable, stack};
use iced::{Element, Length, Padding, alignment};

use super::icons;
use super::storage::chrome::{
    device_backdrop_style, device_buffer_style, icon_button, window_controls,
};
use super::styles::{panel_style, scrollable_style};
use super::theme::{MONO_FONT, TOKYO_MUTED, TOKYO_TEXT, ui_text};
use super::tooltips::shortcut_hint;
use crate::app::{Message, ToolWindowKind};
use crate::i18n::{Key, Lang, NetworkKey};

const WINDOW_WIDTH: f32 = 760.0;
const WINDOW_HEIGHT: f32 = 340.0;

pub(in crate::view) struct NetworkViewState<'a> {
    pub(in crate::view) network: &'a NetworkState,
    pub(in crate::view) settings_open: bool,
    pub(in crate::view) text_view: bool,
    pub(in crate::view) mode: NetworkMode,
    pub(in crate::view) host: &'a str,
    pub(in crate::view) port: &'a str,
    pub(in crate::view) error: Option<&'a str>,
    pub(in crate::view) lang: Lang,
}

pub(in crate::view) fn network_window_overlay(view: NetworkViewState<'_>) -> Element<'_, Message> {
    let backdrop: Element<'_, Message> = mouse_area(
        container(Space::new())
            .width(Length::Fill)
            .height(Length::Fill)
            .style(device_backdrop_style),
    )
    .on_press(Message::CloseNetwork)
    .into();
    let dialog = container(network_content(view, false, false))
        .padding(16)
        .style(panel_style)
        .width(Length::Fixed(WINDOW_WIDTH))
        .height(Length::Fixed(WINDOW_HEIGHT));
    let centered = center(opaque(dialog));

    stack![opaque(backdrop), centered]
        .width(Length::Fill)
        .height(Length::Fill)
        .into()
}

pub(in crate::view) fn network_window(
    view: NetworkViewState<'_>,
    always_on_top: bool,
) -> Element<'_, Message> {
    container(network_content(view, true, always_on_top))
        .padding(16)
        .style(panel_style)
        .width(Length::Fill)
        .height(Length::Fill)
        .into()
}

fn network_content(
    view: NetworkViewState<'_>,
    detached: bool,
    always_on_top: bool,
) -> Element<'_, Message> {
    let settings_open = view.settings_open;
    let text_view = view.text_view;
    let buffers = row![
        buffer_panel(
            view.lang.t(Key::Network(NetworkKey::RxBuffer)),
            if text_view {
                format_network_text(&view.network.rx_buffer)
            } else {
                format_network_buffer(&view.network.rx_buffer)
            },
        ),
        buffer_panel(
            view.lang.t(Key::Network(NetworkKey::LastTransmittedValue)),
            if text_view {
                format_network_text(&view.network.tx_buffer)
            } else {
                format_last_transmitted_value(&view.network.tx_buffer)
            },
        ),
    ]
    .spacing(12)
    .height(Length::Fill);
    let device_body = column![buffers, footer(view.network, view.lang)]
        .spacing(12)
        .width(Length::Fill)
        .height(Length::Fill);
    let body: Element<'_, Message> = column![
        header(detached, always_on_top, settings_open, text_view, view.lang),
        Space::new().height(Length::Fixed(12.0)),
        device_body,
    ]
    .width(Length::Fill)
    .height(Length::Fill)
    .into();

    if settings_open {
        stack![body, super::network_settings::settings_overlay(view)].into()
    } else {
        body
    }
}

fn header(
    detached: bool,
    always_on_top: bool,
    settings_open: bool,
    text_view: bool,
    lang: Lang,
) -> Element<'static, Message> {
    row![
        window_controls(ToolWindowKind::Network, detached, always_on_top, lang),
        icon_button(
            icons::type_icon(),
            Some(Message::ToggleNetworkBufferView),
            lang.t(Key::Network(if text_view {
                NetworkKey::ShowBytes
            } else {
                NetworkKey::ShowText
            })),
            text_view,
            None,
        ),
        Space::new().width(Length::Fixed(6.0)),
        icon_button(
            icons::globe(),
            Some(Message::OpenNetworkSettings),
            lang.t(Key::Network(NetworkKey::Settings)),
            settings_open,
            None,
        ),
        Space::new().width(Length::Fixed(6.0)),
        icon_button(
            icons::brush_cleaning(),
            Some(Message::ClearNetworkBuffers),
            lang.t(Key::Network(NetworkKey::ClearBuffers)),
            false,
            None,
        ),
        Space::new().width(Length::Fixed(6.0)),
        icon_button(
            icons::window_close(),
            Some(Message::CloseNetwork),
            lang.t(Key::MonitorClose),
            false,
            shortcut_hint(&Message::CloseNetwork),
        ),
    ]
    .align_y(alignment::Vertical::Center)
    .into()
}

fn buffer_panel(title: &'static str, text: String) -> Element<'static, Message> {
    let empty = text.is_empty();
    let content = scrollable(
        container(
            iced::widget::text(text)
                .font(MONO_FONT)
                .size(11)
                .color(TOKYO_TEXT)
                .wrapping(iced::widget::text::Wrapping::None),
        )
        .padding(if empty { [34, 12] } else { [12, 12] })
        .width(Length::Fill),
    )
    .width(Length::Fill)
    .height(Length::Fill)
    .style(|theme, status| scrollable_style(true, theme, status));
    let frame = container(content)
        .width(Length::Fill)
        .height(Length::Fill)
        .style(device_buffer_style)
        .clip(true);
    let label = container(ui_text(title, 13, TOKYO_MUTED))
        .padding(Padding {
            top: 8.0,
            right: 12.0,
            bottom: 0.0,
            left: 12.0,
        })
        .width(Length::Fill);

    if empty {
        stack![frame, label]
            .width(Length::FillPortion(1))
            .height(Length::Fill)
            .into()
    } else {
        frame
            .width(Length::FillPortion(1))
            .height(Length::Fill)
            .into()
    }
}

fn footer<'a>(state: &'a NetworkState, lang: Lang) -> Element<'a, Message> {
    let mode = match state.mode {
        NetworkMode::Client => lang.t(Key::Network(NetworkKey::ModeClient)),
        NetworkMode::Server => lang.t(Key::Network(NetworkKey::ModeServer)),
    };
    let status = network_status(state, lang);
    let meta = format!(
        "{}: {status}   {}: {mode}   {}: {}:{}   {}: {}   {}: {}",
        lang.t(Key::Network(NetworkKey::Status)),
        lang.t(Key::Network(NetworkKey::Mode)),
        lang.t(Key::Network(NetworkKey::Endpoint)),
        state.host,
        state.port,
        lang.t(Key::Network(NetworkKey::RxTotal)),
        state.rx_total,
        lang.t(Key::Network(NetworkKey::TxTotal)),
        state.tx_total,
    );
    iced::widget::text(meta)
        .font(MONO_FONT)
        .size(12)
        .color(TOKYO_TEXT)
        .wrapping(iced::widget::text::Wrapping::None)
        .into()
}

pub(super) fn center<'a>(content: Element<'a, Message>) -> Element<'a, Message> {
    column![
        Space::new().height(Length::Fill),
        row![
            Space::new().width(Length::Fill),
            content,
            Space::new().width(Length::Fill)
        ],
        Space::new().height(Length::Fill),
    ]
    .width(Length::Fill)
    .height(Length::Fill)
    .into()
}

fn format_network_buffer(bytes: &[u8]) -> String {
    let mut output = String::new();
    for (line, chunk) in bytes.chunks(16).enumerate() {
        if line != 0 {
            output.push('\n');
        }
        let _ = write!(output, "{:04X}:", line * 16);
        for byte in chunk {
            let _ = write!(output, " {byte:02X}");
        }
    }
    output
}

fn format_network_text(bytes: &[u8]) -> String {
    decode_oem_text(bytes).replace('\t', "    ")
}

fn format_last_transmitted_value(bytes: &[u8]) -> String {
    bytes
        .last()
        .map(|byte| format!("{byte:02X}"))
        .unwrap_or_default()
}

fn network_status(state: &NetworkState, lang: Lang) -> String {
    match &state.connection {
        ConnectionState::Refused => lang
            .t(Key::Network(NetworkKey::ConnectionRefused))
            .to_owned(),
        ConnectionState::TimedOut => lang
            .t(Key::Network(NetworkKey::ConnectionTimedOut))
            .to_owned(),
        ConnectionState::Error(_) => lang.t(Key::Network(NetworkKey::ConnectionError)).to_owned(),
        _ => match &state.status {
            DeviceStatus::Ready => lang.t(Key::DeviceStatusReady).to_owned(),
            DeviceStatus::NotReady => lang.t(Key::DeviceStatusNotReady).to_owned(),
            DeviceStatus::Busy => lang.t(Key::DeviceStatusBusy).to_owned(),
            DeviceStatus::NoData => lang.t(Key::DeviceStatusNoData).to_owned(),
            DeviceStatus::Connected => lang.t(Key::DeviceStatusConnected).to_owned(),
            DeviceStatus::Listening => lang.t(Key::DeviceStatusListening).to_owned(),
            DeviceStatus::Disconnected => lang.t(Key::DeviceStatusDisconnected).to_owned(),
            DeviceStatus::Error(_) => lang.t(Key::Network(NetworkKey::ConnectionError)).to_owned(),
        },
    }
}

#[cfg(test)]
mod tests {
    use super::{
        format_last_transmitted_value, format_network_buffer, format_network_text, network_status,
    };
    use crate::backend::{ConnectionState, DeviceStatus, NetworkMode, NetworkState};
    use crate::i18n::Lang;

    #[test]
    fn network_buffer_uses_hex_offsets_and_sixteen_bytes_per_line() {
        let bytes = (0..18).collect::<Vec<_>>();
        assert_eq!(
            format_network_buffer(&bytes),
            "0000: 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F\n0010: 10 11"
        );
    }

    #[test]
    fn transmitted_value_has_no_offset_and_uses_the_last_byte() {
        assert_eq!(format_last_transmitted_value(&[0x40, 0x41]), "41");
    }

    #[test]
    fn network_text_view_decodes_oem_and_normalizes_controls() {
        assert_eq!(
            format_network_text(&[0x8F, 0xE0, b'!', b'\r', b'\n', b'\t', 0x01]),
            "Пр!\n    ·"
        );
    }

    #[test]
    fn network_status_never_exposes_socket_error_details() {
        let mut state = NetworkState {
            mode: NetworkMode::Client,
            host: "127.0.0.1".to_owned(),
            port: 5800,
            connection: ConnectionState::Error("os error 10061".to_owned()),
            rx_buffer: Vec::new(),
            tx_buffer: Vec::new(),
            rx_total: 0,
            tx_total: 0,
            last_error: Some("os error 10061".to_owned()),
            status: DeviceStatus::Error("os error 10061".to_owned()),
        };

        assert_eq!(network_status(&state, Lang::Ru), "Ошибка");

        state.connection = ConnectionState::Refused;
        assert_eq!(network_status(&state, Lang::Ru), "Отклонено");
    }
}