GOOD Refactor GUI components and add PlainSlider widget

- Added a new `PlainSlider` widget for keyboard-editable numeric sliders.
- Updated various panels to utilize the new `PlainSlider` for better user interaction.
- Removed unused code and dead code warnings across multiple files.
- Improved organization of modules by adding a `widgets` module.
- Cleaned up imports in several files to streamline the codebase.
- Added Qodana configuration file for code analysis and quality checks.
This commit is contained in:
2026-07-19 00:55:40 +03:00
parent bbabd2acdb
commit 9388e6f096
46 changed files with 1680 additions and 872 deletions
Generated
+45 -1
View File
@@ -69,6 +69,12 @@ dependencies = [
"memchr",
]
[[package]]
name = "aliasable"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd"
[[package]]
name = "aligned"
version = "0.4.3"
@@ -1333,7 +1339,7 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e92f10a49176cbffacaedabfaa11d51db1ea0f80a83c26e1873b43cd1742c24"
dependencies = [
"heck",
"heck 0.5.0",
"proc-macro2",
"proc-macro2-diagnostics",
]
@@ -2884,6 +2890,12 @@ dependencies = [
"tokio",
]
[[package]]
name = "heck"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
[[package]]
name = "heck"
version = "0.5.0"
@@ -3226,6 +3238,7 @@ dependencies = [
"iced_runtime",
"num-traits",
"once_cell",
"ouroboros",
"rustc-hash 2.1.2",
"thiserror 1.0.69",
"unicode-segmentation",
@@ -4614,6 +4627,30 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "ouroboros"
version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59"
dependencies = [
"aliasable",
"ouroboros_macro",
"static_assertions",
]
[[package]]
name = "ouroboros_macro"
version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0"
dependencies = [
"heck 0.4.1",
"proc-macro2",
"proc-macro2-diagnostics",
"quote",
"syn 2.0.118",
]
[[package]]
name = "owned_ttf_parser"
version = "0.25.1"
@@ -4989,6 +5026,7 @@ dependencies = [
"quote",
"syn 2.0.118",
"version_check",
"yansi",
]
[[package]]
@@ -7951,6 +7989,12 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
[[package]]
name = "yansi"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
[[package]]
name = "yazi"
version = "0.1.6"
+1 -1
View File
@@ -62,7 +62,7 @@ eframe = { version = "0.34", default-features = false, features = ["default
egui_extras = { version = "0.34", features = ["svg", "image"] }
egui_dock = { version = "0.19", features = ["serde"] }
rfd = "0.15"
iced = { version = "0.13", features = ["tokio", "image", "svg", "canvas", "wgpu", "advanced"] }
iced = { version = "0.13", features = ["tokio", "image", "svg", "canvas", "wgpu", "advanced", "lazy"] }
# Utilities
base64 = "0.22"
-1
View File
@@ -1,5 +1,4 @@
use hcie_blend::blend_pixels;
use hcie_fx::protocol_to_hcie_fx_effect;
use hcie_protocol::Layer;
use rayon::prelude::*;
-1
View File
@@ -1,6 +1,5 @@
use hcie_blend::blend_pixels;
use hcie_fx::apply_layer_effects;
use hcie_fx::protocol_to_hcie_fx_effect;
use hcie_protocol::Layer;
use hcie_tile::{TiledLayer, TILE_SIZE};
use rayon::prelude::*;
@@ -630,12 +630,22 @@ impl<'a> TabViewer for HcieTabViewer<'a> {
// Pin / float toggle for docked utility panels only.
// LayerStyles, LayerDetails and Plugins are dialog-style panels: they
// cannot float and do not show a pin toggle.
let supports_float =
!tab.is_document() && !matches!(tab, HciePane::Tools | HciePane::LayerStyles | HciePane::LayerDetails | HciePane::Plugins);
let supports_float = !tab.is_document()
&& !matches!(
tab,
HciePane::Tools
| HciePane::LayerStyles
| HciePane::LayerDetails
| HciePane::Plugins
);
if supports_float {
// Pin icon is always visible on the right side of utility tabs so
// users can float/re-dock a panel without first hovering the tab.
let pin_offset = if is_closeable && response.hovered() { 24.0 } else { 8.0 };
let pin_offset = if is_closeable && response.hovered() {
24.0
} else {
8.0
};
let pin_center =
egui::pos2(response.rect.right() - pin_offset, response.rect.center().y);
let pin_rect = egui::Rect::from_center_size(pin_center, egui::vec2(14.0, 14.0));
@@ -750,7 +760,10 @@ impl<'a> TabViewer for HcieTabViewer<'a> {
!tab.is_document()
&& !matches!(
tab,
HciePane::Tools | HciePane::LayerStyles | HciePane::LayerDetails | HciePane::Plugins
HciePane::Tools
| HciePane::LayerStyles
| HciePane::LayerDetails
| HciePane::Plugins
)
}
@@ -837,7 +850,13 @@ impl<'a> TabViewer for HcieTabViewer<'a> {
// Float / close options intentionally disabled for LayerStyles, LayerDetails and Plugins
// because these panels render as popups / dialogs, not docked utility cards.
let is_floating_allowed = !is_doc
&& !matches!(tab, HciePane::Tools | HciePane::LayerStyles | HciePane::LayerDetails | HciePane::Plugins);
&& !matches!(
tab,
HciePane::Tools
| HciePane::LayerStyles
| HciePane::LayerDetails
| HciePane::Plugins
);
if is_floating_allowed {
let mut float = false;
menu_item(ui, "Yeni Pencere", true, &mut float);
@@ -863,7 +882,11 @@ impl<'a> TabViewer for HcieTabViewer<'a> {
let close_enabled = self.is_closeable(tab);
// Dialog-style panels (LayerStyles/LayerDetails/Plugins) are closed by the
// dialog itself, not by the dock tab.
let close_in_context = close_enabled && !matches!(tab, HciePane::LayerStyles | HciePane::LayerDetails | HciePane::Plugins);
let close_in_context = close_enabled
&& !matches!(
tab,
HciePane::LayerStyles | HciePane::LayerDetails | HciePane::Plugins
);
menu_item(ui, close_label, close_in_context, &mut close);
if close && close_in_context {
if let HciePane::Document(idx) = tab {
@@ -11,9 +11,16 @@ pub use crate::app::document_state::{AppDocument, AppMode};
// Settings is named directly only by integration tests, but re-exported for API continuity.
#[allow(unused_imports)]
pub use crate::app::settings::{DockProfile, RecentFileEntry, Settings};
pub use crate::app::theme::{ThemeColors, ThemePreset, apply_theme, default_font_bytes, setup_custom_fonts};
pub use crate::app::tool_state::{SelectionOp, SelectionTransform, TextEditState, ToolState, TransformHandle};
pub use crate::app::utils::{build_save_dialog, composite_flood_fill, crop_colorimage, draw_premium_pin, format_color, format_save_error_hint, save_colorimage, suggest_alternative_path};
pub use crate::app::theme::{
apply_theme, default_font_bytes, setup_custom_fonts, ThemeColors, ThemePreset,
};
pub use crate::app::tool_state::{
SelectionOp, SelectionTransform, TextEditState, ToolState, TransformHandle,
};
pub use crate::app::utils::{
build_save_dialog, composite_flood_fill, crop_colorimage, draw_premium_pin, format_color,
format_save_error_hint, save_colorimage, suggest_alternative_path,
};
use crate::event_bus::{AppEvent, EventBus};
use crate::plugins;
use eframe::egui;
@@ -78,8 +85,6 @@ pub const TOOL_SLOTS: &[&[Tool]] = &[
],
];
// ── HcieApp ──────────────────────────────────────────────────────────────────
pub struct HcieApp {
@@ -267,11 +272,8 @@ fn default_dock_state() -> egui_dock::DockState<dock::HciePane> {
// Default split fractions are chosen for a 1280px-wide window and are
// immediately overridden by the layout solver using stored column widths.
// They only determine the tree structure, not final pixel sizes.
let [remaining, left_col2] = tree.split_left(
egui_dock::NodeIndex::root(),
0.17,
vec![HciePane::Brushes],
);
let [remaining, left_col2] =
tree.split_left(egui_dock::NodeIndex::root(), 0.17, vec![HciePane::Brushes]);
let [_left_brushes, _left_filters] = tree.split_below(left_col2, 0.5, vec![HciePane::Filters]);
let [center, right_col2] = tree.split_right(remaining, 0.83, vec![HciePane::Layers]);
@@ -677,7 +679,10 @@ impl HcieApp {
log::info!("Added {} ABR brush preset(s) from {:?}", added, path);
rfd::MessageDialog::new()
.set_title("Brush Import")
.set_description(&format!("Added {} brush preset(s) to the Imported category.", added))
.set_description(&format!(
"Added {} brush preset(s) to the Imported category.",
added
))
.set_level(rfd::MessageLevel::Info)
.show();
}
@@ -818,13 +823,11 @@ impl HcieApp {
self.dock_settle_repaints = self.dock_settle_repaints.max(5);
}
/// Push a newly opened/created document tab into the center document leaf.
fn push_document_to_center_placeholder() {}
/// Panic-safe tab removal: validates indices before calling egui_dock::remove_tab.
/// Panic-safe tab removal: validates indices before calling egui_dock::remove_tab.
/// Returns `None` and logs a warning if indices are stale.
fn dock_safe_remove_tab(
@@ -1021,9 +1024,7 @@ impl HcieApp {
}
self.active_doc = new_idx;
} else {
log::trace!(
"Removing document tab from egui_dock multi-document state"
);
log::trace!("Removing document tab from egui_dock multi-document state");
let tabs_to_remove = self.dock_find_tabs(
|t| matches!(t, dock::HciePane::Document(d) if *d == idx),
);
@@ -1833,10 +1834,7 @@ impl HcieApp {
.into_iter()
.collect::<Vec<_>>()
} else {
crate::brush_import::import_abr_with_prefix(
&data,
&file_stem,
)
crate::brush_import::import_abr_with_prefix(&data, &file_stem)
};
if imported.is_empty() {
rfd::MessageDialog::new()
@@ -1850,12 +1848,21 @@ impl HcieApp {
} else {
let added = imported.len();
for preset in imported {
if !self.state.brush_presets.iter().any(|p| p.id == preset.id) {
if !self
.state
.brush_presets
.iter()
.any(|p| p.id == preset.id)
{
self.state.brush_presets.push(preset);
}
}
self.event_bus.push(AppEvent::SaveSettings);
log::info!("Added {} imported brush preset(s) from {:?}", added, path);
log::info!(
"Added {} imported brush preset(s) from {:?}",
added,
path
);
rfd::MessageDialog::new()
.set_title("Brush Import")
.set_description(&format!(
@@ -5118,7 +5125,9 @@ impl eframe::App for HcieApp {
.state
.brush_presets
.iter()
.filter(|p| p.category == "Custom" || p.category == "Imported" || p.category == "Imported (ABR)")
.filter(|p| {
p.category == "Custom" || p.category == "Imported" || p.category == "Imported (ABR)"
})
.cloned()
.collect();
if let Ok(json) = serde_json::to_string(&persisted_presets) {
@@ -5139,4 +5148,3 @@ impl eframe::App for HcieApp {
std::time::Duration::from_secs(120)
}
}
@@ -218,26 +218,21 @@ pub fn apply_theme(ctx: &egui::Context, theme: ThemePreset) {
};
// ── Font sizes matching Qt 12px default ──
style.text_styles.insert(
egui::TextStyle::Body,
egui::FontId::proportional(12.0),
);
style.text_styles.insert(
egui::TextStyle::Button,
egui::FontId::proportional(12.0),
);
style.text_styles.insert(
egui::TextStyle::Small,
egui::FontId::proportional(11.0),
);
style.text_styles.insert(
egui::TextStyle::Heading,
egui::FontId::proportional(13.5),
);
style.text_styles.insert(
egui::TextStyle::Monospace,
egui::FontId::proportional(11.5),
);
style
.text_styles
.insert(egui::TextStyle::Body, egui::FontId::proportional(12.0));
style
.text_styles
.insert(egui::TextStyle::Button, egui::FontId::proportional(12.0));
style
.text_styles
.insert(egui::TextStyle::Small, egui::FontId::proportional(11.0));
style
.text_styles
.insert(egui::TextStyle::Heading, egui::FontId::proportional(13.5));
style
.text_styles
.insert(egui::TextStyle::Monospace, egui::FontId::proportional(11.5));
// ── Harmonized layout parameters (8px grid) ──
style.spacing.item_spacing = egui::vec2(4.0, 4.0);
@@ -18,7 +18,8 @@ fn matches_category(active_cat: &str, preset_cat: &str) -> bool {
return preset_cat.eq_ignore_ascii_case("Custom");
}
if active_cat == "Imported" {
return preset_cat.eq_ignore_ascii_case("Imported") || preset_cat.eq_ignore_ascii_case("Imported (ABR)");
return preset_cat.eq_ignore_ascii_case("Imported")
|| preset_cat.eq_ignore_ascii_case("Imported (ABR)");
}
let mapped: &[&str] = match active_cat {
"Drawing" => &["Basic", "Sketch"],
@@ -102,16 +103,17 @@ fn preset_thumb_hash(preset: &BrushPreset) -> u64 {
hasher.finish()
}
fn render_thumb_to_texture(
ctx: &egui::Context,
preset: &BrushPreset,
) -> egui::TextureHandle {
fn render_thumb_to_texture(ctx: &egui::Context, preset: &BrushPreset) -> egui::TextureHandle {
let w = THUMB_W as usize;
let h = THUMB_H as usize;
let mut pixels = vec![0u8; w * h * 4];
render_thumb_into_rgba(preset, &mut pixels, w, h);
let image = egui::ColorImage::from_rgba_unmultiplied([w, h], &pixels);
ctx.load_texture(format!("thumb_{}", preset.id), image, egui::TextureOptions::LINEAR)
ctx.load_texture(
format!("thumb_{}", preset.id),
image,
egui::TextureOptions::LINEAR,
)
}
fn render_thumb_into_rgba(preset: &BrushPreset, buf: &mut [u8], w: usize, h: usize) {
@@ -132,7 +134,10 @@ fn render_thumb_into_rgba(preset: &BrushPreset, buf: &mut [u8], w: usize, h: usi
}
match style {
BrushStyle::Bitmap => {
if preset.tip.bitmap_pixels.is_empty() || preset.tip.bitmap_w == 0 || preset.tip.bitmap_h == 0 {
if preset.tip.bitmap_pixels.is_empty()
|| preset.tip.bitmap_w == 0
|| preset.tip.bitmap_h == 0
{
return;
}
let bw = preset.tip.bitmap_w as f32;
@@ -149,7 +154,9 @@ fn render_thumb_into_rgba(preset: &BrushPreset, buf: &mut [u8], w: usize, h: usi
let mut sx = 0u32;
let mut ppx = 0.0;
while sx < preset.tip.bitmap_w {
let mask = preset.tip.bitmap_pixels[(sy * preset.tip.bitmap_w + sx) as usize] as f32 / 255.0;
let mask = preset.tip.bitmap_pixels[(sy * preset.tip.bitmap_w + sx) as usize]
as f32
/ 255.0;
if mask > 0.01 {
let a = (mask * 255.0).round() as u8;
let rx = (ox + ppx).round() as i32;
@@ -160,7 +167,11 @@ fn render_thumb_into_rgba(preset: &BrushPreset, buf: &mut [u8], w: usize, h: usi
for dx in 0..rw {
let px_i = rx + dx;
let py_i = ry + dy;
if px_i >= 0 && py_i >= 0 && (px_i as usize) < w && (py_i as usize) < h {
if px_i >= 0
&& py_i >= 0
&& (px_i as usize) < w
&& (py_i as usize) < h
{
let idx = ((py_i as usize) * w + (px_i as usize)) * 4;
buf[idx] = gray[0];
buf[idx + 1] = gray[1];
@@ -299,15 +310,23 @@ pub fn draw_brush_preview(
hcie_engine_api::tools::BrushStyle::Texture => {
for &(x, y, t) in &points {
let p = (t * std::f32::consts::PI).sin();
if p < 0.15 { continue; }
if p < 0.15 {
continue;
}
let alpha = 0.3 + 0.5 * (x * 23.0).sin().abs();
painter.circle_filled(egui::pos2(x, y), 1.2 + 2.0 * p, color.linear_multiply(alpha));
painter.circle_filled(
egui::pos2(x, y),
1.2 + 2.0 * p,
color.linear_multiply(alpha),
);
}
}
hcie_engine_api::tools::BrushStyle::Spray => {
for &(x, y, t) in &points {
let p = (t * std::f32::consts::PI).sin();
if p < 0.05 { continue; }
if p < 0.05 {
continue;
}
for s in 0..6 {
let angle = (x * 9.0 + s as f32 * 1.1).sin() * std::f32::consts::TAU;
let dist = (y * 11.0 + s as f32 * 1.7).cos().abs() * 8.0 * p;
@@ -359,14 +378,20 @@ pub fn draw_brush_preview(
hcie_engine_api::tools::BrushStyle::Charcoal => {
for &(x, y, t) in &points {
let p = (t * std::f32::consts::PI).sin();
if p < 0.1 { continue; }
if p < 0.1 {
continue;
}
for s in 0..4 {
let angle = (x * 7.3 + s as f32).sin() * std::f32::consts::TAU;
let dist = (x * 11.2 + s as f32).cos().abs() * 3.5 * p;
let px = x + angle.cos() * dist;
let py = y + angle.sin() * dist;
let alpha = 0.25 + 0.55 * (x * 19.3).sin().abs();
painter.circle_filled(egui::pos2(px, py), 1.0 + 1.2 * p, color.linear_multiply(alpha));
painter.circle_filled(
egui::pos2(px, py),
1.0 + 1.2 * p,
color.linear_multiply(alpha),
);
}
}
}
@@ -379,7 +404,11 @@ pub fn draw_brush_preview(
for &(x, y, t) in &points {
let p = (t * std::f32::consts::PI).sin();
let r = 8.0 * p + 1.5;
painter.circle_stroke(egui::pos2(x, y), r, egui::Stroke::new(0.8, color.linear_multiply(0.12)));
painter.circle_stroke(
egui::pos2(x, y),
r,
egui::Stroke::new(0.8, color.linear_multiply(0.12)),
);
}
}
hcie_engine_api::tools::BrushStyle::Calligraphy => {
@@ -394,14 +423,19 @@ pub fn draw_brush_preview(
}
hcie_engine_api::tools::BrushStyle::Leaf => {
for (i, &(x, y, t)) in points.iter().enumerate() {
if i % 4 != 0 { continue; }
if i % 4 != 0 {
continue;
}
let p = (t * std::f32::consts::PI).sin();
if p < 0.15 { continue; }
if p < 0.15 {
continue;
}
let shade = 0.35 + 0.55 * (i as f32 * 0.7).sin().abs();
let size = 1.8 + 2.0 * p;
let lx = x + (i as f32 * 1.3).sin() * 1.5;
let leaf_rect = egui::Rect::from_center_size(
egui::pos2(lx, y), egui::vec2(size * 0.45, size * 1.1),
egui::pos2(lx, y),
egui::vec2(size * 0.45, size * 1.1),
);
painter.rect_filled(
leaf_rect.translate(egui::vec2(0.4, 0.6)),
@@ -417,25 +451,36 @@ pub fn draw_brush_preview(
}
hcie_engine_api::tools::BrushStyle::Rock => {
for (i, &(x, y, t)) in points.iter().enumerate() {
if i % 2 != 0 { continue; }
if i % 2 != 0 {
continue;
}
let p = (t * std::f32::consts::PI).sin();
if p < 0.1 { continue; }
if p < 0.1 {
continue;
}
let rw = 3.0 + 3.5 * p;
let rh = 2.0 + 2.5 * p;
let shade = 0.40 + 0.40 * (i as f32 * 0.7).sin().abs();
let rx = x + (i as f32).sin() * 1.5;
let ry = y + (i as f32 * 0.7).cos() * 1.0;
let rock_rect = egui::Rect::from_center_size(egui::pos2(rx, ry), egui::vec2(rw, rh));
let rock_rect =
egui::Rect::from_center_size(egui::pos2(rx, ry), egui::vec2(rw, rh));
painter.rect_filled(
rock_rect, egui::CornerRadius::same(2), color.linear_multiply(shade),
rock_rect,
egui::CornerRadius::same(2),
color.linear_multiply(shade),
);
}
}
hcie_engine_api::tools::BrushStyle::Meadow => {
for (i, &(x, y, t)) in points.iter().enumerate() {
if i % 2 != 0 { continue; }
if i % 2 != 0 {
continue;
}
let p = (t * std::f32::consts::PI).sin();
if p < 0.1 { continue; }
if p < 0.1 {
continue;
}
let blade_h = 4.0 + 5.0 * p;
let wind = (x * 5.0 + i as f32 * 0.5).sin() * 2.0;
let shade = 0.35 + 0.45 * (i as f32 * 0.7 + p).sin().abs();
@@ -444,7 +489,10 @@ pub fn draw_brush_preview(
let mid = egui::pos2(x + wind * 0.4, y - blade_h * 0.5);
let tip = egui::pos2(x + wind, y - blade_h);
painter.line_segment([base, mid], egui::Stroke::new(1.0, blade_color));
painter.line_segment([mid, tip], egui::Stroke::new(0.5, blade_color.linear_multiply(0.9)));
painter.line_segment(
[mid, tip],
egui::Stroke::new(0.5, blade_color.linear_multiply(0.9)),
);
}
}
hcie_engine_api::tools::BrushStyle::Wood => {
@@ -496,8 +544,16 @@ pub fn draw_brush_preview(
for &(x, y, t) in &points {
let p = (t * std::f32::consts::PI).sin();
let r = 8.5 * p;
painter.circle_filled(egui::pos2(x, y), r, egui::Color32::WHITE.linear_multiply(0.16));
painter.circle_filled(egui::pos2(x, y - 2.0), r * 0.8, egui::Color32::WHITE.linear_multiply(0.24));
painter.circle_filled(
egui::pos2(x, y),
r,
egui::Color32::WHITE.linear_multiply(0.16),
);
painter.circle_filled(
egui::pos2(x, y - 2.0),
r * 0.8,
egui::Color32::WHITE.linear_multiply(0.24),
);
}
}
hcie_engine_api::tools::BrushStyle::Tree => {
@@ -535,17 +591,25 @@ pub fn draw_brush_preview(
hcie_engine_api::tools::BrushStyle::Crayon => {
for &(x, y, t) in &points {
let p = (t * std::f32::consts::PI).sin();
if p < 0.1 { continue; }
if p < 0.1 {
continue;
}
for s in 0..3 {
let jx = (x * 17.5 + s as f32 * 5.0).sin() * 2.0 * p;
let jy = (y * 23.4 + s as f32 * 9.0).cos() * 2.0 * p;
painter.circle_filled(egui::pos2(x + jx, y + jy), 1.2 * p, color.linear_multiply(0.48));
painter.circle_filled(
egui::pos2(x + jx, y + jy),
1.2 * p,
color.linear_multiply(0.48),
);
}
}
}
hcie_engine_api::tools::BrushStyle::Hatch => {
for (i, &(x, y, t)) in points.iter().enumerate() {
if i % 4 != 0 { continue; }
if i % 4 != 0 {
continue;
}
let p = (t * std::f32::consts::PI).sin();
let sz = 4.2 * p;
painter.line_segment(
@@ -564,7 +628,11 @@ pub fn draw_brush_preview(
egui::Stroke::new(6.0 * p, color),
);
if i % 10 == 0 && i > 0 {
painter.circle_filled(egui::pos2(x1, y1 + 4.2 * p), 2.2 * p, color.linear_multiply(0.78));
painter.circle_filled(
egui::pos2(x1, y1 + 4.2 * p),
2.2 * p,
color.linear_multiply(0.78),
);
}
}
}
@@ -585,7 +653,10 @@ pub fn draw_brush_preview(
let w = 5.2 * p;
let mc = color.linear_multiply(0.42);
painter.line_segment(
[egui::pos2(x - w, y + w * 1.8), egui::pos2(x + w, y - w * 1.8)],
[
egui::pos2(x - w, y + w * 1.8),
egui::pos2(x + w, y - w * 1.8),
],
egui::Stroke::new(3.8, mc),
);
}
@@ -609,22 +680,38 @@ pub fn draw_brush_preview(
}
hcie_engine_api::tools::BrushStyle::Star => {
for (i, &(x, y, t)) in points.iter().enumerate() {
if i % 6 != 0 { continue; }
if i % 6 != 0 {
continue;
}
let p = (t * std::f32::consts::PI).sin();
let r = 4.2 * p;
if r < 1.0 { continue; }
painter.line_segment([egui::pos2(x - r, y), egui::pos2(x + r, y)], egui::Stroke::new(1.1, color));
painter.line_segment([egui::pos2(x, y - r), egui::pos2(x, y + r)], egui::Stroke::new(1.1, color));
if r < 1.0 {
continue;
}
painter.line_segment(
[egui::pos2(x - r, y), egui::pos2(x + r, y)],
egui::Stroke::new(1.1, color),
);
painter.line_segment(
[egui::pos2(x, y - r), egui::pos2(x, y + r)],
egui::Stroke::new(1.1, color),
);
}
}
hcie_engine_api::tools::BrushStyle::Dirt => {
for &(x, y, t) in &points {
let p = (t * std::f32::consts::PI).sin();
if p < 0.1 { continue; }
if p < 0.1 {
continue;
}
for s in 0..2 {
let ox = (x * 47.3 + s as f32 * 11.0).sin() * 6.0 * p;
let oy = (y * 53.2 + s as f32 * 17.0).cos() * 6.0 * p;
painter.circle_filled(egui::pos2(x + ox, y + oy), 0.7, color.linear_multiply(0.4));
painter.circle_filled(
egui::pos2(x + ox, y + oy),
0.7,
color.linear_multiply(0.4),
);
}
}
}
@@ -1068,7 +1155,9 @@ pub fn show_brushes_ui(app: &mut HcieApp, ui: &mut egui::Ui) {
ui.add_space(6.0);
// 2. Category Tab Filters - Oval Pills with 12px rounding
let categories = ["All", "Drawing", "Painting", "Effects", "Custom", "Imported"];
let categories = [
"All", "Drawing", "Painting", "Effects", "Custom", "Imported",
];
let _category_map = |cat: &str| -> Vec<&'static str> {
match cat {
"Drawing" => vec!["Basic", "Sketch"],
@@ -1221,7 +1310,11 @@ pub fn show_brushes_ui(app: &mut HcieApp, ui: &mut egui::Ui) {
state.primary_color[3],
);
crate::app::shell::scrollable_panel::scrollable_panel_with_scrollbar(ui, "brush_preset_list", true, |ui| {
crate::app::shell::scrollable_panel::scrollable_panel_with_scrollbar(
ui,
"brush_preset_list",
true,
|ui| {
match view_mode {
2 => {
// CARD VIEW — Premium rounded cards with glow shadows and contrasting background
@@ -1255,7 +1348,8 @@ pub fn show_brushes_ui(app: &mut HcieApp, ui: &mut egui::Ui) {
egui::Sense::click(),
);
let cid = format!("panel.brushes.card.{}", preset.id);
let response = crate::app::shell::gui_layout::track(&cid, ui, &response);
let response =
crate::app::shell::gui_layout::track(&cid, ui, &response);
let is_hovered = response.hovered();
// 8.0 rounded corners & dynamic glow shadows
@@ -1314,7 +1408,9 @@ pub fn show_brushes_ui(app: &mut HcieApp, ui: &mut egui::Ui) {
);
let h = preset_thumb_hash(preset);
let cache_key = format!("{}.{}", preset.id, h);
if state.brush_thumbnail_dirty || !state.brush_thumbnail_cache.contains_key(&cache_key) {
if state.brush_thumbnail_dirty
|| !state.brush_thumbnail_cache.contains_key(&cache_key)
{
let tex = render_thumb_to_texture(ui.ctx(), preset);
state.brush_thumbnail_cache.insert(cache_key.clone(), tex);
}
@@ -1322,7 +1418,10 @@ pub fn show_brushes_ui(app: &mut HcieApp, ui: &mut egui::Ui) {
painter.image(
tex.id(),
preview_rect,
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
egui::Rect::from_min_max(
egui::pos2(0.0, 0.0),
egui::pos2(1.0, 1.0),
),
egui::Color32::WHITE,
);
}
@@ -1400,13 +1499,14 @@ pub fn show_brushes_ui(app: &mut HcieApp, ui: &mut egui::Ui) {
.size(10.0)
.color(colors.text_secondary),
);
let resp =
ui.add(egui::TextEdit::singleline(&mut buf).desired_width(140.0));
let resp = ui
.add(egui::TextEdit::singleline(&mut buf).desired_width(140.0));
if resp.changed() {
ui.data_mut(|d| d.insert_temp(rename_id, buf.clone()));
}
ui.horizontal(|ui| {
if ui.small_button("✓ Rename").clicked() && !buf.trim().is_empty()
if ui.small_button("✓ Rename").clicked()
&& !buf.trim().is_empty()
{
if let Some(p) = state
.brush_presets
@@ -1464,7 +1564,11 @@ pub fn show_brushes_ui(app: &mut HcieApp, ui: &mut egui::Ui) {
let painter = ui.painter();
if is_active {
painter.rect_filled(rect, egui::CornerRadius::same(3), colors.bg_hover);
painter.rect_filled(
rect,
egui::CornerRadius::same(3),
colors.bg_hover,
);
painter.rect_filled(
egui::Rect::from_min_size(
rect.left_top(),
@@ -1495,7 +1599,9 @@ pub fn show_brushes_ui(app: &mut HcieApp, ui: &mut egui::Ui) {
painter.rect_filled(preview_rect, egui::CornerRadius::same(2), bg_fill);
let h = preset_thumb_hash(preset);
let cache_key = format!("{}.{}", preset.id, h);
if state.brush_thumbnail_dirty || !state.brush_thumbnail_cache.contains_key(&cache_key) {
if state.brush_thumbnail_dirty
|| !state.brush_thumbnail_cache.contains_key(&cache_key)
{
let tex = render_thumb_to_texture(ui.ctx(), preset);
state.brush_thumbnail_cache.insert(cache_key.clone(), tex);
}
@@ -1503,7 +1609,10 @@ pub fn show_brushes_ui(app: &mut HcieApp, ui: &mut egui::Ui) {
painter.image(
tex.id(),
preview_rect,
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
egui::Rect::from_min_max(
egui::pos2(0.0, 0.0),
egui::pos2(1.0, 1.0),
),
egui::Color32::WHITE,
);
}
@@ -1563,13 +1672,14 @@ pub fn show_brushes_ui(app: &mut HcieApp, ui: &mut egui::Ui) {
.size(10.0)
.color(colors.text_secondary),
);
let r =
ui.add(egui::TextEdit::singleline(&mut buf).desired_width(120.0));
let r = ui
.add(egui::TextEdit::singleline(&mut buf).desired_width(120.0));
if r.changed() {
ui.data_mut(|d| d.insert_temp(rename_id, buf.clone()));
}
ui.horizontal(|ui| {
if ui.small_button("✓ Rename").clicked() && !buf.trim().is_empty()
if ui.small_button("✓ Rename").clicked()
&& !buf.trim().is_empty()
{
if let Some(p) = state
.brush_presets
@@ -1710,14 +1820,24 @@ pub fn show_brushes_ui(app: &mut HcieApp, ui: &mut egui::Ui) {
} else {
egui::Color32::from_gray(28)
};
painter.rect_filled(preview_rect, egui::CornerRadius::same(3), bg_fill);
painter.rect_filled(
preview_rect,
egui::CornerRadius::same(3),
bg_fill,
);
painter.rect_stroke(
preview_rect,
egui::CornerRadius::same(3),
egui::Stroke::new(0.5, colors.border_low),
egui::StrokeKind::Outside,
);
draw_brush_preview(painter, preview_rect, style, primary_srgba, None);
draw_brush_preview(
painter,
preview_rect,
style,
primary_srgba,
None,
);
let name_pos =
egui::pos2(cell_rect.center().x, preview_rect.bottom() + 1.0);
@@ -1791,7 +1911,8 @@ pub fn show_brushes_ui(app: &mut HcieApp, ui: &mut egui::Ui) {
ui.data_mut(|d| d.insert_temp(category_id, active_cat.clone()));
}
});
});
},
);
// Clear dirty flag after all thumbnails have been rendered this frame
state.brush_thumbnail_dirty = false;
@@ -102,6 +102,7 @@ pub fn show_layer_styles_panel(doc: &mut AppDocument, state: &mut ToolState, ui:
size: 5.0,
angle: 120.0,
altitude: 30.0,
contour: "Linear".to_string(),
highlight_opacity: 0.75,
shadow_opacity: 0.75,
direction: "Up".to_string(),
@@ -352,6 +353,49 @@ fn technique_combobox(ui: &mut egui::Ui, current: &mut String) -> bool {
changed
}
/// **Purpose:**
/// Displays a dropdown combo box to select the bevel contour profile.
///
/// **Logic & Workflow:**
/// Lists common contour presets (Linear, Cone, Gaussian, etc.).
///
/// **Arguments:**
/// - `ui`: Mutable egui UI layout context.
/// - `current`: Mutable reference to the contour string.
///
/// **Returns:**
/// - `bool`: True if selection changed.
fn contour_combobox(ui: &mut egui::Ui, current: &mut String) -> bool {
let mut changed = false;
let contours = [
"Linear",
"Cone",
"Cone-Inverted",
"Gaussian",
"Half Round",
"Round",
"Ring",
"Ring-Double",
"Sawtooth",
"Square",
"Valley",
"Shallow-Slope",
"Wave",
"Cove",
"Washboard",
];
egui::ComboBox::from_label("Contour")
.selected_text(current.as_str())
.show_ui(ui, |ui| {
for c in contours {
if ui.selectable_value(current, c.to_string(), c).changed() {
changed = true;
}
}
});
changed
}
/// **Purpose:**
/// Displays a dropdown combo box to select the stroke position.
///
@@ -476,7 +520,7 @@ fn show_style_effect_mut(style: &mut LayerStyle, ui: &mut egui::Ui, colors: &The
false,
)
.show_header(ui, |ui| {
if ui.checkbox(enabled, "").changed() {
if ui.checkbox(enabled, String::new()).changed() {
changed = true;
}
ui.label(RichText::new(title).strong().color(header_color));
@@ -527,7 +571,7 @@ fn show_style_effect_mut(style: &mut LayerStyle, ui: &mut egui::Ui, colors: &The
false,
)
.show_header(ui, |ui| {
if ui.checkbox(enabled, "").changed() {
if ui.checkbox(enabled, String::new()).changed() {
changed = true;
}
ui.label(RichText::new(title).strong().color(header_color));
@@ -576,7 +620,7 @@ fn show_style_effect_mut(style: &mut LayerStyle, ui: &mut egui::Ui, colors: &The
false,
)
.show_header(ui, |ui| {
if ui.checkbox(enabled, "").changed() {
if ui.checkbox(enabled, String::new()).changed() {
changed = true;
}
ui.label(RichText::new(title).strong().color(header_color));
@@ -619,7 +663,7 @@ fn show_style_effect_mut(style: &mut LayerStyle, ui: &mut egui::Ui, colors: &The
false,
)
.show_header(ui, |ui| {
if ui.checkbox(enabled, "").changed() {
if ui.checkbox(enabled, String::new()).changed() {
changed = true;
}
ui.label(RichText::new(title).strong().color(header_color));
@@ -662,6 +706,7 @@ fn show_style_effect_mut(style: &mut LayerStyle, ui: &mut egui::Ui, colors: &The
highlight_color,
shadow_blend_mode,
shadow_color,
contour,
} => {
let title = "Bevel & Emboss";
let header_color = Color32::from_rgb(100, 180, 220);
@@ -671,7 +716,7 @@ fn show_style_effect_mut(style: &mut LayerStyle, ui: &mut egui::Ui, colors: &The
false,
)
.show_header(ui, |ui| {
if ui.checkbox(enabled, "").changed() {
if ui.checkbox(enabled, String::new()).changed() {
changed = true;
}
ui.label(RichText::new(title).strong().color(header_color));
@@ -718,6 +763,18 @@ fn show_style_effect_mut(style: &mut LayerStyle, ui: &mut egui::Ui, colors: &The
});
ui.end_row();
ui.label(
RichText::new("Contour")
.size(10.0)
.color(colors.text_secondary),
);
ui.horizontal(|ui| {
if contour_combobox(ui, contour) {
changed = true;
}
});
ui.end_row();
ui.label(
RichText::new("Depth")
.size(10.0)
@@ -898,7 +955,7 @@ fn show_style_effect_mut(style: &mut LayerStyle, ui: &mut egui::Ui, colors: &The
false,
)
.show_header(ui, |ui| {
if ui.checkbox(enabled, "").changed() {
if ui.checkbox(enabled, String::new()).changed() {
changed = true;
}
ui.label(RichText::new(title).strong().color(header_color));
@@ -942,7 +999,7 @@ fn show_style_effect_mut(style: &mut LayerStyle, ui: &mut egui::Ui, colors: &The
false,
)
.show_header(ui, |ui| {
if ui.checkbox(enabled, "").changed() {
if ui.checkbox(enabled, String::new()).changed() {
changed = true;
}
ui.label(RichText::new(title).strong().color(header_color));
@@ -979,7 +1036,7 @@ fn show_style_effect_mut(style: &mut LayerStyle, ui: &mut egui::Ui, colors: &The
false,
)
.show_header(ui, |ui| {
if ui.checkbox(enabled, "").changed() {
if ui.checkbox(enabled, String::new()).changed() {
changed = true;
}
ui.label(RichText::new(title).strong().color(header_color));
@@ -1021,7 +1078,7 @@ fn show_style_effect_mut(style: &mut LayerStyle, ui: &mut egui::Ui, colors: &The
false,
)
.show_header(ui, |ui| {
if ui.checkbox(enabled, "").changed() {
if ui.checkbox(enabled, String::new()).changed() {
changed = true;
}
ui.label(RichText::new(title).strong().color(header_color));
@@ -1059,7 +1116,7 @@ fn show_style_effect_mut(style: &mut LayerStyle, ui: &mut egui::Ui, colors: &The
false,
)
.show_header(ui, |ui| {
if ui.checkbox(enabled, "").changed() {
if ui.checkbox(enabled, String::new()).changed() {
changed = true;
}
ui.label(RichText::new(title).strong().color(header_color));
@@ -149,7 +149,11 @@ pub fn suggest_alternative_path(original: &std::path::Path) -> std::path::PathBu
/// * `default_format` - Lowercase extension to pre-select (`kra`, `hcie`, `png`, ...).
/// * `file_stem` - Base name for the suggested file.
/// * `is_export` - When `true`, appends `_export` to the file stem.
pub fn build_save_dialog(default_format: &str, file_stem: &str, is_export: bool) -> rfd::FileDialog {
pub fn build_save_dialog(
default_format: &str,
file_stem: &str,
is_export: bool,
) -> rfd::FileDialog {
let suffix = if is_export { "_export" } else { "" };
let file_name = format!("{}{}.{}", file_stem, suffix, default_format);
@@ -252,7 +252,8 @@ fn harvest_brs2_sampled_brushes(data: &[u8], base_pos: usize) -> Vec<BrushPreset
} else {
(w, h)
};
if let Some(decoded) = try_decode_and_scale(slice, w as usize, h as usize, final_w, final_h) {
if let Some(decoded) = try_decode_and_scale(slice, w as usize, h as usize, final_w, final_h)
{
presets.push(BrushPreset {
id: format!("abr_brs2_{}_{}x{}", base_pos + i, final_w, final_h),
name: format!("Brush {}x{}", final_w, final_h),
@@ -441,7 +442,8 @@ fn extract_desc_block_names(data: &[u8]) -> Vec<String> {
if p + 4 > block.len() {
break;
}
let nchars = u32::from_be_bytes([block[p], block[p + 1], block[p + 2], block[p + 3]]) as usize;
let nchars =
u32::from_be_bytes([block[p], block[p + 1], block[p + 2], block[p + 3]]) as usize;
let text_start = p + 4;
if nchars == 0 || text_start + nchars * 2 > block.len() {
idx += 1;
@@ -555,7 +557,12 @@ pub fn import_kpp(data: &[u8], name_hint: &str) -> Option<BrushPreset> {
}
}
Some(BrushPreset {
id: format!("kpp_imported_{}_{}x{}", name_hint.replace(' ', "_"), final_w, final_h),
id: format!(
"kpp_imported_{}_{}x{}",
name_hint.replace(' ', "_"),
final_w,
final_h
),
name: format!("{} (KPP)", name_hint),
category: "Imported".to_string(),
tip: BrushTip {
@@ -623,7 +630,10 @@ mod tests {
}
};
let presets = import_abr(&data);
let bitmap: Vec<_> = presets.iter().filter(|p| p.style == BrushStyle::Bitmap).collect();
let bitmap: Vec<_> = presets
.iter()
.filter(|p| p.style == BrushStyle::Bitmap)
.collect();
assert!(!bitmap.is_empty(), "Should have bitmap presets");
let has_names = bitmap.iter().any(|p| !p.name.starts_with("Brush "));
assert!(has_names, "Bitmap presets should have descriptive names");
@@ -1098,7 +1098,10 @@ impl<'a> egui::Widget for CanvasWidget<'a> {
let t_stroke_end = std::time::Instant::now();
let layer_id = self.doc.engine.active_layer_id();
self.doc.engine.end_stroke(layer_id);
log::warn!("[PERF] self.doc.engine.end_stroke took {}ms", t_stroke_end.elapsed().as_millis());
log::warn!(
"[PERF] self.doc.engine.end_stroke took {}ms",
t_stroke_end.elapsed().as_millis()
);
self.doc.last_stroke_end_time = Some(std::time::Instant::now());
let desc = match self.state.active_tool {
@@ -1474,40 +1477,108 @@ impl<'a> egui::Widget for CanvasWidget<'a> {
}
Tool::Brush | _ => {
tip.style = match self.state.tool_configs.brush.style {
hcie_engine_api::tools::BrushStyle::Default => hcie_engine_api::BrushStyle::Round,
hcie_engine_api::tools::BrushStyle::Round => hcie_engine_api::BrushStyle::Round,
hcie_engine_api::tools::BrushStyle::Square => hcie_engine_api::BrushStyle::Square,
hcie_engine_api::tools::BrushStyle::HardRound => hcie_engine_api::BrushStyle::HardRound,
hcie_engine_api::tools::BrushStyle::SoftRound => hcie_engine_api::BrushStyle::SoftRound,
hcie_engine_api::tools::BrushStyle::Noise => hcie_engine_api::BrushStyle::Noise,
hcie_engine_api::tools::BrushStyle::Texture => hcie_engine_api::BrushStyle::Texture,
hcie_engine_api::tools::BrushStyle::Spray => hcie_engine_api::BrushStyle::Spray,
hcie_engine_api::tools::BrushStyle::Pen => hcie_engine_api::BrushStyle::Pen,
hcie_engine_api::tools::BrushStyle::Oil => hcie_engine_api::BrushStyle::Oil,
hcie_engine_api::tools::BrushStyle::Charcoal => hcie_engine_api::BrushStyle::Charcoal,
hcie_engine_api::tools::BrushStyle::Leaf => hcie_engine_api::BrushStyle::Leaf,
hcie_engine_api::tools::BrushStyle::Rock => hcie_engine_api::BrushStyle::Rock,
hcie_engine_api::tools::BrushStyle::Meadow => hcie_engine_api::BrushStyle::Meadow,
hcie_engine_api::tools::BrushStyle::Wood => hcie_engine_api::BrushStyle::Wood,
hcie_engine_api::tools::BrushStyle::Watercolor => hcie_engine_api::BrushStyle::Watercolor,
hcie_engine_api::tools::BrushStyle::Calligraphy => hcie_engine_api::BrushStyle::Calligraphy,
hcie_engine_api::tools::BrushStyle::Marker => hcie_engine_api::BrushStyle::Marker,
hcie_engine_api::tools::BrushStyle::Sketch => hcie_engine_api::BrushStyle::Sketch,
hcie_engine_api::tools::BrushStyle::Hatch => hcie_engine_api::BrushStyle::Hatch,
hcie_engine_api::tools::BrushStyle::Star => hcie_engine_api::BrushStyle::Star,
hcie_engine_api::tools::BrushStyle::Glow => hcie_engine_api::BrushStyle::Glow,
hcie_engine_api::tools::BrushStyle::Airbrush => hcie_engine_api::BrushStyle::Airbrush,
hcie_engine_api::tools::BrushStyle::Pencil => hcie_engine_api::BrushStyle::Pencil,
hcie_engine_api::tools::BrushStyle::Crayon => hcie_engine_api::BrushStyle::Crayon,
hcie_engine_api::tools::BrushStyle::WetPaint => hcie_engine_api::BrushStyle::WetPaint,
hcie_engine_api::tools::BrushStyle::InkPen => hcie_engine_api::BrushStyle::InkPen,
hcie_engine_api::tools::BrushStyle::Clouds => hcie_engine_api::BrushStyle::Clouds,
hcie_engine_api::tools::BrushStyle::Dirt => hcie_engine_api::BrushStyle::Dirt,
hcie_engine_api::tools::BrushStyle::Tree => hcie_engine_api::BrushStyle::Tree,
hcie_engine_api::tools::BrushStyle::Bristle => hcie_engine_api::BrushStyle::Bristle,
hcie_engine_api::tools::BrushStyle::Mixer => hcie_engine_api::BrushStyle::Mixer,
hcie_engine_api::tools::BrushStyle::Blender => hcie_engine_api::BrushStyle::Blender,
hcie_engine_api::tools::BrushStyle::Bitmap => hcie_engine_api::BrushStyle::Bitmap,
hcie_engine_api::tools::BrushStyle::Default => {
hcie_engine_api::BrushStyle::Round
}
hcie_engine_api::tools::BrushStyle::Round => {
hcie_engine_api::BrushStyle::Round
}
hcie_engine_api::tools::BrushStyle::Square => {
hcie_engine_api::BrushStyle::Square
}
hcie_engine_api::tools::BrushStyle::HardRound => {
hcie_engine_api::BrushStyle::HardRound
}
hcie_engine_api::tools::BrushStyle::SoftRound => {
hcie_engine_api::BrushStyle::SoftRound
}
hcie_engine_api::tools::BrushStyle::Noise => {
hcie_engine_api::BrushStyle::Noise
}
hcie_engine_api::tools::BrushStyle::Texture => {
hcie_engine_api::BrushStyle::Texture
}
hcie_engine_api::tools::BrushStyle::Spray => {
hcie_engine_api::BrushStyle::Spray
}
hcie_engine_api::tools::BrushStyle::Pen => {
hcie_engine_api::BrushStyle::Pen
}
hcie_engine_api::tools::BrushStyle::Oil => {
hcie_engine_api::BrushStyle::Oil
}
hcie_engine_api::tools::BrushStyle::Charcoal => {
hcie_engine_api::BrushStyle::Charcoal
}
hcie_engine_api::tools::BrushStyle::Leaf => {
hcie_engine_api::BrushStyle::Leaf
}
hcie_engine_api::tools::BrushStyle::Rock => {
hcie_engine_api::BrushStyle::Rock
}
hcie_engine_api::tools::BrushStyle::Meadow => {
hcie_engine_api::BrushStyle::Meadow
}
hcie_engine_api::tools::BrushStyle::Wood => {
hcie_engine_api::BrushStyle::Wood
}
hcie_engine_api::tools::BrushStyle::Watercolor => {
hcie_engine_api::BrushStyle::Watercolor
}
hcie_engine_api::tools::BrushStyle::Calligraphy => {
hcie_engine_api::BrushStyle::Calligraphy
}
hcie_engine_api::tools::BrushStyle::Marker => {
hcie_engine_api::BrushStyle::Marker
}
hcie_engine_api::tools::BrushStyle::Sketch => {
hcie_engine_api::BrushStyle::Sketch
}
hcie_engine_api::tools::BrushStyle::Hatch => {
hcie_engine_api::BrushStyle::Hatch
}
hcie_engine_api::tools::BrushStyle::Star => {
hcie_engine_api::BrushStyle::Star
}
hcie_engine_api::tools::BrushStyle::Glow => {
hcie_engine_api::BrushStyle::Glow
}
hcie_engine_api::tools::BrushStyle::Airbrush => {
hcie_engine_api::BrushStyle::Airbrush
}
hcie_engine_api::tools::BrushStyle::Pencil => {
hcie_engine_api::BrushStyle::Pencil
}
hcie_engine_api::tools::BrushStyle::Crayon => {
hcie_engine_api::BrushStyle::Crayon
}
hcie_engine_api::tools::BrushStyle::WetPaint => {
hcie_engine_api::BrushStyle::WetPaint
}
hcie_engine_api::tools::BrushStyle::InkPen => {
hcie_engine_api::BrushStyle::InkPen
}
hcie_engine_api::tools::BrushStyle::Clouds => {
hcie_engine_api::BrushStyle::Clouds
}
hcie_engine_api::tools::BrushStyle::Dirt => {
hcie_engine_api::BrushStyle::Dirt
}
hcie_engine_api::tools::BrushStyle::Tree => {
hcie_engine_api::BrushStyle::Tree
}
hcie_engine_api::tools::BrushStyle::Bristle => {
hcie_engine_api::BrushStyle::Bristle
}
hcie_engine_api::tools::BrushStyle::Mixer => {
hcie_engine_api::BrushStyle::Mixer
}
hcie_engine_api::tools::BrushStyle::Blender => {
hcie_engine_api::BrushStyle::Blender
}
hcie_engine_api::tools::BrushStyle::Bitmap => {
hcie_engine_api::BrushStyle::Bitmap
}
};
tip.size = self.state.tool_configs.brush.size;
tip.opacity = self.state.tool_configs.brush.opacity;
@@ -1600,25 +1671,20 @@ impl<'a> egui::Widget for CanvasWidget<'a> {
});
}
if let Some(tex) = ui
.data(|d| d.get_temp::<egui::TextureHandle>(ui.id().with("cursor_stamp_tex")))
if let Some(tex) =
ui.data(|d| d.get_temp::<egui::TextureHandle>(ui.id().with("cursor_stamp_tex")))
{
// Display the stamp preview at the correct diameter.
// The texture was rendered at tex_size × tex_size pixels;
// display it at diameter_px × diameter_px screen pixels so
// the preview matches the actual brush footprint.
let display_dim = egui::vec2(diameter_px, diameter_px);
let rect = egui::Rect::from_center_size(
egui::pos2(screen_x, screen_y),
display_dim,
);
let rect =
egui::Rect::from_center_size(egui::pos2(screen_x, screen_y), display_dim);
painter.add(egui::Shape::image(
tex.id(),
rect,
egui::Rect::from_min_max(
egui::pos2(0.0, 0.0),
egui::pos2(1.0, 1.0),
),
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
egui::Color32::WHITE,
));
@@ -2551,5 +2617,3 @@ fn find_text_layer_at(engine: &hcie_engine_api::Engine, x: f32, y: f32) -> Optio
}
None
}
@@ -203,11 +203,14 @@ pub fn render_composition(
let src_start = ((y * rw + x0) as usize) * 4;
let dst_start = ((y - y0) as usize * region_w) * 4;
let row_bytes = region_w * 4;
region_pixels[dst_start..dst_start + row_bytes]
.copy_from_slice(&doc.composite_buffer[src_start..src_start + row_bytes]);
region_pixels[dst_start..dst_start + row_bytes].copy_from_slice(
&doc.composite_buffer[src_start..src_start + row_bytes],
);
}
let region_image =
egui::ColorImage::from_rgba_unmultiplied([region_w, region_h], &region_pixels);
let region_image = egui::ColorImage::from_rgba_unmultiplied(
[region_w, region_h],
&region_pixels,
);
tex.set_partial([x0 as usize, y0 as usize], region_image, options);
}
} else {
+2
View File
@@ -1530,9 +1530,11 @@ impl Engine {
}
/// Get mutable reference to shapes on a vector layer for direct manipulation.
/// Marks the layer dirty so the composite is regenerated on the next render pass.
pub fn get_layer_shapes_direct(&mut self, layer_id: u64) -> Option<&mut Vec<VectorShape>> {
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
if let LayerData::Vector { ref mut shapes } = layer.data {
layer.dirty = true;
return Some(shapes);
}
}
@@ -92,7 +92,7 @@ pub enum AiProvider {
impl AiProvider {
/// Return the default base URL for this provider.
pub fn default_url(&self) -> &str {
pub fn _default_url(&self) -> &str {
match self {
AiProvider::Ollama => "http://localhost:11434",
AiProvider::Claude => "https://api.anthropic.com",
@@ -101,7 +101,7 @@ impl AiProvider {
}
/// Return the display name.
pub fn display_name(&self) -> &str {
pub fn _display_name(&self) -> &str {
match self {
AiProvider::Ollama => "Ollama",
AiProvider::Claude => "Claude",
@@ -120,7 +120,7 @@ pub struct AiChatConfig {
/// Model name to use for chat completions.
pub model: String,
/// Maximum conversation turns before auto-truncation.
pub max_turns: u32,
pub _max_turns: u32,
/// Whether to display reasoning/thinking content from the LLM.
pub reasoning_enabled: bool,
/// Whether to send canvas context (dimensions, layers) with each request.
@@ -133,7 +133,7 @@ impl Default for AiChatConfig {
provider: AiProvider::Ollama,
base_url: "http://localhost:11434".to_string(),
model: "qwen2.5:latest".to_string(),
max_turns: 20,
_max_turns: 20,
reasoning_enabled: false,
canvas_context: true,
}
@@ -156,7 +156,7 @@ pub struct AiChatState {
/// Current reasoning/thinking text being streamed.
pub current_reasoning: String,
/// Available models fetched from the endpoint.
pub available_models: Vec<String>,
pub _available_models: Vec<String>,
}
impl Default for AiChatState {
@@ -168,7 +168,7 @@ impl Default for AiChatState {
is_streaming: false,
current_response: String::new(),
current_reasoning: String::new(),
available_models: Vec::new(),
_available_models: Vec::new(),
}
}
}
+54 -18
View File
@@ -152,10 +152,10 @@ pub struct HcieIcedApp {
/// Pending automated or keyboard-triggered full-viewport screenshot.
pub screenshot_request: Option<crate::cli::ScreenshotRequest>,
/// New Image dialog state.
pub dialog_input: String,
pub dialog_input2: String,
pub show_performance: bool,
pub ui_scale: f32,
pub _dialog_input: String,
pub _dialog_input2: String,
pub _show_performance: bool,
pub _ui_scale: f32,
pub modifiers: iced::keyboard::Modifiers,
pub dialog_new_name: String,
pub dialog_new_width: u32,
@@ -280,7 +280,7 @@ pub struct HcieIcedApp {
/// Whether the dock profile dialog is visible.
pub show_dock_profile_dialog: bool,
/// Current UI language for internationalization.
pub language: i18n::Language,
pub _language: i18n::Language,
/// Context menu screen position (x, y). None means closed.
pub canvas_context_menu: Option<(f32, f32)>,
/// Dirty flag set whenever fg/bg/recent colors change, so colors can be
@@ -1356,10 +1356,10 @@ impl HcieIcedApp {
sub_tools_open: false,
sidebar_expanded: settings.panel_layout.sidebar_expanded,
slot_last_used: vec![0; 20],
dialog_input: String::new(),
dialog_input2: String::new(),
show_performance: false,
ui_scale: 1.0,
_dialog_input: String::new(),
_dialog_input2: String::new(),
_show_performance: false,
_ui_scale: 1.0,
modifiers: iced::keyboard::Modifiers::default(),
canvas_context_menu: None,
settings: settings.clone(),
@@ -1434,7 +1434,7 @@ impl HcieIcedApp {
dock_profile_rename_idx: None,
dock_profile_rename_buf: String::new(),
show_dock_profile_dialog: false,
language: i18n::Language::default(),
_language: i18n::Language::default(),
colors_dirty: false,
vector_shapes_snapshot: None,
vector_drag_last: None,
@@ -1582,7 +1582,7 @@ impl HcieIcedApp {
///
/// The canvas image is centered in the pane, with `pan_offset` as an
/// additional offset from center. This function accounts for both.
fn screen_to_canvas(&self, sx: f32, sy: f32) -> Option<(f32, f32)> {
fn _screen_to_canvas(&self, sx: f32, sy: f32) -> Option<(f32, f32)> {
let doc = &self.documents[self.active_doc];
let zoom = doc.zoom;
let (pane_w, pane_h) = doc.pane_size;
@@ -1823,7 +1823,7 @@ impl HcieIcedApp {
// structure is no longer valid, and a partial copy would leave zeros in the new buffer.
if let Some(rect) = dirty_rect.filter(|_| !is_full_copy) {
let w = doc.engine.canvas_width() as usize;
let h = doc.engine.canvas_height() as usize;
let _h = doc.engine.canvas_height() as usize;
let [x0, y0, x1, y1] = rect;
let x0 = x0 as usize;
let y0 = y0 as usize;
@@ -5383,6 +5383,20 @@ impl HcieIcedApp {
self.documents[self.active_doc].vector_drag_preview = None;
return Task::none();
}
// Frame-skip throttle: skip processing if less than ~16ms since
// the last update to keep the overlay drawing at ~60 FPS and avoid
// flooding the message queue.
let now = std::time::Instant::now();
let min_interval = std::time::Duration::from_secs_f32(1.0 / 60.0);
if let Some(last) = self.vector_drag_last_render {
if now.duration_since(last) < min_interval {
// Still update pointer tracking even when skipping preview
self.vector_drag_last = Some((x, y));
return Task::none();
}
}
let shape = crate::vector_edit::transform_shape(
&session,
(x, y),
@@ -5399,6 +5413,7 @@ impl HcieIcedApp {
self.documents[self.active_doc].vector_drag_preview = Some(shape);
self.documents[self.active_doc].modified = true;
self.vector_drag_last = Some((x, y));
self.vector_drag_last_render = Some(now);
self.vector_drag_last_bounds = Some((x1, y1, x2, y2));
self.vector_drag_last_angle = Some(angle);
self.settings.tool_settings.vector_angle = angle.to_degrees();
@@ -5485,12 +5500,32 @@ impl HcieIcedApp {
}
// ── Vector shape apply / cancel / flip ──────
Message::VectorShapeApply => {
// Commit edits: clear snapshot and deselect.
// Commit any pending drag preview to the engine before deselecting,
// then clear the snapshot so Cancel cannot revert.
let need_refresh = {
let doc = &mut self.documents[self.active_doc];
doc.selected_vector_shape = None;
doc.vector_cycle_index = 0;
doc.vector_edit_handle = hcie_engine_api::VectorEditHandle::None;
doc.vector_drag_preview = None;
if let Some(shape) = doc.vector_drag_preview.take() {
let shape_idx = doc.selected_vector_shape;
let (x1, y1, x2, y2) = shape.normalized_bounds();
let angle = shape.angle();
let layer_id = doc.engine.active_layer_id();
if let Some(idx) = shape_idx {
doc.engine.set_vector_shape_bounds(layer_id, idx, x1, y1, x2, y2);
doc.engine.set_vector_shape_angle(layer_id, idx, angle);
}
doc.engine.mark_composite_dirty();
doc.engine.is_composite_dirty()
} else {
false
}
};
if need_refresh {
self.refresh_composite_if_needed();
}
self.documents[self.active_doc].selected_vector_shape = None;
self.documents[self.active_doc].vector_cycle_index = 0;
self.documents[self.active_doc].vector_edit_handle =
hcie_engine_api::VectorEditHandle::None;
self.vector_shapes_snapshot = None;
self.vector_drag_last = None;
self.vector_drag_session = None;
@@ -5510,6 +5545,7 @@ impl HcieIcedApp {
self.vector_drag_session = None;
doc.engine.mark_composite_dirty();
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
}
Message::VectorShapeFlipH => {
@@ -8230,7 +8266,7 @@ mod cycle_one_ux_tests {
/// Find the topmost text layer whose rasterized pixels contain the point
/// `(x, y)` in canvas coordinates.
fn find_text_layer_at(engine: &hcie_engine_api::Engine, x: f32, y: f32) -> Option<u64> {
fn _find_text_layer_at(engine: &hcie_engine_api::Engine, x: f32, y: f32) -> Option<u64> {
let ux = x.round().clamp(0.0, u32::MAX as f32) as u32;
let uy = y.round().clamp(0.0, u32::MAX as f32) as u32;
let infos = engine.layer_infos();
@@ -537,7 +537,7 @@ pub fn import_abr_with_prefix(data: &[u8], prefix: &str) -> Vec<BrushPreset> {
///
/// # Returns
/// Optional `BrushPreset` if parsing succeeds.
pub fn import_kpp(data: &[u8], name_hint: &str) -> Option<BrushPreset> {
pub fn _import_kpp(data: &[u8], name_hint: &str) -> Option<BrushPreset> {
use std::io::Cursor;
use zip::ZipArchive;
@@ -60,11 +60,11 @@ struct OverlayProgram {
/// Active selection transform (for drawing handles).
selection_transform: Option<SelectionTransform>,
/// Current transform handle being hovered/dragged.
active_handle: TransformHandle,
_active_handle: TransformHandle,
/// Crop tool state (for drawing crop overlay).
crop_state: Option<CropState>,
/// Marching ants animation offset (0.0..1.0).
marching_ants_offset: f32,
_marching_ants_offset: f32,
/// Current pen pressure for HUD display (0.0..1.0, 1.0 = no tablet).
pressure: f32,
/// Active tool (drives vector shape preview geometry).
@@ -82,11 +82,11 @@ struct OverlayProgram {
/// Polygon selection points accumulated during click-by-click (canvas-space).
polygon_points: Vec<(u32, u32)>,
/// Extracted edges for marching ants rendering.
marching_ants_edges: Option<std::sync::Arc<Vec<(u32, u32, u32, u32)>>>,
_marching_ants_edges: Option<std::sync::Arc<Vec<(u32, u32, u32, u32)>>>,
/// Exact horizontal selected-pixel spans for irregular selection fill.
selection_fill_spans: Option<std::sync::Arc<Vec<(u32, u32, u32)>>>,
_selection_fill_spans: Option<std::sync::Arc<Vec<(u32, u32, u32)>>>,
/// Whether selected pixels use quick-mask red instead of the normal blue tint.
quick_mask: bool,
_quick_mask: bool,
/// Active brush diameter in canvas pixels for the footprint cursor.
brush_size: f32,
/// Normalized bounds of the selected vector shape in canvas-space (x1, y1, x2, y2).
@@ -891,7 +891,7 @@ impl canvas::Program<Message> for OverlayProgram {
// arrow_thickness = stroke * 1.8, head_len = arrow_thickness * 3
let dx = x1 - x0;
let dy = y1 - y0;
let len = (dx * dx + dy * dy).sqrt().max(1.0);
let _len = (dx * dx + dy * dy).sqrt().max(1.0);
let head_angle = dy.atan2(dx);
let arrow_angle = std::f32::consts::PI / 6.0;
let arrow_thickness = self.zoom; // stroke width in canvas space
@@ -1283,6 +1283,51 @@ impl canvas::Program<Message> for OverlayProgram {
}
}
// Draw vector shape preview during a drag (real-time rotated outline).
// The engine composite is NOT updated during drag, so the overlay must
// draw the preview shape to show the user where the shape will end up.
// A solid bright outline is used instead of dashed so the preview is
// clearly visible during fast pointer movements.
if let Some(preview) = self.vector_drag_preview.as_ref() {
let (px1, py1, px2, py2) = preview.normalized_bounds();
let pang = preview.angle();
let cx = (px1 + px2) * 0.5;
let cy = (py1 + py2) * 0.5;
let cos_a = pang.cos();
let sin_a = pang.sin();
let rotate = |x: f32, y: f32| -> (f32, f32) {
let dx = x - cx;
let dy = y - cy;
(cx + dx * cos_a - dy * sin_a, cy + dx * sin_a + dy * cos_a)
};
let corners = [(px1, py1), (px2, py1), (px2, py2), (px1, py2)];
let rotated_corners: Vec<(f32, f32)> =
corners.iter().map(|&(x, y)| rotate(x, y)).collect();
let preview_path = Path::new(|b| {
for (i, &(rx, ry)) in rotated_corners.iter().enumerate() {
let sx = origin_x + rx * self.zoom;
let sy = origin_y + ry * self.zoom;
if i == 0 {
b.move_to(Point::new(sx, sy));
} else {
b.line_to(Point::new(sx, sy));
}
}
b.close();
});
// Solid bright outline for fast visible feedback
frame.stroke(
&preview_path,
Stroke {
style: canvas::stroke::Style::Solid(
iced::Color::from_rgba(0.2, 0.9, 0.4, 0.95),
),
width: (2.5 / self.zoom.max(1.0)).max(1.0),
..Default::default()
},
);
}
// Draw vector edit handles for selected shape (blue outline, resize + rotation)
if let Some((x1, y1, x2, y2)) = self.selected_vector_bounds {
self.draw_vector_edit_handles(
@@ -1624,12 +1669,17 @@ pub fn view<'a>(
)
} else if let Some(idx) = doc.selected_vector_shape {
let layer_id = doc.engine.active_layer_id();
if let Some(shapes) = doc.engine.active_vector_shapes() {
if let Some(shape) = shapes.get(idx) {
let shapes = doc.engine.active_vector_shapes();
// Validate that the selected index exists on the current active layer.
// If the layer changed or shapes were reordered, the index may be stale.
if let Some(ref shapes) = shapes {
if idx < shapes.len() {
let shape = &shapes[idx];
let (x1, y1, x2, y2) = shape.normalized_bounds();
let angle = doc.engine.vector_shape_angle(layer_id, idx);
(Some((x1, y1, x2, y2)), angle, None)
} else {
// Stale index — silently show nothing until the next valid click
(None, 0.0, None)
}
} else {
@@ -1648,9 +1698,9 @@ pub fn view<'a>(
selection_bounds: doc.selection_bounds,
vector_draw: doc.vector_draw,
selection_transform: doc.selection_transform.clone(),
active_handle: TransformHandle::None, // TODO: track hover state
_active_handle: TransformHandle::None, // TODO: track hover state
crop_state: Some(doc.crop_state.clone()),
marching_ants_offset,
_marching_ants_offset: marching_ants_offset,
pressure,
active_tool: tool_state.active_tool,
star_points,
@@ -1659,9 +1709,9 @@ pub fn view<'a>(
gradient_drag: doc.gradient_drag,
lasso_points: doc.lasso_points.clone(),
polygon_points: doc.polygon_points.clone(),
marching_ants_edges: doc.marching_ants_edges.clone(),
selection_fill_spans: doc.selection_fill_spans.clone(),
quick_mask: doc.quick_mask,
_marching_ants_edges: doc.marching_ants_edges.clone(),
_selection_fill_spans: doc.selection_fill_spans.clone(),
_quick_mask: doc.quick_mask,
brush_size: tool_state.brush_size,
selected_vector_bounds: sel_vec_bounds,
selected_vector_angle: sel_vec_angle,
@@ -31,6 +31,7 @@ pub enum ColorPickerTarget {
/// HSV color representation (Hue, Saturation, Value)
#[derive(Debug, Clone, Copy)]
#[allow(dead_code)]
pub struct Hsv {
pub h: f32, // 0..360
pub s: f32, // 0..1
@@ -38,12 +39,14 @@ pub struct Hsv {
}
impl Hsv {
#[allow(dead_code)]
pub fn to_rgb(&self) -> (u8, u8, u8) {
hsv_to_rgb(self.h, self.s, self.v)
}
}
/// Convert RGB to HSV.
#[allow(dead_code)]
pub fn rgb_to_hsv(r: u8, g: u8, b: u8) -> Hsv {
let r = r as f32 / 255.0;
let g = g as f32 / 255.0;
@@ -69,6 +72,7 @@ pub fn rgb_to_hsv(r: u8, g: u8, b: u8) -> Hsv {
}
/// Convert HSV to RGB.
#[allow(dead_code)]
pub fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (u8, u8, u8) {
let h = h / 360.0;
let (r, g, b) = if s == 0.0 {
@@ -15,6 +15,7 @@ pub enum DockRole {
/// A movable utility panel.
Tool,
/// Shell UI that never enters the dock manager.
#[allow(dead_code)]
Fixed,
}
@@ -108,6 +108,7 @@ impl PaneType {
}
/// Compact readable label used on narrow auto-hide rails.
#[allow(dead_code)]
pub fn rail_label(self) -> &'static str {
match self {
Self::ColorPicker => "Color",
@@ -40,6 +40,7 @@ pub enum TranslationKey {
/// Returns the translated string for the given language and key.
///
/// Uses match arms to return `&'static str` — no allocations at call site.
#[allow(dead_code)]
pub fn t(lang: Language, key: TranslationKey) -> &'static str {
match lang {
Language::Turkish => match key {
@@ -4,3 +4,4 @@ pub mod cli;
pub mod raster;
pub mod selection;
pub mod vector_edit;
pub mod widgets;
@@ -24,6 +24,7 @@ mod sidebar;
mod theme;
mod vector_edit;
mod viewer;
mod widgets;
use app::HcieIcedApp;
@@ -778,7 +778,7 @@ pub fn view(
.padding([2, 8]);
row![lbl, toggle_btn].spacing(4)
},
{ text("Gradient stops configured via color picker").size(10) },
text("Gradient stops configured via color picker").size(10),
]
.spacing(2)
.into()
@@ -7,7 +7,7 @@
use crate::app::Message;
use crate::panels::styles;
use crate::theme::ThemeColors;
use iced::widget::{column, container, horizontal_rule, scrollable, text};
use iced::widget::{column, container, scrollable, text};
use iced::{Element, Length};
/// Build the history panel element.
@@ -229,6 +229,7 @@ fn menu_item_row(
/// All menu definitions matching Photopea's menu structure exactly.
#[derive(Clone)]
struct MenuDef {
#[allow(dead_code)]
label: String,
items: Vec<MenuItem>,
}
@@ -10,10 +10,9 @@ use crate::color_picker::ColorPickerTarget;
use crate::panels::styles;
use crate::panels::typography::{BODY, SECTION};
use crate::theme::ThemeColors;
use crate::widgets::plain_slider;
use hcie_engine_api::{BlendMode, LayerType, Tool, VectorShape};
use iced::widget::{
button, checkbox, column, container, horizontal_rule, row, scrollable, slider, text,
};
use iced::widget::{button, checkbox, column, container, horizontal_rule, row, scrollable, text};
use iced::{Element, Length};
/// Human-readable label for a shape at a given index.
@@ -200,11 +199,15 @@ fn layer_info_section<'a>(
layer_id: u64,
_colors: ThemeColors,
) -> Element<'a, Message> {
let opacity_slider = slider(0.0..=1.0, opacity, move |v| {
Message::LayerSetOpacity(layer_id, v)
})
.step(0.01)
.width(Length::Fill);
let opacity_slider = plain_slider(
"Opacity",
opacity,
0.0..=1.0,
0.01,
"%",
0,
move |v| Message::LayerSetOpacity(layer_id, v),
);
column![
text("Layer Info").size(SECTION),
@@ -213,18 +216,11 @@ fn layer_info_section<'a>(
text(name).size(BODY),
]
.spacing(4),
row![
text("Opacity").size(BODY).width(Length::Fixed(58.0)),
text(format!("{:.0}%", opacity * 100.0))
.size(BODY)
.width(Length::Fixed(36.0)),
]
.spacing(4),
opacity_slider,
row![
text("Visible").size(BODY).width(Length::Fixed(58.0)),
checkbox("", visible)
.on_toggle(move |v| Message::LayerToggleVisibility(layer_id))
.on_toggle(move |_v| Message::LayerToggleVisibility(layer_id))
.size(11),
]
.spacing(4)
@@ -289,44 +285,42 @@ fn vector_select_properties<'a>(
text("Shape Properties").size(SECTION),
row![text("Shape").size(BODY), text(label).size(BODY)].spacing(4),
row![text("Type").size(BODY), text(shape_type).size(BODY)].spacing(4),
row![
text("Stroke").size(BODY),
text(format!("{:.1}", stroke)).size(BODY)
]
.spacing(4),
slider(0.0..=50.0, stroke, move |v| Message::VectorStrokeChanged(v))
.step(0.5)
.width(Length::Fill),
row![
text("Opacity").size(BODY),
text(format!("{:.0}%", opacity * 100.0)).size(BODY)
]
.spacing(4),
slider(0.0..=1.0, opacity, move |v| Message::VectorOpacityChanged(
v
))
.step(0.01)
.width(Length::Fill),
row![
text("Hardness").size(BODY),
text(format!("{:.0}%", hardness * 100.0)).size(BODY)
]
.spacing(4),
slider(0.0..=1.0, hardness, move |v| {
Message::GeometryHardnessChanged(v)
})
.step(0.01)
.width(Length::Fill),
row![
text("Angle").size(BODY),
text(format!("{:.0}°", vector_angle)).size(BODY)
]
.spacing(4),
slider(-180.0..=180.0, vector_angle, move |v| {
Message::VectorAngleChanged(v)
})
.step(1.0)
.width(Length::Fill),
plain_slider(
"Stroke",
stroke,
0.0..=50.0,
0.5,
"px",
1,
move |v| Message::VectorStrokeChanged(v),
),
plain_slider(
"Opacity",
opacity,
0.0..=1.0,
0.01,
"%",
0,
move |v| Message::VectorOpacityChanged(v),
),
plain_slider(
"Hardness",
hardness,
0.0..=1.0,
0.01,
"%",
0,
move |v| Message::GeometryHardnessChanged(v),
),
plain_slider(
"Angle",
vector_angle,
-180.0..=180.0,
1.0,
"°",
0,
move |v| Message::VectorAngleChanged(v),
),
row![
text("Fill").size(BODY),
checkbox("", filled)
@@ -380,22 +374,24 @@ fn vector_tool_properties<'a>(
) -> Element<'a, Message> {
let mut col = column![
text("Vector Shape").size(11),
row![
text("Stroke").size(10),
text(format!("{:.1}", stroke)).size(10)
]
.spacing(4),
slider(0.0..=50.0, stroke, |v| Message::VectorStrokeChanged(v))
.step(0.5)
.width(Length::Fill),
row![
text("Opacity").size(10),
text(format!("{:.0}%", opacity * 100.0)).size(10)
]
.spacing(4),
slider(0.0..=1.0, opacity, |v| Message::VectorOpacityChanged(v))
.step(0.01)
.width(Length::Fill),
plain_slider(
"Stroke",
stroke,
0.0..=50.0,
0.5,
"px",
1,
|v| Message::VectorStrokeChanged(v),
),
plain_slider(
"Opacity",
opacity,
0.0..=1.0,
0.01,
"%",
0,
|v| Message::VectorOpacityChanged(v),
),
row![
text("Fill").size(10),
checkbox("", fill)
@@ -426,45 +422,37 @@ fn vector_tool_properties<'a>(
// Shape-specific controls
match tool {
Tool::VectorRect => {
col = col.push(
row![
text("Radius").size(10),
text(format!("{:.1}", radius)).size(10)
]
.spacing(4),
);
col = col.push(
slider(0.0..=100.0, radius, |v| Message::VectorRadiusChanged(v))
.step(1.0)
.width(Length::Fill),
);
col = col.push(plain_slider(
"Radius",
radius,
0.0..=100.0,
1.0,
"px",
1,
|v| Message::VectorRadiusChanged(v),
));
}
Tool::VectorStar => {
col = col.push(
row![
text("Points").size(10),
text(format!("{}", points)).size(10)
]
.spacing(4),
);
col = col.push(
slider(3.0..=20.0, points as f32, |v| {
Message::VectorPointsChanged(v as u32)
})
.step(1.0)
.width(Length::Fill),
);
col = col.push(plain_slider(
"Points",
points as f32,
3.0..=20.0,
1.0,
"",
0,
|v| Message::VectorPointsChanged(v as u32),
));
}
Tool::VectorPolygon => {
col = col
.push(row![text("Sides").size(10), text(format!("{}", sides)).size(10)].spacing(4));
col = col.push(
slider(3.0..=12.0, sides as f32, |v| {
Message::VectorSidesChanged(v as u32)
})
.step(1.0)
.width(Length::Fill),
);
col = col.push(plain_slider(
"Sides",
sides as f32,
3.0..=12.0,
1.0,
"",
0,
|v| Message::VectorSidesChanged(v as u32),
));
}
_ => {}
}
@@ -488,32 +476,35 @@ fn brush_properties<'a>(
hardness: f32,
_colors: ThemeColors,
) -> Element<'a, Message> {
let size_slider = slider(1.0..=200.0, size, |v| Message::BrushSizeChanged(v))
.step(1.0)
.width(Length::Fill);
let opacity_slider = slider(0.0..=1.0, opacity, |v| Message::BrushOpacityChanged(v))
.step(0.01)
.width(Length::Fill);
let hardness_slider = slider(0.0..=1.0, hardness, |v| Message::BrushHardnessChanged(v))
.step(0.01)
.width(Length::Fill);
column![
text("Brush Properties").size(11),
row![text("Size").size(10), text(format!("{:.0}", size)).size(10)].spacing(4),
size_slider,
row![
text("Opacity").size(10),
text(format!("{:.0}%", opacity * 100.0)).size(10)
]
.spacing(4),
opacity_slider,
row![
text("Hardness").size(10),
text(format!("{:.0}%", hardness * 100.0)).size(10)
]
.spacing(4),
hardness_slider,
plain_slider(
"Size",
size,
1.0..=200.0,
1.0,
"px",
0,
|v| Message::BrushSizeChanged(v),
),
plain_slider(
"Opacity",
opacity,
0.0..=1.0,
0.01,
"%",
0,
|v| Message::BrushOpacityChanged(v),
),
plain_slider(
"Hardness",
hardness,
0.0..=1.0,
0.01,
"%",
0,
|v| Message::BrushHardnessChanged(v),
),
]
.spacing(4)
.into()
@@ -106,6 +106,7 @@ pub fn panel_background(colors: ThemeColors) -> iced::widget::container::Style {
}
/// Panel header — #333333 (Photopea header color).
#[allow(dead_code)]
pub fn panel_header(colors: ThemeColors) -> iced::widget::container::Style {
iced::widget::container::Style {
background: Some(iced::Background::Color(colors.bg_active)),
@@ -53,9 +53,9 @@ pub(crate) fn menu_anchor_x(menu_index: usize, viewport_width: f32, dropdown_wid
pub fn view<'a>(
doc_name: &'a str,
modified: bool,
zoom: f32,
canvas_w: u32,
canvas_h: u32,
_zoom: f32,
_canvas_w: u32,
_canvas_h: u32,
colors: ThemeColors,
active_menu: Option<usize>,
) -> Element<'a, Message> {
@@ -13,7 +13,7 @@ use crate::app::{Message, ToolState};
use crate::panels::styles;
use crate::theme::ThemeColors;
use hcie_engine_api::Tool;
use iced::widget::{button, column, container, horizontal_rule, row, slider, text, text_input};
use iced::widget::{button, container, row, slider, text, text_input};
use iced::{Element, Length};
/// Build the Photopea-style tool options bar.
@@ -25,8 +25,8 @@ use iced::{Element, Length};
/// - Move: Auto-Select
/// - Text: Font, Size, Color
/// - Gradient: Mode, Opacity
pub fn view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, Message> {
let tool_name = text(tool_name_label(tool_state.active_tool))
pub fn _view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, Message> {
let tool_name = text(_tool_name_label(tool_state.active_tool))
.size(11)
.style(move |_theme: &iced::Theme| iced::widget::text::Style {
color: Some(colors.text_primary),
@@ -202,7 +202,7 @@ pub fn view<'a>(tool_state: &'a ToolState, colors: ThemeColors) -> Element<'a, M
}
/// Get the display name for a tool.
fn tool_name_label(tool: Tool) -> &'static str {
fn _tool_name_label(tool: Tool) -> &'static str {
match tool {
Tool::Move => "Move Tool",
Tool::Select => "Rectangle Select",
@@ -226,6 +226,7 @@ fn tool_name_label(tool: Tool) -> &'static str {
}
/// A small checkbox-style label (visual only, for display).
#[allow(dead_code)]
fn checkbox_label(label: &str, checked: bool) -> Element<'static, Message> {
let mark = if checked { "" } else { "" };
let label_owned = label.to_string();
@@ -236,6 +237,7 @@ fn checkbox_label(label: &str, checked: bool) -> Element<'static, Message> {
}
/// A small flat button for tool options.
#[allow(dead_code)]
fn small_button(label: &str, colors: ThemeColors) -> Element<'static, Message> {
let bg_hover = colors.bg_hover;
let bg_panel = colors.bg_panel;
@@ -17,7 +17,7 @@ use crate::settings::AppSettings;
use crate::theme::ThemeColors;
use hcie_engine_api::Tool;
use iced::widget::{
button, checkbox, column, container, horizontal_rule, row, scrollable, slider, text, text_input,
button, column, container, horizontal_rule, row, scrollable, slider, text, text_input,
};
use iced::{Element, Length};
@@ -309,10 +309,10 @@ fn text_settings(
let draft_angle = text_draft.map(|d| d.angle).unwrap_or(ts2.text_angle);
let draft_color = text_draft.map(|d| d.color).unwrap_or(fg_color);
let draft_content = text_draft.map(|d| d.content.clone()).unwrap_or_default();
let alignment = text_draft
let _alignment = text_draft
.map(|d| format!("{:?}", d.alignment))
.unwrap_or_else(|| "Left".to_string());
let orientation = text_draft
let _orientation = text_draft
.map(|d| format!("{:?}", d.orientation))
.unwrap_or_else(|| "Horizontal".to_string());
@@ -231,6 +231,7 @@ fn svg_btn(
)
}
#[allow(dead_code)]
fn small_btn(label: &str, colors: ThemeColors) -> Element<'static, Message> {
let c = colors;
let l = label.to_string();
@@ -1,9 +1,3 @@
pub mod executor;
pub mod parser;
pub mod types;
pub use executor::execute_actions;
pub use parser::parse_dsl_script;
pub use parser::parse_error_line;
pub use parser::suggest_fix;
pub use parser::DEFAULT_SCRIPT;
@@ -93,6 +93,7 @@ pub fn extract_masked_pixels(
/// * `floating` - The floating selection transform containing pixels to composite
/// * `canvas_w` - Width of the canvas in pixels
/// * `canvas_h` - Height of the canvas in pixels
#[allow(dead_code)]
pub fn composite_floating_to_layer(
layer_pixels: &mut [u8],
floating: &SelectionTransform,
@@ -339,6 +339,7 @@ impl TransformHandle {
];
/// Get cursor icon hint for this handle.
#[allow(dead_code)]
pub fn cursor_hint(&self) -> &'static str {
match self {
TransformHandle::None => "default",
@@ -22,6 +22,7 @@ use iced::{Element, Length};
pub struct ToolSlot {
pub tools: &'static [Tool],
pub label: &'static str,
#[allow(dead_code)]
pub icon: &'static str,
}
@@ -39,6 +39,7 @@ impl ThemeState {
/// **Returns:** The preset used to produce the current color tokens.
/// **Logic & Workflow:** Reads the preset from the same lock as its resolved colors.
/// **Side Effects / Dependencies:** Briefly locks shared theme state.
#[allow(dead_code)]
pub fn preset(&self) -> ThemePreset {
self.state.lock().unwrap().0
}
@@ -183,6 +183,7 @@ impl ViewerState {
}
/// Get subdirectories of a path, cached to avoid repeated disk reads.
#[allow(dead_code)]
pub fn get_subdirs(&mut self, path: &Path) -> &[PathBuf] {
if !self.subdirs_cache.contains_key(path) {
let mut dirs = Vec::new();
@@ -154,6 +154,7 @@ fn thumbnail_card(
}
/// Count how many thumbnails need loading.
#[allow(dead_code)]
pub fn count_unloaded(state: &ViewerState) -> usize {
state
.images
@@ -0,0 +1,10 @@
//! Reusable iced widgets for the HCIE GUI.
//!
//! Contains the keyboard-editable `PlainSlider`, mirroring the egui version
//! used elsewhere in the project.
#[allow(deprecated)]
pub mod plain_slider;
#[allow(deprecated)]
pub use plain_slider::plain_slider;
@@ -0,0 +1,334 @@
//! A keyboard-editable numeric slider widget for iced.
//!
//! Mirrors the egui `PlainSlider` used in the egui GUI so the iced
//! Properties panel supports direct value entry from the keyboard.
//! The widget is implemented as an iced `Component`, which keeps the
//! transient edit state inside the widget instead of in the application
//! state. This trait is deprecated in iced 0.13, but it is still the
//! simplest way to bundle local state with a reusable widget.
#![allow(deprecated)]
use iced::widget::{column, component, mouse_area, row, slider, text, text_input, Component};
use iced::{Element, Length, Renderer, Theme};
use std::sync::Arc;
/// Internal state for a `PlainSlider` instance.
#[derive(Debug, Default, Clone)]
pub(crate) struct State {
/// Whether the user is currently typing a value.
editing: bool,
/// Current contents of the text input while editing.
buffer: String,
}
/// Internal events produced by the widget UI.
#[derive(Debug, Clone)]
pub(crate) enum Event {
/// The slider thumb was dragged to a new value.
SliderChanged(f32),
/// The value label was clicked; switch to text-input mode.
StartEdit,
/// The text input contents changed.
InputChanged(String),
/// The user pressed Enter or otherwise submitted the typed value.
Submit,
}
/// A numeric slider with an editable value label.
///
/// The value label displays the current value with a suffix such as `%`
/// or `px`. Clicking the label replaces it with a text input so the user
/// can type an exact value.
#[derive(Clone)]
pub(crate) struct PlainSlider<Message> {
/// Human-readable label shown to the left of the value.
label: String,
/// Current value. The parent application owns the authoritative value
/// and passes it back on each frame.
value: f32,
/// Inclusive numeric range the value is allowed to occupy.
range: std::ops::RangeInclusive<f32>,
/// Step size used by the slider thumb.
step: f32,
/// Suffix shown after the value (e.g. "%", "px").
suffix: String,
/// Number of decimal places to display.
decimals: usize,
/// Width of the value label / text input area.
input_width: f32,
/// Callback invoked whenever the value changes, either by dragging
/// the slider or by submitting the text input.
#[allow(clippy::type_complexity)]
on_change: Arc<dyn Fn(f32) -> Message>,
}
impl<Message> std::fmt::Debug for PlainSlider<Message> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PlainSlider")
.field("label", &self.label)
.field("value", &self.value)
.field("range", &self.range)
.field("step", &self.step)
.field("suffix", &self.suffix)
.field("decimals", &self.decimals)
.field("input_width", &self.input_width)
.field("on_change", &"Arc<dyn Fn(f32) -> Message>")
.finish()
}
}
impl<Message> PlainSlider<Message> {
/// Creates a new editable slider.
///
/// # Arguments
/// * `label` - short label shown next to the value.
/// * `value` - current numeric value.
/// * `range` - inclusive allowed range.
/// * `step` - slider step.
/// * `suffix` - unit suffix (e.g. "%", "px").
/// * `decimals` - number of decimals to display.
/// * `on_change` - callback producing a message for the parent.
pub fn new(
label: impl Into<String>,
value: f32,
range: std::ops::RangeInclusive<f32>,
step: f32,
suffix: impl Into<String>,
decimals: usize,
on_change: impl Fn(f32) -> Message + 'static,
) -> Self {
Self {
label: label.into(),
value,
range,
step,
suffix: suffix.into(),
decimals,
input_width: 48.0,
on_change: Arc::new(on_change),
}
}
/// Sets the width of the editable value area.
#[allow(dead_code)]
pub fn input_width(mut self, width: f32) -> Self {
self.input_width = width;
self
}
/// Formats a numeric value for display, including the suffix.
///
/// Percentage values are stored normalized (0..1) and displayed
/// multiplied by 100. Other units are displayed as-is.
fn format_value(&self, value: f32) -> String {
if self.suffix == "%" {
format!("{:.*}{}", self.decimals, value * 100.0, self.suffix)
} else if self.suffix.is_empty() {
format!("{:.*}", self.decimals, value)
} else {
format!("{:.*} {}", self.decimals, value, self.suffix)
}
}
/// Parses a raw string back into a numeric value, taking `%` into account.
fn parse_value(&self, raw: &str) -> Option<f32> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
let numeric = trimmed
.trim_end_matches(&self.suffix)
.trim()
.replace(',', ".");
let parsed: f32 = numeric.parse().ok()?;
let value = if self.suffix == "%" { parsed / 100.0 } else { parsed };
Some(value.clamp(*self.range.start(), *self.range.end()))
}
}
impl<Message> Component<Message, Theme, Renderer> for PlainSlider<Message>
where
Message: Clone,
{
type State = State;
type Event = Event;
fn update(&mut self, state: &mut State, event: Event) -> Option<Message> {
match event {
Event::SliderChanged(new_value) => {
// Update the local value so the displayed text follows the thumb.
self.value = new_value.clamp(*self.range.start(), *self.range.end());
Some((self.on_change)(self.value))
}
Event::StartEdit => {
state.editing = true;
state.buffer = self.format_value(self.value);
None
}
Event::InputChanged(new_value) => {
state.buffer = new_value;
None
}
Event::Submit => {
if let Some(v) = self.parse_value(&state.buffer) {
self.value = v;
state.editing = false;
state.buffer.clear();
Some((self.on_change)(v))
} else {
state.editing = false;
state.buffer.clear();
None
}
}
}
}
fn view(&self, state: &State) -> Element<'_, Event> {
let value_text = self.format_value(self.value);
let slider_control = slider(self.range.clone(), self.value, Event::SliderChanged)
.step(self.step)
.width(Length::Fill);
if state.editing {
let input = text_input("", &state.buffer)
.width(Length::Fixed(self.input_width))
.size(12)
.on_input(Event::InputChanged)
.on_submit(Event::Submit);
let editor = row![input]
.align_y(iced::Alignment::Center)
.spacing(4);
column![
row![
text(&self.label).size(12).width(Length::Shrink),
editor,
]
.spacing(4)
.align_y(iced::Alignment::Center),
slider_control,
]
.spacing(2)
.into()
} else {
let value_label = mouse_area(
text(value_text)
.size(12)
.width(Length::Fixed(self.input_width))
.align_x(iced::alignment::Horizontal::Right),
)
.on_press(Event::StartEdit);
column![
row![
text(&self.label).size(12).width(Length::Shrink),
value_label,
]
.spacing(4)
.align_y(iced::Alignment::Center),
slider_control,
]
.spacing(2)
.into()
}
}
}
/// Convenience constructor that wraps `PlainSlider` in a lazy component element.
///
/// Use this from panel code in place of the standard `slider` widget.
pub fn plain_slider<Message>(
label: impl Into<String>,
value: f32,
range: std::ops::RangeInclusive<f32>,
step: f32,
suffix: impl Into<String>,
decimals: usize,
on_change: impl Fn(f32) -> Message + 'static,
) -> Element<'static, Message>
where
Message: 'static + Clone,
{
component(PlainSlider::new(label, value, range, step, suffix, decimals, on_change))
}
#[cfg(test)]
mod tests {
use super::PlainSlider;
use iced::widget::Component;
fn dummy(_: f32) -> () {}
#[test]
fn formats_percentage_without_decimals() {
let slider = PlainSlider::new("Opacity", 0.5, 0.0..=1.0, 0.01, "%", 0, dummy);
assert_eq!(slider.format_value(0.5), "50%");
assert_eq!(slider.format_value(1.0), "100%");
assert_eq!(slider.format_value(0.0), "0%");
}
#[test]
fn formats_pxx_with_decimals() {
let slider = PlainSlider::new("Stroke", 37.0, 0.0..=50.0, 0.5, "px", 1, dummy);
assert_eq!(slider.format_value(37.0), "37.0 px");
}
#[test]
fn parses_percentage_input() {
let slider = PlainSlider::new("Opacity", 0.5, 0.0..=1.0, 0.01, "%", 0, dummy);
assert!((slider.parse_value("75%").unwrap() - 0.75).abs() < 1e-6);
assert!((slider.parse_value("75").unwrap() - 0.75).abs() < 1e-6);
assert_eq!(slider.parse_value(""), None);
}
#[test]
fn parses_px_input() {
let slider = PlainSlider::new("Stroke", 10.0, 0.0..=50.0, 0.5, "px", 1, dummy);
assert!((slider.parse_value("25 px").unwrap() - 25.0).abs() < 1e-6);
assert!((slider.parse_value("25").unwrap() - 25.0).abs() < 1e-6);
}
#[test]
fn clamps_out_of_range_input() {
let slider = PlainSlider::new("Opacity", 0.5, 0.0..=1.0, 0.01, "%", 0, dummy);
assert_eq!(slider.parse_value("150"), Some(1.0));
assert_eq!(slider.parse_value("-10"), Some(0.0));
}
#[test]
fn update_emits_on_slider_drag() {
let mut slider = PlainSlider::new("Size", 10.0, 1.0..=200.0, 1.0, "px", 0, dummy);
let mut state = super::State::default();
let message = slider.update(&mut state, super::Event::SliderChanged(42.0));
assert_eq!(message, Some(()));
assert!((slider.value - 42.0).abs() < 1e-6);
}
#[test]
fn update_commits_valid_text_input() {
let mut slider = PlainSlider::new("Opacity", 0.5, 0.0..=1.0, 0.01, "%", 0, dummy);
let mut state = super::State::default();
slider.update(&mut state, super::Event::StartEdit);
slider.update(&mut state, super::Event::InputChanged("80".to_string()));
let message = slider.update(&mut state, super::Event::Submit);
assert!((slider.value - 0.8).abs() < 1e-6);
assert_eq!(message, Some(()));
assert!(!state.editing);
}
#[test]
fn update_ignores_invalid_text_input() {
let mut slider = PlainSlider::new("Opacity", 0.5, 0.0..=1.0, 0.01, "%", 0, dummy);
let mut state = super::State::default();
slider.update(&mut state, super::Event::StartEdit);
slider.update(&mut state, super::Event::InputChanged("abc".to_string()));
let message = slider.update(&mut state, super::Event::Submit);
assert!((slider.value - 0.5).abs() < 1e-6);
assert_eq!(message, None);
assert!(!state.editing);
}
}
+1 -1
View File
@@ -80,7 +80,7 @@ fn main() {
&alpha, CANVAS, CANVAS,
s.depth, s.size, s.soften, 120.0, s.altitude,
&dir_enum, &hcie_fx::types::Technique::Smooth, &style_enum,
best_lb, best_hl, best_sh, 1.0,
best_lb, best_hl, best_sh, 1.0, 1.0,
);
let px = npix();
-2
View File
@@ -1,5 +1,3 @@
use image::GenericImageView;
fn main() {
let ref_img = image::open("_images/_psd_stil_test/test_2/Emboss.png")
.unwrap()
@@ -127,7 +127,7 @@ fn main() {
&rect_alpha, unsafe { CANVAS }, unsafe { CANVAS },
s.depth, s.size, s.soften, s.angle, 30.0,
&dir_enum, &hcie_fx::types::Technique::Smooth, &style_enum,
lighting_blur, hl_scale, sh_scale, profile_exp,
lighting_blur, hl_scale, sh_scale, profile_exp, 1.0,
);
let px = npix();
+46
View File
@@ -0,0 +1,46 @@
#-------------------------------------------------------------------------------#
# Qodana analysis is configured by qodana.yaml file #
# https://www.jetbrains.com/help/qodana/qodana-yaml.html #
#-------------------------------------------------------------------------------#
#################################################################################
# WARNING: Do not store sensitive information in this file, #
# as its contents will be included in the Qodana report. #
#################################################################################
version: "1.0"
#Specify inspection profile for code analysis
profile:
name: qodana.starter
#Enable inspections
#include:
# - name: <SomeEnabledInspectionId>
#Disable inspections
#exclude:
# - name: <SomeDisabledInspectionId>
# paths:
# - <path/where/not/run/inspection>
#Execute shell command before Qodana execution (Applied in CI/CD pipeline)
#bootstrap: sh ./prepare-qodana.sh
#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline)
#plugins:
# - id: <plugin.id> #(plugin id can be found at https://plugins.jetbrains.com)
# Quality gate. Will fail the CI/CD pipeline if any condition is not met
# severityThresholds - configures maximum thresholds for different problem severities
# testCoverageThresholds - configures minimum code coverage on a whole project and newly added code
# Code Coverage is available in Ultimate and Ultimate Plus plans
#failureConditions:
# severityThresholds:
# any: 15
# critical: 5
# testCoverageThresholds:
# fresh: 70
# total: 50
#Specify Qodana linter for analysis (Applied in CI/CD pipeline)
linter: jetbrains/qodana-<linter>:2026.1