Menu
cosmic-mobile
publicLatest 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(),
}))
}),
)
}