diff --git a/hcie-iced-app/crates/hcie-iced-gui/Cargo.toml b/hcie-iced-app/crates/hcie-iced-gui/Cargo.toml index b20ddf8..5cf79da 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/Cargo.toml +++ b/hcie-iced-app/crates/hcie-iced-gui/Cargo.toml @@ -33,3 +33,4 @@ dirs = "5.0" reqwest = { version = "0.12", features = ["json", "stream"] } tokio = { version = "1", features = ["full"] } futures-util = "0.3" +zip = { workspace = true } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs index 3e93c75..ce97a4b 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs @@ -283,6 +283,8 @@ pub struct ToolState { pub last_composite_refresh: std::time::Instant, /// Timestamp of the last update() call for frame timing. pub last_update_instant: std::time::Instant, + /// Imported brush presets from ABR/KPP files. + pub imported_brushes: Vec, } impl Default for ToolState { @@ -299,6 +301,7 @@ impl Default for ToolState { brush_spacing: 1.0, last_composite_refresh: std::time::Instant::now(), last_update_instant: std::time::Instant::now(), + imported_brushes: Vec::new(), } } } @@ -384,6 +387,8 @@ pub enum Message { BrushSizeChanged(f32), BrushOpacityChanged(f32), BrushHardnessChanged(f32), + BrushImportAbr, + BrushImportAbrFile(Result, String>), ResetToolDefaults, // ── Filters ───────────────────────────────────────── @@ -1861,6 +1866,49 @@ impl HcieIcedApp { self.settings.apply_to_tool_state(&mut self.tool_state); let _ = self.settings.save(); } + Message::BrushImportAbr => { + log::info!("Import ABR brush requested"); + let task = Task::perform( + async { + let result = rfd::AsyncFileDialog::new() + .add_filter("ABR Brush Files", &["abr"]) + .add_filter("All Files", &["*"]) + .set_title("Import ABR Brush") + .pick_file() + .await + .map(|handle| handle.path().to_path_buf()); + match result { + Some(path) => { + let file_name = path.file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + match std::fs::read(&path) { + Ok(data) => { + let presets = crate::brush_import::import_abr_with_prefix(&data, &file_name); + Ok(presets) + } + Err(e) => Err(format!("Failed to read file: {}", e)), + } + } + None => Err("No file selected".to_string()), + } + }, + |result| Message::BrushImportAbrFile(result), + ); + return task; + } + Message::BrushImportAbrFile(result) => { + match result { + Ok(presets) => { + log::info!("Imported {} brush presets", presets.len()); + // Store imported presets in the tool state + self.tool_state.imported_brushes.extend(presets); + } + Err(e) => { + log::error!("Failed to import ABR brush: {}", e); + } + } + } // ── Filters ─────────────────────────────────── Message::FilterSelect(filter) => { diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/brush_import.rs b/hcie-iced-app/crates/hcie-iced-gui/src/brush_import.rs new file mode 100644 index 0000000..fc42ab8 --- /dev/null +++ b/hcie-iced-app/crates/hcie-iced-gui/src/brush_import.rs @@ -0,0 +1,623 @@ +//! ABR (Photoshop brush) file import module. +//! +//! Parses Photoshop `.abr` files and extracts: +//! - Sampled bitmap brushes (PackBits encoded) +//! - Computed/parametric brushes +//! +//! Returns `BrushPreset` entries that can be added to the brush library. + +use hcie_engine_api::brush::{BrushStyle, BrushTip}; +use hcie_engine_api::BrushPreset; +use std::io::{Cursor, Read, Seek, SeekFrom}; + +/// Parse an ABR brush file and return a list of `BrushPreset` entries. +/// +/// The ABR format stores sampled bitmap brushes in a `samp` resource block and +/// parametric/computed brushes in `computedB` markers. Brush names are extracted +/// from the `desc` block (which contains `Nm TEXT` tags) when available. +/// +/// # Arguments +/// * `data` - Raw bytes of the ABR file +/// +/// # Returns +/// Vector of `BrushPreset` entries parsed from the file. +pub fn import_abr(data: &[u8]) -> Vec { + let mut presets = Vec::new(); + let mut cursor = Cursor::new(data); + + let mut buf = [0u8; 4]; + if cursor.read_exact(&mut buf).is_err() { + return presets; + } + + let mut pos = 0; + while pos + 8 < data.len() { + if &data[pos..pos + 4] == b"8BIM" { + let tag = String::from_utf8_lossy(&data[pos + 4..pos + 8]).to_string(); + let _ = cursor.seek(SeekFrom::Start(pos as u64 + 8)); + let size = read_u32(&mut cursor); + let block_end = (cursor.position() + size as u64).min(data.len() as u64); + + if tag == "samp" { + let start = cursor.position() as usize; + let end = block_end as usize; + if end > start && end <= data.len() { + presets.extend(harvest_sampled_brushes(&data[start..end], start)); + } + } else if tag == "Brs2" || tag == "brst" { + let start = cursor.position() as usize; + let end = block_end as usize; + if end > start && end <= data.len() { + presets.extend(harvest_brs2_sampled_brushes(&data[start..end], start)); + } + } + pos = (block_end as usize + 1) & !1; + } else { + pos += 1; + } + } + + presets.extend(harvest_computed_brushes(data)); + + let desc_names = extract_desc_block_names(data); + apply_desc_names(&mut presets, &desc_names); + + presets +} + +fn safe_read_i32(data: &[u8], pos: usize) -> Option { + if pos + 4 > data.len() { + return None; + } + Some(i32::from_be_bytes([ + data[pos], + data[pos + 1], + data[pos + 2], + data[pos + 3], + ])) +} + +fn harvest_sampled_brushes(data: &[u8], base_pos: usize) -> Vec { + let mut presets = Vec::new(); + let mut i = 0; + while i + 20 < data.len() { + if data[i] == 8 && data[i + 1] == 1 { + let sig_pos = if i > 0 && data[i - 1] == 0 { i - 1 } else { i }; + let mut found = false; + if sig_pos >= 16 { + let t = safe_read_i32(data, sig_pos - 16); + let l = safe_read_i32(data, sig_pos - 12); + let b = safe_read_i32(data, sig_pos - 8); + let r = safe_read_i32(data, sig_pos - 4); + if let (Some(t), Some(l), Some(b), Some(r)) = (t, l, b, r) { + let h = (b as i64 - t as i64).abs() as u32; + let w = (r as i64 - l as i64).abs() as u32; + if w > 0 && h > 0 && w < 10000 && h < 10000 { + let (final_w, final_h) = if w > 1024 || h > 1024 { + let scale = 1024.0 / (w.max(h) as f32); + ((w as f32 * scale) as u32, (h as f32 * scale) as u32) + } else { + (w, h) + }; + if let Some(decoded) = try_decode_and_scale( + &data[i + 2..], + w as usize, + h as usize, + final_w, + final_h, + ) { + presets.push(BrushPreset { + id: format!("abr_v6_{}_{}x{}", base_pos + i, final_w, final_h), + name: format!("Brush {}x{}", final_w, final_h), + category: "Imported (ABR)".to_string(), + tip: BrushTip { + style: BrushStyle::Bitmap, + size: final_w.max(final_h) as f32, + opacity: 1.0, + hardness: 1.0, + spacing: 0.1, + jitter_amount: 0.0, + scatter_amount: 0.0, + angle: 0.0, + roundness: 1.0, + spray_particle_size: 2.0, + spray_density: 100, + bitmap_pixels: decoded, + bitmap_w: final_w, + bitmap_h: final_h, + flow: 1.0, + color_variant: false, + variant_amount: 0.0, + density: 1.0, + drawing_angle: false, + rotation_random: 0.0, + }, + style: BrushStyle::Bitmap, + pressure_size: true, + pressure_opacity: true, + pressure_flow: true, + jitter_position: 0.0, + jitter_angle: 0.0, + }); + found = true; + } + } + } + } + if found { + i += 20; + continue; + } + } + i += 1; + } + presets +} + +fn try_decode_and_scale( + data: &[u8], + src_w: usize, + src_h: usize, + dst_w: u32, + dst_h: u32, +) -> Option> { + // Some ABR files store the width as (right - left) but the PackBits stream + // contains one extra padding column/row. Accept decoded data that is at least + // src_w * src_h and truncate any surplus instead of rejecting the brush. + let mut decoded = decode_packbits_lenient(data, src_w * src_h); + if decoded.len() < src_w * src_h { + return None; + } + if decoded.len() > src_w * src_h { + decoded.truncate(src_w * src_h); + } + if src_w as u32 == dst_w && src_h as u32 == dst_h { + return Some(decoded); + } + + let mut scaled = Vec::with_capacity((dst_w * dst_h) as usize); + for y in 0..dst_h { + for x in 0..dst_w { + let src_x = (x as f32 * (src_w as f32 / dst_w as f32)) as usize; + let src_y = (y as f32 * (src_h as f32 / dst_h as f32)) as usize; + scaled.push(decoded[src_y * src_w + src_x]); + } + } + Some(scaled) +} + +fn decode_packbits_lenient(data: &[u8], expected_size: usize) -> Vec { + let mut out = Vec::with_capacity(expected_size); + let mut i = 0; + while i < data.len() && out.len() < expected_size { + let n = data[i] as i8; + i += 1; + if n >= 0 { + let count = n as usize + 1; + if i + count <= data.len() { + out.extend_from_slice(&data[i..i + count]); + i += count; + } else { + break; + } + } else if n != -128 { + let count = (-n) as usize + 1; + if i < data.len() { + let val = data[i]; + i += 1; + for _ in 0..count { + out.push(val); + } + } else { + break; + } + } + } + if out.is_empty() { + return out; + } + while out.len() < expected_size { + out.push(0); + } + if out.len() > expected_size { + out.truncate(expected_size); + } + out +} + +/// Heuristic extraction for sampled brushes embedded in a `Brs2` / `brst` +/// resource block. Older ABR versions lay out the data as: +/// [count u32] for each brush [l t r b i32] [depth?] [name pascal] +/// [encoding byte?] [PackBits image data] +/// This is intentionally lenient so we do not crash on partially unknown formats. +fn harvest_brs2_sampled_brushes(data: &[u8], base_pos: usize) -> Vec { + let mut presets = Vec::new(); + if data.len() < 4 { + return presets; + } + let count = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize; + if count == 0 || count > 1000 { + return presets; + } + let mut i = 4usize; + for _ in 0..count { + if i + 16 > data.len() { + break; + } + let l = i32::from_be_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]); + let t = i32::from_be_bytes([data[i + 4], data[i + 5], data[i + 6], data[i + 7]]); + let r = i32::from_be_bytes([data[i + 8], data[i + 9], data[i + 10], data[i + 11]]); + let b = i32::from_be_bytes([data[i + 12], data[i + 13], data[i + 14], data[i + 15]]); + i += 16; + let w = (r as i64 - l as i64).abs() as u32; + let h = (b as i64 - t as i64).abs() as u32; + if w == 0 || h == 0 || w > 10000 || h > 10000 { + continue; + } + // Skip depth/name/etc until we find an encoding signature or raw data. + // Try to locate a PackBits-like run near expected data offset. + let data_start = i; + let data_end = (i + (w * h * 2) as usize + 1024).min(data.len()); + let slice = &data[data_start..data_end]; + let (final_w, final_h) = if w > 1024 || h > 1024 { + let scale = 1024.0 / (w.max(h) as f32); + ((w as f32 * scale) as u32, (h as f32 * scale) as u32) + } else { + (w, 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), + category: "Imported (ABR)".to_string(), + tip: BrushTip { + style: BrushStyle::Bitmap, + size: final_w.max(final_h) as f32, + opacity: 1.0, + hardness: 1.0, + spacing: 0.1, + jitter_amount: 0.0, + scatter_amount: 0.0, + angle: 0.0, + roundness: 1.0, + spray_particle_size: 2.0, + spray_density: 100, + bitmap_pixels: decoded, + bitmap_w: final_w, + bitmap_h: final_h, + flow: 1.0, + color_variant: false, + variant_amount: 0.0, + density: 1.0, + drawing_angle: false, + rotation_random: 0.0, + }, + style: BrushStyle::Bitmap, + pressure_size: true, + pressure_opacity: true, + pressure_flow: true, + jitter_position: 0.0, + jitter_angle: 0.0, + }); + } + i = data_end; + } + presets +} + +fn harvest_computed_brushes(data: &[u8]) -> Vec { + let mut presets = Vec::new(); + let marker = b"computedB"; + let mut search_pos = 0; + while search_pos + marker.len() < data.len() { + if &data[search_pos..search_pos + marker.len()] == marker { + let window_end = (search_pos + 4096).min(data.len()); + let window = &data[search_pos..window_end]; + let name = parse_nm_text(window).unwrap_or_else(|| "Computed Brush".to_string()); + let diameter = parse_f64_field(window, b"Dmtr").unwrap_or(10.0); + let hardness = parse_f64_field(window, b"Hrdn").unwrap_or(100.0); + let spacing = parse_f64_field(window, b"Spcn").unwrap_or(25.0); + if diameter > 0.0 && diameter < 5000.0 { + presets.push(BrushPreset { + id: format!("abr_comp_{:x}", search_pos), + name, + category: "Imported (ABR)".to_string(), + tip: BrushTip { + style: BrushStyle::Round, + size: diameter as f32, + opacity: 1.0, + hardness: (hardness / 100.0).clamp(0.0, 1.0) as f32, + spacing: (spacing / 100.0).clamp(0.01, 5.0) as f32, + jitter_amount: 0.0, + scatter_amount: 0.0, + angle: 0.0, + roundness: 1.0, + spray_particle_size: 2.0, + spray_density: 100, + bitmap_pixels: Vec::new(), + bitmap_w: 0, + bitmap_h: 0, + flow: 1.0, + color_variant: false, + variant_amount: 0.0, + density: 1.0, + drawing_angle: false, + rotation_random: 0.0, + }, + style: BrushStyle::Round, + pressure_size: true, + pressure_opacity: true, + pressure_flow: true, + jitter_position: 0.0, + jitter_angle: 0.0, + }); + } + search_pos += marker.len(); + } else { + search_pos += 1; + } + } + presets +} + +fn parse_nm_text(data: &[u8]) -> Option { + let tag = b"Nm TEXT"; + let pos = find_bytes(data, tag)?; + let p = pos + tag.len(); + if p + 4 > data.len() { + return None; + } + let nchars = u32::from_be_bytes([data[p], data[p + 1], data[p + 2], data[p + 3]]) as usize; + let p = p + 4; + if p + nchars * 2 > data.len() { + return None; + } + let chars: Vec = data[p..p + nchars * 2] + .chunks_exact(2) + .map(|b| u16::from_be_bytes([b[0], b[1]])) + .collect(); + let full = String::from_utf16_lossy(&chars); + let name = full + .split('=') + .last() + .unwrap_or(&full) + .trim_matches('\x00') + .to_string(); + if name.is_empty() { + None + } else { + Some(name) + } +} + +fn parse_f64_field(data: &[u8], tag: &[u8]) -> Option { + let pos = find_bytes(data, tag)?; + let p = pos + tag.len() + 8; + if p + 8 > data.len() { + return None; + } + Some(f64::from_be_bytes([ + data[p], + data[p + 1], + data[p + 2], + data[p + 3], + data[p + 4], + data[p + 5], + data[p + 6], + data[p + 7], + ])) +} + +fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option { + haystack.windows(needle.len()).position(|w| w == needle) +} + +fn read_u32(cursor: &mut Cursor<&[u8]>) -> u32 { + let mut buf = [0u8; 4]; + let _ = cursor.read_exact(&mut buf); + u32::from_be_bytes(buf) +} + +/// Extract brush names from the `desc` block's `Nm TEXT` tags. +/// +/// ABR files store a `desc` resource block that contains `BrshVlLs` (Brush +/// Value Lists). Each entry has a `Nm TEXT` field with the brush name in +/// UTF-16 encoding. The names appear in the same order as the brushes in the +/// `samp` block, so they can be associated by index. +fn extract_desc_block_names(data: &[u8]) -> Vec { + let mut names = Vec::new(); + let desc_pos = match find_bytes(data, b"8BIMdesc") { + Some(p) => p, + None => return names, + }; + let size_pos = desc_pos + 8; + if size_pos + 4 > data.len() { + return names; + } + let size = u32::from_be_bytes([ + data[size_pos], + data[size_pos + 1], + data[size_pos + 2], + data[size_pos + 3], + ]) as usize; + let block_start = size_pos + 4; + let block_end = (block_start + size).min(data.len()); + if block_end <= block_start { + return names; + } + let block = &data[block_start..block_end]; + + let mut idx = 0; + while idx < block.len() - 8 { + if &block[idx..idx + 8] == b"Nm TEXT" { + let p = idx + 8; + 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 text_start = p + 4; + if nchars == 0 || text_start + nchars * 2 > block.len() { + idx += 1; + continue; + } + let chars: Vec = block[text_start..text_start + nchars * 2] + .chunks_exact(2) + .map(|b| u16::from_be_bytes([b[0], b[1]])) + .collect(); + let full = String::from_utf16_lossy(&chars); + let name = full + .split('=') + .last() + .unwrap_or(&full) + .trim_matches('\x00') + .to_string(); + if !name.is_empty() { + names.push(name); + } + idx = text_start + nchars * 2; + } else { + idx += 1; + } + } + names +} + +/// Apply names extracted from the `desc` block to presets. +/// +/// The desc block names appear in the same order as sampled brushes were +/// collected. Computed brushes already have their names from `Nm TEXT` tags +/// inside the computed brush data, so only sampled presets (Bitmap style) +/// get updated. +fn apply_desc_names(presets: &mut [BrushPreset], desc_names: &[String]) { + if desc_names.is_empty() { + return; + } + let mut name_idx = 0; + for preset in presets.iter_mut() { + if preset.style == BrushStyle::Bitmap && name_idx < desc_names.len() { + let new_name = &desc_names[name_idx]; + if !new_name.is_empty() { + preset.name = new_name.clone(); + } + name_idx += 1; + } + } +} + +/// Parse an ABR file and prefix generated IDs/names so imports from different +/// files do not collide. +/// +/// # Arguments +/// * `data` - Raw bytes of the ABR file +/// * `prefix` - Prefix to add to all preset IDs and names +/// +/// # Returns +/// Vector of `BrushPreset` entries with prefixed IDs and names. +pub fn import_abr_with_prefix(data: &[u8], prefix: &str) -> Vec { + let mut presets = import_abr(data); + let safe_prefix = prefix.replace(' ', "_").replace('.', "_"); + for preset in &mut presets { + preset.id = format!("{}_{}", safe_prefix, preset.id); + preset.name = format!("{} ({})", preset.name, safe_prefix); + } + presets +} + +/// Parse a Krita brush preset (`.kpp`). +/// +/// A `.kpp` file is a ZIP archive. This implementation extracts the `preview.png` +/// thumbnail, converts it to a grayscale alpha mask, and returns a single +/// `BrushStyle::Bitmap` preset. +/// +/// # Arguments +/// * `data` - Raw bytes of the KPP file +/// * `name_hint` - Hint for the brush name (usually the filename) +/// +/// # Returns +/// Optional `BrushPreset` if parsing succeeds. +pub fn import_kpp(data: &[u8], name_hint: &str) -> Option { + use std::io::Cursor; + use zip::ZipArchive; + + let cursor = Cursor::new(data); + let mut zip = ZipArchive::new(cursor).ok()?; + let mut preview_bytes: Option> = None; + for i in 0..zip.len() { + let mut entry = zip.by_index(i).ok()?; + let entry_name = entry.name().to_lowercase(); + if entry_name.ends_with("preview.png") || entry_name == "preview.png" { + let mut buf = Vec::new(); + if entry.read_to_end(&mut buf).is_ok() && !buf.is_empty() { + preview_bytes = Some(buf); + break; + } + } + } + let preview_bytes = preview_bytes?; + let img = image::load_from_memory(&preview_bytes).ok()?; + let rgba = img.to_rgba8(); + let (w, h) = rgba.dimensions(); + if w == 0 || h == 0 { + return None; + } + // Downscale huge previews so GPU brush stamps stay cheap. + let (final_w, final_h) = if w > 512 || h > 512 { + let scale = 512.0 / (w.max(h) as f32); + ((w as f32 * scale) as u32, (h as f32 * scale) as u32) + } else { + (w, h) + }; + let mut gray = Vec::with_capacity((final_w * final_h) as usize); + for y in 0..final_h { + for x in 0..final_w { + let src_x = (x as f32 * (w as f32 / final_w as f32)) as u32; + let src_y = (y as f32 * (h as f32 / final_h as f32)) as u32; + let px = rgba.get_pixel(src_x.min(w - 1), src_y.min(h - 1)); + // Use perceived luminance from RGB and keep full alpha range. + let l = (0.299 * px[0] as f32 + 0.587 * px[1] as f32 + 0.114 * px[2] as f32) + .clamp(0.0, 255.0) as u8; + let a = px[3]; + // Mask value = luminance scaled by alpha. + gray.push((l as u16 * a as u16 / 255) as u8); + } + } + Some(BrushPreset { + id: format!( + "kpp_imported_{}_{}x{}", + name_hint.replace(' ', "_"), + final_w, + final_h + ), + name: format!("{} (KPP)", name_hint), + category: "Imported".to_string(), + tip: BrushTip { + style: BrushStyle::Bitmap, + size: final_w.max(final_h) as f32, + opacity: 1.0, + hardness: 1.0, + spacing: 0.1, + jitter_amount: 0.0, + scatter_amount: 0.0, + angle: 0.0, + roundness: 1.0, + spray_particle_size: 2.0, + spray_density: 100, + bitmap_pixels: gray, + bitmap_w: final_w, + bitmap_h: final_h, + flow: 1.0, + color_variant: false, + variant_amount: 0.0, + density: 1.0, + drawing_angle: false, + rotation_random: 0.0, + }, + style: BrushStyle::Bitmap, + pressure_size: true, + pressure_opacity: true, + pressure_flow: true, + jitter_position: 0.0, + jitter_angle: 0.0, + }) +} diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/main.rs b/hcie-iced-app/crates/hcie-iced-gui/src/main.rs index 7d43399..219f43c 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/main.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/main.rs @@ -5,6 +5,7 @@ mod ai_chat; mod ai_script; mod app; +mod brush_import; mod canvas; mod color_picker; mod dialogs; diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs index 9736d8d..517f944 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs @@ -331,6 +331,27 @@ pub fn view( horizontal_rule(1), scrollable(grid_rows).height(Length::Fill), horizontal_rule(1), + row![ + button(text("Import ABR").size(10)) + .on_press(Message::BrushImportAbr) + .padding([4, 8]) + .style(move |_theme: &iced::Theme, status: iced::widget::button::Status| { + match status { + iced::widget::button::Status::Hovered => iced::widget::button::Style { + background: Some(iced::Background::Color(colors.accent)), + text_color: colors.text_primary, + border: iced::Border::default(), + ..Default::default() + }, + _ => iced::widget::button::Style { + background: Some(iced::Background::Color(colors.bg_panel)), + text_color: colors.text_primary, + border: iced::Border::default().color(colors.border_low).width(1), + ..Default::default() + }, + } + }) + ].spacing(4), text("Preview").size(10), live_preview, text(format!("Size: {:.0}", current_size)).size(10),