Initial commit (v0.1.0)
1
repos/singularity/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
5015
repos/singularity/Cargo.lock
generated
Normal file
18
repos/singularity/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "singularity"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1"
|
||||||
|
directories = "5"
|
||||||
|
iced = { version = "0.12", features = ["advanced", "tokio", "multi-window", "svg"] }
|
||||||
|
resvg = "0.36"
|
||||||
|
tiny-skia = "0.11"
|
||||||
|
rfd = "0.14"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
toml = "0.8"
|
||||||
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
|
walkdir = "2"
|
||||||
|
which = "6"
|
||||||
|
zip = "0.6"
|
||||||
5
repos/singularity/icons/close.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="32" cy="32" r="22" fill="currentColor" fill-opacity="0.06" />
|
||||||
|
<line x1="22" y1="22" x2="42" y2="42" />
|
||||||
|
<line x1="42" y1="22" x2="22" y2="42" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 328 B |
5
repos/singularity/icons/download.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="32" y1="12" x2="32" y2="34" />
|
||||||
|
<polyline points="24 26 32 34 40 26" />
|
||||||
|
<path d="M16 40v6a4 4 0 0 0 4 4h24a4 4 0 0 0 4-4v-6" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 309 B |
12
repos/singularity/icons/drag-up-down.svg
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="32" y1="10" x2="32" y2="22" />
|
||||||
|
<polyline points="26 16 32 10 38 16" />
|
||||||
|
<line x1="32" y1="42" x2="32" y2="54" />
|
||||||
|
<polyline points="26 48 32 54 38 48" />
|
||||||
|
<circle cx="27" cy="26" r="1.6" fill="currentColor" stroke="none" />
|
||||||
|
<circle cx="37" cy="26" r="1.6" fill="currentColor" stroke="none" />
|
||||||
|
<circle cx="27" cy="32" r="1.6" fill="currentColor" stroke="none" />
|
||||||
|
<circle cx="37" cy="32" r="1.6" fill="currentColor" stroke="none" />
|
||||||
|
<circle cx="27" cy="38" r="1.6" fill="currentColor" stroke="none" />
|
||||||
|
<circle cx="37" cy="38" r="1.6" fill="currentColor" stroke="none" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 762 B |
4
repos/singularity/icons/folder-open.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M10 24v-4a4 4 0 0 1 4-4h12l4 4h20a4 4 0 0 1 4 4v4" />
|
||||||
|
<path d="M10 28h44l-6 20a4 4 0 0 1-4 3H16a4 4 0 0 1-4-4V28z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 297 B |
5
repos/singularity/icons/package.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M32 10L16 20L32 30L48 20L32 10Z" />
|
||||||
|
<path d="M16 20V42L32 52V30L16 20Z" />
|
||||||
|
<path d="M48 20V42L32 52V30L48 20Z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 295 B |
6
repos/singularity/icons/refresh.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M20 26A16 16 0 0 1 44 26" />
|
||||||
|
<polyline points="40 22 44 26 40 30" />
|
||||||
|
<path d="M44 38A16 16 0 0 1 20 38" />
|
||||||
|
<polyline points="24 34 20 38 24 42" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 330 B |
9
repos/singularity/icons/save.svg
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M 12 10 L 44 10 L 52 18 L 52 54 L 12 54 Z" />
|
||||||
|
<rect x="18" y="10" width="20" height="10" rx="1.5" />
|
||||||
|
<line x1="32" y1="12" x2="32" y2="18" stroke-width="2" />
|
||||||
|
<rect x="18" y="36" width="28" height="18" rx="1.5" stroke-width="2" />
|
||||||
|
<line x1="22" y1="42" x2="42" y2="42" stroke-width="2" />
|
||||||
|
<line x1="22" y1="46" x2="42" y2="46" stroke-width="2" />
|
||||||
|
<line x1="22" y1="50" x2="42" y2="50" stroke-width="2" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 594 B |
4
repos/singularity/icons/settings-cog.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M 27.22 9.50 A 23.00 23.00 0 0 1 36.78 9.50 L 36.94 16.78 A 16.00 16.00 0 0 1 42.71 20.11 L 49.09 16.61 A 23.00 23.00 0 0 1 53.87 24.89 L 47.65 28.67 A 16.00 16.00 0 0 1 47.65 35.33 L 53.87 39.11 A 23.00 23.00 0 0 1 49.09 47.39 L 42.71 43.89 A 16.00 16.00 0 0 1 36.94 47.22 L 36.78 54.50 A 23.00 23.00 0 0 1 27.22 54.50 L 27.06 47.22 A 16.00 16.00 0 0 1 21.29 43.89 L 14.91 47.39 A 23.00 23.00 0 0 1 10.13 39.11 L 16.35 35.33 A 16.00 16.00 0 0 1 16.35 28.67 L 10.13 24.89 A 23.00 23.00 0 0 1 14.91 16.61 L 21.29 20.11 A 16.00 16.00 0 0 1 27.06 16.78 L 27.22 9.50 Z" />
|
||||||
|
<circle cx="32" cy="32" r="8" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 781 B |
72
repos/singularity/icons/singularity.svg
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="64mm"
|
||||||
|
height="64mm"
|
||||||
|
viewBox="0 0 64 64"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
|
||||||
|
sodipodi:docname="singularity.svg"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview1"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:document-units="px"
|
||||||
|
inkscape:zoom="2.1392361"
|
||||||
|
inkscape:cx="100.50317"
|
||||||
|
inkscape:cy="121.53871"
|
||||||
|
inkscape:window-width="1440"
|
||||||
|
inkscape:window-height="890"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="0"
|
||||||
|
inkscape:window-maximized="0"
|
||||||
|
inkscape:current-layer="layer1" />
|
||||||
|
<defs
|
||||||
|
id="defs1" />
|
||||||
|
<g
|
||||||
|
inkscape:label="Layer 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1">
|
||||||
|
<circle
|
||||||
|
style="fill:#000000;stroke-width:0.264583"
|
||||||
|
id="path1"
|
||||||
|
cx="32"
|
||||||
|
cy="32"
|
||||||
|
r="6.75" />
|
||||||
|
<path
|
||||||
|
style="fill:none;stroke:#000000;stroke-width:0.529167;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
d="m 17.439052,32.157117 c 4.303269,1.842883 6.566716,8.842883 14.470703,9.842883"
|
||||||
|
id="path10" />
|
||||||
|
<path
|
||||||
|
style="fill:none;stroke:#000000;stroke-width:0.529167;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
d="M 9.2760915,35.249149 C 17.834317,36.836421 23.152596,44.590107 31.538711,45.63837"
|
||||||
|
id="path11" />
|
||||||
|
<path
|
||||||
|
style="fill:none;stroke:#000000;stroke-width:0.529167;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
d="M 46.751501,32.157117 C 42.448232,34 40.184785,41 32.280798,42"
|
||||||
|
id="path12" />
|
||||||
|
<path
|
||||||
|
style="fill:none;stroke:#000000;stroke-width:0.529167;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
d="M 54.172374,35.249149 C 45.614149,36.836421 40.29587,44.590107 31.909754,45.63837"
|
||||||
|
id="path13" />
|
||||||
|
<path
|
||||||
|
style="fill:none;stroke:#000000;stroke-width:0.529167;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
d="M 15,30 C 21,29 24,21 24,12"
|
||||||
|
id="path14" />
|
||||||
|
<path
|
||||||
|
style="fill:none;stroke:#000000;stroke-width:0.529167;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
d="M 49,30 C 43,29 40,21 40,12"
|
||||||
|
id="path15" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
5
repos/singularity/icons/singularity.svg.1
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M 10 32 A 22 6 0 0 0 54 32 A 24 10 0 0 1 10 32 Z" />
|
||||||
|
<path d="M 17 30 A 15 3 0 0 0 47 30 A 17 6 0 0 1 17 30 Z" />
|
||||||
|
<circle cx="32" cy="32" r="7" fill="currentColor" stroke="none" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 362 B |
5
repos/singularity/icons/singularity.svg.2
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M 6 36 C 18 36 24 25 32 25 C 40 25 46 36 58 36" />
|
||||||
|
<path d="M 12 33 C 21 33 26 27 32 27 C 38 27 43 33 52 33" />
|
||||||
|
<circle cx="32" cy="32" r="7" fill="currentColor" stroke="none" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 360 B |
5
repos/singularity/icons/upload.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="32" y1="42" x2="32" y2="18" />
|
||||||
|
<polyline points="24 26 32 18 40 26" />
|
||||||
|
<path d="M16 42v6a4 4 0 0 0 4 4h24a4 4 0 0 0 4-4v-6" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 309 B |
5
repos/singularity/icons/warning.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="32" cy="32" r="22" />
|
||||||
|
<line x1="32" y1="18" x2="32" y2="38" />
|
||||||
|
<circle cx="32" cy="46" r="2.5" fill="currentColor" stroke="none" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 316 B |
2827
repos/singularity/src/app.rs
Normal file
123
repos/singularity/src/config.rs
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use directories::{BaseDirs, ProjectDirs};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{PathBuf};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub enum ThemeChoice {
|
||||||
|
SingularityDark,
|
||||||
|
SteamDark,
|
||||||
|
Nord,
|
||||||
|
TokyoNightStorm,
|
||||||
|
Oxocarbon,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ThemeChoice {
|
||||||
|
pub const ALL: [ThemeChoice; 5] = [
|
||||||
|
ThemeChoice::SingularityDark,
|
||||||
|
ThemeChoice::SteamDark,
|
||||||
|
ThemeChoice::Nord,
|
||||||
|
ThemeChoice::TokyoNightStorm,
|
||||||
|
ThemeChoice::Oxocarbon,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ThemeChoice {
|
||||||
|
fn default() -> Self {
|
||||||
|
ThemeChoice::SingularityDark
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ThemeChoice {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
ThemeChoice::SingularityDark => write!(f, "Singularity Dark"),
|
||||||
|
ThemeChoice::SteamDark => write!(f, "Steam Slate"),
|
||||||
|
ThemeChoice::Nord => write!(f, "Nord"),
|
||||||
|
ThemeChoice::TokyoNightStorm => write!(f, "Tokyo Night Storm"),
|
||||||
|
ThemeChoice::Oxocarbon => write!(f, "Oxocarbon"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AppConfig {
|
||||||
|
pub base_dir: PathBuf,
|
||||||
|
pub steam_dir: Option<PathBuf>,
|
||||||
|
pub games: Vec<GameEntry>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub theme: ThemeChoice,
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_selected_game: Option<Uuid>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub recent_games: Vec<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AppConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
base_dir: default_base_dir(),
|
||||||
|
steam_dir: None,
|
||||||
|
games: Vec::new(),
|
||||||
|
theme: ThemeChoice::default(),
|
||||||
|
last_selected_game: None,
|
||||||
|
recent_games: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct GameEntry {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GameEntry {
|
||||||
|
pub fn new(name: String, path: PathBuf) -> Self {
|
||||||
|
Self {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
name,
|
||||||
|
path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_base_dir() -> PathBuf {
|
||||||
|
if let Some(base_dirs) = BaseDirs::new() {
|
||||||
|
base_dirs.home_dir().join(".mods")
|
||||||
|
} else {
|
||||||
|
PathBuf::from(".mods")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn config_path() -> Result<PathBuf> {
|
||||||
|
if let Some(project_dirs) = ProjectDirs::from("com", "singularity", "singularity") {
|
||||||
|
let dir = project_dirs.config_dir();
|
||||||
|
fs::create_dir_all(dir).context("create config directory")?;
|
||||||
|
Ok(dir.join("config.toml"))
|
||||||
|
} else {
|
||||||
|
let fallback = default_base_dir();
|
||||||
|
fs::create_dir_all(&fallback).context("create fallback config directory")?;
|
||||||
|
Ok(fallback.join("config.toml"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_config() -> Result<AppConfig> {
|
||||||
|
let path = config_path()?;
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(AppConfig::default());
|
||||||
|
}
|
||||||
|
let contents = fs::read_to_string(&path).context("read config")?;
|
||||||
|
let config = toml::from_str(&contents).context("parse config")?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_config(config: &AppConfig) -> Result<()> {
|
||||||
|
let path = config_path()?;
|
||||||
|
let contents = toml::to_string_pretty(config).context("serialize config")?;
|
||||||
|
fs::write(&path, contents).context("write config")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
256
repos/singularity/src/drag_overlay.rs
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
use iced::advanced::layout;
|
||||||
|
use iced::advanced::mouse;
|
||||||
|
use iced::advanced::overlay;
|
||||||
|
use iced::advanced::renderer;
|
||||||
|
use iced::advanced::widget;
|
||||||
|
use iced::advanced::{Clipboard, Layout, Shell, Widget};
|
||||||
|
use iced::{Element, Length, Point, Rectangle, Renderer, Size, Theme, Vector};
|
||||||
|
|
||||||
|
pub struct DragOverlay<'a, Message> {
|
||||||
|
content: Element<'a, Message>,
|
||||||
|
ghost: Element<'a, Message>,
|
||||||
|
show_ghost: bool,
|
||||||
|
cursor: Option<Point>,
|
||||||
|
ghost_offset_x: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, Message: 'a> DragOverlay<'a, Message> {
|
||||||
|
pub fn new(content: impl Into<Element<'a, Message>>) -> Self {
|
||||||
|
Self {
|
||||||
|
content: content.into(),
|
||||||
|
ghost: iced::widget::space::Space::new(Length::Shrink, Length::Shrink).into(),
|
||||||
|
show_ghost: false,
|
||||||
|
cursor: None,
|
||||||
|
ghost_offset_x: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ghost(
|
||||||
|
mut self,
|
||||||
|
ghost: impl Into<Element<'a, Message>>,
|
||||||
|
cursor: Point,
|
||||||
|
offset_x: f32,
|
||||||
|
) -> Self {
|
||||||
|
self.ghost = ghost.into();
|
||||||
|
self.show_ghost = true;
|
||||||
|
self.cursor = Some(cursor);
|
||||||
|
self.ghost_offset_x = offset_x;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, Message> Widget<Message, Theme, Renderer> for DragOverlay<'a, Message> {
|
||||||
|
fn children(&self) -> Vec<widget::Tree> {
|
||||||
|
vec![widget::Tree::new(&self.content), widget::Tree::new(&self.ghost)]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn diff(&self, tree: &mut widget::Tree) {
|
||||||
|
tree.diff_children(&[self.content.as_widget(), self.ghost.as_widget()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn size(&self) -> Size<Length> {
|
||||||
|
self.content.as_widget().size()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn layout(
|
||||||
|
&self,
|
||||||
|
tree: &mut widget::Tree,
|
||||||
|
renderer: &Renderer,
|
||||||
|
limits: &layout::Limits,
|
||||||
|
) -> layout::Node {
|
||||||
|
self.content
|
||||||
|
.as_widget()
|
||||||
|
.layout(&mut tree.children[0], renderer, limits)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn operate(
|
||||||
|
&self,
|
||||||
|
tree: &mut widget::Tree,
|
||||||
|
layout: Layout<'_>,
|
||||||
|
renderer: &Renderer,
|
||||||
|
operation: &mut dyn widget::Operation<Message>,
|
||||||
|
) {
|
||||||
|
self.content.as_widget().operate(
|
||||||
|
&mut tree.children[0],
|
||||||
|
layout,
|
||||||
|
renderer,
|
||||||
|
operation,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_event(
|
||||||
|
&mut self,
|
||||||
|
tree: &mut widget::Tree,
|
||||||
|
event: iced::Event,
|
||||||
|
layout: Layout<'_>,
|
||||||
|
cursor: mouse::Cursor,
|
||||||
|
renderer: &Renderer,
|
||||||
|
clipboard: &mut dyn Clipboard,
|
||||||
|
shell: &mut Shell<'_, Message>,
|
||||||
|
viewport: &Rectangle,
|
||||||
|
) -> iced::event::Status {
|
||||||
|
self.content.as_widget_mut().on_event(
|
||||||
|
&mut tree.children[0],
|
||||||
|
event,
|
||||||
|
layout,
|
||||||
|
cursor,
|
||||||
|
renderer,
|
||||||
|
clipboard,
|
||||||
|
shell,
|
||||||
|
viewport,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mouse_interaction(
|
||||||
|
&self,
|
||||||
|
tree: &widget::Tree,
|
||||||
|
layout: Layout<'_>,
|
||||||
|
cursor: mouse::Cursor,
|
||||||
|
viewport: &Rectangle,
|
||||||
|
renderer: &Renderer,
|
||||||
|
) -> mouse::Interaction {
|
||||||
|
self.content.as_widget().mouse_interaction(
|
||||||
|
&tree.children[0],
|
||||||
|
layout,
|
||||||
|
cursor,
|
||||||
|
viewport,
|
||||||
|
renderer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw(
|
||||||
|
&self,
|
||||||
|
tree: &widget::Tree,
|
||||||
|
renderer: &mut Renderer,
|
||||||
|
theme: &Theme,
|
||||||
|
style: &renderer::Style,
|
||||||
|
layout: Layout<'_>,
|
||||||
|
cursor: mouse::Cursor,
|
||||||
|
viewport: &Rectangle,
|
||||||
|
) {
|
||||||
|
self.content.as_widget().draw(
|
||||||
|
&tree.children[0],
|
||||||
|
renderer,
|
||||||
|
theme,
|
||||||
|
style,
|
||||||
|
layout,
|
||||||
|
cursor,
|
||||||
|
viewport,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn overlay<'b>(
|
||||||
|
&'b mut self,
|
||||||
|
tree: &'b mut widget::Tree,
|
||||||
|
layout: Layout<'_>,
|
||||||
|
renderer: &Renderer,
|
||||||
|
translation: Vector,
|
||||||
|
) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
|
||||||
|
let mut overlays = Vec::new();
|
||||||
|
let (content_tree, ghost_tree) = tree.children.split_at_mut(1);
|
||||||
|
let content_tree = &mut content_tree[0];
|
||||||
|
let ghost_tree = &mut ghost_tree[0];
|
||||||
|
let content_overlay =
|
||||||
|
self.content
|
||||||
|
.as_widget_mut()
|
||||||
|
.overlay(content_tree, layout, renderer, translation);
|
||||||
|
if let Some(overlay) = content_overlay {
|
||||||
|
overlays.push(overlay);
|
||||||
|
}
|
||||||
|
if self.show_ghost {
|
||||||
|
if let Some(cursor) = self.cursor {
|
||||||
|
let bounds = layout.bounds();
|
||||||
|
let position = Point::new(bounds.x + translation.x, bounds.y + translation.y);
|
||||||
|
overlays.push(overlay::Element::new(Box::new(GhostOverlay {
|
||||||
|
ghost: &self.ghost,
|
||||||
|
state: ghost_tree,
|
||||||
|
cursor,
|
||||||
|
position,
|
||||||
|
content_size: bounds.size(),
|
||||||
|
offset_x: self.ghost_offset_x,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if overlays.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(overlay::Group::with_children(overlays).overlay())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, Message: 'a> From<DragOverlay<'a, Message>> for Element<'a, Message> {
|
||||||
|
fn from(overlay: DragOverlay<'a, Message>) -> Self {
|
||||||
|
Element::new(overlay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GhostOverlay<'a, Message> {
|
||||||
|
ghost: &'a Element<'a, Message>,
|
||||||
|
state: &'a mut widget::Tree,
|
||||||
|
cursor: Point,
|
||||||
|
position: Point,
|
||||||
|
content_size: Size,
|
||||||
|
offset_x: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, Message> overlay::Overlay<Message, Theme, Renderer> for GhostOverlay<'a, Message> {
|
||||||
|
fn layout(&mut self, renderer: &Renderer, bounds: Size) -> layout::Node {
|
||||||
|
let ghost_layout = self.ghost.as_widget().layout(
|
||||||
|
self.state,
|
||||||
|
renderer,
|
||||||
|
&layout::Limits::new(Size::ZERO, bounds),
|
||||||
|
);
|
||||||
|
let size = ghost_layout.bounds().size();
|
||||||
|
let mut x = self.position.x + (self.content_size.width - size.width) / 2.0 + self.offset_x;
|
||||||
|
let mut y = self.position.y + self.cursor.y - size.height / 2.0;
|
||||||
|
let max_x = self.position.x + self.content_size.width - size.width;
|
||||||
|
if max_x >= self.position.x {
|
||||||
|
x = x.clamp(self.position.x, max_x);
|
||||||
|
} else {
|
||||||
|
x = self.position.x;
|
||||||
|
}
|
||||||
|
let max_y = self.position.y + self.content_size.height - size.height;
|
||||||
|
if max_y >= self.position.y {
|
||||||
|
y = y.clamp(self.position.y, max_y);
|
||||||
|
} else {
|
||||||
|
y = self.position.y;
|
||||||
|
}
|
||||||
|
if x + size.width > bounds.width {
|
||||||
|
x = (bounds.width - size.width).max(0.0);
|
||||||
|
}
|
||||||
|
if y + size.height > bounds.height {
|
||||||
|
y = (bounds.height - size.height).max(0.0);
|
||||||
|
}
|
||||||
|
layout::Node::with_children(size, vec![ghost_layout])
|
||||||
|
.translate(Vector::new(x, y))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw(
|
||||||
|
&self,
|
||||||
|
renderer: &mut Renderer,
|
||||||
|
theme: &Theme,
|
||||||
|
style: &renderer::Style,
|
||||||
|
layout: Layout<'_>,
|
||||||
|
cursor_position: mouse::Cursor,
|
||||||
|
) {
|
||||||
|
self.ghost.as_widget().draw(
|
||||||
|
self.state,
|
||||||
|
renderer,
|
||||||
|
theme,
|
||||||
|
style,
|
||||||
|
layout.children().next().unwrap(),
|
||||||
|
cursor_position,
|
||||||
|
&Rectangle::with_size(Size::INFINITY),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_over(
|
||||||
|
&self,
|
||||||
|
_layout: Layout<'_>,
|
||||||
|
_renderer: &Renderer,
|
||||||
|
_cursor_position: Point,
|
||||||
|
) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
102
repos/singularity/src/fs.rs
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use uuid::Uuid;
|
||||||
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct GameDirs {
|
||||||
|
pub root: PathBuf,
|
||||||
|
pub downloads: PathBuf,
|
||||||
|
pub zips: PathBuf,
|
||||||
|
pub mods: PathBuf,
|
||||||
|
pub staging: PathBuf,
|
||||||
|
pub profiles: PathBuf,
|
||||||
|
pub backups: PathBuf,
|
||||||
|
pub state: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn game_root(base_dir: &Path, game_id: Uuid) -> PathBuf {
|
||||||
|
base_dir.join("games").join(game_id.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ensure_base_dir(base_dir: &Path) -> Result<()> {
|
||||||
|
fs::create_dir_all(base_dir).context("create base dir")?;
|
||||||
|
fs::create_dir_all(base_dir.join("games")).context("create games dir")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ensure_game_dirs(base_dir: &Path, game_id: Uuid) -> Result<GameDirs> {
|
||||||
|
let root = game_root(base_dir, game_id);
|
||||||
|
let downloads = root.join("downloads");
|
||||||
|
let zips = root.join("zips");
|
||||||
|
let mods = root.join("mods");
|
||||||
|
let staging = root.join("staging");
|
||||||
|
let profiles = root.join("profiles");
|
||||||
|
let backups = root.join("backups");
|
||||||
|
let state = root.join("game_state.toml");
|
||||||
|
|
||||||
|
for dir in [&root, &downloads, &zips, &mods, &staging, &profiles, &backups] {
|
||||||
|
fs::create_dir_all(dir).context("create game dir")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(GameDirs {
|
||||||
|
root,
|
||||||
|
downloads,
|
||||||
|
zips,
|
||||||
|
mods,
|
||||||
|
staging,
|
||||||
|
profiles,
|
||||||
|
backups,
|
||||||
|
state,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clone_directory_tree(src: &Path, dest: &Path) -> Result<()> {
|
||||||
|
if !src.is_dir() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
fs::create_dir_all(dest).context("create staging root")?;
|
||||||
|
for entry in WalkDir::new(src).min_depth(1) {
|
||||||
|
let entry = entry.context("walk game tree")?;
|
||||||
|
if entry.file_type().is_dir() {
|
||||||
|
let rel = entry.path().strip_prefix(src).context("strip prefix")?;
|
||||||
|
let target = dest.join(rel);
|
||||||
|
fs::create_dir_all(&target).context("create staging dir")?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn case_match_path(game_root: &Path, rel_path: &Path) -> Result<PathBuf> {
|
||||||
|
let mut resolved = PathBuf::new();
|
||||||
|
let mut current = game_root.to_path_buf();
|
||||||
|
|
||||||
|
for component in rel_path.components() {
|
||||||
|
let segment = component.as_os_str().to_string_lossy().to_string();
|
||||||
|
let matched = match_case_in_dir(¤t, &segment);
|
||||||
|
let final_segment = matched.unwrap_or(segment);
|
||||||
|
resolved.push(&final_segment);
|
||||||
|
current = current.join(&final_segment);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn match_case_in_dir(dir: &Path, name: &str) -> Option<String> {
|
||||||
|
if !dir.is_dir() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let target = name.to_lowercase();
|
||||||
|
let entries = fs::read_dir(dir).ok()?;
|
||||||
|
for entry in entries {
|
||||||
|
let entry = entry.ok()?;
|
||||||
|
let file_name = entry.file_name();
|
||||||
|
let file_name_str = file_name.to_string_lossy();
|
||||||
|
if file_name_str.to_lowercase() == target {
|
||||||
|
return Some(file_name_str.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
16
repos/singularity/src/main.rs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
mod app;
|
||||||
|
mod config;
|
||||||
|
mod drag_overlay;
|
||||||
|
mod fs;
|
||||||
|
mod mods;
|
||||||
|
mod state;
|
||||||
|
mod steam;
|
||||||
|
|
||||||
|
use iced::multi_window::Application;
|
||||||
|
|
||||||
|
fn main() -> iced::Result {
|
||||||
|
let mut settings = iced::Settings::default();
|
||||||
|
settings.window.icon = app::window_icon();
|
||||||
|
settings.default_font = iced::Font::with_name("Sans");
|
||||||
|
app::SingularityApp::run(settings)
|
||||||
|
}
|
||||||
1351
repos/singularity/src/mods.rs
Normal file
99
repos/singularity/src/state.rs
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct GameState {
|
||||||
|
pub active_profile: String,
|
||||||
|
pub profiles: Vec<Profile>,
|
||||||
|
pub last_launch: Option<ProfileSnapshot>,
|
||||||
|
pub previous_launch: Option<ProfileSnapshot>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub backups: Vec<std::path::PathBuf>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_dirs: HashMap<String, Vec<std::path::PathBuf>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Profile {
|
||||||
|
pub name: String,
|
||||||
|
pub enabled_mods: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ProfileSnapshot {
|
||||||
|
pub name: String,
|
||||||
|
pub enabled_mods: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for GameState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
active_profile: "default".to_string(),
|
||||||
|
profiles: vec![Profile {
|
||||||
|
name: "default".to_string(),
|
||||||
|
enabled_mods: Vec::new(),
|
||||||
|
}],
|
||||||
|
last_launch: None,
|
||||||
|
previous_launch: None,
|
||||||
|
backups: Vec::new(),
|
||||||
|
created_dirs: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GameState {
|
||||||
|
pub fn active_profile_mut(&mut self) -> Option<&mut Profile> {
|
||||||
|
self.profiles
|
||||||
|
.iter_mut()
|
||||||
|
.find(|profile| profile.name == self.active_profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn active_profile(&self) -> Option<&Profile> {
|
||||||
|
self.profiles
|
||||||
|
.iter()
|
||||||
|
.find(|profile| profile.name == self.active_profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ensure_default_profile(&mut self) {
|
||||||
|
if self.active_profile.is_empty() {
|
||||||
|
self.active_profile = "default".to_string();
|
||||||
|
}
|
||||||
|
if self.active_profile().is_none() {
|
||||||
|
self.profiles.push(Profile {
|
||||||
|
name: self.active_profile.clone(),
|
||||||
|
enabled_mods: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_launch_snapshot(&mut self) {
|
||||||
|
if let Some(profile) = self.active_profile() {
|
||||||
|
let snapshot = ProfileSnapshot {
|
||||||
|
name: profile.name.clone(),
|
||||||
|
enabled_mods: profile.enabled_mods.clone(),
|
||||||
|
};
|
||||||
|
self.previous_launch = self.last_launch.take();
|
||||||
|
self.last_launch = Some(snapshot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_game_state(path: &Path) -> Result<GameState> {
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(GameState::default());
|
||||||
|
}
|
||||||
|
let contents = fs::read_to_string(path).context("read game state")?;
|
||||||
|
let mut state: GameState = toml::from_str(&contents).context("parse game state")?;
|
||||||
|
state.ensure_default_profile();
|
||||||
|
Ok(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_game_state(path: &Path, state: &GameState) -> Result<()> {
|
||||||
|
let contents = toml::to_string_pretty(state).context("serialize game state")?;
|
||||||
|
fs::write(path, contents).context("write game state")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
95
repos/singularity/src/steam.rs
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct GameCandidate {
|
||||||
|
pub name: String,
|
||||||
|
pub path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn normalize_steam_root(input: &Path) -> Option<PathBuf> {
|
||||||
|
let mut candidates = Vec::new();
|
||||||
|
candidates.push(input.to_path_buf());
|
||||||
|
candidates.push(input.join("steam"));
|
||||||
|
candidates.push(input.join("root"));
|
||||||
|
|
||||||
|
for candidate in candidates {
|
||||||
|
if candidate.join("steamapps").is_dir() {
|
||||||
|
return Some(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.file_name().and_then(|name| name.to_str()) == Some("steamapps") {
|
||||||
|
if input.join("common").is_dir() {
|
||||||
|
return input.parent().map(PathBuf::from);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.file_name().and_then(|name| name.to_str()) == Some("common") {
|
||||||
|
if let Some(parent) = input.parent() {
|
||||||
|
if parent.join("libraryfolders.vdf").is_file() || parent.join("common").is_dir() {
|
||||||
|
if let Some(root) = parent.parent() {
|
||||||
|
if root.join("steamapps").is_dir() {
|
||||||
|
return Some(root.to_path_buf());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn discover_games(steam_dir: &Path) -> Result<Vec<GameCandidate>> {
|
||||||
|
let library_paths = discover_library_paths(steam_dir)?;
|
||||||
|
let mut games = Vec::new();
|
||||||
|
|
||||||
|
for library in library_paths {
|
||||||
|
let common = library.join("steamapps").join("common");
|
||||||
|
if !common.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for entry in fs::read_dir(&common).context("read steam common")? {
|
||||||
|
let entry = entry.context("steam entry")?;
|
||||||
|
if !entry.file_type().context("entry type")?.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let path = entry.path();
|
||||||
|
let name = entry
|
||||||
|
.file_name()
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
games.push(GameCandidate { name, path });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(games)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn discover_library_paths(steam_dir: &Path) -> Result<Vec<PathBuf>> {
|
||||||
|
let mut libraries = HashSet::new();
|
||||||
|
libraries.insert(steam_dir.to_path_buf());
|
||||||
|
|
||||||
|
let vdf_path = steam_dir
|
||||||
|
.join("steamapps")
|
||||||
|
.join("libraryfolders.vdf");
|
||||||
|
if vdf_path.is_file() {
|
||||||
|
let contents = fs::read_to_string(&vdf_path).context("read libraryfolders")?;
|
||||||
|
for line in contents.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if !line.starts_with('"') || !line.contains("\"path\"") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let parts: Vec<&str> = line.split('"').collect();
|
||||||
|
if parts.len() >= 4 {
|
||||||
|
let raw_path = parts[3];
|
||||||
|
let normalized = raw_path.replace("\\\\", "\\");
|
||||||
|
libraries.insert(PathBuf::from(normalized));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(libraries.into_iter().collect())
|
||||||
|
}
|
||||||