buoyant 0.7.0

SwiftUI-like UIs in Rust for embedded devices
Documentation
use crate::FONT;
use crate::definitions::{GoodPixelColor, IpType, Octet, Palette, RenderData, State, TemporaryIp};
use buoyant::focus::BoundaryBehavior;
use buoyant::view::prelude::*;
use buoyant::view::rotary::{Rotary, RotaryEvent, RotaryState};
use core::{fmt::Write, net::Ipv4Addr};
use heapless::String;

// Rustc cannot infer certain types of closures so we help it a bit.
fn funnel<F: Fn(&mut State)>(f: F) -> F {
    f
}

pub fn settings<'a, 'b, C: GoodPixelColor, F: Fn(&State) + 'a>(
    data: RenderData<'a, C>,
    state: &'b State,
    save: F,
) -> impl View<C, State> + use<'a, C, F> {
    let p = data.palette;

    let save = funnel(move |s| save(s));
    let toggle_dhcp = funnel(|s| s.dhcp = !s.dhcp);
    let enter_ip = |t: IpType| {
        funnel(move |s| {
            s.opened_input = Some(t);
            s.temporary_ip = match t {
                IpType::StaticIp => s.static_ip.into(),
                IpType::Gateway => s.gateway.into(),
                IpType::Dns => s.dns.into(),
            }
        })
    };
    let set_ip = |t: IpType| {
        funnel(move |s| {
            if let Ok(ip) = s.temporary_ip.try_into() {
                s.opened_input = None;
                match t {
                    IpType::StaticIp => s.static_ip = ip,
                    IpType::Gateway => s.gateway = ip,
                    IpType::Dns => s.dns = ip,
                }
            }
        })
    };
    let cancel_ip = funnel(move |s: &mut State| s.opened_input = None);

    let static_ip = state.static_ip;
    let net_mask = state.net_mask;
    let gateway = state.gateway;
    let dns = state.dns;
    let dhcp = state.dhcp;
    let ip = state.temporary_ip;

    VStack::new((
        HStack::new((
            Text::new("Static IP", &FONT),
            Spacer::default(),
            Button::new(enter_ip(IpType::StaticIp), move |s| {
                button_gut(s.is_focused(), p, ip_view(static_ip))
            }),
        )),
        Spacer::default(),
        HStack::new((
            Text::new("Net Mask", &FONT),
            Spacer::default(),
            netmask(net_mask, p),
        )),
        Spacer::default(),
        HStack::new((
            Text::new("Gateway", &FONT),
            Spacer::default(),
            Button::new(enter_ip(IpType::Gateway), move |s| {
                button_gut(s.is_focused(), p, ip_view(gateway))
            }),
        )),
        Spacer::default(),
        HStack::new((
            Text::new("DNS", &FONT),
            Spacer::default(),
            Button::new(enter_ip(IpType::Dns), move |s| {
                button_gut(s.is_focused(), p, ip_view(dns))
            }),
        )),
        Spacer::default(),
        HStack::new((
            Text::new("DHCP", &FONT),
            Spacer::default(),
            Button::new(toggle_dhcp, move |s| {
                let text = if dhcp { "ON" } else { "OFF" };
                button_gut(s.is_focused(), p, Text::new(text, &FONT))
            }),
        )),
        Button::new(save, move |s| {
            button_gut(s.is_focused(), p, Text::new("Save", &FONT))
        }),
    ))
    .popover(state.opened_input.as_ref(), move |opened_input| {
        buoyant::match_view!(opened_input, {
            IpType::StaticIp => ip_setter(ip, set_ip(IpType::StaticIp), cancel_ip, p),
            IpType::Gateway => ip_setter(ip, set_ip(IpType::Gateway), cancel_ip, p),
            IpType::Dns => ip_setter(ip, set_ip(IpType::Dns), cancel_ip, p),
        })
        .background(
            Alignment::Center,
            RoundedRectangle::new(5)
                .stroked(1)
                .foreground_color(p.light_gray()),
        )
        .background_color(p.dark_blue(), RoundedRectangle::new(5))
        .padding(Edges::All, 10)
        .bound_focus(BoundaryBehavior::Wrap)
    })
    // // TODO: handle escape alongside submit
    // ZStack::new((main, state.opened_input.as_ref().map(|_| overlay)))
    //     .with_alignment(Alignment::Center)
}

fn ip_view<C: GoodPixelColor>(ip: Ipv4Addr) -> impl View<C, State> {
    // TODO: implement "display" version of `Text`. It would probably require multiple
    // writes of the same buffer, but sometimes it should be worth it.
    let [a, b, c, d] = ip.octets();
    let mut buf = String::<15, u8>::new();
    write!(&mut buf, "{a}.{b}.{c}.{d}").ok();
    Text::new(buf, &FONT)
}

