Menu
cosmic-mobile
publicLatest change 93a533a0f7d91047c27e088fff06958ac8443511 - Explain empty core app histories without fake records 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 layout;
mod navigation;
#[derive(PartialEq)]
enum Page {
Home,
Notes,
Calculator,
Clock,
System,
Core(usize),
}
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,
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 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, Color32::from_rgb(16, 17, 20));
}
// 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",
]
.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",
]
.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 {
match self.page {
Page::Calculator => "Calculator",
Page::Clock => "Clock",
Page::Core(i) => {
["Phone", "Contacts", "Calendar", "Messages"][i]
}
_ => "Settings",
}
});
ui.add_space(24.);
Frame::NONE.show(ui, |ui| {
ui.set_width(r - l);
if self.page == Page::Notes {
ui.label("PERSONAL NOTE");
ui.add_space(16.);
ui.add_sized(
[ui.available_width(), (b - t - 310.).max(200.)],
TextEdit::multiline(&mut self.notes)
.hint_text("Start writing…")
.frame(false),
);
if ui.button("Save note").clicked() {
self.error = match apps::save_note(
&navigation::path().with_file_name("note.txt"),
&self.notes,
) {
Ok(()) => "Saved".into(),
Err(e) => format!("Save failed: {e}"),
};
}
} else if self.page == Page::Calculator {
self.calculator.ui(ui);
} else if self.page == Page::Clock {
ui.label(RichText::new(clock()).size(48.));
ui.label("UTC");
ui.add_space(32.);
ui.heading("Stopwatch");
ui.add_space(24.);
self.stopwatch.ui(ui);
} else if let Page::Core(i) = self.page {
let name = ["Phone", "Contacts", "Calendar", "Messages"][i];
ui.label(if i == 0 || i == 3 {
"DEMO — no cellular connection. Nothing is sent."
} else {
"Local only — no cloud sync"
});
ui.add_space(16.);
ui.label(
["Number", "Name", "Date (YYYY-MM-DD)", "Recipient"][i],
);
ui.add_sized(
[ui.available_width(), 48.],
TextEdit::singleline(&mut self.first),
);
if i == 0 {
ui.add_space(16.);
let width = ((ui.available_width() - 32.) / 3.).max(48.);
Grid::new("phone-keypad").spacing(vec2(16., 12.)).show(
ui,
|ui| {
for (index, digit) in [
"1", "2", "3", "4", "5", "6", "7", "8", "9",
"*", "0", "#",
]
.iter()
.enumerate()
{
if ui
.add_sized(
[width, 56.],
Button::new(
RichText::new(*digit).size(24.),
),
)
.clicked()
&& self.first.len() < 64
{
self.first.push_str(digit);
}
if index % 3 == 2 {
ui.end_row();
}
}
},
);
if ui
.add_sized([width, 48.], Button::new("Delete"))
.clicked()
{
self.first.pop();
}
ui.add_space(16.);
}
ui.label(
["Call note", "Phone number", "Event title", "Message"][i],
);
ui.add_sized(
[ui.available_width(), 48.],
TextEdit::singleline(&mut self.second),
);
ui.add_space(16.);
if ui
.add_sized(
[ui.available_width(), 48.],
Button::new(
[
"Simulate call",
"Save contact",
"Save event",
"Simulate send",
][i],
)
.fill(Color32::from_rgb(48, 52, 62))
.corner_radius(28),
)
.clicked()
{
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}"),
};
}
ui.separator();
match &self.records {
Ok(store) => {
if !store.rows.iter().any(|r| r.0 == name) {
ui.add_space(16.);
ui.label(
[
"No calls yet",
"No contacts yet",
"No events yet",
"No messages yet",
][i],
);
}
for row in store.rows.iter().filter(|r| r.0 == name) {
ui.label(format!("{} · {}", row.1, row.2));
ui.small(&row.3);
ui.add_space(16.);
}
}
Err(e) => {
ui.label(format!("Storage unavailable: {e}"));
}
}
} else {
ui.checkbox(&mut self.show_clock, "Show clock widget");
ui.checkbox(&mut self.opaque, "Reduce transparency");
ui.label("Navigation buttons always visible");
ui.add_space(20.);
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);
}
});
});
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(),
}))
}),
)
}