feat: add runtime build-ID management to increment and track application build numbers
- Implemented a new module for managing build IDs at runtime.
- Introduced functions to find the repository root, check for stale locks, and increment the build ID.
- Ensured atomic writes to the build ID file with proper locking mechanisms.
- The build version string is formatted as "0.1.0+build.{build_id}" for display purposes.
This commit is contained in:
@@ -331,6 +331,8 @@ pub struct HcieIcedApp {
|
||||
pub brush_category: crate::panels::brushes::BrushCategory,
|
||||
/// Context menu screen position (x, y). None means closed.
|
||||
pub canvas_context_menu: Option<(f32, f32)>,
|
||||
/// Runtime build version string (e.g. `"0.1.0+build.992"`), incremented on each launch.
|
||||
pub runtime_version: String,
|
||||
/// Dirty flag set whenever fg/bg/recent colors change, so colors can be
|
||||
/// persisted to disk (egui persists these under the `hcie_colors` key).
|
||||
pub colors_dirty: bool,
|
||||
@@ -507,6 +509,12 @@ pub struct IcedDocument {
|
||||
/// Transformed vector shape being previewed during a drag (not yet committed
|
||||
/// to the engine). Kept in the document so the overlay canvas can draw it.
|
||||
pub vector_drag_preview: Option<hcie_engine_api::VectorShape>,
|
||||
/// True while the user is holding Shift during a rotation drag (enables snap visual feedback).
|
||||
pub rotation_snap_active: bool,
|
||||
/// The snapped angle (radians) currently displayed in the snap indicator overlay.
|
||||
pub rotation_snap_angle: f32,
|
||||
/// True while the user is holding Shift during a resize drag (enables aspect-lock visual feedback).
|
||||
pub resize_snap_active: bool,
|
||||
/// Cached layer thumbnails keyed by layer ID — (thumb_w, thumb_h, RGBA pixels).
|
||||
/// Regenerated only for dirty layers in `refresh_panel_caches()`.
|
||||
pub layer_thumbnails: std::collections::HashMap<u64, (u32, u32, Vec<u8>)>,
|
||||
@@ -1679,6 +1687,9 @@ impl HcieIcedApp {
|
||||
vector_cycle_index: 0,
|
||||
vector_edit_handle: hcie_engine_api::VectorEditHandle::None,
|
||||
vector_drag_preview: None,
|
||||
rotation_snap_active: false,
|
||||
rotation_snap_angle: 0.0,
|
||||
resize_snap_active: false,
|
||||
layer_thumbnails: std::collections::HashMap::new(),
|
||||
thumb_gen: 0,
|
||||
};
|
||||
@@ -1786,6 +1797,7 @@ impl HcieIcedApp {
|
||||
show_dock_profile_dialog: false,
|
||||
_language: i18n::Language::default(),
|
||||
brush_category: crate::panels::brushes::BrushCategory::All,
|
||||
runtime_version: crate::build_info::runtime_build_version(),
|
||||
colors_dirty: false,
|
||||
color_dragging: false,
|
||||
pending_drag_color: None,
|
||||
@@ -5270,6 +5282,9 @@ impl HcieIcedApp {
|
||||
vector_cycle_index: 0,
|
||||
vector_edit_handle: hcie_engine_api::VectorEditHandle::None,
|
||||
vector_drag_preview: None,
|
||||
rotation_snap_active: false,
|
||||
rotation_snap_angle: 0.0,
|
||||
resize_snap_active: false,
|
||||
layer_thumbnails: std::collections::HashMap::new(),
|
||||
thumb_gen: 0,
|
||||
};
|
||||
@@ -5769,7 +5784,7 @@ impl HcieIcedApp {
|
||||
let radius = self.settings.tool_settings.vector_radius;
|
||||
let points = self.settings.tool_settings.vector_points;
|
||||
let sides = self.settings.tool_settings.vector_sides;
|
||||
let fill_color = color;
|
||||
let fill_color = self.bg_color;
|
||||
|
||||
let shape = match self.tool_state.active_tool {
|
||||
Tool::VectorRect => Some(hcie_engine_api::VectorShape::Rect {
|
||||
@@ -6411,6 +6426,24 @@ impl HcieIcedApp {
|
||||
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();
|
||||
|
||||
// Track snap state for visual overlay feedback
|
||||
let is_rotate = matches!(
|
||||
session.handle,
|
||||
hcie_engine_api::VectorEditHandle::Rotate
|
||||
);
|
||||
let is_resize = matches!(
|
||||
session.handle,
|
||||
hcie_engine_api::VectorEditHandle::TopLeft
|
||||
| hcie_engine_api::VectorEditHandle::TopRight
|
||||
| hcie_engine_api::VectorEditHandle::BottomLeft
|
||||
| hcie_engine_api::VectorEditHandle::BottomRight
|
||||
);
|
||||
self.documents[self.active_doc].rotation_snap_active =
|
||||
is_rotate && self.modifiers.shift();
|
||||
self.documents[self.active_doc].rotation_snap_angle = angle;
|
||||
self.documents[self.active_doc].resize_snap_active =
|
||||
is_resize && self.modifiers.shift();
|
||||
}
|
||||
}
|
||||
Message::VectorSelectDragEnd => {
|
||||
@@ -6448,6 +6481,8 @@ impl HcieIcedApp {
|
||||
self.documents[self.active_doc].vector_drag_preview = None;
|
||||
self.documents[self.active_doc].vector_edit_handle =
|
||||
hcie_engine_api::VectorEditHandle::None;
|
||||
self.documents[self.active_doc].rotation_snap_active = false;
|
||||
self.documents[self.active_doc].resize_snap_active = false;
|
||||
self.refresh_composite_if_needed();
|
||||
}
|
||||
}
|
||||
@@ -8076,6 +8111,9 @@ impl HcieIcedApp {
|
||||
vector_cycle_index: 0,
|
||||
vector_edit_handle: hcie_engine_api::VectorEditHandle::None,
|
||||
vector_drag_preview: None,
|
||||
rotation_snap_active: false,
|
||||
rotation_snap_angle: 0.0,
|
||||
resize_snap_active: false,
|
||||
layer_thumbnails: std::collections::HashMap::new(),
|
||||
thumb_gen: 0,
|
||||
});
|
||||
@@ -9237,6 +9275,7 @@ impl HcieIcedApp {
|
||||
self.active_menu,
|
||||
&self.title_search,
|
||||
self.show_panel_list,
|
||||
&self.runtime_version,
|
||||
);
|
||||
|
||||
// Toolbox — 36px strip (single column) or 72px (2 columns) on the left edge
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
//! Runtime build-ID management.
|
||||
//!
|
||||
//! **Purpose:** Reads, atomically increments, and writes `build.id` on every application startup
|
||||
//! so each launch receives a unique, monotonically increasing build number — without requiring
|
||||
//! a wrapper script or a separate build step.
|
||||
//! **Logic & Workflow:** Locates the repository root by walking upward from `CARGO_MANIFEST_DIR`
|
||||
//! until a `Cargo.toml` containing `[workspace]` is found, then reads/increments/writes `build.id`
|
||||
//! under a file lock. Exposes the resulting version string for title-bar display.
|
||||
//! **Side Effects / Dependencies:** Performs one file-system write per startup; concurrent launches
|
||||
//! are serialized via an advisory directory lock.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Maximum age (seconds) before a lock is considered stale and forcefully removed.
|
||||
const LOCK_STALE_SECS: u64 = 30;
|
||||
|
||||
/// File-system lock directory used to serialize concurrent increments.
|
||||
const LOCK_DIR: &str = ".build-id.lock";
|
||||
|
||||
/// Finds the repository root by walking upward from the compile-time manifest directory.
|
||||
///
|
||||
/// **Returns:** The repository root `PathBuf`.
|
||||
/// **Side Effects / Dependencies:** Panics if the workspace root cannot be located.
|
||||
fn find_repo_root() -> PathBuf {
|
||||
let manifest_dir = PathBuf::from(
|
||||
std::env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is required"),
|
||||
);
|
||||
let mut candidate = manifest_dir.clone();
|
||||
loop {
|
||||
let tombstone = candidate.join("Cargo.toml");
|
||||
if tombstone.exists() {
|
||||
if let Ok(content) = fs::read_to_string(&tombstone) {
|
||||
if content.contains("[workspace]") {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !candidate.pop() {
|
||||
panic!("could not locate repository root above {:?}", manifest_dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns current Unix timestamp in seconds, or 0 on clock error.
|
||||
fn unix_now() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Checks whether a lock directory is stale (older than `LOCK_STALE_SECS`).
|
||||
fn is_lock_stale(lock_dir: &Path) -> bool {
|
||||
let ts_file = lock_dir.join("ts");
|
||||
if let Ok(ts_str) = fs::read_to_string(&ts_file) {
|
||||
if let Ok(ts) = ts_str.trim().parse::<u64>() {
|
||||
return unix_now().saturating_sub(ts) > LOCK_STALE_SECS;
|
||||
}
|
||||
}
|
||||
// No timestamp file or unparseable → treat as stale.
|
||||
true
|
||||
}
|
||||
|
||||
/// Acquires an advisory directory lock, increments the counter, and releases the lock.
|
||||
///
|
||||
/// **Returns:** The new (post-increment) build ID.
|
||||
/// **Side Effects / Dependencies:** Creates and removes a temporary lock directory.
|
||||
fn increment_build_id(root: &Path) -> u64 {
|
||||
let lock_dir = root.join(LOCK_DIR);
|
||||
let counter_file = root.join("build.id");
|
||||
|
||||
// Busy-wait with stale-lock detection.
|
||||
loop {
|
||||
match fs::create_dir(&lock_dir) {
|
||||
Ok(()) => {
|
||||
// Write timestamp for stale-lock detection by other instances.
|
||||
let _ = fs::write(
|
||||
lock_dir.join("ts"),
|
||||
format!("{}\n", unix_now()),
|
||||
);
|
||||
break;
|
||||
}
|
||||
Err(_) => {
|
||||
if is_lock_stale(&lock_dir) {
|
||||
let _ = fs::remove_dir_all(&lock_dir);
|
||||
continue;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read current value.
|
||||
let current: u64 = fs::read_to_string(&counter_file)
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let next = current + 1;
|
||||
|
||||
// Atomic write: write to temp file, then rename.
|
||||
let tmp = counter_file.with_extension("id.tmp");
|
||||
fs::write(&tmp, format!("{}\n", next)).expect("failed to write build.id tmp");
|
||||
fs::rename(&tmp, &counter_file).expect("failed to rename build.id");
|
||||
|
||||
// Release lock.
|
||||
let _ = fs::remove_dir_all(&lock_dir);
|
||||
|
||||
next
|
||||
}
|
||||
|
||||
/// Reads, increments, and returns the build version string at runtime.
|
||||
///
|
||||
/// **Returns:** A version string like `"0.1.0+build.992"`.
|
||||
/// **Side Effects / Dependencies:** Writes to `build.id` on every call (intended for startup only).
|
||||
pub fn runtime_build_version() -> String {
|
||||
let root = find_repo_root();
|
||||
let build_id = increment_build_id(&root);
|
||||
let base_version = hcie_build_info::VERSION
|
||||
.split("+build.")
|
||||
.next()
|
||||
.unwrap_or("0.1.0");
|
||||
format!("{base_version}+build.{build_id}")
|
||||
}
|
||||
@@ -97,6 +97,12 @@ struct OverlayProgram {
|
||||
vector_edit_handle: hcie_engine_api::VectorEditHandle,
|
||||
/// Transformed vector shape being previewed during a drag (not yet committed).
|
||||
vector_drag_preview: Option<hcie_engine_api::VectorShape>,
|
||||
/// True while the user is holding Shift during a rotation drag (enables snap visual feedback).
|
||||
rotation_snap_active: bool,
|
||||
/// The snapped angle (radians) currently displayed in the snap indicator overlay.
|
||||
rotation_snap_angle: f32,
|
||||
/// True while the user is holding Shift during a resize drag (enables aspect-lock visual feedback).
|
||||
resize_snap_active: bool,
|
||||
}
|
||||
|
||||
/// Overlay state — tracks hover and cursor position for crosshair.
|
||||
@@ -1352,6 +1358,123 @@ impl canvas::Program<Message> for OverlayProgram {
|
||||
origin_y,
|
||||
state.hovered_vector_handle,
|
||||
);
|
||||
|
||||
// ── Snap indicator overlay ──────────────────────────────────
|
||||
// When Shift is held during rotation/resize, draw a visual indicator
|
||||
// to confirm snapping is active.
|
||||
let cx = (x1 + x2) / 2.0;
|
||||
let cy = (y1 + y2) / 2.0;
|
||||
|
||||
if self.rotation_snap_active {
|
||||
// Draw a dashed arc from center showing the snapped angle
|
||||
let snap_angle = self.rotation_snap_angle;
|
||||
let indicator_radius = 40.0_f32.min((x2 - x1).abs().min((y2 - y1).abs()) * 0.4);
|
||||
let arc_segments = 48;
|
||||
let snap_color = iced::Color::from_rgba(0.0, 0.8, 1.0, 0.85);
|
||||
|
||||
// Draw arc from 0 to snapped angle
|
||||
let arc_path = Path::new(|b| {
|
||||
let start_x = cx + indicator_radius;
|
||||
let start_y = cy;
|
||||
b.move_to(Point::new(
|
||||
origin_x + start_x * self.zoom,
|
||||
origin_y + start_y * self.zoom,
|
||||
));
|
||||
for i in 1..=arc_segments {
|
||||
let t = i as f32 / arc_segments as f32;
|
||||
let a = snap_angle * t;
|
||||
let px = cx + indicator_radius * a.cos();
|
||||
let py = cy + indicator_radius * a.sin();
|
||||
b.line_to(Point::new(
|
||||
origin_x + px * self.zoom,
|
||||
origin_y + py * self.zoom,
|
||||
));
|
||||
}
|
||||
});
|
||||
frame.stroke(
|
||||
&arc_path,
|
||||
Stroke {
|
||||
style: canvas::stroke::Style::Solid(snap_color),
|
||||
width: 2.0 / self.zoom.max(1.0),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
// Draw radial line from center to arc end
|
||||
let end_x = cx + indicator_radius * snap_angle.cos();
|
||||
let end_y = cy + indicator_radius * snap_angle.sin();
|
||||
let radial_line = Path::line(
|
||||
Point::new(origin_x + cx * self.zoom, origin_y + cy * self.zoom),
|
||||
Point::new(
|
||||
origin_x + end_x * self.zoom,
|
||||
origin_y + end_y * self.zoom,
|
||||
),
|
||||
);
|
||||
frame.stroke(
|
||||
&radial_line,
|
||||
Stroke {
|
||||
style: canvas::stroke::Style::Solid(snap_color),
|
||||
width: 1.5 / self.zoom.max(1.0),
|
||||
line_dash: canvas::LineDash {
|
||||
segments: &[6.0, 4.0],
|
||||
offset: 0,
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
// Small dot at arc end
|
||||
frame.fill(
|
||||
&Path::circle(
|
||||
Point::new(
|
||||
origin_x + end_x * self.zoom,
|
||||
origin_y + end_y * self.zoom,
|
||||
),
|
||||
3.0,
|
||||
),
|
||||
snap_color,
|
||||
);
|
||||
}
|
||||
|
||||
if self.resize_snap_active {
|
||||
// Draw corner brackets at the shape corners to indicate aspect-lock
|
||||
let bracket_size = 12.0_f32;
|
||||
let bracket_color = iced::Color::from_rgba(1.0, 0.6, 0.0, 0.85);
|
||||
let corners = [(x1, y1), (x2, y1), (x2, y2), (x1, y2)];
|
||||
let cos_a = self.selected_vector_angle.cos();
|
||||
let sin_a = self.selected_vector_angle.sin();
|
||||
let rotate = |px: f32, py: f32| -> (f32, f32) {
|
||||
let dx = px - cx;
|
||||
let dy = py - cy;
|
||||
(cx + dx * cos_a - dy * sin_a, cy + dx * sin_a + dy * cos_a)
|
||||
};
|
||||
|
||||
for (_i, &(px, py)) in corners.iter().enumerate() {
|
||||
let (rpx, rpy) = rotate(px, py);
|
||||
let sx = origin_x + rpx * self.zoom;
|
||||
let sy = origin_y + rpy * self.zoom;
|
||||
// Direction from center toward corner
|
||||
let dir_x = (rpx - cx).signum();
|
||||
let dir_y = (rpy - cy).signum();
|
||||
let bs = bracket_size / self.zoom.max(1.0);
|
||||
|
||||
let bracket = Path::new(|b| {
|
||||
// Horizontal segment
|
||||
b.move_to(Point::new(sx + dir_x * bs * self.zoom, sy));
|
||||
b.line_to(Point::new(sx, sy));
|
||||
// Vertical segment
|
||||
b.line_to(Point::new(sx, sy + dir_y * bs * self.zoom));
|
||||
});
|
||||
frame.stroke(
|
||||
&bracket,
|
||||
Stroke {
|
||||
style: canvas::stroke::Style::Solid(bracket_color),
|
||||
width: 2.0 / self.zoom.max(1.0),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw gradient endpoint preview line.
|
||||
@@ -1717,6 +1840,9 @@ pub fn view<'a>(
|
||||
selected_vector_angle: sel_vec_angle,
|
||||
vector_edit_handle: doc.vector_edit_handle,
|
||||
vector_drag_preview: vector_preview,
|
||||
rotation_snap_active: doc.rotation_snap_active,
|
||||
rotation_snap_angle: doc.rotation_snap_angle,
|
||||
resize_snap_active: doc.resize_snap_active,
|
||||
};
|
||||
|
||||
let overlay_canvas = canvas(overlay_program)
|
||||
|
||||
@@ -6,6 +6,7 @@ mod ai_chat;
|
||||
mod ai_script;
|
||||
mod app;
|
||||
mod brush_import;
|
||||
mod build_info;
|
||||
mod canvas;
|
||||
mod cli;
|
||||
mod color_picker;
|
||||
|
||||
@@ -27,8 +27,6 @@ pub(crate) const MENU_LEFT_INSET: f32 = 6.0;
|
||||
/// Unified menu/title bar height shared with dropdown placement.
|
||||
pub(crate) const MENU_BAR_HEIGHT: f32 = 30.0;
|
||||
|
||||
const APP_VERSION: &str = hcie_build_info::VERSION;
|
||||
|
||||
/// Returns the clamped left edge for a menu dropdown.
|
||||
pub(crate) fn menu_anchor_x(menu_index: usize, viewport_width: f32, dropdown_width: f32) -> f32 {
|
||||
let natural = MENU_LEFT_INSET
|
||||
@@ -54,6 +52,7 @@ pub fn view<'a>(
|
||||
active_menu: Option<usize>,
|
||||
title_search: &'a str,
|
||||
show_panel_list: bool,
|
||||
runtime_version: &'a str,
|
||||
) -> Element<'a, Message> {
|
||||
// ── App icon ──
|
||||
let icon = text("H")
|
||||
@@ -164,7 +163,7 @@ pub fn view<'a>(
|
||||
// ── Document info — centered: "HCIE v{version} — New Project.psd *" ──
|
||||
let title_str = format!(
|
||||
"HCIE v{} — {}{}",
|
||||
APP_VERSION,
|
||||
runtime_version,
|
||||
doc_name,
|
||||
if modified { " *" } else { "" }
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user