fn ip_setter<C: GoodPixelColor>(
    ip: TemporaryIp,
    submit: impl Fn(&mut State),
    _cancel: impl Fn(&mut State),
    palette: &'static Palette<C>,
) -> impl View<C, State> {
    fn set<const OCTET: usize, const INDEX: usize>(s: &mut State, v: u8) {
        const { assert!(INDEX < 3) };
        const { assert!(OCTET < 4) };

        s.temporary_ip.0[OCTET].0[INDEX] = v;
    }

    let TemporaryIp(
        [
            // -
            Octet([a0, a1, a2]),
            Octet([b0, b1, b2]),
            Octet([c0, c1, c2]),
            Octet([d0, d1, d2]),
        ],
    ) = ip;

    let or5_9 = |is_max: bool| if is_max { 5 } else { 9 };

    let [a1max, a2max] = [or5_9(a0 == 2), or5_9((a0, a1) == (2, 5))];
    let [b1max, b2max] = [or5_9(b0 == 2), or5_9((b0, b1) == (2, 5))];
    let [c1max, c2max] = [or5_9(c0 == 2), or5_9((c0, c1) == (2, 5))];
    let [d1max, d2max] = [or5_9(d0 == 2), or5_9((d0, d1) == (2, 5))];

    let a = HStack::new((
        ip_digit(a0, 2, set::<0, 0>, palette),
        ip_digit(a1, a1max, set::<0, 1>, palette),
        ip_digit(a2, a2max, set::<0, 2>, palette),
    ));
    let b = HStack::new((
        ip_digit(b0, 2, set::<1, 0>, palette),
        ip_digit(b1, b1max, set::<1, 1>, palette),
        ip_digit(b2, b2max, set::<1, 2>, palette),
    ));
    let c = HStack::new((
        ip_digit(c0, 2, set::<2, 0>, palette),
        ip_digit(c1, c1max, set::<2, 1>, palette),
        ip_digit(c2, c2max, set::<2, 2>, palette),
    ));
    let d = HStack::new((
        ip_digit(d0, 2, set::<3, 0>, palette),
        ip_digit(d1, d1max, set::<3, 1>, palette),
        ip_digit(d2, d2max, set::<3, 2>, palette),
    ));

    let ip = HStack::new((
        a,
        Text::new(".", &FONT),
        b,
        Text::new(".", &FONT),
        c,
        Text::new(".", &FONT),
        d,
    ));

    VStack::new((
        ip,
        Spacer::default().frame().with_height(10),
        Button::new(submit, move |s| {
            button_gut(s.is_focused(), palette, Text::new("Submit", &FONT))
        }),
    ))
    .padding(Edges::Vertical, 5)
    .padding(Edges::Horizontal, 10)
}

fn ip_digit<C: GoodPixelColor>(
    num: u8,
    max: u8,
    set_ditit: impl Fn(&mut State, u8) + 'static,
    palette: &'static Palette<C>,
) -> impl View<C, State> {
    #[derive(Copy, Clone)]
    struct Num(u8);

    #[rustfmt::skip]
    impl AsRef<str> for Num {
        fn as_ref(&self) -> &str {
            match self.0 {
                0 => "0", 1 => "1", 2 => "2", 3 => "3", 4 => "4",
                5 => "5", 6 => "6", 7 => "7", 8 => "8", 9 => "9", _ => "?",
            }
        }
    }

    let on_action = move |s: &mut State, event: RotaryEvent| match (event, num) {
        (RotaryEvent::Previous, _) if num == max => set_ditit(s, 0),
        (RotaryEvent::Previous, _) => set_ditit(s, num + 1),
        (RotaryEvent::Next, 0) => set_ditit(s, max),
        (RotaryEvent::Next, _) => set_ditit(s, num - 1),
        _ => (),
    };

    Rotary::new(on_action, move |page_state: RotaryState| {
        let is_focused = page_state == RotaryState::Focused || page_state == RotaryState::Captive;
        button_gut(is_focused, palette, Text::new(Num(num), &FONT))
    })
}

fn netmask<C: GoodPixelColor>(mask: u8, palette: &'static Palette<C>) -> impl View<C, State> {
    let on_action = |s: &mut State, event: RotaryEvent| match (event, s.net_mask) {
        (RotaryEvent::Previous, 32) => s.net_mask = 8,
        (RotaryEvent::Previous, _) => s.net_mask += 1,
        (RotaryEvent::Next, 8) => s.net_mask = 32,
        (RotaryEvent::Next, _) => s.net_mask -= 1,
        _ => (),
    };

    Rotary::new(on_action, move |page_state: RotaryState| {
        let is_focused = page_state == RotaryState::Focused || page_state == RotaryState::Captive;
        let mut buf = String::<3, u8>::new();
        write!(&mut buf, "/{mask}").ok();
        button_gut(is_focused, palette, Text::new(buf, &FONT))
    })
}

fn button_gut<C: GoodPixelColor>(
    is_focused: bool,
    palette: &'static Palette<C>,
    inner: impl View<C, State>,
) -> impl View<C, State> {
    let inner = inner.padding(Edges::All, 3);
    buoyant::if_view!((is_focused) {
        inner.background(
            Alignment::Center,
            RoundedRectangle::new(3)
                .stroked(1)
                .foreground_color(palette.light_gray())
        )
    } else {
        inner
    })
}