Refactor and enhance various components across the application

- Removed obsolete plans for stroke tuning, commit regression gate, and wxPlainSlider port.
- Updated scrollable panel functionality to include optional scrollbar visibility.
- Enhanced brush panel to filter brush styles by category for improved user experience.
- Adjusted canvas rendering logic to prevent UI freezes during active drawing.
- Modified stroke brush logic to allow single dab drawing at the current position when no movement occurs.
This commit is contained in:
2026-07-10 02:02:21 +03:00
parent 07a9b35208
commit 089ce00b49
34 changed files with 214 additions and 4226 deletions
@@ -40,6 +40,17 @@ pub fn scrollable_panel<R>(
ui: &mut egui::Ui,
id: &str,
contents: impl FnOnce(&mut egui::Ui) -> R,
) -> R {
scrollable_panel_with_scrollbar(ui, id, false, contents)
}
/// Wrap `contents` in a vertically scrollable panel with optional visible scrollbar,
/// mouse-wheel support, and left-drag panning.
pub fn scrollable_panel_with_scrollbar<R>(
ui: &mut egui::Ui,
id: &str,
show_scrollbar: bool,
contents: impl FnOnce(&mut egui::Ui) -> R,
) -> R {
let panel_id = ui.id().with(id);
let state_id = panel_id.with("scroll_state_v2");
@@ -71,11 +82,17 @@ pub fn scrollable_panel<R>(
.layout(*ui.layout()),
);
let scrollbar_visibility = if show_scrollbar {
egui::scroll_area::ScrollBarVisibility::VisibleWhenNeeded
} else {
egui::scroll_area::ScrollBarVisibility::AlwaysHidden
};
let scroll_response = egui::ScrollArea::vertical()
.id_salt(panel_id)
.drag_to_scroll(false)
.vertical_scroll_offset(state.offset_y)
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
.scroll_bar_visibility(scrollbar_visibility)
.max_height(visible_h)
.max_width(visible_rect.width().max(1.0))
.show(&mut child_ui, |ui| contents(ui));
@@ -29,6 +29,60 @@ fn matches_category(active_cat: &str, preset_cat: &str) -> bool {
mapped.iter().any(|c| c.eq_ignore_ascii_case(preset_cat))
}
/// Check if a brush style belongs to the given category for the Styles grid view.
fn matches_style_category(active_cat: &str, style: hcie_engine_api::tools::BrushStyle) -> bool {
if active_cat == "All" || active_cat == "Custom" || active_cat == "Imported" {
return true;
}
let matches = match active_cat {
"Drawing" => matches!(
style,
hcie_engine_api::tools::BrushStyle::Default
| hcie_engine_api::tools::BrushStyle::Round
| hcie_engine_api::tools::BrushStyle::HardRound
| hcie_engine_api::tools::BrushStyle::SoftRound
| hcie_engine_api::tools::BrushStyle::Square
| hcie_engine_api::tools::BrushStyle::Pencil
| hcie_engine_api::tools::BrushStyle::Pen
| hcie_engine_api::tools::BrushStyle::InkPen
| hcie_engine_api::tools::BrushStyle::Calligraphy
| hcie_engine_api::tools::BrushStyle::Marker
| hcie_engine_api::tools::BrushStyle::Sketch
| hcie_engine_api::tools::BrushStyle::Hatch
| hcie_engine_api::tools::BrushStyle::Star
),
"Painting" => matches!(
style,
hcie_engine_api::tools::BrushStyle::Oil
| hcie_engine_api::tools::BrushStyle::Charcoal
| hcie_engine_api::tools::BrushStyle::Watercolor
| hcie_engine_api::tools::BrushStyle::Crayon
| hcie_engine_api::tools::BrushStyle::WetPaint
| hcie_engine_api::tools::BrushStyle::Airbrush
| hcie_engine_api::tools::BrushStyle::Bristle
| hcie_engine_api::tools::BrushStyle::Mixer
| hcie_engine_api::tools::BrushStyle::Blender
),
"Effects" => matches!(
style,
hcie_engine_api::tools::BrushStyle::Noise
| hcie_engine_api::tools::BrushStyle::Texture
| hcie_engine_api::tools::BrushStyle::Spray
| hcie_engine_api::tools::BrushStyle::Leaf
| hcie_engine_api::tools::BrushStyle::Rock
| hcie_engine_api::tools::BrushStyle::Meadow
| hcie_engine_api::tools::BrushStyle::Wood
| hcie_engine_api::tools::BrushStyle::Glow
| hcie_engine_api::tools::BrushStyle::Clouds
| hcie_engine_api::tools::BrushStyle::Dirt
| hcie_engine_api::tools::BrushStyle::Tree
| hcie_engine_api::tools::BrushStyle::Bitmap
),
_ => false,
};
matches
}
fn preset_thumb_hash(preset: &BrushPreset) -> u64 {
let mut hasher = DefaultHasher::new();
preset.style.hash(&mut hasher);
@@ -1167,7 +1221,7 @@ pub fn show_brushes_ui(app: &mut HcieApp, ui: &mut egui::Ui) {
state.primary_color[3],
);
crate::app::shell::scrollable_panel::scrollable_panel(ui, "brush_preset_list", |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
@@ -1575,6 +1629,13 @@ pub fn show_brushes_ui(app: &mut HcieApp, ui: &mut egui::Ui) {
let cell_w = 56.0_f32; // compact square cell
let cell_h = 44.0_f32;
// Filter styles by active category
let filtered_styles: Vec<_> = all_styles
.iter()
.filter(|&&(style, _)| matches_style_category(&active_cat, style))
.cloned()
.collect();
// Explicit row layout: compute how many cells fit in the current
// panel width, then lay each row out as a separate horizontal so
// reflow always follows the actual width on every frame.
@@ -1586,12 +1647,12 @@ pub fn show_brushes_ui(app: &mut HcieApp, ui: &mut egui::Ui) {
let cols = ((panel_w + spacing) / total_cell_w).floor().max(1.0) as usize;
let mut idx = 0;
while idx < all_styles.len() {
let end = (idx + cols).min(all_styles.len());
while idx < filtered_styles.len() {
let end = (idx + cols).min(filtered_styles.len());
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = spacing;
ui.spacing_mut().item_spacing.y = 0.0;
for &(style, name) in &all_styles[idx..end] {
for &(style, name) in &filtered_styles[idx..end] {
let is_active = current_style == style;
let (cell_rect, cell_resp) = ui.allocate_exact_size(
@@ -1434,11 +1434,11 @@ impl<'a> egui::Widget for CanvasWidget<'a> {
if self.state.active_tool.shows_pen_tips() {
let screen_x = canvas_rect.min.x + cx as f32 * zoom;
let screen_y = canvas_rect.min.y + cy as f32 * zoom;
let color = egui::Color32::from_rgba_unmultiplied(
let cursor_color = egui::Color32::from_rgba_unmultiplied(
self.state.primary_color[0],
self.state.primary_color[1],
self.state.primary_color[2],
180,
120,
);
let active_tip = self
.state
@@ -1447,69 +1447,134 @@ impl<'a> egui::Widget for CanvasWidget<'a> {
.find(|p| p.id == self.state.active_brush_preset_id)
.map(|p| &p.tip);
if let Some(tip) = active_tip {
if tip.style == BrushStyle::Bitmap
&& !tip.bitmap_pixels.is_empty()
&& tip.bitmap_w > 0
&& tip.bitmap_h > 0
{
let bw = tip.bitmap_w as f32;
let bh = tip.bitmap_h as f32;
let stamp_size = self.state.active_size() * zoom;
let scale = (stamp_size / bw).min(stamp_size / bh);
let draw_w = bw * scale;
let draw_h = bh * scale;
let origin_x = screen_x - draw_w * 0.5;
let origin_y = screen_y - draw_h * 0.5;
let px_size = scale.max(1.0);
let mut shapes = Vec::new();
let mut py = 0.0;
let mut sy = 0u32;
while sy < tip.bitmap_h {
let mut px = 0.0;
let mut sx = 0u32;
while sx < tip.bitmap_w {
let mask =
tip.bitmap_pixels[(sy * tip.bitmap_w + sx) as usize] as f32
match tip.style {
BrushStyle::Bitmap
if !tip.bitmap_pixels.is_empty()
&& tip.bitmap_w > 0
&& tip.bitmap_h > 0 =>
{
let bw = tip.bitmap_w as f32;
let bh = tip.bitmap_h as f32;
let stamp_size = self.state.active_size() * zoom;
let scale = (stamp_size / bw).min(stamp_size / bh);
let draw_w = bw * scale;
let draw_h = bh * scale;
let origin_x = screen_x - draw_w * 0.5;
let origin_y = screen_y - draw_h * 0.5;
let px_size = scale.max(1.0);
let mut shapes = Vec::new();
let mut py = 0.0;
let mut sy = 0u32;
while sy < tip.bitmap_h {
let mut px = 0.0;
let mut sx = 0u32;
while sx < tip.bitmap_w {
let mask = tip.bitmap_pixels
[(sy * tip.bitmap_w + sx) as usize]
as f32
/ 255.0;
if mask > 0.01 {
let a = (mask * 180.0).round() as u8;
if mask > 0.01 {
let a = (mask * 120.0).round() as u8;
let c = egui::Color32::from_rgba_unmultiplied(
cursor_color.r(),
cursor_color.g(),
cursor_color.b(),
a,
);
let p = egui::pos2(origin_x + px, origin_y + py);
shapes.push(egui::Shape::rect_filled(
egui::Rect::from_min_size(
p,
egui::vec2(px_size, px_size),
),
0.0,
c,
));
}
px += px_size;
sx += 1;
}
py += px_size;
sy += 1;
}
painter.add(egui::Shape::Vec(shapes));
}
BrushStyle::Round
| BrushStyle::HardRound
| BrushStyle::SoftRound => {
let radius = self.state.active_size() * zoom * 0.5;
let hardness = tip.hardness;
if hardness >= 0.95 {
painter.circle_stroke(
egui::pos2(screen_x, screen_y),
radius,
egui::Stroke::new(1.5, cursor_color),
);
} else {
let softness = 1.0 - hardness;
let layers = 5;
for i in 0..layers {
let frac = i as f32 / layers as f32;
let r = radius * (1.0 + softness * 0.3 * (1.0 - frac));
let a = (120.0 * (1.0 - frac * 0.6)) as u8;
let c = egui::Color32::from_rgba_unmultiplied(
color.r(),
color.g(),
color.b(),
cursor_color.r(),
cursor_color.g(),
cursor_color.b(),
a,
);
let p = egui::pos2(origin_x + px, origin_y + py);
shapes.push(egui::Shape::rect_filled(
egui::Rect::from_min_size(
p,
egui::vec2(px_size, px_size),
),
0.0,
c,
));
painter.circle_stroke(
egui::pos2(screen_x, screen_y),
r,
egui::Stroke::new(1.0, c),
);
}
px += px_size;
sx += 1;
}
py += px_size;
sy += 1;
}
painter.add(egui::Shape::Vec(shapes));
} else {
let radius = self.state.active_size() * zoom * 0.5;
painter.circle_stroke(
egui::pos2(screen_x, screen_y),
radius,
egui::Stroke::new(1.5, color),
);
BrushStyle::Square => {
let radius = self.state.active_size() * zoom * 0.5;
let rect = egui::Rect::from_center_size(
egui::pos2(screen_x, screen_y),
egui::vec2(radius * 2.0, radius * 2.0),
);
painter.rect_stroke(
rect,
0.0,
egui::Stroke::new(1.5, cursor_color),
egui::StrokeKind::Outside,
);
}
BrushStyle::Star | BrushStyle::Spray => {
let radius = self.state.active_size() * zoom * 1.6;
painter.circle_stroke(
egui::pos2(screen_x, screen_y),
radius,
egui::Stroke::new(1.0, cursor_color),
);
}
BrushStyle::Clouds | BrushStyle::Tree => {
let radius = self.state.active_size() * zoom;
painter.circle_stroke(
egui::pos2(screen_x, screen_y),
radius,
egui::Stroke::new(1.0, cursor_color),
);
}
_ => {
let radius = self.state.active_size() * zoom * 0.5;
painter.circle_stroke(
egui::pos2(screen_x, screen_y),
radius,
egui::Stroke::new(1.5, cursor_color),
);
}
}
} else {
let radius = self.state.active_size() * zoom * 0.5;
painter.circle_stroke(
egui::pos2(screen_x, screen_y),
radius,
egui::Stroke::new(1.5, color),
egui::Stroke::new(1.5, cursor_color),
);
}
}
@@ -118,7 +118,7 @@ pub fn extract_selection_edges(
pub fn render_composition(
ctx: &egui::Context,
doc: &mut AppDocument,
_state: &mut ToolState,
state: &mut ToolState,
_canvas_rect: egui::Rect,
_zoom: f32,
) -> bool {
@@ -135,6 +135,12 @@ pub fn render_composition(
return false;
}
// Skip expensive composite rendering during active drawing to prevent UI freezes.
// The composite will be rendered on the next frame after the stroke ends.
if state.is_drawing && doc.composite_texture.is_some() && has_buffer {
return false;
}
// Ensure buffer exists with correct size
if !has_buffer {
doc.composite_buffer = vec![0u8; full_size];