AkurAI Build
Menu

cosmic-mobile

public

Latest change f47146a971ed832ad262fcf886b9852c58f40f0d - Keep full dock above gesture safe area by AkurAI Build

use eframe::{
    egui::{self, *},
    glow::HasContext,
};
use std::{
    process::{Child, Command},
    time::{Duration, SystemTime, UNIX_EPOCH},
};
mod layout;
#[derive(PartialEq)]
enum Page {
    Home,
    Notes,
    System,
}
struct Shell {
    page: Page,
    notes: String,
    renderer: String,
    search: String,
    error: String,
    child: Option<Child>,
    wallpaper: TextureHandle,
}
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() -> Frame {
    Frame::new()
        .fill(Color32::from_rgba_unmultiplied(225, 231, 255, 32))
        .stroke(Stroke::new(1., 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]);
    ui.painter().rect_stroke(
        tile,
        18,
        Stroke::new(
            if response.has_focus() { 3. } else { 1. },
            Color32::from_white_alpha(150),
        ),
        StrokeKind::Inside,
    );
    let c = tile.center();
    let st = Stroke::new(2.5, 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,
            _ => {
                if self
                    .child
                    .as_mut()
                    .is_some_and(|c| matches!(c.try_wait(), Ok(None)))
                {
                    self.error = "Terminal already running. Close it to return.".into();
                    return;
                }
                match Command::new("foot")
                    .args(["--app-id=mobile-terminal", "--font=monospace:size=16"])
                    .spawn()
                {
                    Ok(c) => self.child = Some(c),
                    Err(e) => self.error = format!("Could not open Terminal: {e}"),
                }
            }
        }
    }
}
impl eframe::App for Shell {
    fn update(&mut self, ctx: &Context, _: &mut eframe::Frame) {
        ctx.request_repaint_after(Duration::from_secs(1));
        if ctx.input(|i| i.key_pressed(Key::Escape)) {
            self.page = Page::Home;
        }
        let screen = ctx.content_rect();
        let [l, t, r, b] = layout::safe_area(screen.width(), screen.height());
        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,
        );
        // 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.),
        );
        p.rect_filled(home, 3, Color32::from_white_alpha(230));
        Area::new(Id::new("content"))
            .fixed_pos(pos2(l, t))
            .show(ctx, |ui| {
                ui.set_width(r - l);
                ui.set_max_height(b - t - 156.);
                ScrollArea::vertical()
                    .max_height(b - t - 156.)
                    .show(ui, |ui| {
                        if self.page == Page::Home {
                            ui.add_space(24.);
                            ui.vertical_centered(|ui| {
                                ui.label(RichText::new(clock()).size(76.).strong());
                                ui.label(RichText::new("Make room for what matters.").size(16.));
                            });
                            ui.add_space(42.);
                            glass().show(ui, |ui| {
                                ui.set_width(r - l - 40.);
                                ui.add(
                                    TextEdit::singleline(&mut self.search)
                                        .hint_text("Search apps")
                                        .desired_width(f32::INFINITY)
                                        .frame(false),
                                );
                            });
                            ui.add_space(32.);
                            ui.horizontal_wrapped(|ui| {
                                ui.spacing_mut().item_spacing = vec2(24., 20.);
                                for (i, label) in
                                    ["Notes", "Terminal", "Settings"].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"]
                                    .iter()
                                    .any(|n| n.contains(&self.search.to_lowercase()))
                            {
                                ui.label("No matching apps");
                            }
                        } else {
                            ui.add_space(16.);
                            if ui
                                .add_sized([88., 48.], Button::new("‹ Home").frame(false))
                                .clicked()
                            {
                                self.page = Page::Home;
                            }
                            ui.add_space(16.);
                            ui.heading(if self.page == Page::Notes {
                                "Notes"
                            } else {
                                "Settings"
                            });
                            ui.add_space(24.);
                            glass().show(ui, |ui| {
                                ui.set_width(r - l - 40.);
                                if self.page == Page::Notes {
                                    ui.label("Scratchpad · session only");
                                    ui.add_space(16.);
                                    ui.add_sized(
                                        [ui.available_width(), 300.],
                                        TextEdit::multiline(&mut self.notes)
                                            .hint_text("Start writing…")
                                            .frame(false),
                                    );
                                } else {
                                    ui.label("Graphics");
                                    ui.label(&self.renderer);
                                    ui.add_space(20.);
                                    ui.label("Native Rust · Wayland · OpenGL");
                                    ui.label("OnePlus 13R target; VM hardware");
                                    ui.add_space(20.);
                                    ui.label("Safe area: 56 top / 34 bottom");
                                }
                            });
                        }
                        if !self.error.is_empty() {
                            ui.add_space(16.);
                            ui.label(&self.error);
                        }
                    });
            });
        Area::new(Id::new("dock"))
            .fixed_pos(pos2(l, b - 140.))
            .show(ctx, |ui| {
                glass().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_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}");
            cc.egui_ctx.set_pixels_per_point(2.);
            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: String::new(),
                renderer,
                search: String::new(),
                error: String::new(),
                child: None,
                wallpaper,
            }))
        }),
    )
}