AkurAI Build
Menu

cosmic-mobile

public

Latest change 975a13e7afc3dea7e34c04e77e3879cf635a1bf7 - Upgrade system app components to iOS design language by AkurAI Build

use eframe::{egui::*, glow::HasContext};
use std::{
    process::{Child, Command},
    time::{Duration, SystemTime, UNIX_EPOCH},
};
mod apps;
mod boot;
mod core_apps;
mod ios;
mod layout;
mod navigation;
#[derive(PartialEq)]
enum Page {
    Home,
    Notes,
    Calculator,
    Clock,
    System,
    Core(usize),
    Store,
}
struct Shell {
    page: Page,
    notes: String,
    records: Result<core_apps::Store, std::io::Error>,
    first: String,
    second: String,
    drafts: [(String, String); 4],
    calculator: apps::Calculator,
    stopwatch: apps::Stopwatch,
    renderer: String,
    search: String,
    error: String,
    child: Option<Child>,
    wallpaper: TextureHandle,
    show_clock: bool,
    opaque: bool,
    android_navigation: bool,
    boot_start: std::time::Instant,
}
fn clock() -> String {
    let n = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs();
    format!("{:02}:{:02}", n / 3600 % 24, n / 60 % 60)
}
fn glass(opaque: bool) -> Frame {
    Frame::new()
        .fill(if opaque {
            Color32::from_rgb(38, 45, 65)
        } else {
            Color32::from_rgba_unmultiplied(225, 231, 255, 32)
        })
        .stroke(Stroke::new(1_f32, Color32::from_white_alpha(64)))
        .corner_radius(28)
        .inner_margin(20)
}
fn icon(ui: &mut Ui, label: &str, index: usize) -> Response {
    let (r, response) = ui.allocate_exact_size(vec2(76., 100.), Sense::click());
    response.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, label));
    let tile = Rect::from_min_size(r.min + vec2(6., 0.), vec2(64., 64.));
    let colors = [
        Color32::from_rgb(239, 184, 74),
        Color32::from_rgb(78, 161, 180),
        Color32::from_rgb(119, 129, 164),
    ];
    ui.painter().rect_filled(tile, 18, colors[index % 3]);
    ui.painter().rect_stroke(
        tile,
        18,
        Stroke::new(
            if response.has_focus() { 3_f32 } else { 1_f32 },
            Color32::from_white_alpha(150),
        ),
        StrokeKind::Inside,
    );
    let c = tile.center();
    let st = Stroke::new(2.5_f32, Color32::WHITE);
    match index {
        0 => {
            for y in [-9., 0., 9.] {
                ui.painter()
                    .line_segment([c + vec2(-13., y), c + vec2(13., y)], st);
            }
        }
        1 => {
            ui.painter()
                .line_segment([c + vec2(-14., -10.), c + vec2(-4., 0.)], st);
            ui.painter()
                .line_segment([c + vec2(-4., 0.), c + vec2(-14., 10.)], st);
            ui.painter()
                .line_segment([c + vec2(1., 10.), c + vec2(14., 10.)], st);
        }
        _ => {
            ui.painter().circle_stroke(c, 14., st);
            ui.painter().circle_stroke(c, 5., st);
        }
    }
    ui.painter().text(
        pos2(r.center().x, r.top() + 78.),
        Align2::CENTER_TOP,
        label,
        FontId::proportional(14.),
        Color32::WHITE,
    );
    response
}
impl Shell {
    fn open(&mut self, index: usize) {
        self.error.clear();
        match index {
            0 => self.page = Page::Notes,
            2 => self.page = Page::System,
            3 => self.page = Page::Calculator,
            4 => self.page = Page::Clock,
            9 => self.page = Page::Store,
            5..=8 => {
                self.page = Page::Core(index - 5);
                (self.first, self.second) = self.drafts[index - 5].clone();
            }
            _ => {
                if self
                    .child
                    .as_mut()
                    .is_some_and(|c| matches!(c.try_wait(), Ok(None)))
                {
                    match Command::new("niri")
                        .args(["msg", "action", "focus-workspace", "apps"])
                        .status()
                    {
                        Ok(s) if s.success() => {}
                        _ => self.error = "Could not switch to apps".into(),
                    };
                    return;
                }
                match Command::new("alacritty")
                    .args(["--class", "Alacritty"])
                    .spawn()
                {
                    Ok(c) => self.child = Some(c),
                    Err(e) => self.error = format!("Could not open Terminal: {e}"),
                }
            }
        }
    }
}
impl Shell {
    fn store_page(&mut self, ui: &mut Ui) {
        ios::caption(ui, "BúiOS registry");
        ios::card(ui, |ui| {
            ios::row(ui, "Registry", "Not connected");
            ui.separator();
            ios::row(ui, "Installed packages", "0");
        });
        ui.label(RichText::new("App installation requires a signed registry and independent app packages. Bundled system pages are not installable packages.").size(15.).color(ios::SECONDARY));
    }
    fn notes_page(&mut self, ui: &mut Ui, height: f32) {
        ios::caption(ui, "Personal note");
        Frame::new()
            .fill(ios::CARD)
            .corner_radius(16)
            .inner_margin(16)
            .show(ui, |ui| {
                ui.add_sized(
                    [ui.available_width(), (height - 360.).max(200.)],
                    TextEdit::multiline(&mut self.notes)
                        .hint_text("Start writing…")
                        .font(FontId::proportional(17.))
                        .frame(false),
                );
            });
        if ios::primary(ui, "Save", ios::BLUE) {
            self.error = match apps::save_note(
                &navigation::path().with_file_name("note.txt"),
                &self.notes,
            ) {
                Ok(()) => "Saved".into(),
                Err(e) => format!("Save failed: {e}"),
            };
        }
    }
    fn clock_page(&mut self, ui: &mut Ui) {
        ios::card(ui, |ui| {
            ui.add_space(12.);
            ui.vertical_centered(|ui| {
                ui.label(RichText::new(clock()).size(64.).strong());
                ui.label(RichText::new("UTC").size(15.).color(ios::SECONDARY));
            });
            ui.add_space(12.);
        });
        ios::caption(ui, "Stopwatch");
        ios::card(ui, |ui| {
            ui.add_space(12.);
            self.stopwatch.ui(ui);
            ui.add_space(12.);
        });
    }
    fn settings_page(&mut self, ui: &mut Ui) {
        ios::caption(ui, "Display");
        ios::card(ui, |ui| {
            ui.add_space(6.);
            ui.checkbox(
                &mut self.show_clock,
                RichText::new("Show clock widget").size(17.),
            );
            ui.separator();
            ui.checkbox(
                &mut self.opaque,
                RichText::new("Reduce transparency").size(17.),
            );
            ui.separator();
            ios::row(ui, "Navigation buttons", "Always visible");
            ui.add_space(6.);
        });
        ios::caption(ui, "Graphics");
        ios::card(ui, |ui| {
            ios::row(ui, "Renderer", &self.renderer);
            ui.separator();
            ios::row(ui, "Stack", "Rust · Wayland · OpenGL");
        });
        ios::caption(ui, "Device");
        ios::card(ui, |ui| {
            ios::row(ui, "Target", "OnePlus 13R");
            ui.separator();
            ios::row(ui, "Hardware", "VM");
            ui.separator();
            ios::row(ui, "Safe area", "56 top / 34 bottom");
        });
    }
    fn core_page(&mut self, ui: &mut Ui, i: usize) {
        let name = ["Phone", "Contacts", "Calendar", "Messages"][i];
        ui.label(
            RichText::new(if i == 0 || i == 3 {
                "Demo — no cellular connection. Nothing is sent."
            } else {
                "Local only — no cloud sync"
            })
            .size(13.)
            .color(ios::SECONDARY),
        );
        ui.add_space(12.);
        if i == 0 {
            ui.vertical_centered(|ui| {
                ui.add_space(8.);
                ui.label(
                    RichText::new(if self.first.is_empty() {
                        " "
                    } else {
                        &self.first
                    })
                    .size(36.),
                );
                ui.add_space(16.);
            });
            let size = ((ui.available_width() - 2. * 28.) / 3.).clamp(64., 84.);
            let gap = (ui.available_width() - 3. * size) / 2.;
            for row in [
                ["1", "2", "3"],
                ["4", "5", "6"],
                ["7", "8", "9"],
                ["*", "0", "#"],
            ] {
                ui.horizontal(|ui| {
                    ui.spacing_mut().item_spacing.x = gap;
                    for digit in row {
                        if ios::key(ui, digit, size, ios::KEY, Color32::WHITE).clicked()
                            && self.first.len() < 64
                        {
                            self.first.push_str(digit);
                        }
                    }
                });
                ui.add_space(12.);
            }
            ui.horizontal(|ui| {
                ui.spacing_mut().item_spacing.x = gap;
                ui.add_space(size + gap);
                let call = ios::key(ui, "📞", size, ios::GREEN, Color32::WHITE);
                let del = ios::key(ui, "⌫", size, ios::BACKGROUND, ios::KEY_LIGHT);
                if del.clicked() {
                    self.first.pop();
                }
                if call.clicked() {
                    self.save_record(name);
                }
            });
            ui.add_space(12.);
            ios::field(ui, &mut self.second, "Call note");
        } else {
            ios::card(ui, |ui| {
                ui.add_space(8.);
                ios::field(
                    ui,
                    &mut self.first,
                    ["", "Name", "Date (YYYY-MM-DD)", "Recipient"][i],
                );
                ui.add_space(8.);
                ios::field(
                    ui,
                    &mut self.second,
                    ["", "Phone number", "Event title", "Message"][i],
                );
                ui.add_space(8.);
            });
            if ios::primary(
                ui,
                ["", "Save Contact", "Add Event", "Send"][i],
                if i == 3 { ios::GREEN } else { ios::BLUE },
            ) {
                self.save_record(name);
            }
        }
        ui.add_space(8.);
        ios::caption(ui, ["Recents", "Contacts", "Upcoming", "Messages"][i]);
        match &self.records {
            Ok(store) => {
                let rows = store.for_app(name);
                if rows.is_empty() {
                    ui.label(
                        RichText::new(
                            [
                                "No calls yet",
                                "No contacts yet",
                                "No events yet",
                                "No messages yet",
                            ][i],
                        )
                        .color(ios::SECONDARY),
                    );
                } else {
                    ios::card(ui, |ui| {
                        for (n, row) in rows.iter().enumerate() {
                            if n > 0 {
                                ui.separator();
                            }
                            ui.add_space(6.);
                            ui.label(RichText::new(&row.1).size(17.));
                            ui.label(RichText::new(&row.2).size(15.).color(ios::SECONDARY));
                            ui.label(RichText::new(&row.3).size(13.).color(ios::SECONDARY));
                            ui.add_space(6.);
                        }
                    });
                }
            }
            Err(e) => {
                ui.label(format!("Storage unavailable: {e}"));
            }
        }
    }
    fn save_record(&mut self, name: &str) {
        self.error = match &mut self.records {
            Ok(store) => match store.add(name, &self.first, &self.second) {
                Ok(()) => "Saved locally".into(),
                Err(e) => e.to_string(),
            },
            Err(e) => format!("Storage unavailable: {e}"),
        };
    }
}
impl eframe::App for Shell {
    fn update(&mut self, ctx: &Context, _: &mut eframe::Frame) {
        ctx.request_repaint_after(Duration::from_secs(1));
        let boot_time = self.boot_start.elapsed().as_secs_f32();
        if boot::active(boot_time) {
            boot::paint(ctx, boot_time);
            return;
        }
        if let Page::Core(i) = self.page {
            self.drafts[i] = (self.first.clone(), self.second.clone());
        }
        if ctx.input(|i| i.key_pressed(Key::Escape)) {
            self.page = Page::Home;
        }
        let screen = ctx.content_rect();
        let [l, t, r, mut b] = layout::safe_area(screen.width(), screen.height());
        if self.android_navigation {
            b = b.min(screen.bottom() - 72.);
        }
        let p = ctx.layer_painter(LayerId::background());
        p.image(
            self.wallpaper.id(),
            screen,
            Rect::from_min_max(pos2(0., 0.), pos2(1., 1.)),
            Color32::WHITE,
        );
        if self.page != Page::Home {
            p.rect_filled(screen, 0, ios::BACKGROUND);
        }
        // ponytail: conservative VM insets; replace with compositor/device cutout metadata on hardware.
        p.text(
            pos2(l, 27.),
            Align2::LEFT_CENTER,
            clock(),
            FontId::proportional(16.),
            Color32::WHITE,
        );
        p.text(
            pos2(r, 27.),
            Align2::RIGHT_CENTER,
            "VM · UTC",
            FontId::proportional(12.),
            Color32::from_white_alpha(210),
        );
        let home = Rect::from_center_size(
            pos2(screen.center().x, screen.bottom() - 17.),
            vec2(124., 5.),
        );
        if !self.android_navigation {
            p.rect_filled(home, 3, Color32::from_white_alpha(230));
        } else {
            Area::new(Id::new("android-navigation"))
                .order(Order::Foreground)
                .fixed_pos(pos2(0., screen.bottom() - 56.))
                .show(ctx, |ui| {
                    ui.spacing_mut().item_spacing.x = 0.;
                    ui.horizontal(|ui| {
                        for index in 0..3 {
                            let (rect, response) = ui.allocate_exact_size(
                                vec2(screen.width() / 3., 56.),
                                Sense::click(),
                            );
                            let c = rect.center();
                            let stroke = Stroke::new(2_f32, Color32::WHITE);
                            match index {
                                0 => {
                                    ui.painter().add(Shape::closed_line(
                                        vec![
                                            c + vec2(8., -10.),
                                            c + vec2(-9., 0.),
                                            c + vec2(8., 10.),
                                        ],
                                        stroke,
                                    ));
                                }
                                1 => {
                                    ui.painter().circle_stroke(c, 10., stroke);
                                }
                                _ => {
                                    ui.painter().rect_stroke(
                                        Rect::from_center_size(c, vec2(19., 19.)),
                                        2,
                                        stroke,
                                        StrokeKind::Inside,
                                    );
                                }
                            }
                            response.widget_info(|| {
                                WidgetInfo::labeled(
                                    WidgetType::Button,
                                    true,
                                    ["Back", "Home", "Recent apps"][index],
                                )
                            });
                            if response.clicked() {
                                match index {
                                    0 | 1 => self.page = Page::Home,
                                    _ => {
                                        if !Command::new("niri")
                                            .args(["msg", "action", "toggle-overview"])
                                            .status()
                                            .is_ok_and(|s| s.success())
                                        {
                                            self.error = "App switcher unavailable".into();
                                        }
                                    }
                                }
                            }
                        }
                    });
                });
        }
        Area::new(Id::new("content"))
            .fixed_pos(pos2(l, t))
            .show(ctx, |ui| {
                ui.set_width(r - l);
                ui.set_max_height(b - t - if self.page == Page::Home { 226. } else { 16. });
                ScrollArea::vertical()
                    .max_height(b - t - if self.page == Page::Home { 226. } else { 16. })
                    .show(ui, |ui| {
                        if self.page == Page::Home {
                            ui.add_space(24.);
                            if self.show_clock {
                                ui.vertical_centered(|ui| {
                                    ui.label(RichText::new(clock()).size(48.).strong());
                                });
                                ui.add_space(24.);
                            }
                            ui.horizontal_wrapped(|ui| {
                                ui.spacing_mut().item_spacing = vec2(24., 20.);
                                for (i, label) in [
                                    "Notes",
                                    "Terminal",
                                    "Settings",
                                    "Calculator",
                                    "Clock",
                                    "Phone",
                                    "Contacts",
                                    "Calendar",
                                    "Messages",
                                    "App Store",
                                ]
                                .iter()
                                .enumerate()
                                {
                                    if label.to_lowercase().contains(&self.search.to_lowercase())
                                        && icon(ui, label, i).clicked()
                                    {
                                        self.open(i);
                                    }
                                }
                            });
                            if !self.search.is_empty()
                                && ![
                                    "notes",
                                    "terminal",
                                    "settings",
                                    "calculator",
                                    "clock",
                                    "phone",
                                    "contacts",
                                    "calendar",
                                    "messages",
                                    "app store",
                                ]
                                .iter()
                                .any(|n| n.contains(&self.search.to_lowercase()))
                            {
                                ui.label("No matching apps");
                            }
                        } else {
                            ui.add_space(8.);
                            if ios::back(ui, "Home") {
                                self.page = Page::Home;
                            }
                            let title = match self.page {
                                Page::Notes => "Notes",
                                Page::Calculator => "Calculator",
                                Page::Clock => "Clock",
                                Page::Store => "App Store",
                                Page::Core(i) => ["Phone", "Contacts", "Calendar", "Messages"][i],
                                _ => "Settings",
                            };
                            ios::large_title(ui, title);
                            ui.set_width(r - l);
                            match self.page {
                                Page::Store => self.store_page(ui),
                                Page::Notes => self.notes_page(ui, b - t),
                                Page::Calculator => self.calculator.ui(ui),
                                Page::Clock => self.clock_page(ui),
                                Page::Core(i) => self.core_page(ui, i),
                                _ => self.settings_page(ui),
                            }
                        }
                        if !self.error.is_empty() {
                            ui.add_space(8.);
                            ui.label(RichText::new(&self.error).color(ios::SECONDARY));
                        }
                    });
            });
        if self.page == Page::Home {
            Area::new(Id::new("search"))
                .fixed_pos(pos2(l, b - 210.))
                .show(ctx, |ui| {
                    glass(self.opaque).show(ui, |ui| {
                        ui.add_sized(
                            [r - l - 40., 32.],
                            TextEdit::singleline(&mut self.search)
                                .hint_text("Search apps")
                                .frame(false),
                        );
                    });
                });
        }
        if self.page == Page::Home {
            Area::new(Id::new("dock"))
                .fixed_pos(pos2(l, b - 140.))
                .show(ctx, |ui| {
                    glass(self.opaque).show(ui, |ui| {
                        ui.set_width(r - l - 40.);
                        ui.horizontal(|ui| {
                            ui.spacing_mut().item_spacing.x = ((r - l - 40.) - 228.) / 2.;
                            for (i, label) in ["Notes", "Terminal", "Settings"].iter().enumerate() {
                                if icon(ui, label, i).clicked() {
                                    self.open(i);
                                }
                            }
                        });
                    });
                });
        }
    }
}
fn main() -> eframe::Result {
    if std::env::var_os("WAYLAND_DISPLAY").is_none() {
        eprintln!("Wayland required");
        std::process::exit(1);
    }
    eframe::run_native(
        "cosmic-mobile",
        eframe::NativeOptions {
            renderer: eframe::Renderer::Glow,
            viewport: ViewportBuilder::default()
                .with_app_id("cosmic-mobile")
                .with_fullscreen(true),
            ..Default::default()
        },
        Box::new(|cc| {
            let gl = cc.gl.as_ref().ok_or("OpenGL missing")?;
            // SAFETY: eframe makes this context current during creation.
            let renderer = unsafe { gl.get_parameter_string(eframe::glow::RENDERER) };
            if ["llvmpipe", "softpipe", "swrast", "swiftshader"]
                .iter()
                .any(|s| renderer.to_lowercase().contains(s))
            {
                return Err("Software GPU rejected".into());
            }
            eprintln!("GPU_RENDERER={renderer}");
            // Compositor supplies display scale.
            let mut style = (*cc.egui_ctx.style()).clone();
            style.visuals = Visuals::dark();
            style.visuals.override_text_color = Some(Color32::WHITE);
            style.spacing.interact_size = vec2(48., 48.);
            style
                .text_styles
                .insert(TextStyle::Body, FontId::proportional(17.));
            cc.egui_ctx.set_style(style);
            let mut pixels = Vec::new();
            for y in 0..512 {
                for x in 0..256 {
                    let (u, v) = (x as f32 / 256., y as f32 / 512.);
                    let a = (-((u - 0.85).powi(2) * 6. + (v - 0.25).powi(2) * 9.)).exp();
                    let b = (-((u - 0.1).powi(2) * 7. + (v - 0.8).powi(2) * 7.)).exp();
                    pixels.push(Color32::from_rgb(
                        (22. + 100. * a + 20. * b) as u8,
                        (29. + 47. * a + 74. * b) as u8,
                        (59. + 54. * a + 65. * b) as u8,
                    ));
                }
            }
            let wallpaper = cc.egui_ctx.load_texture(
                "dusk",
                ColorImage::new([256, 512], pixels),
                TextureOptions::LINEAR,
            );
            Ok(Box::new(Shell {
                page: Page::Home,
                notes: std::fs::read_to_string(navigation::path().with_file_name("note.txt"))
                    .unwrap_or_default(),
                records: core_apps::Store::load(navigation::path().with_file_name("core-apps.tsv")),
                first: String::new(),
                second: String::new(),
                drafts: std::array::from_fn(|_| (String::new(), String::new())),
                calculator: apps::Calculator::default(),
                stopwatch: apps::Stopwatch::default(),
                renderer,
                search: String::new(),
                error: String::new(),
                child: None,
                wallpaper,
                show_clock: true,
                opaque: false,
                android_navigation: true,
                boot_start: std::time::Instant::now(),
            }))
        }),
    )
}