fix: and even more fixes for M3
This commit is contained in:
@@ -113,7 +113,7 @@ pub fn rebuild_from_filesystem_with_progress(
|
|||||||
report.templates_updated = tpl_report.updated;
|
report.templates_updated = tpl_report.updated;
|
||||||
report.errors.extend(tpl_report.errors);
|
report.errors.extend(tpl_report.errors);
|
||||||
|
|
||||||
// 5. Rebuild scripts (0.85 .. 1.0)
|
// 5. Rebuild scripts (0.85 .. 0.95)
|
||||||
progress(0.85, "Rebuilding scripts...");
|
progress(0.85, "Rebuilding scripts...");
|
||||||
let script_report =
|
let script_report =
|
||||||
script_rebuild::rebuild_scripts_from_filesystem(conn, data_dir, project_id)?;
|
script_rebuild::rebuild_scripts_from_filesystem(conn, data_dir, project_id)?;
|
||||||
@@ -121,6 +121,11 @@ pub fn rebuild_from_filesystem_with_progress(
|
|||||||
report.scripts_updated = script_report.updated;
|
report.scripts_updated = script_report.updated;
|
||||||
report.errors.extend(script_report.errors);
|
report.errors.extend(script_report.errors);
|
||||||
|
|
||||||
|
// 6. Import tags from tags.json and sync from posts (0.95 .. 1.0)
|
||||||
|
progress(0.95, "Importing tags...");
|
||||||
|
let _ = super::tag::import_tags_from_file(conn, data_dir, project_id);
|
||||||
|
let _ = super::tag::sync_tags_from_posts(conn, project_id);
|
||||||
|
|
||||||
progress(1.0, "Rebuild complete");
|
progress(1.0, "Rebuild complete");
|
||||||
Ok(report)
|
Ok(report)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,11 +188,61 @@ pub fn merge_tags(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sync tags from all posts: collect unique tag names, create missing tags in DB.
|
/// Import tags from meta/tags.json into DB, preserving colors and properties.
|
||||||
pub fn sync_tags_from_posts(
|
/// Creates new tags or updates existing ones with file-based properties.
|
||||||
|
pub fn import_tags_from_file(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
data_dir: &Path,
|
data_dir: &Path,
|
||||||
project_id: &str,
|
project_id: &str,
|
||||||
|
) -> EngineResult<()> {
|
||||||
|
let entries = match meta::read_tags_json(data_dir) {
|
||||||
|
Ok(entries) => entries,
|
||||||
|
Err(_) => return Ok(()), // File doesn't exist or is invalid — nothing to import
|
||||||
|
};
|
||||||
|
|
||||||
|
let now = now_unix_ms();
|
||||||
|
for entry in &entries {
|
||||||
|
let name = entry.name.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match tag_q::get_tag_by_project_and_name(conn, project_id, name) {
|
||||||
|
Ok(existing) => {
|
||||||
|
// Update color/post_template_slug from file if provided
|
||||||
|
if entry.color.is_some() || entry.post_template_slug.is_some() {
|
||||||
|
let mut updated = existing;
|
||||||
|
if entry.color.is_some() {
|
||||||
|
updated.color = entry.color.clone();
|
||||||
|
}
|
||||||
|
if entry.post_template_slug.is_some() {
|
||||||
|
updated.post_template_slug = entry.post_template_slug.clone();
|
||||||
|
}
|
||||||
|
updated.updated_at = now;
|
||||||
|
tag_q::update_tag(conn, &updated)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
let tag = Tag {
|
||||||
|
id: Uuid::new_v4().to_string(),
|
||||||
|
project_id: project_id.to_string(),
|
||||||
|
name: name.to_string(),
|
||||||
|
color: entry.color.clone(),
|
||||||
|
post_template_slug: entry.post_template_slug.clone(),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
tag_q::insert_tag(conn, &tag)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sync tags from all posts: collect unique tag names, create missing tags in DB.
|
||||||
|
/// This is additive only — it does NOT rewrite tags.json.
|
||||||
|
pub fn sync_tags_from_posts(
|
||||||
|
conn: &Connection,
|
||||||
|
project_id: &str,
|
||||||
) -> EngineResult<Vec<Tag>> {
|
) -> EngineResult<Vec<Tag>> {
|
||||||
let posts = post_q::list_posts_by_project(conn, project_id)?;
|
let posts = post_q::list_posts_by_project(conn, project_id)?;
|
||||||
|
|
||||||
@@ -225,7 +275,6 @@ pub fn sync_tags_from_posts(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rewrite_tags_json(conn, data_dir, project_id)?;
|
|
||||||
let all_tags = tag_q::list_tags_by_project(conn, project_id)?;
|
let all_tags = tag_q::list_tags_by_project(conn, project_id)?;
|
||||||
Ok(all_tags)
|
Ok(all_tags)
|
||||||
}
|
}
|
||||||
@@ -483,15 +532,32 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let tags = sync_tags_from_posts(db.conn(), dir.path(), "p1").unwrap();
|
let tags = sync_tags_from_posts(db.conn(), "p1").unwrap();
|
||||||
assert_eq!(tags.len(), 3);
|
assert_eq!(tags.len(), 3);
|
||||||
let names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect();
|
let names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect();
|
||||||
assert!(names.contains(&"go"));
|
assert!(names.contains(&"go"));
|
||||||
assert!(names.contains(&"rust"));
|
assert!(names.contains(&"rust"));
|
||||||
assert!(names.contains(&"web"));
|
assert!(names.contains(&"web"));
|
||||||
|
}
|
||||||
|
|
||||||
let entries = meta::read_tags_json(dir.path()).unwrap();
|
#[test]
|
||||||
assert_eq!(entries.len(), 3);
|
fn import_tags_from_file_preserves_colors() {
|
||||||
|
let (db, dir) = setup();
|
||||||
|
// Write tags.json with colors
|
||||||
|
let entries = vec![
|
||||||
|
TagEntry { name: "rust".into(), color: Some("#ff0000".into()), post_template_slug: None },
|
||||||
|
TagEntry { name: "web".into(), color: None, post_template_slug: Some("blog".into()) },
|
||||||
|
];
|
||||||
|
meta::write_tags_json(dir.path(), &entries).unwrap();
|
||||||
|
|
||||||
|
import_tags_from_file(db.conn(), dir.path(), "p1").unwrap();
|
||||||
|
|
||||||
|
let tags = tag_q::list_tags_by_project(db.conn(), "p1").unwrap();
|
||||||
|
assert_eq!(tags.len(), 2);
|
||||||
|
let rust_tag = tags.iter().find(|t| t.name == "rust").unwrap();
|
||||||
|
assert_eq!(rust_tag.color.as_deref(), Some("#ff0000"));
|
||||||
|
let web_tag = tags.iter().find(|t| t.name == "web").unwrap();
|
||||||
|
assert_eq!(web_tag.post_template_slug.as_deref(), Some("blog"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -95,12 +95,49 @@ fn syntect_to_iced(c: syntect::highlighting::Color) -> Color {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Map a visual row index (from scroll top) to (logical_line, char_offset).
|
||||||
|
/// Returns None if beyond end of buffer.
|
||||||
|
fn visual_to_logical(
|
||||||
|
buf: &EditorBuffer,
|
||||||
|
scroll: usize,
|
||||||
|
visual_row: usize,
|
||||||
|
chars_per_visual_line: usize,
|
||||||
|
) -> Option<(usize, usize)> {
|
||||||
|
if chars_per_visual_line == 0 {
|
||||||
|
// No wrapping — direct 1:1 mapping
|
||||||
|
let line = scroll + visual_row;
|
||||||
|
return if line < buf.line_count() { Some((line, 0)) } else { None };
|
||||||
|
}
|
||||||
|
let target = scroll + visual_row;
|
||||||
|
let mut vis_count = 0usize;
|
||||||
|
for line_idx in 0..buf.line_count() {
|
||||||
|
let line_chars = buf.line(line_idx)
|
||||||
|
.map(|l| {
|
||||||
|
let n = l.len_chars();
|
||||||
|
if n > 0 && l.char(n - 1) == '\n' { n - 1 } else { n }
|
||||||
|
})
|
||||||
|
.unwrap_or(0);
|
||||||
|
let wrap_count = if line_chars > chars_per_visual_line {
|
||||||
|
(line_chars + chars_per_visual_line - 1) / chars_per_visual_line
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
};
|
||||||
|
if vis_count + wrap_count > target {
|
||||||
|
let sub = target - vis_count;
|
||||||
|
return Some((line_idx, sub * chars_per_visual_line));
|
||||||
|
}
|
||||||
|
vis_count += wrap_count;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// A syntax-highlighting code editor widget for Iced.
|
/// A syntax-highlighting code editor widget for Iced.
|
||||||
pub struct CodeEditor<'a, Message> {
|
pub struct CodeEditor<'a, Message> {
|
||||||
buffer: &'a RefCell<EditorBuffer>,
|
buffer: &'a RefCell<EditorBuffer>,
|
||||||
highlighter: &'a Highlighter,
|
highlighter: &'a Highlighter,
|
||||||
extension: &'a str,
|
extension: &'a str,
|
||||||
on_change: Option<Box<dyn Fn(EditorMessage) -> Message + 'a>>,
|
on_change: Option<Box<dyn Fn(EditorMessage) -> Message + 'a>>,
|
||||||
|
word_wrap: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a, Message> CodeEditor<'a, Message> {
|
impl<'a, Message> CodeEditor<'a, Message> {
|
||||||
@@ -114,6 +151,7 @@ impl<'a, Message> CodeEditor<'a, Message> {
|
|||||||
highlighter,
|
highlighter,
|
||||||
extension,
|
extension,
|
||||||
on_change: None,
|
on_change: None,
|
||||||
|
word_wrap: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +159,11 @@ impl<'a, Message> CodeEditor<'a, Message> {
|
|||||||
self.on_change = Some(Box::new(f));
|
self.on_change = Some(Box::new(f));
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn word_wrap(mut self, enabled: bool) -> Self {
|
||||||
|
self.word_wrap = enabled;
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a, Message, Renderer> Widget<Message, Theme, Renderer> for CodeEditor<'a, Message>
|
impl<'a, Message, Renderer> Widget<Message, Theme, Renderer> for CodeEditor<'a, Message>
|
||||||
@@ -200,22 +243,53 @@ where
|
|||||||
let highlighted = self.highlighter.highlight_lines(&full_text, syntax);
|
let highlighted = self.highlighter.highlight_lines(&full_text, syntax);
|
||||||
|
|
||||||
let font = iced::Font::MONOSPACE;
|
let font = iced::Font::MONOSPACE;
|
||||||
|
let text_area_width = bounds.width - GUTTER_WIDTH - 8.0;
|
||||||
|
let chars_per_visual_line = if self.word_wrap && text_area_width > metrics.char_width {
|
||||||
|
(text_area_width / metrics.char_width).floor() as usize
|
||||||
|
} else {
|
||||||
|
0 // 0 = no wrapping
|
||||||
|
};
|
||||||
|
|
||||||
// Render visible lines
|
// Build visual line map: skip 'scroll' visual lines, then render 'visible_lines' visual lines.
|
||||||
for vis_idx in 0..visible_lines {
|
// Each entry: (logical_line, char_offset, is_first_visual_line)
|
||||||
let line_idx = scroll + vis_idx;
|
let mut visual_rows: Vec<(usize, usize, bool)> = Vec::new();
|
||||||
if line_idx >= buf.line_count() {
|
let mut vis_skip = 0usize; // visual lines to skip for scroll offset
|
||||||
break;
|
let mut line_idx = 0usize;
|
||||||
|
|
||||||
|
while visual_rows.len() < visible_lines && line_idx < buf.line_count() {
|
||||||
|
let line_chars = buf.line(line_idx)
|
||||||
|
.map(|l| {
|
||||||
|
let n = l.len_chars();
|
||||||
|
if n > 0 && l.char(n - 1) == '\n' { n - 1 } else { n }
|
||||||
|
})
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let wrap_count = if chars_per_visual_line > 0 && line_chars > chars_per_visual_line {
|
||||||
|
(line_chars + chars_per_visual_line - 1) / chars_per_visual_line
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
};
|
||||||
|
|
||||||
|
for w in 0..wrap_count {
|
||||||
|
if vis_skip < scroll {
|
||||||
|
vis_skip += 1;
|
||||||
|
} else if visual_rows.len() < visible_lines {
|
||||||
|
visual_rows.push((line_idx, w * chars_per_visual_line, w == 0));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
line_idx += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let text_x = bounds.x + GUTTER_WIDTH + 8.0;
|
||||||
|
|
||||||
|
// Render visual rows
|
||||||
|
for (vis_idx, &(line_idx, char_offset, is_first)) in visual_rows.iter().enumerate() {
|
||||||
let y = bounds.y + vis_idx as f32 * metrics.line_height;
|
let y = bounds.y + vis_idx as f32 * metrics.line_height;
|
||||||
if y + metrics.line_height < bounds.y || y > bounds.y + bounds.height {
|
if y + metrics.line_height < bounds.y || y > bounds.y + bounds.height {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let text_x = bounds.x + GUTTER_WIDTH + 8.0;
|
// Draw selection highlight for this visual line
|
||||||
|
|
||||||
// Draw selection highlight for this line
|
|
||||||
if let Some(sel) = selection {
|
if let Some(sel) = selection {
|
||||||
if !sel.is_empty() {
|
if !sel.is_empty() {
|
||||||
let (start, end) = sel.ordered();
|
let (start, end) = sel.ordered();
|
||||||
@@ -223,33 +297,28 @@ where
|
|||||||
.line(line_idx)
|
.line(line_idx)
|
||||||
.map(|l| {
|
.map(|l| {
|
||||||
let len = l.len_chars();
|
let len = l.len_chars();
|
||||||
if len > 0 && l.char(len - 1) == '\n' {
|
if len > 0 && l.char(len - 1) == '\n' { len - 1 } else { len }
|
||||||
len - 1
|
|
||||||
} else {
|
|
||||||
len
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
if line_idx >= start.0 && line_idx <= end.0 {
|
if line_idx >= start.0 && line_idx <= end.0 {
|
||||||
let sel_start_col = if line_idx == start.0 { start.1 } else { 0 };
|
let sel_start_col = if line_idx == start.0 { start.1 } else { 0 };
|
||||||
let sel_end_col = if line_idx == end.0 {
|
let sel_end_col = if line_idx == end.0 { end.1 } else { line_len + 1 };
|
||||||
end.1
|
|
||||||
|
// Clip selection to current visual line range
|
||||||
|
let vis_end = if chars_per_visual_line > 0 {
|
||||||
|
char_offset + chars_per_visual_line
|
||||||
} else {
|
} else {
|
||||||
line_len + 1
|
usize::MAX
|
||||||
};
|
};
|
||||||
let sel_x = text_x + sel_start_col as f32 * metrics.char_width;
|
let s = sel_start_col.max(char_offset);
|
||||||
let sel_w =
|
let e = sel_end_col.min(vis_end);
|
||||||
(sel_end_col - sel_start_col) as f32 * metrics.char_width;
|
if s < e {
|
||||||
if sel_w > 0.0 {
|
let sel_x = text_x + (s - char_offset) as f32 * metrics.char_width;
|
||||||
|
let sel_w = (e - s) as f32 * metrics.char_width;
|
||||||
renderer.fill_quad(
|
renderer.fill_quad(
|
||||||
renderer::Quad {
|
renderer::Quad {
|
||||||
bounds: Rectangle {
|
bounds: Rectangle { x: sel_x, y, width: sel_w, height: metrics.line_height },
|
||||||
x: sel_x,
|
|
||||||
y,
|
|
||||||
width: sel_w,
|
|
||||||
height: metrics.line_height,
|
|
||||||
},
|
|
||||||
border: iced::Border::default(),
|
border: iced::Border::default(),
|
||||||
shadow: iced::Shadow::default(),
|
shadow: iced::Shadow::default(),
|
||||||
},
|
},
|
||||||
@@ -260,54 +329,70 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Line number
|
// Line number (only on first visual line of each logical line)
|
||||||
let line_num = format!("{:>4}", line_idx + 1);
|
if is_first {
|
||||||
let num_color = if line_idx == cursor_line {
|
let line_num = format!("{:>4}", line_idx + 1);
|
||||||
ACTIVE_LINE_NUM
|
let num_color = if line_idx == cursor_line { ACTIVE_LINE_NUM } else { GUTTER_TEXT };
|
||||||
} else {
|
renderer.fill_text(
|
||||||
GUTTER_TEXT
|
text::Text {
|
||||||
};
|
content: line_num,
|
||||||
renderer.fill_text(
|
bounds: Size::new(GUTTER_WIDTH - 8.0, metrics.line_height),
|
||||||
text::Text {
|
size: Pixels(FONT_SIZE),
|
||||||
content: line_num,
|
line_height: iced::widget::text::LineHeight::Absolute(Pixels(metrics.line_height)),
|
||||||
bounds: Size::new(GUTTER_WIDTH - 8.0, metrics.line_height),
|
font,
|
||||||
size: Pixels(FONT_SIZE),
|
horizontal_alignment: iced::alignment::Horizontal::Right,
|
||||||
line_height: iced::widget::text::LineHeight::Absolute(Pixels(
|
vertical_alignment: iced::alignment::Vertical::Top,
|
||||||
metrics.line_height,
|
shaping: iced::widget::text::Shaping::Basic,
|
||||||
)),
|
wrapping: iced::widget::text::Wrapping::None,
|
||||||
font,
|
},
|
||||||
horizontal_alignment: iced::alignment::Horizontal::Right,
|
Point::new(bounds.x, y),
|
||||||
vertical_alignment: iced::alignment::Vertical::Top,
|
num_color,
|
||||||
shaping: iced::widget::text::Shaping::Basic,
|
bounds,
|
||||||
wrapping: iced::widget::text::Wrapping::None,
|
);
|
||||||
},
|
}
|
||||||
Point::new(bounds.x, y),
|
|
||||||
num_color,
|
|
||||||
bounds,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Line content with syntax highlighting
|
// Line content with syntax highlighting
|
||||||
if line_idx < highlighted.len() {
|
if line_idx < highlighted.len() {
|
||||||
let spans = &highlighted[line_idx];
|
let spans = &highlighted[line_idx];
|
||||||
let mut x_off = text_x;
|
// Flatten spans into characters with colors for correct wrapping
|
||||||
|
let mut chars_with_color: Vec<(char, Color)> = Vec::new();
|
||||||
for (style, span_text) in spans {
|
for (style, span_text) in spans {
|
||||||
let mut display_text = span_text.clone();
|
|
||||||
if display_text.ends_with('\n') {
|
|
||||||
display_text.pop();
|
|
||||||
}
|
|
||||||
if display_text.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let color = syntect_to_iced(style.foreground);
|
let color = syntect_to_iced(style.foreground);
|
||||||
|
for ch in span_text.chars() {
|
||||||
|
if ch == '\n' { continue; }
|
||||||
|
chars_with_color.push((ch, color));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the slice for this visual line
|
||||||
|
let end_char = if chars_per_visual_line > 0 {
|
||||||
|
(char_offset + chars_per_visual_line).min(chars_with_color.len())
|
||||||
|
} else {
|
||||||
|
chars_with_color.len()
|
||||||
|
};
|
||||||
|
let slice = if char_offset < chars_with_color.len() {
|
||||||
|
&chars_with_color[char_offset..end_char]
|
||||||
|
} else {
|
||||||
|
&[]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Group consecutive chars with same color into spans
|
||||||
|
let mut x_off = text_x;
|
||||||
|
let mut span_start = 0;
|
||||||
|
while span_start < slice.len() {
|
||||||
|
let color = slice[span_start].1;
|
||||||
|
let mut span_end = span_start + 1;
|
||||||
|
while span_end < slice.len() && slice[span_end].1 == color {
|
||||||
|
span_end += 1;
|
||||||
|
}
|
||||||
|
let display_text: String = slice[span_start..span_end].iter().map(|(c, _)| *c).collect();
|
||||||
let span_width = display_text.len() as f32 * metrics.char_width;
|
let span_width = display_text.len() as f32 * metrics.char_width;
|
||||||
renderer.fill_text(
|
renderer.fill_text(
|
||||||
text::Text {
|
text::Text {
|
||||||
content: display_text,
|
content: display_text,
|
||||||
bounds: Size::new(span_width + metrics.char_width, metrics.line_height),
|
bounds: Size::new(span_width + metrics.char_width, metrics.line_height),
|
||||||
size: Pixels(FONT_SIZE),
|
size: Pixels(FONT_SIZE),
|
||||||
line_height: iced::widget::text::LineHeight::Absolute(Pixels(
|
line_height: iced::widget::text::LineHeight::Absolute(Pixels(metrics.line_height)),
|
||||||
metrics.line_height,
|
|
||||||
)),
|
|
||||||
font,
|
font,
|
||||||
horizontal_alignment: iced::alignment::Horizontal::Left,
|
horizontal_alignment: iced::alignment::Horizontal::Left,
|
||||||
vertical_alignment: iced::alignment::Vertical::Top,
|
vertical_alignment: iced::alignment::Vertical::Top,
|
||||||
@@ -319,52 +404,61 @@ where
|
|||||||
bounds,
|
bounds,
|
||||||
);
|
);
|
||||||
x_off += span_width;
|
x_off += span_width;
|
||||||
|
span_start = span_end;
|
||||||
}
|
}
|
||||||
} else if let Some(line) = buf.line(line_idx) {
|
} else if let Some(line) = buf.line(line_idx) {
|
||||||
// Fallback: plain text rendering
|
// Fallback: plain text rendering
|
||||||
let mut line_text: String = line.chars().collect();
|
let line_text: String = line.chars().filter(|c| *c != '\n').collect();
|
||||||
if line_text.ends_with('\n') {
|
let end_char = if chars_per_visual_line > 0 {
|
||||||
line_text.pop();
|
(char_offset + chars_per_visual_line).min(line_text.len())
|
||||||
|
} else {
|
||||||
|
line_text.len()
|
||||||
|
};
|
||||||
|
let display_text = if char_offset < line_text.len() {
|
||||||
|
&line_text[char_offset..end_char]
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
if !display_text.is_empty() {
|
||||||
|
renderer.fill_text(
|
||||||
|
text::Text {
|
||||||
|
content: display_text.to_string(),
|
||||||
|
bounds: Size::new(text_area_width, metrics.line_height),
|
||||||
|
size: Pixels(FONT_SIZE),
|
||||||
|
line_height: iced::widget::text::LineHeight::Absolute(Pixels(metrics.line_height)),
|
||||||
|
font,
|
||||||
|
horizontal_alignment: iced::alignment::Horizontal::Left,
|
||||||
|
vertical_alignment: iced::alignment::Vertical::Top,
|
||||||
|
shaping: iced::widget::text::Shaping::Advanced,
|
||||||
|
wrapping: iced::widget::text::Wrapping::None,
|
||||||
|
},
|
||||||
|
Point::new(text_x, y),
|
||||||
|
TEXT_COLOR,
|
||||||
|
bounds,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
renderer.fill_text(
|
|
||||||
text::Text {
|
|
||||||
content: line_text,
|
|
||||||
bounds: Size::new(
|
|
||||||
bounds.width - GUTTER_WIDTH - 8.0,
|
|
||||||
metrics.line_height,
|
|
||||||
),
|
|
||||||
size: Pixels(FONT_SIZE),
|
|
||||||
line_height: iced::widget::text::LineHeight::Absolute(Pixels(
|
|
||||||
metrics.line_height,
|
|
||||||
)),
|
|
||||||
font,
|
|
||||||
horizontal_alignment: iced::alignment::Horizontal::Left,
|
|
||||||
vertical_alignment: iced::alignment::Vertical::Top,
|
|
||||||
shaping: iced::widget::text::Shaping::Advanced,
|
|
||||||
wrapping: iced::widget::text::Wrapping::None,
|
|
||||||
},
|
|
||||||
Point::new(text_x, y),
|
|
||||||
TEXT_COLOR,
|
|
||||||
bounds,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw cursor on current line
|
// Draw cursor on this visual line if it contains the cursor
|
||||||
if line_idx == cursor_line && state.is_focused {
|
if line_idx == cursor_line && state.is_focused {
|
||||||
let cursor_x = text_x + cursor_col as f32 * metrics.char_width;
|
let vis_end = if chars_per_visual_line > 0 {
|
||||||
renderer.fill_quad(
|
char_offset + chars_per_visual_line
|
||||||
renderer::Quad {
|
} else {
|
||||||
bounds: Rectangle {
|
usize::MAX
|
||||||
x: cursor_x,
|
};
|
||||||
y,
|
if cursor_col >= char_offset && cursor_col < vis_end {
|
||||||
width: 2.0,
|
let cursor_x = text_x + (cursor_col - char_offset) as f32 * metrics.char_width;
|
||||||
height: metrics.line_height,
|
renderer.fill_quad(
|
||||||
|
renderer::Quad {
|
||||||
|
bounds: Rectangle {
|
||||||
|
x: cursor_x, y, width: 2.0, height: metrics.line_height,
|
||||||
|
},
|
||||||
|
border: iced::Border::default(),
|
||||||
|
shadow: iced::Shadow::default(),
|
||||||
},
|
},
|
||||||
border: iced::Border::default(),
|
CURSOR_COLOR,
|
||||||
shadow: iced::Shadow::default(),
|
);
|
||||||
},
|
}
|
||||||
CURSOR_COLOR,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -383,6 +477,12 @@ where
|
|||||||
let state = tree.state.downcast_mut::<EditorState>();
|
let state = tree.state.downcast_mut::<EditorState>();
|
||||||
let bounds = layout.bounds();
|
let bounds = layout.bounds();
|
||||||
let metrics = mono_metrics();
|
let metrics = mono_metrics();
|
||||||
|
let text_area_width = bounds.width - GUTTER_WIDTH - 8.0;
|
||||||
|
let cpl = if self.word_wrap && text_area_width > metrics.char_width {
|
||||||
|
(text_area_width / metrics.char_width).floor() as usize
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
match event {
|
match event {
|
||||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
|
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
|
||||||
@@ -392,10 +492,11 @@ where
|
|||||||
|
|
||||||
if let Some(pos) = cursor.position_in(bounds) {
|
if let Some(pos) = cursor.position_in(bounds) {
|
||||||
let mut buf = self.buffer.borrow_mut();
|
let mut buf = self.buffer.borrow_mut();
|
||||||
let line = (pos.y / metrics.line_height) as usize
|
let vis_row = (pos.y / metrics.line_height) as usize;
|
||||||
+ buf.scroll_offset();
|
let raw_col = ((pos.x - GUTTER_WIDTH - 8.0).max(0.0) / metrics.char_width) as usize;
|
||||||
let col = ((pos.x - GUTTER_WIDTH - 8.0).max(0.0) / metrics.char_width)
|
let (line, char_off) = visual_to_logical(&buf, buf.scroll_offset(), vis_row, cpl)
|
||||||
as usize;
|
.unwrap_or((buf.line_count().saturating_sub(1), 0));
|
||||||
|
let col = char_off + raw_col;
|
||||||
|
|
||||||
// Double-click detection
|
// Double-click detection
|
||||||
let now = std::time::Instant::now();
|
let now = std::time::Instant::now();
|
||||||
@@ -429,10 +530,11 @@ where
|
|||||||
if state.is_dragging && state.is_focused {
|
if state.is_dragging && state.is_focused {
|
||||||
if let Some(pos) = cursor.position_in(bounds) {
|
if let Some(pos) = cursor.position_in(bounds) {
|
||||||
let mut buf = self.buffer.borrow_mut();
|
let mut buf = self.buffer.borrow_mut();
|
||||||
let line = (pos.y / metrics.line_height) as usize
|
let vis_row = (pos.y / metrics.line_height) as usize;
|
||||||
+ buf.scroll_offset();
|
let raw_col = ((pos.x - GUTTER_WIDTH - 8.0).max(0.0) / metrics.char_width) as usize;
|
||||||
let col = ((pos.x - GUTTER_WIDTH - 8.0).max(0.0) / metrics.char_width)
|
let (line, char_off) = visual_to_logical(&buf, buf.scroll_offset(), vis_row, cpl)
|
||||||
as usize;
|
.unwrap_or((buf.line_count().saturating_sub(1), 0));
|
||||||
|
let col = char_off + raw_col;
|
||||||
// Extend selection by simulating shift+movement
|
// Extend selection by simulating shift+movement
|
||||||
let clamped_line = line.min(buf.line_count().saturating_sub(1));
|
let clamped_line = line.min(buf.line_count().saturating_sub(1));
|
||||||
let clamped_col = if clamped_line < buf.line_count() {
|
let clamped_col = if clamped_line < buf.line_count() {
|
||||||
|
|||||||
@@ -1002,6 +1002,7 @@ impl BdsApp {
|
|||||||
MediaEditorMsg::AltChanged(s) => { state.alt = s; state.is_dirty = true; }
|
MediaEditorMsg::AltChanged(s) => { state.alt = s; state.is_dirty = true; }
|
||||||
MediaEditorMsg::CaptionChanged(s) => { state.caption = s; state.is_dirty = true; }
|
MediaEditorMsg::CaptionChanged(s) => { state.caption = s; state.is_dirty = true; }
|
||||||
MediaEditorMsg::AuthorChanged(s) => { state.author = s; state.is_dirty = true; }
|
MediaEditorMsg::AuthorChanged(s) => { state.author = s; state.is_dirty = true; }
|
||||||
|
MediaEditorMsg::SwitchLanguage(lang) => { state.switch_language(&lang); }
|
||||||
MediaEditorMsg::Save => {
|
MediaEditorMsg::Save => {
|
||||||
return self.save_media_editor(&tab_id);
|
return self.save_media_editor(&tab_id);
|
||||||
}
|
}
|
||||||
@@ -1134,11 +1135,31 @@ impl BdsApp {
|
|||||||
Message::PostLoaded(result) => {
|
Message::PostLoaded(result) => {
|
||||||
match result {
|
match result {
|
||||||
Ok(post) => {
|
Ok(post) => {
|
||||||
let translations = self.db.as_ref()
|
let mut translations = self.db.as_ref()
|
||||||
.and_then(|db| bds_core::db::queries::post_translation::list_post_translations_by_post(
|
.and_then(|db| bds_core::db::queries::post_translation::list_post_translations_by_post(
|
||||||
db.conn(), &post.id,
|
db.conn(), &post.id,
|
||||||
).ok())
|
).ok())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
// Published translations don't store body in DB — read from file
|
||||||
|
if let Some(ref data_dir) = self.data_dir {
|
||||||
|
for tr in &mut translations {
|
||||||
|
if tr.content.is_none() {
|
||||||
|
let rel = bds_core::util::paths::translation_file_path(
|
||||||
|
post.created_at,
|
||||||
|
&post.slug,
|
||||||
|
&tr.language,
|
||||||
|
);
|
||||||
|
let path = data_dir.join(&rel);
|
||||||
|
if let Ok(raw) = std::fs::read_to_string(&path) {
|
||||||
|
if let Ok((_fm, body)) =
|
||||||
|
bds_core::util::frontmatter::read_translation_file(&raw)
|
||||||
|
{
|
||||||
|
tr.content = Some(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let (outlinks, backlinks) = self.load_post_links(&post.id);
|
let (outlinks, backlinks) = self.load_post_links(&post.id);
|
||||||
let state = PostEditorState::from_post(&post, &self.blog_languages, &translations, outlinks, backlinks);
|
let state = PostEditorState::from_post(&post, &self.blog_languages, &translations, outlinks, backlinks);
|
||||||
self.post_editors.insert(post.id.clone(), state);
|
self.post_editors.insert(post.id.clone(), state);
|
||||||
@@ -1150,7 +1171,12 @@ impl BdsApp {
|
|||||||
Message::MediaLoaded(result) => {
|
Message::MediaLoaded(result) => {
|
||||||
match result {
|
match result {
|
||||||
Ok(media) => {
|
Ok(media) => {
|
||||||
let state = MediaEditorState::from_media(&media);
|
let translations = self.db.as_ref()
|
||||||
|
.and_then(|db| bds_core::db::queries::media_translation::list_media_translations_by_media(
|
||||||
|
db.conn(), &media.id,
|
||||||
|
).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let state = MediaEditorState::from_media(&media, &self.blog_languages, &translations);
|
||||||
self.media_editors.insert(media.id.clone(), state);
|
self.media_editors.insert(media.id.clone(), state);
|
||||||
}
|
}
|
||||||
Err(e) => self.notify(ToastLevel::Error, &e),
|
Err(e) => self.notify(ToastLevel::Error, &e),
|
||||||
@@ -1873,13 +1899,22 @@ impl BdsApp {
|
|||||||
fn handle_tags_msg(&mut self, msg: TagsMsg) -> Task<Message> {
|
fn handle_tags_msg(&mut self, msg: TagsMsg) -> Task<Message> {
|
||||||
// Ensure tags view state exists
|
// Ensure tags view state exists
|
||||||
if self.tags_view_state.is_none() {
|
if self.tags_view_state.is_none() {
|
||||||
let tags = if let (Some(db), Some(project)) = (&self.db, &self.active_project) {
|
let (tags, counts) = if let (Some(db), Some(project)) = (&self.db, &self.active_project) {
|
||||||
bds_core::db::queries::tag::list_tags_by_project(db.conn(), &project.id)
|
let tags = bds_core::db::queries::tag::list_tags_by_project(db.conn(), &project.id)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default();
|
||||||
|
let posts = bds_core::db::queries::post::list_posts_by_project(db.conn(), &project.id)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let mut counts = std::collections::HashMap::new();
|
||||||
|
for post in &posts {
|
||||||
|
for tag_name in &post.tags {
|
||||||
|
*counts.entry(tag_name.to_lowercase()).or_insert(0usize) += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(tags, counts)
|
||||||
} else {
|
} else {
|
||||||
Vec::new()
|
(Vec::new(), std::collections::HashMap::new())
|
||||||
};
|
};
|
||||||
self.tags_view_state = Some(TagsViewState::new(tags));
|
self.tags_view_state = Some(TagsViewState::new(tags, counts));
|
||||||
}
|
}
|
||||||
let state = self.tags_view_state.as_mut().unwrap();
|
let state = self.tags_view_state.as_mut().unwrap();
|
||||||
match msg {
|
match msg {
|
||||||
@@ -2042,6 +2077,18 @@ impl BdsApp {
|
|||||||
let _ = open::that(dir);
|
let _ = open::that(dir);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
SettingsMsg::FocusSection(section) => {
|
||||||
|
// Expand the target section, collapse all others
|
||||||
|
use crate::views::settings_view::SettingsSection;
|
||||||
|
let all_others: Vec<SettingsSection> = SettingsSection::all()
|
||||||
|
.iter()
|
||||||
|
.filter(|s| **s != section)
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
state.collapsed = all_others;
|
||||||
|
// Clear search filter to ensure section is visible
|
||||||
|
state.search_query.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
@@ -2104,9 +2151,29 @@ impl BdsApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Load translations for translation flags bar
|
// Load translations for translation flags bar
|
||||||
let translations = bds_core::db::queries::post_translation::list_post_translations_by_post(
|
let mut translations = bds_core::db::queries::post_translation::list_post_translations_by_post(
|
||||||
db.conn(), &post.id,
|
db.conn(), &post.id,
|
||||||
).unwrap_or_default();
|
).unwrap_or_default();
|
||||||
|
// Published translations don't store body in DB — read from file
|
||||||
|
if let Some(ref data_dir) = self.data_dir {
|
||||||
|
for tr in &mut translations {
|
||||||
|
if tr.content.is_none() {
|
||||||
|
let rel = bds_core::util::paths::translation_file_path(
|
||||||
|
post.created_at,
|
||||||
|
&post.slug,
|
||||||
|
&tr.language,
|
||||||
|
);
|
||||||
|
let path = data_dir.join(&rel);
|
||||||
|
if let Ok(raw) = std::fs::read_to_string(&path) {
|
||||||
|
if let Ok((_fm, body)) =
|
||||||
|
bds_core::util::frontmatter::read_translation_file(&raw)
|
||||||
|
{
|
||||||
|
tr.content = Some(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let (outlinks, backlinks) = self.load_post_links(&post.id);
|
let (outlinks, backlinks) = self.load_post_links(&post.id);
|
||||||
self.post_editors.insert(
|
self.post_editors.insert(
|
||||||
post.id.clone(),
|
post.id.clone(),
|
||||||
@@ -2123,7 +2190,10 @@ impl BdsApp {
|
|||||||
if !self.media_editors.contains_key(&tab.id) {
|
if !self.media_editors.contains_key(&tab.id) {
|
||||||
match bds_core::db::queries::media::get_media_by_id(db.conn(), &tab.id) {
|
match bds_core::db::queries::media::get_media_by_id(db.conn(), &tab.id) {
|
||||||
Ok(media) => {
|
Ok(media) => {
|
||||||
self.media_editors.insert(media.id.clone(), MediaEditorState::from_media(&media));
|
let translations = bds_core::db::queries::media_translation::list_media_translations_by_media(
|
||||||
|
db.conn(), &media.id,
|
||||||
|
).unwrap_or_default();
|
||||||
|
self.media_editors.insert(media.id.clone(), MediaEditorState::from_media(&media, &self.blog_languages, &translations));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
self.notify(ToastLevel::Error, &format!("Failed to load media: {e}"));
|
self.notify(ToastLevel::Error, &format!("Failed to load media: {e}"));
|
||||||
@@ -2185,17 +2255,30 @@ impl BdsApp {
|
|||||||
TabType::Tags => {
|
TabType::Tags => {
|
||||||
if self.tags_view_state.is_none() {
|
if self.tags_view_state.is_none() {
|
||||||
let project_id = self.active_project.as_ref().map(|p| p.id.as_str()).unwrap_or("");
|
let project_id = self.active_project.as_ref().map(|p| p.id.as_str()).unwrap_or("");
|
||||||
// Sync tags from posts first to ensure tags table is populated
|
// Import tags from file first, then sync from posts (additive only)
|
||||||
if let Some(ref data_dir) = self.data_dir {
|
if let Some(ref data_dir) = self.data_dir {
|
||||||
let _ = bds_core::engine::tag::sync_tags_from_posts(
|
let _ = bds_core::engine::tag::import_tags_from_file(
|
||||||
db.conn(), data_dir, project_id,
|
db.conn(), data_dir, project_id,
|
||||||
);
|
);
|
||||||
|
let _ = bds_core::engine::tag::sync_tags_from_posts(
|
||||||
|
db.conn(), project_id,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let tags = bds_core::db::queries::tag::list_tags_by_project(
|
let tags = bds_core::db::queries::tag::list_tags_by_project(
|
||||||
db.conn(),
|
db.conn(),
|
||||||
project_id,
|
project_id,
|
||||||
).unwrap_or_default();
|
).unwrap_or_default();
|
||||||
self.tags_view_state = Some(TagsViewState::new(tags));
|
// Compute post counts per tag for cloud sizing
|
||||||
|
let posts = bds_core::db::queries::post::list_posts_by_project(
|
||||||
|
db.conn(), project_id,
|
||||||
|
).unwrap_or_default();
|
||||||
|
let mut tag_post_counts = std::collections::HashMap::new();
|
||||||
|
for post in &posts {
|
||||||
|
for tag_name in &post.tags {
|
||||||
|
*tag_post_counts.entry(tag_name.to_lowercase()).or_insert(0usize) += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.tags_view_state = Some(TagsViewState::new(tags, tag_post_counts));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TabType::Settings => {
|
TabType::Settings => {
|
||||||
|
|||||||
@@ -1,14 +1,26 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use iced::widget::{button, column, container, image, row, scrollable, text, Space};
|
use iced::widget::{button, column, container, image, row, scrollable, text, Space};
|
||||||
|
use iced::widget::text::Shaping;
|
||||||
use iced::{Color, Element, Length};
|
use iced::{Color, Element, Length};
|
||||||
|
|
||||||
use bds_core::i18n::UiLocale;
|
use bds_core::i18n::{self, UiLocale};
|
||||||
use bds_core::model::Media;
|
use bds_core::model::{Media, MediaTranslation};
|
||||||
|
|
||||||
use crate::app::Message;
|
use crate::app::Message;
|
||||||
use crate::components::inputs;
|
use crate::components::inputs;
|
||||||
use crate::i18n::t;
|
use crate::i18n::t;
|
||||||
|
use crate::views::post_editor::TranslationFlag;
|
||||||
|
|
||||||
|
/// Saved draft content for a single media translation language.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct MediaTranslationDraft {
|
||||||
|
pub title: String,
|
||||||
|
pub alt: String,
|
||||||
|
pub caption: String,
|
||||||
|
pub is_dirty: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// State for an open media editor.
|
/// State for an open media editor.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -30,10 +42,28 @@ pub struct MediaEditorState {
|
|||||||
pub created_at: i64,
|
pub created_at: i64,
|
||||||
pub updated_at: i64,
|
pub updated_at: i64,
|
||||||
pub is_dirty: bool,
|
pub is_dirty: bool,
|
||||||
|
// ── Translation flags ──
|
||||||
|
pub active_language: String,
|
||||||
|
pub canonical_language: String,
|
||||||
|
pub blog_languages: Vec<String>,
|
||||||
|
pub saved_canonical: Option<MediaTranslationDraft>,
|
||||||
|
pub translation_drafts: HashMap<String, MediaTranslationDraft>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MediaEditorState {
|
impl MediaEditorState {
|
||||||
pub fn from_media(media: &Media) -> Self {
|
pub fn from_media(media: &Media, blog_languages: &[String], translations: &[MediaTranslation]) -> Self {
|
||||||
|
let canonical_lang = media.language.clone().unwrap_or_else(|| "en".to_string());
|
||||||
|
|
||||||
|
let mut translation_drafts = HashMap::new();
|
||||||
|
for tr in translations {
|
||||||
|
translation_drafts.insert(tr.language.clone(), MediaTranslationDraft {
|
||||||
|
title: tr.title.clone().unwrap_or_default(),
|
||||||
|
alt: tr.alt.clone().unwrap_or_default(),
|
||||||
|
caption: tr.caption.clone().unwrap_or_default(),
|
||||||
|
is_dirty: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
media_id: media.id.clone(),
|
media_id: media.id.clone(),
|
||||||
filename: media.filename.clone(),
|
filename: media.filename.clone(),
|
||||||
@@ -46,14 +76,90 @@ impl MediaEditorState {
|
|||||||
alt: media.alt.clone().unwrap_or_default(),
|
alt: media.alt.clone().unwrap_or_default(),
|
||||||
caption: media.caption.clone().unwrap_or_default(),
|
caption: media.caption.clone().unwrap_or_default(),
|
||||||
author: media.author.clone().unwrap_or_default(),
|
author: media.author.clone().unwrap_or_default(),
|
||||||
language: media.language.clone().unwrap_or_default(),
|
language: canonical_lang.clone(),
|
||||||
file_path: media.file_path.clone(),
|
file_path: media.file_path.clone(),
|
||||||
tags: media.tags.clone(),
|
tags: media.tags.clone(),
|
||||||
created_at: media.created_at,
|
created_at: media.created_at,
|
||||||
updated_at: media.updated_at,
|
updated_at: media.updated_at,
|
||||||
is_dirty: false,
|
is_dirty: false,
|
||||||
|
active_language: canonical_lang.clone(),
|
||||||
|
canonical_language: canonical_lang,
|
||||||
|
blog_languages: blog_languages.to_vec(),
|
||||||
|
saved_canonical: None,
|
||||||
|
translation_drafts,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Switch to a different language. Saves current fields, loads target.
|
||||||
|
pub fn switch_language(&mut self, target_lang: &str) {
|
||||||
|
if target_lang == self.active_language {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Save current fields
|
||||||
|
if self.active_language == self.canonical_language {
|
||||||
|
self.saved_canonical = Some(MediaTranslationDraft {
|
||||||
|
title: self.title.clone(),
|
||||||
|
alt: self.alt.clone(),
|
||||||
|
caption: self.caption.clone(),
|
||||||
|
is_dirty: self.is_dirty,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
self.translation_drafts.insert(self.active_language.clone(), MediaTranslationDraft {
|
||||||
|
title: self.title.clone(),
|
||||||
|
alt: self.alt.clone(),
|
||||||
|
caption: self.caption.clone(),
|
||||||
|
is_dirty: self.is_dirty,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Load target fields
|
||||||
|
if target_lang == self.canonical_language {
|
||||||
|
if let Some(saved) = &self.saved_canonical {
|
||||||
|
self.title = saved.title.clone();
|
||||||
|
self.alt = saved.alt.clone();
|
||||||
|
self.caption = saved.caption.clone();
|
||||||
|
self.is_dirty = saved.is_dirty;
|
||||||
|
}
|
||||||
|
} else if let Some(draft) = self.translation_drafts.get(target_lang) {
|
||||||
|
self.title = draft.title.clone();
|
||||||
|
self.alt = draft.alt.clone();
|
||||||
|
self.caption = draft.caption.clone();
|
||||||
|
self.is_dirty = draft.is_dirty;
|
||||||
|
} else {
|
||||||
|
self.title = String::new();
|
||||||
|
self.alt = String::new();
|
||||||
|
self.caption = String::new();
|
||||||
|
self.is_dirty = false;
|
||||||
|
}
|
||||||
|
self.active_language = target_lang.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build translation flags for the view.
|
||||||
|
pub fn translation_flags(&self) -> Vec<TranslationFlag> {
|
||||||
|
if self.translation_drafts.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let mut flags = Vec::new();
|
||||||
|
let canon = &self.canonical_language;
|
||||||
|
let canon_locale = i18n::normalize_language(canon);
|
||||||
|
flags.push(TranslationFlag {
|
||||||
|
language: canon.clone(),
|
||||||
|
flag_emoji: canon_locale.flag_emoji().to_string(),
|
||||||
|
status: "canonical".to_string(),
|
||||||
|
is_active: self.active_language == *canon,
|
||||||
|
});
|
||||||
|
let mut langs: Vec<&String> = self.translation_drafts.keys().collect();
|
||||||
|
langs.sort();
|
||||||
|
for lang in langs {
|
||||||
|
let locale = i18n::normalize_language(lang);
|
||||||
|
flags.push(TranslationFlag {
|
||||||
|
language: lang.clone(),
|
||||||
|
flag_emoji: locale.flag_emoji().to_string(),
|
||||||
|
status: "translation".to_string(),
|
||||||
|
is_active: self.active_language == **lang,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
flags
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Media editor messages.
|
/// Media editor messages.
|
||||||
@@ -63,6 +169,7 @@ pub enum MediaEditorMsg {
|
|||||||
AltChanged(String),
|
AltChanged(String),
|
||||||
CaptionChanged(String),
|
CaptionChanged(String),
|
||||||
AuthorChanged(String),
|
AuthorChanged(String),
|
||||||
|
SwitchLanguage(String),
|
||||||
Save,
|
Save,
|
||||||
Delete,
|
Delete,
|
||||||
}
|
}
|
||||||
@@ -91,6 +198,30 @@ pub fn view<'a>(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Translation flags bar
|
||||||
|
let flags = state.translation_flags();
|
||||||
|
let flags_bar: Element<'a, Message> = if flags.is_empty() {
|
||||||
|
Space::new(0, 0).into()
|
||||||
|
} else {
|
||||||
|
let flag_buttons: Vec<Element<'a, Message>> = flags
|
||||||
|
.iter()
|
||||||
|
.map(|flag| {
|
||||||
|
let label = format!("{} {}", flag.flag_emoji, flag.language);
|
||||||
|
let color = if flag.is_active {
|
||||||
|
Color::WHITE
|
||||||
|
} else {
|
||||||
|
Color::from_rgb(0.55, 0.58, 0.65)
|
||||||
|
};
|
||||||
|
button(text(label).size(12).shaping(Shaping::Advanced).color(color))
|
||||||
|
.on_press(Message::MediaEditor(MediaEditorMsg::SwitchLanguage(flag.language.clone())))
|
||||||
|
.padding([4, 8])
|
||||||
|
.style(|_: &iced::Theme, _| button::Style::default())
|
||||||
|
.into()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
row(flag_buttons).spacing(4).into()
|
||||||
|
};
|
||||||
|
|
||||||
// Preview section
|
// Preview section
|
||||||
let preview: Element<'a, Message> = if state.mime_type.starts_with("image/") {
|
let preview: Element<'a, Message> = if state.mime_type.starts_with("image/") {
|
||||||
if let Some(dir) = data_dir {
|
if let Some(dir) = data_dir {
|
||||||
@@ -166,6 +297,7 @@ pub fn view<'a>(
|
|||||||
let body = scrollable(
|
let body = scrollable(
|
||||||
column![
|
column![
|
||||||
header,
|
header,
|
||||||
|
flags_bar,
|
||||||
preview,
|
preview,
|
||||||
info,
|
info,
|
||||||
inputs::section_header(&t(locale, "editor.metadata")),
|
inputs::section_header(&t(locale, "editor.metadata")),
|
||||||
|
|||||||
@@ -301,6 +301,7 @@ pub enum PostEditorMsg {
|
|||||||
pub fn view<'a>(
|
pub fn view<'a>(
|
||||||
state: &'a PostEditorState,
|
state: &'a PostEditorState,
|
||||||
locale: UiLocale,
|
locale: UiLocale,
|
||||||
|
word_wrap: bool,
|
||||||
) -> Element<'a, Message> {
|
) -> Element<'a, Message> {
|
||||||
// ── Header bar ──
|
// ── Header bar ──
|
||||||
let dirty_indicator = if state.is_dirty { " \u{25CF}" } else { "" };
|
let dirty_indicator = if state.is_dirty { " \u{25CF}" } else { "" };
|
||||||
@@ -521,6 +522,7 @@ pub fn view<'a>(
|
|||||||
highlighter(),
|
highlighter(),
|
||||||
"md",
|
"md",
|
||||||
)
|
)
|
||||||
|
.word_wrap(word_wrap)
|
||||||
.on_change(|msg| match msg {
|
.on_change(|msg| match msg {
|
||||||
EditorMessage::ContentChanged(s) => Message::PostEditor(PostEditorMsg::ContentChanged(s)),
|
EditorMessage::ContentChanged(s) => Message::PostEditor(PostEditorMsg::ContentChanged(s)),
|
||||||
EditorMessage::SaveRequested => Message::PostEditor(PostEditorMsg::Save),
|
EditorMessage::SaveRequested => Message::PostEditor(PostEditorMsg::Save),
|
||||||
|
|||||||
@@ -174,6 +174,8 @@ pub enum SettingsMsg {
|
|||||||
RebuildLinks,
|
RebuildLinks,
|
||||||
RegenerateThumbnails,
|
RegenerateThumbnails,
|
||||||
OpenDataFolder,
|
OpenDataFolder,
|
||||||
|
/// Navigate to a specific section from sidebar; expand it, collapse all others.
|
||||||
|
FocusSection(SettingsSection),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the settings view.
|
/// Render the settings view.
|
||||||
|
|||||||
@@ -905,35 +905,41 @@ pub fn view(
|
|||||||
}
|
}
|
||||||
SidebarView::Settings => {
|
SidebarView::Settings => {
|
||||||
// Per sidebar_views.allium SettingsNav: 9 fixed-order sections
|
// Per sidebar_views.allium SettingsNav: 9 fixed-order sections
|
||||||
let sections = [
|
use crate::views::settings_view::{SettingsSection, SettingsMsg};
|
||||||
"settings.nav.project",
|
let sections: &[(&str, Option<SettingsSection>)] = &[
|
||||||
"settings.nav.editor",
|
("settings.nav.project", Some(SettingsSection::Project)),
|
||||||
"settings.nav.content",
|
("settings.nav.editor", Some(SettingsSection::Editor)),
|
||||||
"settings.nav.ai",
|
("settings.nav.content", Some(SettingsSection::Content)),
|
||||||
"settings.nav.technology",
|
("settings.nav.ai", Some(SettingsSection::AI)),
|
||||||
"settings.nav.publishing",
|
("settings.nav.technology", Some(SettingsSection::Technology)),
|
||||||
"settings.nav.data",
|
("settings.nav.publishing", Some(SettingsSection::Publishing)),
|
||||||
"settings.nav.mcp",
|
("settings.nav.data", Some(SettingsSection::Data)),
|
||||||
"settings.nav.style",
|
("settings.nav.mcp", Some(SettingsSection::MCP)),
|
||||||
|
("settings.nav.style", None),
|
||||||
];
|
];
|
||||||
let items: Vec<Element<'static, Message>> = sections
|
let items: Vec<Element<'static, Message>> = sections
|
||||||
.iter()
|
.iter()
|
||||||
.map(|key| {
|
.map(|(key, section_opt)| {
|
||||||
let label = t(locale, key);
|
let label = t(locale, key);
|
||||||
let label_text = text(label)
|
let label_text = text(label)
|
||||||
.size(12)
|
.size(12)
|
||||||
.shaping(Shaping::Advanced);
|
.shaping(Shaping::Advanced);
|
||||||
button(
|
let msg = if let Some(section) = section_opt {
|
||||||
container(label_text)
|
Message::Settings(SettingsMsg::FocusSection(section.clone()))
|
||||||
.width(Length::Fill)
|
} else {
|
||||||
)
|
Message::OpenTab(Tab {
|
||||||
.on_press(Message::OpenTab(Tab {
|
|
||||||
id: "settings".to_string(),
|
id: "settings".to_string(),
|
||||||
tab_type: TabType::Settings,
|
tab_type: TabType::Settings,
|
||||||
title: t(locale, "common.settings"),
|
title: t(locale, "common.settings"),
|
||||||
is_transient: false,
|
is_transient: false,
|
||||||
is_dirty: false,
|
is_dirty: false,
|
||||||
}))
|
})
|
||||||
|
};
|
||||||
|
button(
|
||||||
|
container(label_text)
|
||||||
|
.width(Length::Fill)
|
||||||
|
)
|
||||||
|
.on_press(msg)
|
||||||
.padding([3, 6])
|
.padding([3, 6])
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.style(item_style)
|
.style(item_style)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use iced::widget::{button, column, container, row, scrollable, text, text_input, Space};
|
use iced::widget::{button, column, container, row, scrollable, text, text_input, Space};
|
||||||
use iced::{Alignment, Background, Color, Element, Length, Theme};
|
use iced::{Alignment, Background, Color, Element, Length, Theme};
|
||||||
|
|
||||||
@@ -23,6 +25,7 @@ pub enum TagsSection {
|
|||||||
pub struct TagsViewState {
|
pub struct TagsViewState {
|
||||||
pub section: TagsSection,
|
pub section: TagsSection,
|
||||||
pub tags: Vec<Tag>,
|
pub tags: Vec<Tag>,
|
||||||
|
pub tag_post_counts: HashMap<String, usize>,
|
||||||
pub search_query: String,
|
pub search_query: String,
|
||||||
/// For "Manage": the currently editing tag
|
/// For "Manage": the currently editing tag
|
||||||
pub editing_tag: Option<EditingTag>,
|
pub editing_tag: Option<EditingTag>,
|
||||||
@@ -40,10 +43,11 @@ pub struct EditingTag {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl TagsViewState {
|
impl TagsViewState {
|
||||||
pub fn new(tags: Vec<Tag>) -> Self {
|
pub fn new(tags: Vec<Tag>, tag_post_counts: HashMap<String, usize>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
section: TagsSection::Cloud,
|
section: TagsSection::Cloud,
|
||||||
tags,
|
tags,
|
||||||
|
tag_post_counts,
|
||||||
search_query: String::new(),
|
search_query: String::new(),
|
||||||
editing_tag: None,
|
editing_tag: None,
|
||||||
merge_source: None,
|
merge_source: None,
|
||||||
@@ -133,14 +137,27 @@ fn view_cloud<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Mes
|
|||||||
.into();
|
.into();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate min/max post counts for font scaling
|
||||||
|
let counts: Vec<usize> = state.tags.iter()
|
||||||
|
.map(|t| *state.tag_post_counts.get(&t.name.to_lowercase()).unwrap_or(&0))
|
||||||
|
.collect();
|
||||||
|
let min_count = *counts.iter().min().unwrap_or(&0);
|
||||||
|
let max_count = *counts.iter().max().unwrap_or(&1);
|
||||||
|
let count_range = if max_count > min_count { (max_count - min_count) as f32 } else { 1.0 };
|
||||||
|
const MIN_FONT: f32 = 11.0;
|
||||||
|
const MAX_FONT: f32 = 24.0;
|
||||||
|
|
||||||
let chips: Vec<Element<'a, Message>> = state
|
let chips: Vec<Element<'a, Message>> = state
|
||||||
.tags
|
.tags
|
||||||
.iter()
|
.iter()
|
||||||
.map(|tag| {
|
.map(|tag| {
|
||||||
let color = parse_tag_color(tag.color.as_deref().unwrap_or(DEFAULT_TAG_COLOR));
|
let color = parse_tag_color(tag.color.as_deref().unwrap_or(DEFAULT_TAG_COLOR));
|
||||||
button(text(&tag.name).size(13).color(Color::WHITE))
|
let post_count = *state.tag_post_counts.get(&tag.name.to_lowercase()).unwrap_or(&0);
|
||||||
|
let font_size = MIN_FONT + ((post_count - min_count) as f32 / count_range) * (MAX_FONT - MIN_FONT);
|
||||||
|
let vert_pad = ((MAX_FONT - font_size) / 4.0).max(2.0) as u16;
|
||||||
|
button(text(&tag.name).size(font_size).color(Color::WHITE))
|
||||||
.on_press(Message::Tags(TagsMsg::SelectTag(tag.id.clone())))
|
.on_press(Message::Tags(TagsMsg::SelectTag(tag.id.clone())))
|
||||||
.padding([4, 10])
|
.padding([vert_pad, 10])
|
||||||
.style(move |_: &Theme, _| button::Style {
|
.style(move |_: &Theme, _| button::Style {
|
||||||
background: Some(Background::Color(color)),
|
background: Some(Background::Color(color)),
|
||||||
border: iced::Border {
|
border: iced::Border {
|
||||||
|
|||||||
@@ -310,7 +310,8 @@ fn route_content_area<'a>(
|
|||||||
match tab.tab_type {
|
match tab.tab_type {
|
||||||
TabType::Post => {
|
TabType::Post => {
|
||||||
if let Some(state) = post_editors.get(tab_id) {
|
if let Some(state) = post_editors.get(tab_id) {
|
||||||
post_editor::view(state, locale)
|
let wrap = settings_state.map(|s| s.wrap_long_lines).unwrap_or(false);
|
||||||
|
post_editor::view(state, locale, wrap)
|
||||||
} else {
|
} else {
|
||||||
loading_view(locale)
|
loading_view(locale)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user