feature/gui-configuration:支持从 GUI 配置程序。 #4

Merged
Ivan merged 19 commits from feature/gui-configuration into master 2023-01-26 23:44:19 +08:00
40 changed files with 3461 additions and 1052 deletions

View File

@ -0,0 +1,5 @@
module.exports = {
twin: {
preset: 'emotion',
},
};

View File

@ -10,27 +10,44 @@
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^1.1.0",
"@emotion/react": "^11.10.5",
"@emotion/styled": "^11.10.5",
"@fortawesome/fontawesome-svg-core": "^6.2.1",
"@fortawesome/free-regular-svg-icons": "^6.2.1",
"@fortawesome/free-solid-svg-icons": "^6.2.1",
"@fortawesome/react-fontawesome": "^0.2.0",
"@mui/material": "^5.11.4",
"@tauri-apps/api": "^1.2.0",
"clsx": "^1.2.1",
"debug": "^4.3.4",
"ramda": "^0.28.0",
"react": "^18.2.0",
"react-async-hook": "^4.0.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@tauri-apps/cli": "^1.1.0",
"@types/node": "^18.7.10",
"@types/react": "^18.0.15",
"@types/react-dom": "^18.0.6",
"@vitejs/plugin-react": "^2.0.0",
"@babel/plugin-transform-react-jsx": "^7.20.7",
"@emotion/babel-plugin-jsx-pragmatic": "^0.2.0",
"@emotion/serialize": "^1.1.1",
"@tauri-apps/cli": "^1.2.2",
"@types/debug": "^4.1.7",
"@types/node": "^18.11.18",
"@types/ramda": "^0.28.20",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"@vitejs/plugin-react": "^2.2.0",
"autoprefixer": "^10.4.13",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jsx-a11y": "^6.6.1",
"babel-plugin-macros": "^3.1.0",
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-import": "^2.27.4",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-simple-import-sort": "^8.0.0",
"postcss": "^8.4.19",
"prettier": "^2.7.1",
"postcss": "^8.4.21",
"prettier": "^2.8.3",
"tailwindcss": "^3.2.4",
"typescript": "^4.6.4",
"vite": "^3.0.2"
"twin.macro": "^3.1.0",
"typescript": "^4.9.4",
"vite": "^3.2.5"
}
}

1461
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

887
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -33,6 +33,8 @@ time = { version = "0.3.17", features = ["formatting"] }
color_space = "0.5.3"
futures = "0.3.25"
either = "1.8.0"
image = "0.24.5"
mdns = "3.0.0"
[features]
# by default Tauri runs in production mode
@ -41,3 +43,6 @@ 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" ]
[dev-dependencies]
test_dir = "0.2.0"

View File

