Compare commits
24 Commits
Author | SHA1 | Date |
---|---|---|
|
4e52e95cde | |
|
97bf38165f | |
|
200f71e0ac | |
|
36db69356c | |
|
91ce120921 | |
|
79a31a161c | |
|
5f7041ddc2 | |
|
a51ae66019 | |
|
f20b32c986 | |
|
31bbf44171 | |
|
2489210068 | |
|
c5c587ad3c | |
|
14a86f4b63 | |
|
dbafaa77d6 | |
|
72ef196223 | |
|
62253bbeb6 | |
|
468cd79692 | |
|
9e6e688a97 | |
|
f60fcf0556 | |
|
1dd2eac1a0 | |
|
8452a15001 | |
|
8433a128db | |
|
849e437ae5 | |
|
990fcfbc79 |
|
@ -1,2 +1,3 @@
|
|||
node_modules/
|
||||
dist/
|
||||
bundle/
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
[[language]]
|
||||
name = "svelte"
|
||||
auto-format = true
|
||||
|
||||
[[language]]
|
||||
name = "typescript"
|
||||
auto-format = true
|
||||
|
||||
[[language]]
|
||||
name = 'toml'
|
||||
auto-format = true
|
|
@ -1,4 +1,4 @@
|
|||
branches: main
|
||||
branches: [main, dev]
|
||||
pipeline:
|
||||
build:
|
||||
image: gitea.greenboi.me/emerald/cathode-build:latest
|
||||
|
@ -7,4 +7,5 @@ pipeline:
|
|||
event: [push, pull_request]
|
||||
commands:
|
||||
- npm ci
|
||||
- npm run tauri build
|
||||
- npm run build
|
||||
- cd src-tauri && cargo build
|
||||
|
|
|
@ -6,11 +6,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
|
||||
## [v0.1.0]
|
||||
|
||||
### Added
|
||||
- Load last selected ray file at launch
|
||||
- Add `blink_interval` to config file
|
||||
- Add `mic_sens` to config file
|
||||
- Add `new` button to reset the loaded ray
|
||||
- Add icon to indicate an image is being loaded
|
||||
- Add shift-click to clear a frame
|
||||
|
||||
### Changed
|
||||
- Loading rays at launch should be more consistent
|
||||
|
||||
### Fixed
|
||||
- Fixed config not being respected on launch
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
FROM node
|
||||
FROM node:bullseye
|
||||
RUN apt update -yy && apt upgrade -yy
|
||||
RUN apt install -yy libwebkit2gtk-4.0-dev build-essential curl wget libssl-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev
|
||||
RUN apt install -yy libasound2-dev
|
||||
|
|
38
README.md
38
README.md
|
@ -1,4 +1,40 @@
|
|||
# Cathode [](https://ci.greenboi.me/emerald/cathode)
|
||||
a small app for PNG tubing. Think Veadotube-mini but completely FOSS.
|
||||
|
||||
Built with Tauri and Svelte.
|
||||
Built with Tauri and Svelte.
|
||||
|
||||
## Installation
|
||||
|
||||
### Packages
|
||||
There are a few prebuilt packages available [here](https://gitea.greenboi.me/emerald/-/packages/generic/cathode-tube/) for the latest stable release. They are built on Bullseye Debian, so should be compatible with most up to date systems. Debian derived distros will want the `.deb` file, and Fedora users will want the `.rpm` file. Download the correct file and install it with your package manager.
|
||||
|
||||
Alternatively, download the AppImage, which should work on any glibc linux distro at the cost of being a considerable larger file. Once you download the `.AppImage` file, give it execution permissions (eg: `chmod +x cathode-tube.AppImage`) and run it like a command or script.
|
||||
|
||||
### Building from source
|
||||
#### Prerequisites
|
||||
In order to build from source you will need a few things to get started
|
||||
- Nodejs/npm (I recommend using [nvm](https://github.com/nvm-sh/nvm) for this)
|
||||
- [Rust](https://rustup.rs/)
|
||||
- Tauri's [development dependencies](https://tauri.app/v1/guides/getting-started/prerequisites#installing)
|
||||
#### Building
|
||||
Once all of these are installed, clone the repo and run
|
||||
```
|
||||
npm install
|
||||
```
|
||||
This will install everything needed to build the frontend, as well as the tauri cli. Building the project itself is then as simple as
|
||||
```
|
||||
npm run tauri build
|
||||
```
|
||||
This will build the frontend and backend, and bundle the `.deb` and `.AppImage` packages, found in `src-tauri/target/release/bundle`.
|
||||
|
||||
The binary itself (at `src-tauri/target/release/cathode-tube`) is all that is needed to run the program, so if neither bundle works for you simply copy the executable to somewhere in your path, or run
|
||||
```
|
||||
cargo install --path src-tauri
|
||||
```
|
||||
|
||||
#### Just
|
||||
If you have the [just](https://github.com/casey/just) command runner installed, as well as the other prerequisites, then you can run
|
||||
```
|
||||
just install
|
||||
```
|
||||
Which will build the project and install it to `/usr/bin`, along with the `.desktop` file and icons
|
31
justfile
31
justfile
|
@ -13,8 +13,29 @@ log RUST_LOG:
|
|||
|
||||
build:
|
||||
cargo tauri build
|
||||
cd src-tauri
|
||||
cargo generate-rpm
|
||||
cp -r target/release/bundle ..
|
||||
cp -r target/generate-rpm ../bundle
|
||||
|
||||
cd src-tauri && cargo generate-rpm
|
||||
cp -r src-tauri/target/release/bundle bundle
|
||||
cp -r src-tauri/target/generate-rpm bundle
|
||||
|
||||
install:
|
||||
@cargo tauri build -b none
|
||||
@echo Copying binary to /usr/bin/cathode...
|
||||
@sudo cp src-tauri/target/release/cathode /usr/bin/cathode
|
||||
@echo Installing desktop file...
|
||||
@sudo cp src-tauri/cathode-tube.desktop /usr/share/applications/cathode-tube.desktop
|
||||
@sudo cp src-tauri/application-cathode.xml /usr/share/mime/packages/application-cathode.xml
|
||||
@echo Installing icons...
|
||||
@sudo cp src-tauri/icons/128x128.png /usr/share/icons/hicolor/128x128/apps/cathode-tube.png
|
||||
@sudo cp src-tauri/icons/32x32.png /usr/share/icons/hicolor/32x32/apps/cathode-tube.png
|
||||
@sudo cp src-tauri/icons/128x128@2x.png /usr/share/icons/hicolor/256x256/apps/cathode-tube.png
|
||||
@sudo update-desktop-database
|
||||
|
||||
uninstall:
|
||||
@echo Removing cathode...
|
||||
@sudo rm -f /usr/bin/cathode
|
||||
@sudo rm -f /usr/share/applications/cathode-tube.desktop
|
||||
@sudo rm -f /usr/share/mime/packages/application-cathode.xml
|
||||
@sudo rm -f /usr/share/icons/hicolor/128x128/apps/cathode-tube.png
|
||||
@sudo rm -f /usr/share/icons/hicolor/32x32/apps/cathode-tube.png
|
||||
@sudo rm -f /usr/share/icons/hicolor/256x256/apps/cathode-tube.png
|
||||
@sudo update-desktop-database
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -12,7 +12,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@neodrag/svelte": "^1.2.3",
|
||||
"@tauri-apps/api": "^1.0.2"
|
||||
"@tauri-apps/api": "^2.0.0-alpha.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^1.0.1",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "ray_format"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
license = "GPL-3.0-or-later"
|
||||
authors = ["AnActualEmerald"]
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
use anyhow::Result;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::{hash_map::Keys, HashMap},
|
||||
fs,
|
||||
|
@ -11,15 +12,15 @@ use crate::archive::Archive;
|
|||
pub struct Ray {
|
||||
frames: [Vec<u8>; 4],
|
||||
extras: HashMap<String, Vec<u8>>,
|
||||
meta: HashMap<String, String>,
|
||||
meta: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
impl Ray {
|
||||
pub fn get_meta_keys(&self) -> Keys<String, String> {
|
||||
pub fn get_meta_keys(&self) -> Keys<String, Value> {
|
||||
self.meta.keys()
|
||||
}
|
||||
|
||||
pub fn get_meta_value(&self, key: &str) -> Option<&String> {
|
||||
pub fn get_meta_value(&self, key: &str) -> Option<&Value> {
|
||||
self.meta.get(key)
|
||||
}
|
||||
|
||||
|
@ -36,17 +37,19 @@ impl Ray {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn add_meta(&mut self, key: String, value: String) -> bool {
|
||||
pub fn add_meta(&mut self, key: String, value: Value) -> bool {
|
||||
self.meta.insert(key, value).is_none()
|
||||
}
|
||||
|
||||
pub fn save(&self, path: &Path) -> Result<()> {
|
||||
let mut archive = Archive::new();
|
||||
|
||||
for (i, f) in self.frames.iter().enumerate() {
|
||||
if f.len() > 0 {
|
||||
archive.add_file(&format!("{}", i), f)?
|
||||
}
|
||||
}
|
||||
|
||||
for (n, f) in self.extras.iter() {
|
||||
archive.add_file(&n, f)?;
|
||||
}
|
||||
|
@ -71,7 +74,7 @@ impl Ray {
|
|||
}
|
||||
|
||||
if let Ok(buf) = archive.get_file("meta.json") {
|
||||
let meta: HashMap<String, String> = serde_json::from_slice(&buf)?;
|
||||
let meta: HashMap<String, Value> = serde_json::from_slice(&buf)?;
|
||||
ray.meta = meta;
|
||||
}
|
||||
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -9,13 +9,18 @@ edition = "2021"
|
|||
|
||||
[package.metadata.generate-rpm]
|
||||
assets = [
|
||||
{source= "target/release/cathode-tube", dest= "/usr/bin/cathode", mode= "755"},
|
||||
{source="cathode-tube.desktop", dest="/usr/share/applications/cathode-tube.desktop", mode="0644"},
|
||||
{source="application-cathode.xml", dest="/usr/share/mime/packages/application-cathode.xml", mode="0644"},
|
||||
{source="icons/128x128.png", dest="/usr/share/icons/hicolor/128x128/apps/cathode-tube.png", mode="0644"},
|
||||
{source="icons/128x128@2x.png", dest="/usr/share/icons/hicolor/256x256@2/apps/cathode-tube.png", mode="0644"},
|
||||
|
||||
{ source = "target/release/cathode", dest = "/usr/bin/cathode", mode = "755" },
|
||||
{ source = "cathode-tube.desktop", dest = "/usr/share/applications/cathode-tube.desktop", mode = "0644" },
|
||||
{ source = "application-cathode.xml", dest = "/usr/share/mime/packages/application-cathode.xml", mode = "0644" },
|
||||
{ source = "icons/128x128.png", dest = "/usr/share/icons/hicolor/128x128/apps/cathode-tube.png", mode = "0644" },
|
||||
{ source = "icons/128x128@2x.png", dest = "/usr/share/icons/hicolor/256x256@2/apps/cathode-tube.png", mode = "0644" },
|
||||
]
|
||||
auto-req = "no"
|
||||
|
||||
[package.metadata.generate-rpm.requires]
|
||||
filesystem = ">= 3"
|
||||
gtk3 = ">= 3"
|
||||
webkit2gtk3 = ">= 2"
|
||||
|
||||
|
||||
[workspace]
|
||||
|
@ -27,28 +32,28 @@ lto = true
|
|||
codegen-units = 1
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "1.1.1", features = [] }
|
||||
tauri-build = { version = "2.0.0-alpha.0", features = [] }
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
tauri = { version = "1.1.1", features = ["cli", "dialog-all", "fs-create-dir", "fs-read-dir", "fs-read-file", "fs-write-file", "macos-private-api", "window-minimize", "window-set-max-size", "window-set-min-size", "window-unminimize"] }
|
||||
cpal = { version = "0.14.0", features = ["jack"] }
|
||||
ray_format = {path = "../ray_format", version = "~0.1.0"}
|
||||
anyhow = "1.0.65"
|
||||
tauri = { version = "2.0.0-alpha.0", features = ["cli", "dialog-all", "fs-create-dir", "fs-read-dir", "fs-read-file", "fs-write-file", "macos-private-api", "window-minimize", "window-set-max-size", "window-set-min-size", "window-unminimize"] }
|
||||
cpal = { version = "0.14.2", features = ["jack"] }
|
||||
ray_format = { path = "../ray_format", version = ">=0.1.0" }
|
||||
anyhow = "1.0.67"
|
||||
log = "0.4.17"
|
||||
env_logger = "0.9.1"
|
||||
env_logger = "0.9.3"
|
||||
rand = "0.8.5"
|
||||
tokio = { version = "1.21.2", features = ["full"] }
|
||||
image = "0.24.4"
|
||||
tokio = { version = "1.23.0", features = ["full"] }
|
||||
image = "0.24.5"
|
||||
base64-url = "1.4.13"
|
||||
toml = "0.5.9"
|
||||
toml = "0.5.10"
|
||||
notify = "5.0.0"
|
||||
|
||||
[features]
|
||||
# by default Tauri runs in production mode
|
||||
# when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL
|
||||
default = [ "custom-protocol" ]
|
||||
default = ["custom-protocol"]
|
||||
# this feature is used used for production builds where `devPath` points to the filesystem
|
||||
# DO NOT remove this
|
||||
custom-protocol = [ "tauri/custom-protocol" ]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
Type=Application
|
||||
Icon=cathode-tube
|
||||
Name=Cathode
|
||||
Exec=cathode-tube %U
|
||||
Exec=cathode %U
|
||||
Terminal=false
|
||||
Hidden=false
|
||||
Categories=Graphics; Video
|
||||
|
|
|
@ -12,7 +12,12 @@ use cpal::InputCallbackInfo;
|
|||
use log::debug;
|
||||
use tauri::Window;
|
||||
|
||||
pub async fn monitor(window: Window, threshold: Arc<Mutex<f32>>, level: Arc<Mutex<f32>>) {
|
||||
pub async fn monitor(
|
||||
window: Window,
|
||||
threshold: Arc<Mutex<f32>>,
|
||||
level: Arc<Mutex<f32>>,
|
||||
sens: Arc<Mutex<f32>>,
|
||||
) {
|
||||
let device = initialize().expect("Unable to init audio");
|
||||
debug!("Using device {}", device.name().unwrap());
|
||||
let config = device.default_input_config().unwrap();
|
||||
|
@ -20,7 +25,10 @@ pub async fn monitor(window: Window, threshold: Arc<Mutex<f32>>, level: Arc<Mute
|
|||
.build_input_stream(
|
||||
&config.config(),
|
||||
move |data: &[f32], _: &InputCallbackInfo| {
|
||||
if data.iter().any(|e| e.abs() >= *threshold.lock().unwrap()) {
|
||||
if data
|
||||
.iter()
|
||||
.any(|e| (e.abs() * *sens.lock().unwrap()) >= *threshold.lock().unwrap())
|
||||
{
|
||||
window.emit("mouth-open", "").unwrap();
|
||||
} else {
|
||||
window.emit("mouth-close", "").unwrap();
|
||||
|
@ -28,10 +36,9 @@ pub async fn monitor(window: Window, threshold: Arc<Mutex<f32>>, level: Arc<Mute
|
|||
|
||||
*level.lock().unwrap() = data
|
||||
.iter()
|
||||
.map(|e| e.abs())
|
||||
.max_by(|a, b| a.total_cmp(&b))
|
||||
.unwrap()
|
||||
.clone();
|
||||
.map(|e| e.abs() * *sens.lock().unwrap())
|
||||
.max_by(|a, b| a.total_cmp(b))
|
||||
.unwrap();
|
||||
},
|
||||
move |err| {
|
||||
println!("Audio error: {:?}", err);
|
||||
|
@ -43,7 +50,7 @@ pub async fn monitor(window: Window, threshold: Arc<Mutex<f32>>, level: Arc<Mute
|
|||
|
||||
// The stream will end if it goes out of scope, so just dwell here
|
||||
loop {
|
||||
sleep(Duration::from_secs(60));
|
||||
sleep(Duration::from_secs(5000));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -52,3 +59,12 @@ fn initialize() -> Result<Device> {
|
|||
host.default_input_device()
|
||||
.ok_or_else(|| anyhow!("No default output device"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_devices() -> Vec<String> {
|
||||
let host = cpal::default_host();
|
||||
host.input_devices()
|
||||
.unwrap()
|
||||
.filter_map(|e| e.name().ok())
|
||||
.collect()
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
use std::fmt::Display;
|
||||
use std::{fmt::Display, path::PathBuf};
|
||||
|
||||
use log::{debug, error};
|
||||
use anyhow::Result;
|
||||
use log::{debug, error, trace, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::api::path::config_dir;
|
||||
|
||||
|
@ -10,6 +11,8 @@ pub struct Config {
|
|||
pub background_color: BGColor,
|
||||
#[serde(default = "default_interval")]
|
||||
pub blink_interval: u64,
|
||||
#[serde(default = "default_sens")]
|
||||
pub mic_sens: f32,
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
|
@ -17,6 +20,11 @@ fn default_interval() -> u64 {
|
|||
1500
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn default_sens() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BGColor {
|
||||
|
@ -39,18 +47,39 @@ impl Display for BGColor {
|
|||
Self::Custom(c) => {
|
||||
write!(f, "{}", c)
|
||||
}
|
||||
_ => {
|
||||
write!(f, "{}", self.to_string().to_lowercase())
|
||||
}
|
||||
Self::Green => write!(f, "green"),
|
||||
Self::Blue => write!(f, "blue"),
|
||||
Self::Pink => write!(f, "pink"),
|
||||
Self::Transparent => write!(f, "transparent"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn get_config_dir() -> Option<PathBuf> {
|
||||
Some(config_dir()?.join("cathode"))
|
||||
}
|
||||
|
||||
pub fn load_config() -> Config {
|
||||
if let Some(d) = config_dir() {
|
||||
if let Some(path) = get_config_dir() {
|
||||
use std::fs;
|
||||
let path = d.join("cathode");
|
||||
if !path.exists() {
|
||||
if let Ok(true) = path.try_exists() {
|
||||
let raw = fs::read_to_string(path.join("config.toml")).unwrap_or_else(|f| {
|
||||
warn!("Failed to load config file: {}", f);
|
||||
String::new()
|
||||
});
|
||||
trace!("Using config: {}", raw);
|
||||
match toml::from_str::<Config>(&raw) {
|
||||
Ok(c) => {
|
||||
trace!("parsed config: {:#?}", c);
|
||||
c
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Unable to parse config file: {}", e);
|
||||
Config::default()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if let Err(e) = fs::create_dir_all(&path) {
|
||||
debug!("{:?}", e);
|
||||
error!("Failed to create config directory");
|
||||
|
@ -59,14 +88,24 @@ pub fn load_config() -> Config {
|
|||
debug!("Created config dir at {}", path.display())
|
||||
}
|
||||
Config::default()
|
||||
} else {
|
||||
let raw = fs::read_to_string(path.join("config.toml")).unwrap_or_else(|f| {
|
||||
error!("Failed to load config file: {}", f);
|
||||
String::new()
|
||||
});
|
||||
toml::from_str::<Config>(&raw).unwrap_or_default()
|
||||
}
|
||||
} else {
|
||||
debug!("Using default config");
|
||||
Config::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_config(config: &Config) -> Result<()> {
|
||||
let raw = toml::to_string_pretty(config)?;
|
||||
if let Some(dir) = get_config_dir() {
|
||||
use std::fs;
|
||||
if !dir.exists() {
|
||||
fs::create_dir_all(&dir)?;
|
||||
}
|
||||
debug!("Writing config");
|
||||
trace!("Config: {}", raw);
|
||||
let path = dir.join("config.toml");
|
||||
fs::write(&path, raw)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
@ -8,19 +8,91 @@ use image::ImageFormat;
|
|||
use log::{debug, trace};
|
||||
use ray_format::Ray;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use tauri::api::dialog::blocking::FileDialogBuilder;
|
||||
use tauri::api::path::{cache_dir, home_dir, picture_dir};
|
||||
|
||||
const OBJ_URL: &'static str = "data:image/png;base64,";
|
||||
const OBJ_URL: &str = "data:image/png;base64,";
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct Meta {
|
||||
threshold: Option<String>,
|
||||
close_threshold: Option<String>,
|
||||
custom_anims: Option<HashMap<String, Map<String, Value>>>,
|
||||
frame_meta: Option<Value>,
|
||||
}
|
||||
|
||||
impl Meta {
|
||||
pub(crate) fn to_map(&self) -> HashMap<String, Value> {
|
||||
let mut tmp = HashMap::new();
|
||||
if let Some(t) = &self.threshold {
|
||||
tmp.insert("threshold".to_string(), Value::String(t.clone()));
|
||||
}
|
||||
|
||||
if let Some(t) = &self.close_threshold {
|
||||
tmp.insert("closeThreshold".to_string(), Value::String(t.clone()));
|
||||
}
|
||||
|
||||
if let Some(ca) = &self.custom_anims {
|
||||
let mut t = Map::new();
|
||||
for (k, v) in ca.iter() {
|
||||
t.insert(k.clone(), Value::Object(v.clone()));
|
||||
}
|
||||
tmp.insert("customAnims".to_string(), Value::Object(t));
|
||||
}
|
||||
|
||||
if let Some(fm) = &self.frame_meta {
|
||||
tmp.insert("frameMeta".to_string(), fm.clone());
|
||||
}
|
||||
|
||||
tmp
|
||||
}
|
||||
pub(crate) fn from_map(map: HashMap<String, Value>) -> Self {
|
||||
let threshold = if let Some(Value::String(t)) = map.get("threshold") {
|
||||
Some(t.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let close_threshold = if let Some(Value::String(t)) = map.get("closeThreshold") {
|
||||
Some(t.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let custom_anims = if let Some(Value::Object(o)) = map.get("customAnims") {
|
||||
let mut an = HashMap::new();
|
||||
for (k, v) in o.iter() {
|
||||
if let Value::Object(obj) = v {
|
||||
an.insert(k.clone(), obj.clone());
|
||||
}
|
||||
}
|
||||
Some(an)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let frame_meta = map.get("frameMeta").cloned();
|
||||
|
||||
Meta {
|
||||
threshold,
|
||||
close_threshold,
|
||||
custom_anims,
|
||||
frame_meta,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub(crate) struct WebRay {
|
||||
frames: [String; 4],
|
||||
meta: HashMap<String, String>,
|
||||
meta: Meta,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn open_image() -> Option<String> {
|
||||
pub(crate) async fn open_image() -> Option<String> {
|
||||
debug!("Opening iamge dialog...");
|
||||
let path = FileDialogBuilder::new()
|
||||
.add_filter("Images", &["png", "jpg"])
|
||||
.set_directory(picture_dir().unwrap_or_else(|| home_dir().unwrap()))
|
||||
|
@ -68,7 +140,7 @@ pub(crate) fn load_ray(path: impl AsRef<Path>) -> Option<WebRay> {
|
|||
}
|
||||
|
||||
for k in ray.get_meta_keys() {
|
||||
if let Some(v) = ray.get_meta_value(&k) {
|
||||
if let Some(v) = ray.get_meta_value(k) {
|
||||
meta.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
|
@ -83,7 +155,10 @@ pub(crate) fn load_ray(path: impl AsRef<Path>) -> Option<WebRay> {
|
|||
.as_bytes(),
|
||||
)
|
||||
.unwrap();
|
||||
Some(WebRay { frames, meta })
|
||||
Some(WebRay {
|
||||
frames,
|
||||
meta: Meta::from_map(meta),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
@ -103,7 +178,7 @@ pub(crate) async fn save_ray(ray: WebRay) -> Result<(), String> {
|
|||
res.set_frame(i as usize, decoded);
|
||||
}
|
||||
|
||||
for (k, v) in ray.meta {
|
||||
for (k, v) in ray.meta.to_map() {
|
||||
res.add_meta(k, v);
|
||||
}
|
||||
|
||||
|
|
|
@ -16,7 +16,7 @@ use log::{debug, error, trace, warn};
|
|||
use serde_json::Value;
|
||||
use tauri::{
|
||||
api::path::{cache_dir, config_dir},
|
||||
Manager, State,
|
||||
Manager, State, WindowEvent,
|
||||
};
|
||||
|
||||
use crate::config::Config;
|
||||
|
@ -32,6 +32,7 @@ struct AudioLevel(Arc<Mutex<f32>>);
|
|||
struct BlinkInterval(Arc<Mutex<u64>>);
|
||||
struct CurrentConfig(Arc<Mutex<Config>>);
|
||||
struct RayToLoad(Arc<Mutex<Option<PathBuf>>>);
|
||||
struct MicSense(Arc<Mutex<f32>>);
|
||||
|
||||
fn main() {
|
||||
env_logger::init();
|
||||
|
@ -39,6 +40,7 @@ fn main() {
|
|||
let level = Arc::new(Mutex::new(0.));
|
||||
let blink_interval = Arc::new(Mutex::new(1500));
|
||||
let ray = Arc::new(Mutex::new(None));
|
||||
let sens = Arc::new(Mutex::new(1.0));
|
||||
|
||||
if let Some(d) = cache_dir() {
|
||||
use std::fs;
|
||||
|
@ -52,22 +54,33 @@ fn main() {
|
|||
let config = config::load_config();
|
||||
|
||||
{
|
||||
*blink_interval.lock().unwrap() = config.blink_interval
|
||||
*blink_interval.lock().unwrap() = config.blink_interval;
|
||||
*sens.lock().unwrap() = config.mic_sens;
|
||||
}
|
||||
|
||||
let current_conf = Arc::new(Mutex::new(config));
|
||||
|
||||
let config = current_conf.clone();
|
||||
let c2 = current_conf.clone();
|
||||
tauri::Builder::default()
|
||||
.manage(MicThreshold(threshold.clone()))
|
||||
.manage(AudioLevel(level.clone()))
|
||||
.manage(BlinkInterval(blink_interval.clone()))
|
||||
.manage(CurrentConfig(current_conf.clone()))
|
||||
.manage(CurrentConfig(current_conf))
|
||||
.manage(RayToLoad(ray.clone()))
|
||||
.manage(MicSense(sens.clone()))
|
||||
.on_window_event(move |event| {
|
||||
if let WindowEvent::CloseRequested { .. } = event.event() {
|
||||
debug!("close requested");
|
||||
if let Err(e) = config::save_config(&c2.lock().unwrap()) {
|
||||
error!("Error writing config file: {}", e);
|
||||
}
|
||||
}
|
||||
})
|
||||
.setup(move |app| {
|
||||
let window = app.get_window("main").unwrap();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
monitor(window, threshold, level).await;
|
||||
monitor(window, threshold, level, sens).await;
|
||||
});
|
||||
|
||||
let window = app.get_window("main").unwrap();
|
||||
|
@ -79,8 +92,8 @@ fn main() {
|
|||
warn!("Failed to emit blink event: {}", e);
|
||||
}
|
||||
}
|
||||
let blink = blink_interval.lock().unwrap();
|
||||
sleep(Duration::from_millis(*blink));
|
||||
let blink = *blink_interval.lock().unwrap();
|
||||
sleep(Duration::from_millis(blink));
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -130,14 +143,19 @@ fn main() {
|
|||
log,
|
||||
set_mic_threshold,
|
||||
get_mic_threshold,
|
||||
set_mic_sens,
|
||||
get_mic_sens,
|
||||
get_audio_level,
|
||||
get_blink_interval,
|
||||
set_blink_interval,
|
||||
get_config,
|
||||
set_config,
|
||||
save_current_config,
|
||||
get_ray_to_load,
|
||||
fs::open_image,
|
||||
fs::save_ray,
|
||||
fs::open_ray,
|
||||
audio::get_devices,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
@ -147,15 +165,14 @@ fn main() {
|
|||
fn get_ray_to_load(ray: State<'_, RayToLoad>) -> Option<WebRay> {
|
||||
let ray = { (*ray.0.lock().unwrap()).clone() };
|
||||
if let Some(r) = ray {
|
||||
let ray = fs::load_ray(r);
|
||||
ray
|
||||
fs::load_ray(r)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn log(msg: String) {
|
||||
fn log(msg: String, level: Option<String>) {
|
||||
debug!("frontend: {}", msg);
|
||||
}
|
||||
|
||||
|
@ -169,6 +186,16 @@ fn get_mic_threshold(current: State<'_, MicThreshold>) -> f32 {
|
|||
*current.0.lock().unwrap()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_mic_sens(sens: f32, current: State<'_, MicSense>) {
|
||||
*current.0.lock().unwrap() = sens;
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_mic_sens(current: State<'_, MicSense>) -> f32 {
|
||||
*current.0.lock().unwrap()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_audio_level(level: State<'_, AudioLevel>) -> f32 {
|
||||
*level.0.lock().unwrap()
|
||||
|
@ -188,3 +215,13 @@ fn set_blink_interval(value: u64, current: State<'_, BlinkInterval>) {
|
|||
fn get_config(current: State<'_, CurrentConfig>) -> Config {
|
||||
current.0.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_config(config: Config, current: State<'_, CurrentConfig>) {
|
||||
*current.0.lock().unwrap() = config
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_current_config(current: State<'_, CurrentConfig>) -> Result<(), String> {
|
||||
config::save_config(¤t.0.lock().unwrap()).map_err(|e| format!("{}", e))
|
||||
}
|
||||
|
|
|
@ -6,17 +6,19 @@
|
|||
"distDir": "../dist"
|
||||
},
|
||||
"package": {
|
||||
"productName": "cathode-tube",
|
||||
"version": "0.0.1"
|
||||
"productName": "cathode",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"tauri": {
|
||||
"cli": {
|
||||
"description": "A small app for PNG-tubing",
|
||||
"args": [{
|
||||
"name": "file",
|
||||
"index": 1,
|
||||
"takesValue": true
|
||||
}]
|
||||
"args": [
|
||||
{
|
||||
"name": "file",
|
||||
"index": 1,
|
||||
"takesValue": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"macOSPrivateApi": true,
|
||||
"allowlist": {
|
||||
|
@ -24,7 +26,10 @@
|
|||
"all": true
|
||||
},
|
||||
"fs": {
|
||||
"scope": ["$PUBLIC/*", "$CONFIG/*"],
|
||||
"scope": [
|
||||
"$PUBLIC/*",
|
||||
"$CONFIG/*"
|
||||
],
|
||||
"readFile": true,
|
||||
"readDir": true,
|
||||
"createDir": true,
|
||||
|
@ -42,10 +47,10 @@
|
|||
"category": "DeveloperTool",
|
||||
"copyright": "",
|
||||
"deb": {
|
||||
"files" : {
|
||||
"/usr/share/applications/cathode-tube.desktop": "cathode-tube.desktop",
|
||||
"/usr/share/mime/packages/application-cathode.xml": "application-cathode.xml",
|
||||
"/usr/share/icons/hicolor/256x256/mimetypes/aplication-cathode.png": "icons/application-cathode-256.png"
|
||||
"files": {
|
||||
"/usr/share/applications/cathode-tube.desktop": "cathode-tube.desktop",
|
||||
"/usr/share/mime/packages/application-cathode.xml": "application-cathode.xml",
|
||||
"/usr/share/icons/hicolor/256x256/mimetypes/aplication-cathode.png": "icons/application-cathode-256.png"
|
||||
},
|
||||
"depends": []
|
||||
},
|
||||
|
@ -92,4 +97,4 @@
|
|||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,37 +1,43 @@
|
|||
<script lang="ts">
|
||||
import MainView from "./views/main.svelte";
|
||||
import {listen} from "@tauri-apps/api/event";
|
||||
import {invoke} from "@tauri-apps/api";
|
||||
import {onMount} from "svelte";
|
||||
import {config} from "./store";
|
||||
import type {Config} from "./store";
|
||||
|
||||
$: transparent = $config.background_color === "transparent";
|
||||
$: color = typeof $config.background_color === "object" ? $config.background_color.custom : $config.background_color;
|
||||
|
||||
$: {
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { invoke } from "@tauri-apps/api";
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { config } from "./store";
|
||||
import type { Config } from "./store";
|
||||
|
||||
invoke("log", {msg: `color: ${color} trasnparent: ${transparent}`
|
||||
$: transparent = $config.background_color === "transparent";
|
||||
$: color =
|
||||
typeof $config.background_color === "object"
|
||||
? $config.background_color.custom
|
||||
: $config.background_color;
|
||||
|
||||
$: {
|
||||
invoke("log", {
|
||||
msg: `color: ${color} trasnparent: ${transparent}`,
|
||||
}).catch();
|
||||
}
|
||||
|
||||
|
||||
onMount(async () => {
|
||||
$config = await invoke("get_config") as Config;
|
||||
$config = (await invoke("get_config")) as Config;
|
||||
await listen("reload-config", async () => {
|
||||
$config = await invoke("get_config") as Config;
|
||||
$config = (await invoke("get_config")) as Config;
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
onDestroy(async () => {
|
||||
await invoke("save_current_config");
|
||||
});
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<MainView --active-color={transparent ? 'lightblue' : color} --inactive-color={color}/>
|
||||
<MainView
|
||||
--active-color={transparent ? "lightblue" : color}
|
||||
--inactive-color={color}
|
||||
/>
|
||||
</main>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
:global(*) {
|
||||
-moz-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
|
|
|
@ -1,12 +1,20 @@
|
|||
<script lang="ts" context="module">
|
||||
const hints = ["Eyes open | Mouth closed", "Eyes open | Mouth open", "Eyes closed | Mouth closed", "Eyes closed | Mouth open"];
|
||||
const hints = [
|
||||
"Eyes open | Mouth closed",
|
||||
"Eyes open | Mouth open",
|
||||
"Eyes closed | Mouth closed",
|
||||
"Eyes closed | Mouth open",
|
||||
];
|
||||
|
||||
let loading = [false, false, false, false];
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { frames } from "../store";
|
||||
import { invoke } from "@tauri-apps/api";
|
||||
import { fade } from "svelte/transition";
|
||||
import Context from "../components/context.svelte";
|
||||
import Context from "./context.svelte";
|
||||
import LoadingSVG from "/src/loading.svg";
|
||||
export let index: number;
|
||||
|
||||
let menuTimeout: NodeJS.Timeout | null = null;
|
||||
|
@ -14,24 +22,32 @@
|
|||
let src = "";
|
||||
|
||||
$: {
|
||||
src = $frames[index];
|
||||
src = $frames.get(index)?.img;
|
||||
}
|
||||
|
||||
const openImage = async () => {
|
||||
loading[index] = true;
|
||||
const path = (await invoke("open_image")) as string;
|
||||
if (path) {
|
||||
$frames[index] = path;
|
||||
$frames.get(index).img = path;
|
||||
}
|
||||
loading[index] = false;
|
||||
};
|
||||
const clearImage = () => {
|
||||
$frames.get(index).img = "";
|
||||
};
|
||||
|
||||
//TODO: load frame from ray
|
||||
</script>
|
||||
|
||||
<div class="box">
|
||||
<div
|
||||
<button
|
||||
class="preview"
|
||||
on:click={openImage}
|
||||
on:contextmenu={openImage}
|
||||
on:click={(e) => {
|
||||
if (e.shiftKey) {
|
||||
clearImage();
|
||||
} else {
|
||||
openImage();
|
||||
}
|
||||
}}
|
||||
on:mouseenter={() =>
|
||||
(menuTimeout = setTimeout(() => (showMenu = true), 200))}
|
||||
on:mouseleave={() => {
|
||||
|
@ -41,10 +57,12 @@
|
|||
showMenu = false;
|
||||
}}
|
||||
>
|
||||
{#if src}
|
||||
{#if loading[index]}
|
||||
<img class="loading" src={LoadingSVG} alt="Loading" />
|
||||
{:else if src}
|
||||
<img {src} alt="Frame {{ index }}" />
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{#if showMenu}
|
||||
<div transition:fade={{ duration: 50 }} class="context">
|
||||
<Context>
|
||||
|
@ -55,6 +73,35 @@
|
|||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@-webkit-keyframes rotating {
|
||||
from {
|
||||
-webkit-transform: rotate(0deg);
|
||||
-o-transform: rotate(0deg);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
-webkit-transform: rotate(360deg);
|
||||
-o-transform: rotate(360deg);
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@keyframes rotating {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
-webkit-animation: rotating 2s linear infinite;
|
||||
-moz-animation: rotating 2s linear infinite;
|
||||
-ms-animation: rotating 2s linear infinite;
|
||||
-o-animation: rotating 2s linear infinite;
|
||||
animation: rotating 2s linear infinite;
|
||||
}
|
||||
|
||||
$bg: rgba(150, 150, 150, 0.5);
|
||||
.preview {
|
||||
&:hover {
|
||||
|
@ -76,8 +123,13 @@
|
|||
user-select: none;
|
||||
gap: 10px;
|
||||
display: flex;
|
||||
align-content: center;
|
||||
justify-content: center;
|
||||
.context {
|
||||
align-self: center;
|
||||
z-index: 900;
|
||||
position: absolute;
|
||||
left: 105%;
|
||||
width: max-content;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
import { onMount } from "svelte";
|
||||
import { tweened } from "svelte/motion";
|
||||
import { sineIn, sineOut } from "svelte/easing";
|
||||
import { invoke } from "@tauri-apps/api";
|
||||
// import { invoke } from "@tauri-apps/api";
|
||||
export let progress = 0;
|
||||
|
||||
export let withSetpoint = false;
|
||||
|
@ -13,7 +13,7 @@
|
|||
let bar: HTMLDivElement;
|
||||
let point: HTMLDivElement;
|
||||
let pos = { x: 0, y: 0 };
|
||||
const tweenedProgress = tweened(0, { duration: 40, easing: sineOut });
|
||||
const tweenedProgress = tweened(0, { duration: 100, easing: sineOut });
|
||||
|
||||
onMount(async () => {
|
||||
let rect = bar.getBoundingClientRect();
|
||||
|
@ -26,20 +26,21 @@
|
|||
if (pxProgress > $tweenedProgress) {
|
||||
tweenedProgress
|
||||
.set(pxProgress, {
|
||||
duration: 20,
|
||||
duration: 30,
|
||||
easing: sineOut,
|
||||
})
|
||||
.then();
|
||||
} else {
|
||||
$tweenedProgress = pxProgress;
|
||||
}
|
||||
$tweenedProgress = pxProgress;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
on:resize={async () => {
|
||||
await invoke("log", {
|
||||
msg: `Resized`,
|
||||
});
|
||||
// await invoke("log", {
|
||||
// msg: `Resized`,
|
||||
// });
|
||||
|
||||
pos = { x: 0, y: 0 };
|
||||
let rect = bar.getBoundingClientRect();
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api";
|
||||
|
||||
const devices: Promise<string[]> = invoke("get_devices");
|
||||
</script>
|
||||
|
||||
{#await devices}
|
||||
<span>...</span>
|
||||
{:then d}
|
||||
{#each d as dev}
|
||||
<p>{dev}</p>
|
||||
{/each}
|
||||
{/await}
|
|
@ -1,75 +1,127 @@
|
|||
<script lang="ts">
|
||||
import {config} from "../store";
|
||||
import {createEventDispatcher} from "svelte";
|
||||
import {invoke} from "@tauri-apps/api";
|
||||
import { Config, config } from "../store";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api";
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const log = async (msg: string) => {
|
||||
await invoke("log", { msg });
|
||||
};
|
||||
|
||||
let org = $config;
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
const onClose = () => {
|
||||
if(org != $config) {
|
||||
invoke("set_blink_interval", {value: $config.blink_interval});
|
||||
org = $config;
|
||||
}
|
||||
dispatch('close');
|
||||
}
|
||||
const onSave = async () => {
|
||||
await invoke("set_config", { config: $config });
|
||||
await invoke("set_blink_interval", { value: $config.blink_interval });
|
||||
await invoke("set_mic_sens", { sens: $config.mic_sens });
|
||||
await invoke("save_current_config");
|
||||
};
|
||||
|
||||
const reset = async () => {
|
||||
$config = (await invoke("get_config")) as Config;
|
||||
};
|
||||
|
||||
const onClose = async () => {
|
||||
//TODO: alert if unsaved changes
|
||||
reset();
|
||||
dispatch("close");
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="settings">
|
||||
<span class="close" on:click={onClose}>X</span>
|
||||
<div>
|
||||
<label for="bg-color">Background color:</label>
|
||||
<select bind:value={$config.background_color} id="bg-color">
|
||||
<option value="transparent">Transparent</option>
|
||||
<option value="green">Green</option>
|
||||
<option value="blue">Blue</option>
|
||||
<option value="red">Red</option>
|
||||
<option value="pink">Pink</option>
|
||||
</select>
|
||||
<button class="close" on:click={onClose}>X</button>
|
||||
<div class="setting">
|
||||
<label for="bg-color">Background color:</label>
|
||||
<select bind:value={$config.background_color} id="bg-color">
|
||||
<option value="transparent">Transparent</option>
|
||||
<option value="green">Green</option>
|
||||
<option value="blue">Blue</option>
|
||||
<option value="red">Red</option>
|
||||
<option value="pink">Pink</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="blink">Blink interval: </label>
|
||||
<input value={$config.blink_interval} on:change={(e) => {$config.blink_interval = e.currentTarget.valueAsNumber}} id="blink" type="number" min="0"/>
|
||||
<div class="setting">
|
||||
<label for="blink">Blink interval: </label>
|
||||
<input
|
||||
value={$config.blink_interval}
|
||||
on:change={async (e) => {
|
||||
if (e.currentTarget.checkValidity()) {
|
||||
$config.blink_interval = e.currentTarget.valueAsNumber;
|
||||
} else {
|
||||
$config.blink_interval = Number.parseInt(e.currentTarget.min);
|
||||
}
|
||||
}}
|
||||
id="blink"
|
||||
type="number"
|
||||
min="100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="setting">
|
||||
<label for="sens">Mic sensitivity: </label>
|
||||
<input
|
||||
type="range"
|
||||
id="sens"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
max="1.0"
|
||||
value={$config.mic_sens}
|
||||
on:change={(e) => {
|
||||
$config.mic_sens = e.currentTarget.valueAsNumber;
|
||||
}}
|
||||
/>
|
||||
<span>{Math.trunc($config.mic_sens * 100)}%</span>
|
||||
</div>
|
||||
<footer>
|
||||
<button on:click={onSave}>Save</button><button on:click={reset}
|
||||
>Cancel</button
|
||||
>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
|
||||
<style lang="scss" >
|
||||
.settings {
|
||||
position: absolute;
|
||||
transform: translateX(-50%) translateY(-50%);
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
z-index: 1000;
|
||||
width: 90%;
|
||||
height: 90%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5vh;
|
||||
background-color: rgba(63, 176, 252, 0.9);
|
||||
border-radius: 15px;
|
||||
border: solid black 2px;
|
||||
padding: 15px;
|
||||
label{
|
||||
font-weight: bold;
|
||||
}
|
||||
.close {
|
||||
<style lang="scss">
|
||||
.settings {
|
||||
position: absolute;
|
||||
right: 15px;
|
||||
top: 15px;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
background-color: black;
|
||||
color: white;
|
||||
border: solid black 1px;
|
||||
text-align: center;
|
||||
vertical-align: center;
|
||||
transform: translateX(-50%) translateY(-50%);
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
z-index: 1000;
|
||||
width: 90%;
|
||||
height: 90%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5vh;
|
||||
background-color: rgba(63, 176, 252, 0.9);
|
||||
border-radius: 15px;
|
||||
border: solid black 2px;
|
||||
padding: 20px;
|
||||
label {
|
||||
font-weight: bold;
|
||||
}
|
||||
.close {
|
||||
&:focus {
|
||||
border: solid white 2px;
|
||||
}
|
||||
position: absolute;
|
||||
right: 15px;
|
||||
top: 15px;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
background-color: black;
|
||||
color: white;
|
||||
border: solid black 1px;
|
||||
text-align: center;
|
||||
vertical-align: center;
|
||||
border-radius: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.setting {
|
||||
display: flex;
|
||||
align-content: center;
|
||||
justify-content: space-between;
|
||||
width: 50%;
|
||||