@ -1,27 +1,28 @@
use futures::{future::join_all, stream::FuturesUnordered, StreamExt};
use futures::future::join_all;
use once_cell::sync::OnceCell;
use paris::info;
use paris::{error, info, warn};
use serde::{Deserialize, Serialize};
use serde_json::value::Index;
use std::{collections::HashMap, iter::Map, sync::Arc, thread, time::Duration};
use std::{
collections::{HashMap, HashSet},
sync::Arc,
time::Duration,
};
use tauri::async_runtime::RwLock;
use tokio::{
join,
sync::mpsc,
task,
time::{sleep, Instant},
};
use tracing::warn;
use crate::{
picker::{
config::DisplayConfig, display_picker::DisplayPicker, led_color::LedColor, manager::Picker,
self, config::DisplayConfig, display_picker::DisplayPicker, led_color::LedColor,
screenshot::Screenshot,
},
rpc,
};
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, Clone, Copy)]
pub enum AmbientLightMode {
None,
Follow,
@ -40,25 +41,25 @@ impl CoreManager {
ambient_light_mode: Arc::new(RwLock::new(AmbientLightMode::None)),
});
tokio::spawn(async {
loop {
core.play_flowing_light().await;
match core.play_follow().await {
Ok(_) => {}
Err(error) => {
warn!("Can not following displays. {}", error);
sleep(Duration::from_millis(1000)).await;
}
};
sleep(Duration::from_millis(10)).await;
}
});
core
}
pub async fn set_ambient_light(&self, target_mode: AmbientLightMode) {
let mut mode = self.ambient_light_mode.write().await;
*mode = target_mode;
drop(mode);
match target_mode {
AmbientLightMode::Flowing => self.play_flowing_light().await,
AmbientLightMode::None => {}
AmbientLightMode::Follow => match self.play_follow().await {
Ok(_) => {}
Err(error) => {
warn!("Can not following displays. {}", error);
}
},
};
}
pub async fn play_flowing_light(&self) {
@ -91,74 +92,117 @@ impl CoreManager {
}
pub async fn play_follow(&self) -> anyhow::Result<()> {
let lock = self.ambient_light_mode.read().await;
let mut futs = vec![];
if let AmbientLightMode::Follow = *lock {
drop(lock);
let configs = Picker::global().display_configs.lock().await;
let configs = picker::config::Manager::global().reload_config().await;
let configs = match configs {
Ok(c) => c.display_configs,
Err(err) => anyhow::bail!("can not get display configs. {:?}", err),
};
info!("piker display configs: {:?}", configs);
let (tx, mut rx) = mpsc::channel(10);
let (tx, mut rx) = mpsc::channel(10);
for config in configs.to_owned() {
let tx = tx.clone();
let fut = tokio::spawn(async move {
match Self::follow_display_by_config(config, tx).await {
Ok(_) => {}
Err(error) => {
warn!("following failed. {}", error);
}
for config in configs.clone() {
let tx = tx.clone();
let fut = tokio::spawn(async move {
match Self::follow_display_by_config(config, tx).await {
Ok(_) => {}
Err(error) => {
warn!("following failed. {}", error);
}
});
futs.push(fut);
}
let configs = configs.clone();
tokio::spawn(async move {
let mut global_colors = HashMap::new();
while let Some(screenshot) = rx.recv().await {
let start_at = Instant::now();
match screenshot.get_top_colors().await {
Ok(colors) => {
let start = screenshot.get_top_of_led_start_at().min(screenshot.get_top_of_led_end_at());
let colors_len = colors.len();
for (index, color) in colors.into_iter().enumerate() {
global_colors.insert(index + start, color);
}
info!("led count: {}, spend: {:?}", global_colors.len(), start_at.elapsed());
if global_colors.len() == 60 {
let mut colors = vec![];
for index in 0..global_colors.len() {
colors.push(*global_colors.get(&index).unwrap());
}
global_colors = HashMap::new();
match rpc::manager::Manager::global()
.publish_led_colors(&colors)
.await
{
Ok(_) => {
info!("publish successful",);
}
Err(error) => {
warn!("publish led colors failed. {}", error);
}
}
}
}
Err(_) => {}
};
}
});
join_all(futs).await;
} else {
drop(lock);
return Ok(());
futs.push(fut);
}
let total_colors_count = configs
.iter()
.flat_map(|c| {
vec![
c.led_strip_of_borders.top,
c.led_strip_of_borders.bottom,
c.led_strip_of_borders.left,
c.led_strip_of_borders.right,
]
})
.flat_map(|l| match l {
Some(l) => (l.global_start_position.min(l.global_end_position)
..l.global_start_position.max(l.global_end_position))
.collect(),
None => {
vec![]
}
})
.collect::<HashSet<_>>()
.len();
tokio::spawn(async move {
let mut global_sub_pixels = HashMap::new();
while let Some(screenshot) = rx.recv().await {
let start_at = Instant::now();
let colors = screenshot.get_colors();
let config = screenshot.get_config();
for (colors, config) in vec![
(colors.top, config.led_strip_of_borders.top),
(colors.right, config.led_strip_of_borders.right),
(colors.bottom, config.led_strip_of_borders.bottom),
(colors.left, config.led_strip_of_borders.left),
] {
match config {
Some(config) => {
let (sign, start) =
if config.global_start_position <= config.global_end_position {
(1, config.global_start_position as isize * 3)
} else {
(-1, (config.global_start_position as isize + 1) * 3 - 1)
};
for (index, color) in colors.into_iter().enumerate() {
let pixel_index = index / 3;
let sub_pixel_index = index % 3;
let offset = if sign < 0 {
2 - sub_pixel_index
} else {
sub_pixel_index
};
let global_sub_pixel_index =
(sign * (pixel_index as isize * 3 + offset as isize) + start)
as usize;
global_sub_pixels.insert(global_sub_pixel_index, color);
}
}
None => {}
}
}
// info!(
// "led count: {}, spend: {:?}",
// global_sub_pixels.len(),
// start_at.elapsed()
// );
if global_sub_pixels.len() >= total_colors_count * 3 {
let mut colors = vec![];
for index in 0..global_sub_pixels.len() {
colors.push(*global_sub_pixels.get(&index).unwrap());
}
// info!("{:?}", colors);
global_sub_pixels = HashMap::new();
match rpc::manager::Manager::global()
.publish_led_sub_pixels(colors)
.await
{
Ok(_) => {
// info!("publish successful",);
}
Err(error) => {
warn!("publish led colors failed. {}", error);
}
}
}
}
});
join_all(futs).await;
Ok(())
}
@ -176,18 +220,18 @@ impl CoreManager {
if let AmbientLightMode::Follow = *lock {
drop(lock);
let screenshot = picker.take_screenshot()?;
info!("Take Screenshot Spend: {:?}", start.elapsed());
tx.send(screenshot).await;
// info!("Take Screenshot Spend: {:?}", start.elapsed());
match tx.send(screenshot).await {
Ok(_) => {}
Err(err) => {
error!("send screenshot to main thread was failed. {:?}", err);
}
};
} else {
break;
}
tokio::time::sleep_until(next_tick).await;
}
// // Picker::global().take_screenshots_for_all().await?;
// // let colors = Picker::global().get_led_strip_colors().await?;
// // let colors = colors.into_iter().rev().collect();
Ok(())
}
}

View File

@ -2,6 +2,7 @@
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
#![feature(bool_to_option)]
mod core;
mod picker;
@ -10,23 +11,20 @@ mod rpc;
use crate::core::AmbientLightMode;
use crate::core::CoreManager;
use paris::*;
use picker::led_color::LedColor;
use picker::config::DisplayConfig;
use picker::manager::Picker;
use std::vec;
use picker::screenshot::ScreenshotDto;
use tauri::async_runtime::Mutex;
use once_cell::sync::OnceCell;
static GET_SCREENSHOT_LOCK: OnceCell<Mutex<bool>> = OnceCell::new();
#[tauri::command]
async fn refresh_displays() {
match Picker::global().refresh_displays().await {
Ok(_) => {}
Err(error) => {
error!("{}", error)
}
}
}
#[tauri::command]
async fn take_snapshot() -> Vec<String> {
let manager = Picker::global();
async fn take_snapshot() -> Vec<ScreenshotDto> {
info!("Hi?");
let _lock = GET_SCREENSHOT_LOCK.get_or_init(|| Mutex::new(false)).lock().await;
info!("Hi!");
let manager = Picker::global().await;
let start = time::Instant::now();
let base64_bitmap_list = match manager.list_displays().await {
@ -40,21 +38,45 @@ async fn take_snapshot() -> Vec<String> {
vec![]
}
};
info!("截图花费 {} s", start.elapsed().as_seconds_f32());
info!("截图耗时 {} s", start.elapsed().as_seconds_f32());
base64_bitmap_list
}
#[tauri::command]
async fn get_led_strip_colors() -> Result<Vec<LedColor>, String> {
let colors = Picker::global().get_led_strip_colors().await;
match colors {
Ok(colors) => {
rpc::manager::Manager::global()
.publish_led_colors(&colors.to_vec())
.await;
Ok(colors)
async fn get_screenshot_by_config(config: DisplayConfig) -> Result<ScreenshotDto, String> {
info!("Hi?");
// let _lock = GET_SCREENSHOT_LOCK.get_or_init(|| Mutex::new(false)).lock().await;
// info!("Hi!");
let start = time::Instant::now();
let screenshot_dto = Picker::preview_display_by_config(config).await;
info!("截图耗时 {} s", start.elapsed().as_seconds_f32());
match screenshot_dto {
Ok(screenshot_dto) => Ok(screenshot_dto),
Err(error) => {
error!("preview_display_by_config failed. {}", error);
Err(format!("preview_display_by_config failed. {}", error))
}
}
}
#[tauri::command]
async fn get_picker_config() -> picker::config::Configuration {
let configuration = picker::config::Manager::global().get_config().await;
info!("configuration: {:?}", configuration);
configuration
}
#[tauri::command]
async fn write_picker_config(config: picker::config::Configuration) -> Result<(), String> {
let manager = picker::config::Manager::global();
let path = picker::config::Manager::get_config_file_path();
info!("log save in {:?}", path.to_str());
manager.set_config(&config).await;
match picker::config::Manager::write_config_to_disk(path, &config) {
Ok(_) => Ok(()),
Err(err) => {
error!("can not write picker config. {:?}", err);
Err(format!("can not write picker config. {:?}", err))
}
Err(error) => Err(format!("{}", error)),
}
}
@ -62,7 +84,7 @@ async fn get_led_strip_colors() -> Result<Vec<LedColor>, String> {
async fn play_mode(target_mode: AmbientLightMode) {
info!("target mode: {:?}", target_mode);
CoreManager::global().set_ambient_light(target_mode).await;
tokio::spawn(async move { CoreManager::global().set_ambient_light(target_mode).await });
}
#[tokio::main]
@ -71,9 +93,10 @@ async fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
take_snapshot,
refresh_displays,
get_led_strip_colors,
play_mode,
get_picker_config,
get_screenshot_by_config,
write_picker_config,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@ -1,58 +0,0 @@
#[derive(Clone, Copy)]
pub struct LedStripConfig {
pub index: usize,
pub global_start_position: usize,
pub global_end_position: usize,
}
#[derive(Clone, Copy)]
pub struct DisplayConfig {
pub index_of_display: usize,
pub display_width: usize,
pub display_height: usize,
pub top_led_strip: LedStripConfig,
pub bottom_led_strip: LedStripConfig,
pub left_led_strip: LedStripConfig,
pub right_led_strip: LedStripConfig,
}
#[derive(Clone, Copy)]
pub enum LedFlowX {
LR, // from left to right
RL, // from right to left
}
#[derive(Clone, Copy)]
pub enum LedFlowY {
TB, // from top to bottom
BT, // from bottom to top
}
impl DisplayConfig {
pub fn default(index_of_display: usize, display_width: usize, display_height: usize) -> Self {
Self {
index_of_display,
display_width,
display_height,
top_led_strip: LedStripConfig {
index: 0,
global_start_position: 0,
global_end_position: 0,
},
bottom_led_strip: LedStripConfig {
index: 0,
global_start_position: 0,
global_end_position: 0,
},
left_led_strip: LedStripConfig {
index: 0,
global_start_position: 0,
global_end_position: 0,
},
right_led_strip: LedStripConfig {
index: 0,
global_start_position: 0,
global_end_position: 0,
},
}
}
}

View File

@ -0,0 +1,53 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Serialize, Deserialize, Debug)]
pub struct LedStripConfigOfBorders {
pub top: Option<LedStripConfig>,
pub bottom: Option<LedStripConfig>,
pub left: Option<LedStripConfig>,
pub right: Option<LedStripConfig>,
}
#[derive(Clone, Copy, Serialize, Deserialize, Debug)]
pub struct LedStripConfig {
pub index: usize,
pub global_start_position: usize,
pub global_end_position: usize,
}
#[derive(Clone, Copy, Serialize, Deserialize, Debug)]
pub struct DisplayConfig {
pub id: usize,
pub index_of_display: usize,
pub display_width: usize,
pub display_height: usize,
pub led_strip_of_borders: LedStripConfigOfBorders,
}
impl LedStripConfigOfBorders {
pub fn default() -> Self {
Self {
top: None,
bottom: None,
left: None,
right: None,
}
}
}
impl DisplayConfig {
pub fn default(
id: usize,
index_of_display: usize,
display_width: usize,
display_height: usize,
) -> Self {
Self {
id,
index_of_display,
display_width,
display_height,
led_strip_of_borders: LedStripConfigOfBorders::default(),
}
}
}

View File

@ -0,0 +1,147 @@
use std::{
env::current_dir,
fs::{self, File},
io::Read,
path::PathBuf,
sync::Arc,
};
use once_cell::sync::OnceCell;
use paris::info;
use serde::{Deserialize, Serialize};
use tauri::{api::path::config_dir, async_runtime::Mutex};
use super::DisplayConfig;
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Configuration {
pub config_version: u8,
pub display_configs: Vec<DisplayConfig>,
}
impl Configuration {
pub fn default() -> Self {
Self {
config_version: 1,
display_configs: vec![],
}
}
}
pub struct Manager {
config: Arc<Mutex<Configuration>>,
}
impl Manager {
pub fn global() -> &'static Manager {
static DISPLAY_CONFIG_MANAGE: OnceCell<Manager> = OnceCell::new();
DISPLAY_CONFIG_MANAGE.get_or_init(|| Self::init_from_disk())
}
pub fn default() -> Self {
Self::new(Configuration::default())
}
pub fn new(config: Configuration) -> Self {
Self {
config: Arc::new(Mutex::new(config)),
}
}
pub fn get_config_file_path() -> PathBuf {
config_dir()
.unwrap_or(current_dir().unwrap())
.join("display_config.json")
}
pub fn init_from_disk() -> Self {
let config_file_path = Self::get_config_file_path();
match Self::read_config_from_disk(config_file_path) {
Ok(config) => Self::new(config),
Err(_) => Self::default(),
}
}
pub fn read_config_from_disk(config_file_path: PathBuf) -> anyhow::Result<Configuration> {
let mut file = File::open(config_file_path)
.map_err(|error| anyhow::anyhow!("config file is not existed. {}", error))?;
let mut contents = String::new();
file.read_to_string(&mut contents)
.map_err(|error| anyhow::anyhow!("can not read config file. {}", error))?;
serde_json::from_str(&contents)
.map_err(|error| anyhow::anyhow!("can not parse config file contents. {}", error))
}
pub fn write_config_to_disk(
config_file_path: PathBuf,
config: &Configuration,
) -> anyhow::Result<()> {
let contents = serde_json::to_string(config)
.map_err(|error| anyhow::anyhow!("can not serialize config. {}", error))?;
info!("contents: {}", contents);
fs::write(config_file_path, contents.as_bytes())
.map_err(|error| anyhow::anyhow!("can not write config file. {}", error))?;
Ok(())
}
pub async fn get_config(&self) -> Configuration {
self.config.lock().await.clone()
}
pub async fn set_config(&self, new_config: &Configuration) {
let mut config = self.config.lock().await;
*config = new_config.clone();
}
pub async fn reload_config(&self) -> anyhow::Result<Configuration> {
let mut config = self.config.lock().await;
let new_config = Self::read_config_from_disk(Self::get_config_file_path())
.map_err(|err| anyhow::anyhow!("can not reload config. {:?}", err))?;
*config = new_config.clone();
return anyhow::Ok(new_config);
}
}
#[cfg(test)]
mod tests {
use std::fs;
use serde_json::json;
use test_dir::{DirBuilder, TestDir};
use crate::picker::config::Configuration;
#[tokio::test]
async fn write_config_to_disk_should_be_successful() {
let temp = TestDir::temp().create("config_dir", test_dir::FileType::Dir);
let config_file_path = temp.path("config_dir").join("picker.config.json");
let manager = crate::picker::config::manger::Manager::default();
crate::picker::config::manger::Manager::write_config_to_disk(
config_file_path.clone(),
&Configuration::default(),
)
.unwrap();
let contents = fs::read_to_string(config_file_path.clone()).unwrap();
let _config: Configuration = serde_json::from_str(contents.as_str()).unwrap();
}
#[test]
fn read_config_to_disk_should_be_successful() {
let temp = TestDir::temp().create("config_dir", test_dir::FileType::Dir);
let config_file_path = temp.path("config_dir").join("picker.config.json");
fs::write(
config_file_path.clone(),
json!({
"config_version": 1,
"display_configs": []
})
.to_string()
.as_bytes(),
)
.unwrap();
let _manager =
crate::picker::config::manger::Manager::read_config_from_disk(config_file_path.clone())
.unwrap();
}
}

View File

@ -0,0 +1,5 @@
mod display_config;
mod manger;
pub use display_config::*;
pub use manger::*;

View File

@ -1,7 +1,7 @@
use color_space::{Hsv, Rgb};
use serde::Serialize;
#[derive(Clone, Copy)]
#[derive(Clone, Copy, Debug)]
pub struct LedColor {
bits: [u8; 3],
}

View File

@ -1,18 +1,18 @@
use futures::{stream::FuturesUnordered, StreamExt};
use once_cell::sync::OnceCell;
use paris::info;
use scrap::Display;
use std::sync::Arc;
use tokio::{sync::Mutex, task};
use crate::picker::{
config::{LedFlowX, LedFlowY, LedStripConfig},
screen::Screen,
use tokio::{
sync::{Mutex, OnceCell},
task,
};
use crate::picker::{config, screen::Screen};
use super::{
config::DisplayConfig, display_picker::DisplayPicker, led_color::LedColor,
screenshot::Screenshot,
config::DisplayConfig,
display_picker::DisplayPicker,
screenshot::{Screenshot, ScreenshotDto},
};
pub struct Picker {
@ -22,90 +22,43 @@ pub struct Picker {
}
impl Picker {
pub fn global() -> &'static Picker {
static SCREEN_COLOR_PICKER: OnceCell<Picker> = OnceCell::new();
pub async fn global() -> &'static Picker {
static SCREEN_COLOR_PICKER: OnceCell<Picker> = OnceCell::const_new();
SCREEN_COLOR_PICKER.get_or_init(|| Picker {
screens: Arc::new(Mutex::new(vec![])),
screenshots: Arc::new(Mutex::new(vec![])),
display_configs: Arc::new(Mutex::new(vec![
DisplayConfig {
index_of_display: 1,
display_width: 1920,
display_height: 1200,
top_led_strip: LedStripConfig {
index: 1,
global_start_position: 59,
global_end_position: 32,
},
bottom_led_strip: LedStripConfig {
index: 0,
global_start_position: 0,
global_end_position: 0,
},
left_led_strip: LedStripConfig {
index: 0,
global_start_position: 0,
global_end_position: 0,
},
right_led_strip: LedStripConfig {
index: 0,
global_start_position: 0,
global_end_position: 0,
},
},
DisplayConfig {
index_of_display: 0,
display_width: 3008,
display_height: 1692,
top_led_strip: LedStripConfig {
index: 0,
global_start_position: 31,
global_end_position: 0,
},
bottom_led_strip: LedStripConfig {
index: 0,
global_start_position: 0,
global_end_position: 0,
},
left_led_strip: LedStripConfig {
index: 0,
global_start_position: 0,
global_end_position: 0,
},
right_led_strip: LedStripConfig {
index: 0,
global_start_position: 0,
global_end_position: 0,
},
},
])),
})
SCREEN_COLOR_PICKER
.get_or_init(|| async {
let configs = config::Manager::global().get_config().await.display_configs;
info!("Global Picker use configs. {:?}", configs);
Picker {
screens: Arc::new(Mutex::new(vec![])),
screenshots: Arc::new(Mutex::new(vec![])),
display_configs: Arc::new(Mutex::new(
configs,
)),
}
})
.await
}
pub async fn list_displays(&self) -> anyhow::Result<Vec<String>> {
let mut configs = self.display_configs.lock().await;
let screenshots = self.screenshots.lock().await;
pub async fn list_displays(&self) -> anyhow::Result<Vec<ScreenshotDto>> {
let mut configs = vec![];
let displays = Display::all()
.map_err(|error| anyhow::anyhow!("Can not get all of displays. {}", error))?;
configs.clear();
// configs.clear();
let mut futs = FuturesUnordered::new();
for (index, display) in displays.iter().enumerate() {
let height = display.height();
let width = display.width();
let config = DisplayConfig::default(index, width, height);
let config = DisplayConfig::default(index, index, width, height);
configs.push(config);
}
for (index, display) in displays.iter().enumerate() {
let height = display.height();
let width = display.width();
let config = configs[index];
for config in configs.iter() {
futs.push(async move {
let join = task::spawn(Self::preview_display_by_config(config));
let join = task::spawn(Self::preview_display_by_config(config.clone()));
join.await?
});
}
@ -123,63 +76,12 @@ impl Picker {
Ok(bitmap_string_list)
}
pub async fn preview_display_by_config(config: DisplayConfig) -> anyhow::Result<String> {
pub async fn preview_display_by_config(config: DisplayConfig) -> anyhow::Result<ScreenshotDto> {
let start = time::Instant::now();
let mut picker = DisplayPicker::from_config(config)?;
let screenshot = picker.take_screenshot()?;
info!("Take Screenshot Spend: {}", start.elapsed());
anyhow::Ok(screenshot.to_webp_base64().await)
}
pub async fn refresh_displays(&self) -> anyhow::Result<()> {
// let displays = Display::all()
// .map_err(|error| anyhow::anyhow!("Can not get all of displays. {}", error))?;
// let mut screens = self.screens.lock().await;
// let mut screenshots = self.screenshots.lock().await;
// screens.clear();
// info!("number of displays: {}", displays.len());
// for display in displays {
// let height = display.height();
// let width = display.width();
// match Capturer::new(display) {
// Ok(capturer) => screens.push(Screen::new(capturer, width, height)),
// Err(error) => screens.push(Screen::new_failed(
// anyhow::anyhow!("{}", error),
// width,
// height,
// )),
// };
// screenshots.push(Screenshot::new(width, height));
// }
// screens.reverse();
// screenshots.reverse();
Ok(())
}
pub async fn take_screenshots_for_all(&self) -> anyhow::Result<Vec<Screenshot>> {
let mut screens = self.screens.lock().await;
let screenshots = self.screenshots.lock().await;
for (index, screen) in screens.iter_mut().enumerate() {
let bitmap = screen.take().map_err(|error| {
anyhow::anyhow!("take screenshot for display failed. {}", error)
})?;
}
Ok(screenshots.to_vec())
}
pub async fn get_led_strip_colors(&self) -> anyhow::Result<Vec<LedColor>> {
let screenshots = self.screenshots.lock().await;
let mut colors = Vec::new();
for screenshot in screenshots.iter() {
let result = screenshot
.get_top_colors()
.await
.map_err(|error| anyhow::anyhow!("get top colors failed. {}", error))?;
colors.extend_from_slice(&result);
}
Ok(colors)
anyhow::Ok(screenshot.to_dto().await)
}
}

View File

@ -0,0 +1,83 @@
use futures::{stream::FuturesUnordered, StreamExt};
use once_cell::sync::OnceCell;
use paris::{info, warn};
use scrap::Display;
use std::{borrow::Borrow, sync::Arc};
use tokio::{sync::Mutex, task};
use crate::picker::{config, screen::Screen};
use super::{
config::DisplayConfig,
display_picker::DisplayPicker,
manager::Picker,
screenshot::{Screenshot, ScreenshotDto},
};
pub struct PreviewPicker {
pub pickers: Arc<Mutex<Vec<Arc<Mutex<DisplayPicker>>>>>,
pub screenshots: Arc<Mutex<Vec<Screenshot>>>,
}
impl PreviewPicker {
pub fn global() -> &'static PreviewPicker {
static SCREEN_COLOR_PREVIEW_PICKER: OnceCell<PreviewPicker> = OnceCell::new();
SCREEN_COLOR_PREVIEW_PICKER.get_or_init(|| PreviewPicker {
pickers: Arc::new(Mutex::new(vec![])),
screenshots: Arc::new(Mutex::new(vec![])),
})
}
pub async fn list_displays(&self) {
let mut pickers = self.pickers.lock().await;
let displays = Display::all()
.map_err(|error| anyhow::anyhow!("Can not get all of displays. {}", error))?;
let mut configs = vec![];
let mut futs = FuturesUnordered::new();
for (index, display) in displays.iter().enumerate() {
let height = display.height();
let width = display.width();
let config = DisplayConfig::default(index, width, height);
configs.push(config);
}
for config in configs.iter() {
let picker = DisplayPicker::from_config(*config);
match picker {
Ok(picker) => {
pickers.push(Arc::new(Mutex::new(picker)));
}
Err(_) => {
warn!(
"can not create DisplayPicker from config. config: {:?}",
config
);
}
}
}
}
pub async fn get_screenshot_by_config(
&self,
config: DisplayConfig,
) -> anyhow::Result<ScreenshotDto> {
let start = time::Instant::now();
let mut picker = DisplayPicker::from_config(config)?;
let screenshot = picker.take_screenshot()?;
info!("Take Screenshot Spend: {}", start.elapsed());
anyhow::Ok(screenshot.to_dto().await)
}
pub async fn preview_display_by_config(config: DisplayConfig) -> anyhow::Result<ScreenshotDto> {
let start = time::Instant::now();
let mut picker = DisplayPicker::from_config(config)?;
let screenshot = picker.take_screenshot()?;
info!("Take Screenshot Spend: {}", start.elapsed());
anyhow::Ok(screenshot.to_dto().await)
}
}

View File

@ -1,127 +1,286 @@
use std::ops::Range;
use image::ImageBuffer;
use image::{ImageOutputFormat, Rgb};
use paris::{error, info};
use serde::{Deserialize, Serialize};
use std::iter;
use std::time::SystemTime;
use color_space::{Hsv, Rgb};
use either::Either;
use super::{config::DisplayConfig, led_color::LedColor};
use super::{
config::{DisplayConfig, LedStripConfig},
led_color::LedColor,
};
type Point = (usize, usize);
type LedSamplePoints = Vec<Point>;
#[derive(Clone)]
#[derive(Clone, Serialize, Deserialize, Debug)]
struct ScreenSamplePoints {
pub top: Vec<LedSamplePoints>,
pub bottom: Vec<LedSamplePoints>,
pub left: Vec<LedSamplePoints>,
pub right: Vec<LedSamplePoints>,
}
#[derive(Clone, Serialize, Deserialize, Debug)]
pub struct Screenshot {
bitmap: Vec<u8>,
config: DisplayConfig,
sample_points: ScreenSamplePoints,
}
impl Screenshot {
pub fn new(bitmap: Vec<u8>, config: DisplayConfig) -> Self {
Self { bitmap, config }
}
pub async fn get_top_colors(&self) -> anyhow::Result<Vec<LedColor>> {
self.get_x_colors(XPosition::Top, self.config.top_led_strip)
.await
}
pub async fn get_bottom_colors(&self) -> anyhow::Result<Vec<LedColor>> {
self.get_x_colors(XPosition::Bottom, self.config.bottom_led_strip)
.await
}
pub fn get_top_of_led_start_at(&self) -> usize {
self.config.top_led_strip.global_start_position
}
pub fn get_top_of_led_end_at(&self) -> usize {
self.config.top_led_strip.global_end_position
}
async fn get_x_colors(
&self,
position: XPosition,
strip_config: LedStripConfig,
) -> anyhow::Result<Vec<LedColor>> {
let bitmap = &self.bitmap;
let number_of_leds = strip_config
.global_start_position
.abs_diff(strip_config.global_end_position)
+ 1;
if number_of_leds == 0 {
return Ok(vec![]);
Self {
bitmap,
config,
sample_points: Self::get_sample_points(config),
}
let cell_size_x = self.config.display_width / number_of_leds;
let cell_size_y = self.config.display_height / 8;
let cell_size = cell_size_x * cell_size_y;
let y_range = match position {
XPosition::Top => 20..cell_size_y + 20,
XPosition::Bottom => {
self.config.display_height - 20 - cell_size_y..self.config.display_height - 20
}
fn get_sample_points(config: DisplayConfig) -> ScreenSamplePoints {
let top = match config.led_strip_of_borders.top {
Some(led_strip_config) => Self::get_one_edge_sample_points(
config.display_height / 8,
config.display_width,
led_strip_config
.global_start_position
.abs_diff(led_strip_config.global_end_position) + 1,
5,
),
None => {
vec![]
}
}
.step_by(5);
let x_range = if strip_config.global_start_position < strip_config.global_end_position {
Either::Left(strip_config.global_start_position..=strip_config.global_end_position)
} else {
Either::Right(
(strip_config.global_end_position..=strip_config.global_start_position).rev(),
)
};
let mut colors = Vec::new();
let stride = bitmap.len() / self.config.display_height;
let bottom: Vec<LedSamplePoints> = match config.led_strip_of_borders.bottom {
Some(led_strip_config) => {
let points = Self::get_one_edge_sample_points(
config.display_height / 9,
config.display_width,
led_strip_config
.global_start_position
.abs_diff(led_strip_config.global_end_position) + 1,
5,
);
points
.into_iter()
.map(|groups| -> Vec<Point> {
groups
.into_iter()
.map(|(x, y)| (x, config.display_height - y))
.collect()
})
.collect()
}
None => {
vec![]
}
};
for pos in x_range {
let left: Vec<LedSamplePoints> = match config.led_strip_of_borders.left {
Some(led_strip_config) => {
let points = Self::get_one_edge_sample_points(
config.display_width / 16,
config.display_height,
led_strip_config
.global_start_position
.abs_diff(led_strip_config.global_end_position) + 1,
5,
);
points
.into_iter()
.map(|groups| -> Vec<Point> {
groups.into_iter().map(|(x, y)| (y, x)).collect()
})
.collect()
}
None => {
vec![]
}
};
let right: Vec<LedSamplePoints> = match config.led_strip_of_borders.right {
Some(led_strip_config) => {
let points = Self::get_one_edge_sample_points(
config.display_width / 16,
config.display_height,
led_strip_config
.global_start_position
.abs_diff(led_strip_config.global_end_position) + 1,
5,
);
points
.into_iter()
.map(|groups| -> Vec<Point> {
groups
.into_iter()
.map(|(x, y)| (config.display_width - y, x))
.collect()
})
.collect()
}
None => {
vec![]
}
};
ScreenSamplePoints {
top,
bottom,
left,
right,
}
}
fn get_one_edge_sample_points(
width: usize,
length: usize,
leds: usize,
single_axis_points: usize,
) -> Vec<LedSamplePoints> {
let cell_size_x = length as f64 / single_axis_points as f64 / leds as f64;
let cell_size_y = width / single_axis_points;
let point_start_y = cell_size_y / 2;
let point_start_x = cell_size_x / 2.0;
let point_y_list: Vec<usize> = (point_start_y..width).step_by(cell_size_y).collect();
let point_x_list: Vec<usize> = iter::successors(Some(point_start_x), |i| {
let next = i + cell_size_x;
(next < (length as f64)).then_some(next)
})
.map(|i| i as usize)
.collect();
let points: Vec<Point> = point_x_list
.iter()
.map(|&x| point_y_list.iter().map(move |&y| (x, y)))
.flatten()
.collect();
points
.chunks(single_axis_points * single_axis_points)
.into_iter()
.map(|points| Vec::from(points))
.collect()
}
pub fn get_colors(&self) -> DisplayColorsOfLedStrips {
let top = self
.get_one_edge_colors(&self.sample_points.top)
.into_iter()
.flat_map(|color| color.get_rgb())
.collect();
let bottom = self
.get_one_edge_colors(&self.sample_points.bottom)
.into_iter()
.flat_map(|color| color.get_rgb())
.collect();
let left = self
.get_one_edge_colors(&self.sample_points.left)
.into_iter()
.flat_map(|color| color.get_rgb())
.collect();
let right = self
.get_one_edge_colors(&self.sample_points.right)
.into_iter()
.flat_map(|color| color.get_rgb())
.collect();
DisplayColorsOfLedStrips {
top,
bottom,
left,
right,
}
}
pub fn get_one_edge_colors(
&self,
sample_points_of_leds: &Vec<LedSamplePoints>,
) -> Vec<LedColor> {
let mut colors = vec![];
for led_points in sample_points_of_leds {
let mut r = 0.0;
let mut g = 0.0;
let mut b = 0.0;
let mut count = 0;
for x in (pos * cell_size_x..(pos + 1) * cell_size_x).step_by(5) {
for y in y_range.to_owned() {
let i = stride * y + 4 * x;
r += bitmap[i + 2] as f64;
g += bitmap[i + 1] as f64;
b += bitmap[i] as f64;
count+=1;
}
let len = led_points.len() as f64;
for (x, y) in led_points {
let position = (x + y * self.config.display_width) * 4;
r += self.bitmap[position + 2] as f64;
g += self.bitmap[position + 1] as f64;
b += self.bitmap[position] as f64;
}
let rgb = Rgb::new(
r / count as f64,
g / count as f64,
b / count as f64,
);
let hsv = Hsv::from(rgb);
// info!("HSV: {:?}", [hsv.h, hsv.s, hsv.v]);
let color = LedColor::from_hsv(hsv.h, hsv.s, hsv.v);
let color = LedColor::new((r / len) as u8, (g / len) as u8, (b / len) as u8);
// paris::info!("color: {:?}", color.get_rgb());
colors.push(color);
}
return Ok(colors);
colors
}
pub async fn to_webp_base64(&self) -> String {
let bitmap = &self.bitmap;
let mut bitflipped =
Vec::with_capacity(self.config.display_width * self.config.display_height * 3);
let stride = bitmap.len() / self.config.display_height;
for y in 0..self.config.display_height {
for x in 0..self.config.display_width {
let i = stride * y + 4 * x;
bitflipped.extend_from_slice(&[bitmap[i + 2], bitmap[i + 1], bitmap[i]]);
let mut image_buffer = ImageBuffer::new(
self.config.display_width as u32 / 3,
self.config.display_height as u32 / 3,
);
for y in 0..self.config.display_height / 3 {
for x in 0..self.config.display_width / 3 {
let i = stride * y * 3 + 4 * x * 3;
image_buffer.put_pixel(
x as u32,
y as u32,
Rgb::<u8>([bitmap[i + 2], bitmap[i + 1], bitmap[i]]),
);
}
}
let webp_memory = webp::Encoder::from_rgb(
bitflipped.as_slice(),
self.config.display_width as u32,
self.config.display_height as u32,
)
.encode(100.0);
return base64::encode(&*webp_memory);
// let webp_memory =
// webp::Encoder::from_rgb(bitflipped.as_slice(), size_x, size_y).encode(50.0);
// return base64::encode(&*webp_memory);
let mut cursor = std::io::Cursor::new(vec![]);
match image_buffer.write_to(&mut cursor, ImageOutputFormat::Tiff) {
Ok(_) => {
return base64::encode(cursor.into_inner());
}
Err(err) => {
error!("can not encode image. {:?}", err);
return String::from("");
}
}
}
pub async fn to_dto(&self) -> ScreenshotDto {
let rk = SystemTime::now();
info!("[{:?} {:p}] to_dto", rk.elapsed(), &self);
let encode_image = self.to_webp_base64().await;
info!("[{:?} {:p}] image", rk.elapsed(), &self);
let config = self.config.clone();
info!("[{:?} {:p}] cloned", rk.elapsed(), &self);
let colors = self.get_colors();
info!("[{:?} {:p}] colors", rk.elapsed(), &self);
ScreenshotDto {
encode_image,
config,
colors,
}
}
pub fn get_config(&self) -> DisplayConfig {
self.config
}
}
enum XPosition {
Top,
Bottom,
#[derive(Clone, Serialize, Deserialize, Debug)]
pub struct ScreenshotDto {
pub config: DisplayConfig,
pub encode_image: String,
pub colors: DisplayColorsOfLedStrips,
}
#[derive(Clone, Serialize, Deserialize, Debug)]
pub struct DisplayColorsOfLedStrips {
pub top: Vec<u8>,
pub bottom: Vec<u8>,
pub left: Vec<u8>,
pub right: Vec<u8>,
}

View File

@ -38,4 +38,18 @@ impl Manager {
.await
.map_err(|error| anyhow::anyhow!("mqtt publish failed. {}", error))
}
pub async fn publish_led_sub_pixels(&self, payload: Vec<u8>) -> anyhow::Result<()> {
self.mqtt
.client
.publish(
"display-ambient-light/desktop/colors",
rumqttc::QoS::AtLeastOnce,
false,
payload,
)
.await
.map_err(|error| anyhow::anyhow!("mqtt publish failed. {}", error))
}
}

View File

@ -28,7 +28,7 @@
"icons/icon.icns",
"icons/icon.ico"
],
"identifier": "com.tauri.dev",
"identifier": "cc.ivanli.ambient",
"longDescription": "",
"macOS": {
"entitlements": null,

View File

@ -1,11 +1,14 @@
import { useCallback, useState } from 'react';
import reactLogo from './assets/react.svg';
import { useCallback, useEffect, useState } from 'react';
import tw from 'twin.macro';
import { invoke } from '@tauri-apps/api/tauri';
import './App.css';
import clsx from 'clsx';
import { Configurator } from './configurator/configurator';
import { ButtonSwitch } from './commons/components/button';
type Mode = 'Flowing' | 'Follow' | null;
localStorage.setItem('debug', '*');
function App() {
const [screenshots, setScreenshots] = useState<string[]>([]);
const [ledStripColors, setLedStripColors] = useState<string[]>([]);
@ -17,10 +20,6 @@ function App() {
setScreenshots(base64TextList.map((text) => `data:image/webp;base64,${text}`));
}
const refreshDisplays = useCallback(async () => {
await invoke('refresh_displays');
}, []);
const getLedStripColors = useCallback(async () => {
setLedStripColors(await invoke('get_led_strip_colors'));
}, []);
@ -42,66 +41,31 @@ function App() {
return (
<div>
<div className="flex justify-between">
<div tw="flex justify-between">
{ledStripColors.map((it) => (
<span className=" h-8 flex-auto" style={{ backgroundColor: it }}></span>
<span tw="h-8 flex-auto" style={{ backgroundColor: it }}></span>
))}
</div>
<div className="flex gap-1 justify-center w-screen overflow-hidden">
<div tw="flex gap-1 justify-center w-screen overflow-hidden">
{screenshots.map((screenshot) => (
<div className="flex-auto">
<div tw="flex-auto">
<img src={screenshot} />
</div>
))}
</div>
<div className="flex gap-5 justify-center ">
<button
className="bg-black bg-opacity-20"
type="button"
onClick={() => refreshDisplays()}
>
Refresh Displays
</button>
<button
className="bg-black bg-opacity-20"
type="button"
onClick={() => takeSnapshot()}
>
Take Snapshot
</button>
<button
className="bg-black bg-opacity-20"
type="button"
onClick={() => getLedStripColors()}
>
Get Colors
</button>
<button
className={clsx('bg-black', 'bg-opacity-20', {
'bg-gradient-to-r from-purple-500 to-blue-500': currentMode === 'Flowing',
})}
type="button"
onClick={() => switchCurrentMode('Flowing')}
>
<div tw="flex gap-5 justify-center ">
<ButtonSwitch onClick={() => takeSnapshot()}>Take Snapshot</ButtonSwitch>
<ButtonSwitch onClick={() => getLedStripColors()}>Get Colors</ButtonSwitch>
<ButtonSwitch onClick={() => switchCurrentMode('Flowing')}>
Flowing Light
</button>
<button
className={clsx('bg-black', 'bg-opacity-20', {
'bg-gradient-to-r from-purple-500 to-blue-500': currentMode === 'Follow',
})}
type="button"
onClick={() => switchCurrentMode('Follow')}
>
Follow
</button>
</ButtonSwitch>
<ButtonSwitch onClick={() => switchCurrentMode('Follow')}>Follow</ButtonSwitch>
</div>
<div className="flex gap-5 justify-center">
<img src="/vite.svg" className="logo vite" alt="Vite logo" />
<img src="/tauri.svg" className="logo tauri" alt="Tauri logo" />
<img src={reactLogo} className="logo react" alt="React logo" />
<div tw="flex gap-5 justify-center">
<Configurator />
</div>
</div>
);

View File

@ -0,0 +1,21 @@
import { FC } from 'react';
import styled from '@emotion/styled';
import tw, { theme } from 'twin.macro';
import { css } from '@emotion/react';
interface ButtonProps {
value?: boolean;
isSmall?: boolean;
}
export const ButtonSwitch = styled.button(({ value, isSmall }: ButtonProps) => [
// The common button styles
tw`px-8 py-2 rounded-xl transform duration-75 dark:bg-black m-2 shadow-lg text-opacity-95 dark:shadow-gray-800`,
tw`hover:(scale-105)`,
tw`focus:(scale-100)`,
value && 'bg-gradient-to-r from-purple-500 to-blue-500',
isSmall ? tw`text-sm` : tw`text-lg`,
]);

View File

@ -0,0 +1,133 @@
import debug from 'debug';
import { isNil, lensPath, set, splitEvery, update } from 'ramda';
import { FC, useEffect, useMemo, useState } from 'react';
import tw, { css, styled } from 'twin.macro';
import { Borders, borders } from '../../constants/border';
import { useLedCount } from '../contents/led-count';
import { DisplayConfig, LedStripConfigOfBorders } from '../models/display-config';
import { LedStripConfig } from '../models/led-strip-config';
import { PixelRgb } from '../models/pixel-rgb';
import { ScreenshotDto } from '../models/screenshot.dto';
import { DraggableStrip } from './draggable-strip';
import { StyledPixel } from './styled-pixel';
export const logger = debug('app:completed-led-strip');
interface CompletedLedStripProps {
screenshots: ScreenshotDto[];
onDisplayConfigChange?: (value: DisplayConfig) => void;
}
type BorderLedStrip = {
pixels: PixelRgb[];
config: LedStripConfig | null;
};
const StyledContainer = styled.section(
({ rows, columns }: { rows: number; columns: number }) => [
tw`grid m-4 pb-2 items-center justify-items-center select-none`,
css`
grid-template-columns: repeat(${columns}, 1fr);
grid-template-rows: auto repeat(${rows}, 1fr);
`,
],
);
const StyledCompletedContainer = styled.section(
tw`dark:bg-transparent shadow-xl border-gray-500 border rounded-full flex flex-wrap justify-around items-center mb-2`,
css`
grid-column: 1 / -1;
justify-self: stretch;
`,
);
export const CompletedLedStrip: FC<CompletedLedStripProps> = ({
screenshots,
onDisplayConfigChange,
}) => {
const borderLedStrips: BorderLedStrip[] = useMemo(() => {
return screenshots.flatMap((ss) =>
borders.map((b) => ({
pixels: splitEvery(3, Array.from(ss.colors[b])) as PixelRgb[],
config: ss.config.led_strip_of_borders[b],
})),
);
}, [screenshots]);
const ledCount = useMemo(
() => borderLedStrips.reduce((prev, curr) => prev + curr.pixels.length, 0),
[borderLedStrips],
);
const { setLedCount } = useLedCount();
// setLedCount for context
useEffect(() => {
setLedCount(ledCount);
}, [ledCount, setLedCount]);
const [overrideBorderLedStrips, setOverrideBorderLedStrips] =
useState<BorderLedStrip[]>();
const completedPixels = useMemo(() => {
const completed: PixelRgb[] = new Array(ledCount).fill([0, 0, 0]);
(overrideBorderLedStrips ?? borderLedStrips).forEach(({ pixels, config }) => {
if (isNil(config)) {
return;
}
if (config.global_start_position <= config.global_end_position) {
pixels.forEach((color, i) => {
completed[config.global_start_position + i] = color;
});
} else {
pixels.forEach((color, i) => {
completed[config.global_start_position - i] = color;
});
}
});
return completed.map((color, i) => <StyledPixel rgb={color} key={i} />);
}, [ledCount, borderLedStrips, overrideBorderLedStrips]);
const strips = useMemo(() => {
return borderLedStrips.map(({ config, pixels }, index) =>
config ? (
<DraggableStrip
key={index}
{...{ config, pixels, index: index + 1 }}
onConfigChange={(c) => {
setOverrideBorderLedStrips(
update(index, { config: c, pixels }, borderLedStrips),
);
}}
onConfigFinish={(c) => {
const indexOfDisplay = Math.floor(index / borders.length);
const xLens = lensPath<LedStripConfigOfBorders, Borders>([
borders[index % borders.length],
]);
const displayConfig: DisplayConfig = {
...screenshots[indexOfDisplay].config,
led_strip_of_borders: set(
xLens,
c,
screenshots[indexOfDisplay].config.led_strip_of_borders,
),
};
onDisplayConfigChange?.(displayConfig);
}}
/>
) : (
<div key={index} />
),
);
}, [borderLedStrips, screenshots]);
useEffect(() => {
setOverrideBorderLedStrips(undefined);
}, [borderLedStrips]);
return (
<StyledContainer rows={screenshots.length * borders.length} columns={ledCount}>
<StyledCompletedContainer>{completedPixels}</StyledCompletedContainer>
{strips}
</StyledContainer>
);
};

View File

@ -0,0 +1,100 @@
import { HTMLAttributes, useCallback, useMemo } from 'react';
import { FC } from 'react';
import { DisplayConfig, LedStripConfigOfBorders } from '../models/display-config';
import { LedStrip } from './led-strip';
import tw, { css, styled, theme } from 'twin.macro';
import { ScreenshotDto } from '../models/screenshot.dto';
import { LedStripEditor } from './led-strip-editor';
import { LedStripConfig } from '../models/led-strip-config';
import debug from 'debug';
import { lensPath, lensProp, set, view } from 'ramda';
const logger = debug('app:display-with-led-strips');
export interface DisplayWithLedStripsProps
extends Omit<HTMLAttributes<HTMLElement>, 'onChange'> {
config: DisplayConfig;
screenshot: ScreenshotDto;
onChange?: (config: DisplayConfig) => void;
}
const StyledContainer = styled.section(
tw`m-4 grid gap-1`,
css`
grid-template-columns: ${theme`width.5`} ${theme`width.3`} auto ${theme`width.3`} ${theme`width.5`};
`,
css`
grid-template-rows: ${theme`width.5`} ${theme`width.3`} auto ${theme`width.3`} ${theme`width.5`};
`,
);
export const DisplayWithLedStrips: FC<DisplayWithLedStripsProps> = ({
config,
screenshot,
onChange,
...htmlAttrs
}) => {
const screenshotUrl = useMemo(
() => `data:image/ico;base64,${screenshot.encode_image}`,
[screenshot.encode_image],
);
const onLedStripConfigChange = useCallback(
(position: keyof LedStripConfigOfBorders, value: LedStripConfig | null) => {
const xLens = lensPath<
DisplayConfig,
'led_strip_of_borders',
keyof LedStripConfigOfBorders
>(['led_strip_of_borders', position]);
const c = set(xLens, value, config);
logger('on change. prev: %o, curr: %o', view(xLens, config), value);
onChange?.(c);
},
[config],
);
return (
<StyledContainer {...htmlAttrs}>
<img src={screenshotUrl} tw="row-start-3 col-start-3 w-full" />
<LedStrip
config={config.led_strip_of_borders.top}
colors={screenshot.colors.top}
tw="row-start-2 col-start-3"
/>
<LedStrip
config={config.led_strip_of_borders.left}
colors={screenshot.colors.left}
tw="row-start-3 col-start-2"
/>
<LedStrip
config={config.led_strip_of_borders.right}
colors={screenshot.colors.right}
tw="row-start-3 col-start-4"
/>
<LedStrip
config={config.led_strip_of_borders.bottom}
colors={screenshot.colors.bottom}
tw="row-start-4 col-start-3"
/>
<LedStripEditor
config={config.led_strip_of_borders.top}
tw="row-start-1 col-start-3"
onChange={(value) => onLedStripConfigChange('top', value)}
/>
<LedStripEditor
config={config.led_strip_of_borders.left}
tw="row-start-3 col-start-1"
onChange={(value) => onLedStripConfigChange('left', value)}
/>
<LedStripEditor
config={config.led_strip_of_borders.right}
tw="row-start-3 col-start-5"
onChange={(value) => onLedStripConfigChange('right', value)}
/>
<LedStripEditor
config={config.led_strip_of_borders.bottom}
tw="row-start-5 col-start-3"
onChange={(value) => onLedStripConfigChange('bottom', value)}
/>
</StyledContainer>
);
};

View File

@ -0,0 +1,199 @@
import {
createRef,
FC,
Fragment,
MouseEventHandler,
ReactNode,
RefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { css } from 'twin.macro';
import { useLedCount } from '../contents/led-count';
import { LedStripConfig } from '../models/led-strip-config';
import { PixelRgb } from '../models/pixel-rgb';
import { StyledPixel } from './styled-pixel';
import { logger } from './completed-led-strip';
interface DraggableStripProp {
config: LedStripConfig;
pixels: PixelRgb[];
index: number;
onConfigChange?: (config: LedStripConfig) => void;
onConfigFinish?: (config: LedStripConfig) => void;
}
export const DraggableStrip: FC<DraggableStripProp> = ({
config,
pixels,
index,
onConfigChange,
onConfigFinish,
}) => {
const { ledCount } = useLedCount();
const startXRef = useRef(0);
const currentXRef = useRef(0);
const configRef = useRef<LedStripConfig>();
const [availableConfig, setAvailableConfig] = useState<LedStripConfig>(config);
// const currentDiffRef = useRef(0);
const isDragRef = useRef(false);
const handleMouseMoveRef = useRef<(ev: MouseEvent) => void>();
const [boxTranslateX, setBoxTranslateX] = useState(0);
const ledItems = useMemo(() => {
const step = config.global_start_position - config.global_end_position < 0 ? 1 : -1;
return pixels.map((rgb, i) => (
<StyledPixel
key={i}
rgb={rgb}
css={css`
grid-column: ${(availableConfig.global_start_position ?? 0) + i * step + 1} /
span 1;
grid-row-start: ${index + 1};
pointer-events: none;
`}
/>
));
}, [pixels, availableConfig]);
const [placeholders, placeholderRefs]: [ReactNode[], RefObject<HTMLSpanElement>[]] =
useMemo(
() =>
new Array(ledCount)
.fill(undefined)
.map((_, i) => {
const ref = createRef<HTMLSpanElement>();
const n = (
<span
ref={ref}
key={i}
tw=" h-full w-full"
css={css`
grid-column-start: ${i + 1};
grid-row-start: ${index + 1};
`}
/>
);
return [n, ref] as [ReactNode, RefObject<HTMLSpanElement>];
})
.reduce(
([nList, refList], [n, ref]) => [
[...nList, n],
[...refList, ref],
],
[[], []] as [ReactNode[], RefObject<HTMLSpanElement>[]],
),
[ledCount],
);
// start and moving
const handleMouseDown: MouseEventHandler<HTMLDivElement> = useCallback(
(ev) => {
startXRef.current = ev.pageX;
ev.currentTarget.requestPointerLock();
isDragRef.current = true;
logger('handleMouseDown, config: %o', config);
const placeholderPositions = placeholderRefs.map((it) => {
if (!it.current) {
return [0, 0];
}
const viewportOffset = it.current.getBoundingClientRect();
return [viewportOffset.left, viewportOffset.right] as [number, number];
});
logger('placeholderPositions: %o', placeholderPositions);
// set init position
const initPos = placeholderPositions.findIndex(
([l, r]) => l <= ev.pageX && r >= ev.pageX,
);
let prevMatch = 0;
if (handleMouseMoveRef.current) {
document.body.removeEventListener('mousemove', handleMouseMoveRef.current);
}
handleMouseMoveRef.current = (ev) => {
if (!isDragRef.current) {
return;
}
currentXRef.current = ev.pageX;
setBoxTranslateX(currentXRef.current - startXRef.current);
const match = placeholderPositions.findIndex(
([l, r]) => l <= currentXRef.current && r >= currentXRef.current,
);
if (match === -1) {
return;
}
if (match === prevMatch) {
return;
}
prevMatch = match;
const diff = match - initPos;
const newValue: LedStripConfig = {
...config,
global_start_position: config.global_start_position + diff,
global_end_position: config.global_end_position + diff,
};
configRef.current = newValue;
setAvailableConfig(newValue);
logger('change config. old: %o, new: %o', config, newValue);
onConfigChange?.(newValue);
};
document.body.addEventListener('mousemove', handleMouseMoveRef.current);
},
[placeholderRefs, availableConfig, setAvailableConfig, config],
);
// move event.
useEffect(() => {
const handleMouseUp = (ev: MouseEvent) => {
if (configRef.current && isDragRef.current) {
onConfigFinish?.(configRef.current);
}
startXRef.current = 0;
isDragRef.current = false;
document.exitPointerLock();
if (handleMouseMoveRef.current) {
document.body.removeEventListener('mousemove', handleMouseMoveRef.current);
}
};
document.body.addEventListener('mouseup', handleMouseUp);
return () => {
document.body.removeEventListener('mouseup', handleMouseUp);
};
}, [onConfigFinish]);
// reset translateX when config updated.
useEffect(() => {
startXRef.current = currentXRef.current;
setAvailableConfig(config);
setBoxTranslateX(0);
logger('useEffect, config: %o', config);
}, [config]);
return (
<Fragment>
{placeholders}
{ledItems}
<div
tw="border border-gray-700 h-3 w-full rounded-full"
css={css`
grid-column: ${Math.min(
config?.global_start_position ?? 0,
config?.global_end_position ?? 0,
) + 1} / span
${Math.abs(config?.global_start_position - config?.global_end_position) + 1};
grid-row-start: ${index + 1};
cursor: ew-resize;
transform: translateX(${boxTranslateX}px);
`}
onMouseDown={handleMouseDown}
></div>
</Fragment>
);
};

View File

@ -0,0 +1,86 @@
import { HTMLAttributes, useCallback } from 'react';
import { FC } from 'react';
import { LedStripConfig } from '../models/led-strip-config';
import tw, { styled } from 'twin.macro';
import { faLeftRight, faMinus, faPlus } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
export interface LedStripEditorProps
extends Omit<HTMLAttributes<HTMLElement>, 'onChange'> {
config: LedStripConfig | null;
onChange?: (config: LedStripConfig | null) => void;
}
const StyledContainer = styled.section(
tw`flex flex-wrap gap-2 self-start justify-self-start`,
);
const StyledButton = styled.button(
tw`
bg-yellow-500 dark:bg-amber-600 rounded-full h-4 w-4 text-xs shadow select-none`,
tw`hocus:scale-105 hocus:active:scale-95 active:bg-amber-600 active:dark:bg-amber-500`,
);
export const LedStripEditor: FC<LedStripEditorProps> = ({
config,
onChange,
...htmlAttrs
}) => {
const addLed = useCallback(() => {
if (config) {
if (config.global_start_position <= config.global_end_position) {
onChange?.({ ...config, global_end_position: config.global_end_position + 1 });
} else {
onChange?.({
...config,
global_start_position: config.global_start_position + 1,
});
}
} else {
onChange?.(new LedStripConfig(0, 0, 0));
}
}, [config, onChange]);
const removeLed = useCallback(() => {
if (!config) {
onChange?.(null);
} else if (Math.abs(config.global_start_position - config.global_end_position) <= 1) {
onChange?.(null);
} else {
if (config.global_start_position <= config.global_end_position) {
onChange?.({ ...config, global_end_position: config.global_end_position - 1 });
} else {
onChange?.({
...config,
global_start_position: config.global_start_position - 1,
});
}
}
}, [config, onChange]);
const reverse = useCallback(() => {
if (!config) {
return;
}
onChange?.({
...config,
global_start_position: config.global_end_position,
global_end_position: config.global_start_position,
});
}, [config, onChange]);
return (
<StyledContainer {...htmlAttrs}>
<StyledButton title="Add LED" onClick={addLed}>
<FontAwesomeIcon icon={faPlus} />
</StyledButton>
<StyledButton title="Remove LED" onClick={removeLed}>
<FontAwesomeIcon icon={faMinus} />
</StyledButton>
<StyledButton title="Reverse" onClick={reverse}>
<FontAwesomeIcon icon={faLeftRight} />
</StyledButton>
{`s: ${config?.global_start_position ?? 'x'}, e: ${
config?.global_end_position ?? 'x'
}`}
</StyledContainer>
);
};

View File

@ -0,0 +1,28 @@
import { HTMLAttributes, useMemo } from 'react';
import { FC } from 'react';
import { LedStripConfig } from '../models/led-strip-config';
import tw, { css, styled } from 'twin.macro';
import { splitEvery } from 'ramda';
import { StyledPixel } from './styled-pixel';
export interface LedStripProps extends HTMLAttributes<HTMLElement> {
config: LedStripConfig | null;
colors: Uint8Array;
}
const StyledContainer = styled.section(
tw`dark:bg-transparent shadow-xl border-gray-500 border rounded-full flex flex-wrap justify-around items-center -mx-px -mt-px`,
css``,
);
export const LedStrip: FC<LedStripProps> = ({ config, colors, ...htmlAttrs }) => {
const pixels = useMemo(() => {
const pixels = splitEvery(3, Array.from(colors)) as Array<[number, number, number]>;
return pixels.map((rgb, index) => <StyledPixel key={index} rgb={rgb}></StyledPixel>);
}, [colors]);
return (
<StyledContainer {...htmlAttrs} css={[!config && tw`bg-gray-200`]}>
{pixels}
</StyledContainer>
);
};

View File

@ -0,0 +1,10 @@
import tw, { css, styled } from 'twin.macro';
export const StyledPixel = styled.span(
({ rgb: [r, g, b] }: { rgb: [number, number, number] }) => [
tw`rounded-full h-3 w-3 bg-current block border border-gray-700`,
css`
color: rgb(${r}, ${g}, ${b});
`,
],
);

View File

@ -0,0 +1,123 @@
import { invoke } from '@tauri-apps/api';
import { FC, useEffect, useMemo, useState } from 'react';
import tw, { styled } from 'twin.macro';
import { useAsync, useAsyncCallback } from 'react-async-hook';
import { DisplayWithLedStrips } from './components/display-with-led-strips';
import { PickerConfiguration } from './models/picker-configuration';
import { DisplayConfig } from './models/display-config';
import { ScreenshotDto } from './models/screenshot.dto';
import { Alert, Snackbar } from '@mui/material';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSpinner } from '@fortawesome/free-solid-svg-icons';
import { update } from 'ramda';
import { CompletedLedStrip } from './components/completed-led-strip';
import { LedCountProvider } from './contents/led-count';
import debug from 'debug';
const logger = debug('app:configurator');
const getPickerConfig = () => invoke<PickerConfiguration>('get_picker_config');
const getScreenshotOfDisplays = () =>
invoke<ScreenshotDto[]>('take_snapshot').then((items) => {
return items;
});
const getScreenshotByConfig = async (config: DisplayConfig) => {
return await invoke<ScreenshotDto>('get_screenshot_by_config', {
config,
});
};
const writePickerConfig = async (config: PickerConfiguration) => {
return await invoke<void>('write_picker_config', {
config,
});
};
const StyledConfiguratorContainer = styled.section(tw`flex flex-col items-stretch`);
const StyledDisplayContainer = styled.section(tw`overflow-auto`);
const ConfiguratorInner: FC = () => {
const { loading: pendingPickerConfig, result: savedPickerConfig } = useAsync(
getPickerConfig,
[],
);
const { loading: pendingScreenshotOfDisplays, result: defaultScreenshotOfDisplays } =
useAsync(getScreenshotOfDisplays, []);
const [screenshotOfDisplays, setScreenshotOfDisplays] = useState<ScreenshotDto[]>([]);
const { loading: pendingGetLedColorsByConfig, execute: onDisplayConfigChange } =
useAsyncCallback(async (value: DisplayConfig) => {
const screenshot = await getScreenshotByConfig(value);
setScreenshotOfDisplays((old) => {
const index = old.findIndex((it) => it.config.id === screenshot.config.id);
const newValue = update(index, screenshot, old);
savedPickerConfig &&
writePickerConfig({
...savedPickerConfig,
display_configs: newValue.map((it) => it.config),
});
return newValue;
});
});
const [displayConfigs, setDisplayConfigs] = useState<DisplayConfig[]>([]);
useEffect(() => {
const displayConfigs = savedPickerConfig?.display_configs;
if (displayConfigs && defaultScreenshotOfDisplays) {
setDisplayConfigs(displayConfigs);
setScreenshotOfDisplays(defaultScreenshotOfDisplays);
(async () => {
for (const config of displayConfigs) {
await onDisplayConfigChange(config);
}
})().then();
}
}, [savedPickerConfig, onDisplayConfigChange, defaultScreenshotOfDisplays]);
useEffect(() => {}, [defaultScreenshotOfDisplays]);
const displays = useMemo(() => {
if (screenshotOfDisplays) {
return screenshotOfDisplays.map((screenshot, index) => (
<DisplayWithLedStrips
key={index}
config={screenshot.config}
screenshot={screenshot}
onChange={(value) => onDisplayConfigChange(value)}
/>
));
}
}, [displayConfigs, screenshotOfDisplays]);
if (pendingPickerConfig || pendingScreenshotOfDisplays) {
return (
<section>
{JSON.stringify({ pendingPickerConfig, pendingScreenshotOfDisplays })}
</section>
);
}
return (
<StyledConfiguratorContainer>
<CompletedLedStrip
screenshots={screenshotOfDisplays}
onDisplayConfigChange={onDisplayConfigChange}
/>
<StyledDisplayContainer>{displays}</StyledDisplayContainer>;
<Snackbar open={pendingGetLedColorsByConfig} autoHideDuration={3000}>
<Alert icon={<FontAwesomeIcon icon={faSpinner} />} sx={{ width: '100%' }}>
This is a success message!
</Alert>
</Snackbar>
</StyledConfiguratorContainer>
);
};
export const Configurator = () => {
return (
<LedCountProvider>
<ConfiguratorInner />
</LedCountProvider>
);
};

View File

@ -0,0 +1,25 @@
import {
createContext,
Dispatch,
FC,
ReactNode,
SetStateAction,
useContext,
useState,
} from 'react';
interface LedCountContext {
ledCount: number;
setLedCount: Dispatch<SetStateAction<number>>;
}
const Context = createContext<LedCountContext>(undefined as any);
export const LedCountProvider: FC<{ children: ReactNode }> = ({ children }) => {
const [ledCount, setLedCount] = useState(0);
return (
<Context.Provider value={{ ledCount, setLedCount }}>{children}</Context.Provider>
);
};
export const useLedCount = () => useContext(Context);

View File

@ -0,0 +1,21 @@
import { Borders } from '../../constants/border';
import { LedStripConfig } from "./led-strip-config";
export class LedStripConfigOfBorders implements Record<Borders, LedStripConfig | null> {
constructor(
public top: LedStripConfig | null = null,
public bottom: LedStripConfig | null = null,
public left: LedStripConfig | null = null,
public right: LedStripConfig | null = null,
) {}
}
export class DisplayConfig {
led_strip_of_borders = new LedStripConfigOfBorders();
constructor(
public id: number,
public index_of_display: number,
public display_width: number,
public display_height: number,
) {}
}

View File

@ -0,0 +1,7 @@
export class LedStripConfig {
constructor(
public index: number,
public global_start_position: number,
public global_end_position: number,
) {}
}

View File

@ -0,0 +1,8 @@
import { DisplayConfig } from './display-config';
export class PickerConfiguration {
constructor(
public display_configs: DisplayConfig[] = [],
public config_version: number = 1,
) {}
}

View File

@ -0,0 +1 @@
export type PixelRgb = [number, number, number];

View File

@ -0,0 +1,12 @@
import { DisplayConfig } from './display-config';
export class ScreenshotDto {
encode_image!: string;
config!: DisplayConfig;
colors!: {
top: Uint8Array;
bottom: Uint8Array;
left: Uint8Array;
right: Uint8Array;
};
}

2
src/constants/border.ts Normal file
View File

@ -0,0 +1,2 @@
export const borders = ['top', 'right', 'bottom', 'left'] as const;
export type Borders = typeof borders[number];

View File

@ -1,10 +1,11 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./style.css";
import GlobalStyles from './styles/global-styles';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<GlobalStyles />
<App />
</React.StrictMode>,
);

View File

@ -0,0 +1,20 @@
import React from 'react';
import { Global, css } from '@emotion/react';
import tw, { theme, GlobalStyles as BaseStyles } from 'twin.macro';
const customStyles = css({
body: {
WebkitTapHighlightColor: theme`colors.purple.500`,
...tw`antialiased`,
...tw`dark:bg-dark-800 bg-dark-100 dark:text-gray-100 text-gray-800`,
},
});
const GlobalStyles = () => (
<>
<BaseStyles />
<Global styles={customStyles} />
</>
);
export default GlobalStyles;

View File

@ -1,12 +1,15 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
dark: {
800: '#0f0f0f',
100: '#f6f6f6',
},
},
},
},
plugins: [],
}
};

View File

@ -14,8 +14,12 @@
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
"jsx": "react-jsx",
"jsxImportSource": "@emotion/react"
},
"include": ["src"],
"include": [
"src",
"types"
],
"references": [{ "path": "./tsconfig.node.json" }]
}

18
types/twin.d.ts vendored Normal file
View File

@ -0,0 +1,18 @@
import 'twin.macro';
import { css as cssImport } from '@emotion/react';
import styledImport from '@emotion/styled';
import { CSSInterpolation } from '@emotion/serialize';
declare module 'twin.macro' {
// The styled and css imports
const styled: typeof styledImport;
const css: typeof cssImport;
}
declare module 'react' {
// The tw and css prop
interface DOMAttributes<T> {
tw?: string;
css?: CSSInterpolation;
}
}

View File

@ -3,7 +3,29 @@ import react from "@vitejs/plugin-react";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
optimizeDeps: {
esbuildOptions: {
target: 'es2020',
},
},
plugins: [
react({
babel: {
plugins: [
'babel-plugin-macros',
[
'@emotion/babel-plugin-jsx-pragmatic',
{
export: 'jsx',
import: '__cssprop',
module: '@emotion/react',
},
],
['@babel/plugin-transform-react-jsx', { pragma: '__cssprop' }, 'twin.macro'],
],
},
}),
],
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
// prevent vite from obscuring rust errors
@ -15,12 +37,12 @@ export default defineConfig({
},
// to make use of `TAURI_DEBUG` and other env variables
// https://tauri.studio/v1/api/config#buildconfig.beforedevcommand
envPrefix: ["VITE_", "TAURI_"],
envPrefix: ['VITE_', 'TAURI_'],
build: {
// Tauri supports es2021
target: ["es2021", "chrome100", "safari13"],
target: ['es2021', 'chrome100', 'safari13'],
// don't minify for debug builds
minify: !process.env.TAURI_DEBUG ? "esbuild" : false,
minify: !process.env.TAURI_DEBUG ? 'esbuild' : false,
// produce sourcemaps for debug builds
sourcemap: !!process.env.TAURI_DEBUG,
},