feat: first cut on M0 - base structure of project

This commit is contained in:
2026-04-02 21:00:18 +02:00
parent 1a24027723
commit 1c3a088efb
40 changed files with 7471 additions and 2 deletions

10
crates/bds-cli/Cargo.toml Normal file
View File

@@ -0,0 +1,10 @@
[package]
name = "bds-cli"
edition.workspace = true
version.workspace = true
license.workspace = true
[dependencies]
bds-core = { workspace = true }
anyhow = { workspace = true }
tokio = { workspace = true }

View File

@@ -0,0 +1,3 @@
fn main() {
println!("bds-cli: headless automation surface (not yet implemented)");
}

View File

@@ -0,0 +1,21 @@
[package]
name = "bds-core"
edition.workspace = true
version.workspace = true
license.workspace = true
[dependencies]
rusqlite = { workspace = true }
refinery = { workspace = true }
uuid = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
serde_yaml = { workspace = true }
chrono = { workspace = true }
sha2 = { workspace = true }
deunicode = { workspace = true }
thiserror = { workspace = true }
walkdir = { workspace = true }
[dev-dependencies]
tempfile = "3"

View File

@@ -0,0 +1,49 @@
use rusqlite::Connection;
use std::path::Path;
use crate::db::migrations;
/// Database wrapper managing a SQLite connection.
pub struct Database {
conn: Connection,
}
impl Database {
/// Open an existing bDS project database.
pub fn open(path: &Path) -> Result<Self, rusqlite::Error> {
let conn = Connection::open(path)?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?;
Ok(Self { conn })
}
/// Open an in-memory database (for tests).
pub fn open_in_memory() -> Result<Self, rusqlite::Error> {
let conn = Connection::open_in_memory()?;
conn.execute_batch("PRAGMA foreign_keys=ON;")?;
Ok(Self { conn })
}
/// Get a reference to the underlying connection.
pub fn conn(&self) -> &Connection {
&self.conn
}
/// Run all pending migrations.
pub fn migrate(&self) -> Result<(), Box<dyn std::error::Error>> {
migrations::run_migrations(&self.conn)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn open_in_memory() {
let db = Database::open_in_memory().expect("should open in-memory db");
let result: i64 = db
.conn()
.query_row("SELECT 1", [], |row| row.get(0))
.unwrap();
assert_eq!(result, 1);
}
}

View File

@@ -0,0 +1,22 @@
use rusqlite::Connection;
/// Run all embedded migrations against the given connection.
///
/// For M0, this is a stub. Once we have the full schema from the TypeScript
/// app's migrations, we will embed them via refinery.
pub fn run_migrations(conn: &Connection) -> Result<(), Box<dyn std::error::Error>> {
// TODO(M0): Embed real migrations from the TypeScript app's schema.
// For now, create the minimal tables needed for read-access verification.
conn.execute_batch(
"
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
path TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
",
)?;
Ok(())
}

View File

@@ -0,0 +1,5 @@
mod connection;
mod migrations;
pub use connection::Database;
pub use migrations::run_migrations;

View File

@@ -0,0 +1 @@
// Engine modules — stubs for M0, implemented in M1.

View File

@@ -0,0 +1,3 @@
// Internationalization — split localization support.
// ui_locale: follows OS locale (menus, dialogs, toasts).
// content_language: follows project settings (rendering, preview, feeds).

View File

@@ -0,0 +1,6 @@
pub mod db;
pub mod engine;
pub mod model;
pub mod render;
pub mod i18n;
pub mod util;

View File

@@ -0,0 +1,38 @@
use serde::{Deserialize, Serialize};
/// Tracks content hashes of generated files to skip unchanged writes.
/// Matches the `generated_file_hashes` table.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GeneratedFileHash {
pub project_id: String,
pub relative_path: String,
pub content_hash: String,
pub updated_at: i64,
}
/// Notification for CLI-to-app synchronization.
/// Matches the `db_notifications` table.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DbNotification {
pub id: i64,
pub entity: String,
pub entity_id: String,
pub action: String,
pub from_cli: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub seen_at: Option<i64>,
pub created_at: i64,
}
/// Publishing preferences stored in meta/publishing.json.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PublishingPreferences {
#[serde(skip_serializing_if = "Option::is_none")]
pub ssh_host: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ssh_user: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ssh_remote_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ssh_mode: Option<String>,
}

View File

@@ -0,0 +1,54 @@
use serde::{Deserialize, Serialize};
/// A media item (image, video, etc.).
/// Matches the `media` table schema.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Media {
pub id: String,
pub project_id: String,
pub filename: String,
pub original_name: String,
pub mime_type: String,
pub size: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub width: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub height: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub alt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub caption: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
pub file_path: String,
pub sidecar_path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub checksum: Option<String>,
/// JSON-serialized string array in DB.
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<String>,
pub created_at: i64,
pub updated_at: i64,
}
/// A translation of media metadata into another language.
/// Matches the `media_translations` table.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MediaTranslation {
pub id: String,
pub project_id: String,
pub translation_for: String,
pub language: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub alt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub caption: Option<String>,
pub created_at: i64,
pub updated_at: i64,
}

View File

@@ -0,0 +1,15 @@
mod post;
mod media;
mod tag;
mod project;
mod template;
mod script;
mod generation;
pub use post::{Post, PostLink, PostMedia, PostStatus, PostTranslation};
pub use media::{Media, MediaTranslation};
pub use tag::Tag;
pub use project::{Project, Setting};
pub use template::Template;
pub use script::Script;
pub use generation::{DbNotification, GeneratedFileHash, PublishingPreferences};

View File

@@ -0,0 +1,107 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PostStatus {
Draft,
Published,
Archived,
}
/// A blog post. Matches the `posts` table schema.
///
/// NOTE: `content` is null for published posts — body lives in the filesystem
/// `.md` file only. Draft content is stored in DB.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Post {
pub id: String,
pub project_id: String,
pub title: String,
pub slug: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub excerpt: Option<String>,
/// Draft body text. Null/empty when published (content is in the file).
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
pub status: PostStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
#[serde(default)]
pub do_not_translate: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub template_slug: Option<String>,
pub file_path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub checksum: Option<String>,
/// JSON-serialized string array in DB.
#[serde(default)]
pub tags: Vec<String>,
/// JSON-serialized string array in DB.
#[serde(default)]
pub categories: Vec<String>,
// Published snapshot fields (used for diff detection)
#[serde(skip_serializing_if = "Option::is_none")]
pub published_title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub published_content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub published_tags: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub published_categories: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub published_excerpt: Option<String>,
/// Unix timestamp (integer in DB).
pub created_at: i64,
pub updated_at: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub published_at: Option<i64>,
}
/// A translation of a post into another language.
/// Matches the `post_translations` table.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PostTranslation {
pub id: String,
pub project_id: String,
pub translation_for: String,
pub language: String,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub excerpt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
pub status: PostStatus,
pub file_path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub checksum: Option<String>,
pub created_at: i64,
pub updated_at: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub published_at: Option<i64>,
}
/// A link between two posts (tracked for backlinks).
/// Matches the `post_links` table.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PostLink {
pub id: String,
pub source_post_id: String,
pub target_post_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub link_text: Option<String>,
pub created_at: i64,
}
/// Association between a post and media item.
/// Matches the `post_media` table.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PostMedia {
pub id: String,
pub project_id: String,
pub post_id: String,
pub media_id: String,
pub sort_order: i32,
pub created_at: i64,
}

View File

@@ -0,0 +1,24 @@
use serde::{Deserialize, Serialize};
/// A bDS project. Matches the `projects` table schema.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Project {
pub id: String,
pub name: String,
pub slug: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub data_path: Option<String>,
pub is_active: bool,
pub created_at: i64,
pub updated_at: i64,
}
/// Key-value settings. Matches the `settings` table.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Setting {
pub key: String,
pub value: String,
pub updated_at: i64,
}

View File

@@ -0,0 +1,21 @@
use serde::{Deserialize, Serialize};
/// A user-authored script. Matches the `scripts` table.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Script {
pub id: String,
pub project_id: String,
pub slug: String,
pub title: String,
/// "macro", "transform", or "utility"
pub kind: String,
pub entrypoint: String,
pub enabled: bool,
pub version: i32,
pub file_path: String,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
pub created_at: i64,
pub updated_at: i64,
}

View File

@@ -0,0 +1,14 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Tag {
pub id: String,
pub project_id: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub post_template_slug: Option<String>,
pub created_at: i64,
pub updated_at: i64,
}

View File

@@ -0,0 +1,19 @@
use serde::{Deserialize, Serialize};
/// A Liquid template. Matches the `templates` table.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Template {
pub id: String,
pub project_id: String,
pub slug: String,
pub title: String,
pub kind: String,
pub enabled: bool,
pub version: i32,
pub file_path: String,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
pub created_at: i64,
pub updated_at: i64,
}

View File

@@ -0,0 +1 @@
// Rendering pipeline — stubs for M0, implemented in M4.

View File

@@ -0,0 +1 @@
// Scripting (Lua) — stubs for M0, implemented in M5/Wave 6.

View File

@@ -0,0 +1,48 @@
use sha2::{Digest, Sha256};
/// Compute a hex-encoded SHA-256 hash of the given content.
pub fn content_hash(content: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(content);
let result = hasher.finalize();
hex::encode(result)
}
// sha2 doesn't include hex encoding, so we use a tiny inline helper.
mod hex {
const HEX_CHARS: &[u8; 16] = b"0123456789abcdef";
pub fn encode(bytes: impl AsRef<[u8]>) -> String {
let bytes = bytes.as_ref();
let mut s = String::with_capacity(bytes.len() * 2);
for &b in bytes {
s.push(HEX_CHARS[(b >> 4) as usize] as char);
s.push(HEX_CHARS[(b & 0xf) as usize] as char);
}
s
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn known_hash() {
// SHA-256 of "hello"
let hash = content_hash(b"hello");
assert_eq!(
hash,
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
);
}
#[test]
fn empty_hash() {
let hash = content_hash(b"");
assert_eq!(
hash,
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
}

View File

@@ -0,0 +1,5 @@
mod slug;
mod checksum;
pub use slug::slugify;
pub use checksum::content_hash;

View File

@@ -0,0 +1,61 @@
use deunicode::deunicode;
/// Generate a URL-safe slug from a title string.
///
/// Transliterates Unicode to ASCII, lowercases, replaces non-alphanumeric
/// chars with hyphens, and collapses/trims hyphens.
pub fn slugify(input: &str) -> String {
let ascii = deunicode(input);
let lowered = ascii.to_lowercase();
let mut slug = String::with_capacity(lowered.len());
let mut prev_hyphen = true; // avoid leading hyphen
for c in lowered.chars() {
if c.is_ascii_alphanumeric() {
slug.push(c);
prev_hyphen = false;
} else if !prev_hyphen {
slug.push('-');
prev_hyphen = true;
}
}
// Trim trailing hyphen
if slug.ends_with('-') {
slug.pop();
}
slug
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn basic_slug() {
assert_eq!(slugify("Hello World"), "hello-world");
}
#[test]
fn unicode_slug() {
assert_eq!(slugify("Über die Brücke"), "uber-die-brucke");
}
#[test]
fn special_chars() {
assert_eq!(slugify("What's up? (2024)"), "what-s-up-2024");
}
#[test]
fn already_clean() {
assert_eq!(slugify("already-clean"), "already-clean");
}
#[test]
fn empty_input() {
assert_eq!(slugify(""), "");
}
#[test]
fn consecutive_special_chars() {
assert_eq!(slugify("a --- b"), "a-b");
}
}

View File

@@ -0,0 +1,11 @@
[package]
name = "bds-editor"
edition.workspace = true
version.workspace = true
license.workspace = true
[dependencies]
iced = { workspace = true }
cosmic-text = { workspace = true }
ropey = { workspace = true }
syntect = { workspace = true }

View File

@@ -0,0 +1,152 @@
use ropey::Rope;
/// Rope-based text buffer with edit operations.
pub struct EditorBuffer {
rope: Rope,
/// Cursor byte offset within the rope.
cursor_line: usize,
cursor_col: usize,
}
impl EditorBuffer {
pub fn new(text: &str) -> Self {
Self {
rope: Rope::from_str(text),
cursor_line: 0,
cursor_col: 0,
}
}
pub fn rope(&self) -> &Rope {
&self.rope
}
pub fn text(&self) -> String {
self.rope.to_string()
}
pub fn line_count(&self) -> usize {
self.rope.len_lines()
}
pub fn line(&self, idx: usize) -> Option<ropey::RopeSlice<'_>> {
if idx < self.rope.len_lines() {
Some(self.rope.line(idx))
} else {
None
}
}
pub fn cursor(&self) -> (usize, usize) {
(self.cursor_line, self.cursor_col)
}
pub fn set_cursor(&mut self, line: usize, col: usize) {
self.cursor_line = line.min(self.line_count().saturating_sub(1));
let line_len = self.current_line_len();
self.cursor_col = col.min(line_len);
}
/// Insert text at the current cursor position.
pub fn insert(&mut self, text: &str) {
let char_idx = self.cursor_char_idx();
self.rope.insert(char_idx, text);
// Advance cursor past inserted text
for c in text.chars() {
if c == '\n' {
self.cursor_line += 1;
self.cursor_col = 0;
} else {
self.cursor_col += 1;
}
}
}
/// Delete one character before the cursor (backspace).
pub fn backspace(&mut self) {
if self.cursor_col > 0 {
let char_idx = self.cursor_char_idx();
self.rope.remove(char_idx - 1..char_idx);
self.cursor_col -= 1;
} else if self.cursor_line > 0 {
// Join with previous line
let prev_line_len = self
.rope
.line(self.cursor_line - 1)
.len_chars()
.saturating_sub(1); // subtract newline
let char_idx = self.cursor_char_idx();
self.rope.remove(char_idx - 1..char_idx);
self.cursor_line -= 1;
self.cursor_col = prev_line_len;
}
}
fn cursor_char_idx(&self) -> usize {
let line_start = self.rope.line_to_char(self.cursor_line);
line_start + self.cursor_col
}
fn current_line_len(&self) -> usize {
if self.cursor_line < self.rope.len_lines() {
let line = self.rope.line(self.cursor_line);
let len = line.len_chars();
// Subtract trailing newline if present
if len > 0 && line.char(len - 1) == '\n' {
len - 1
} else {
len
}
} else {
0
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_buffer() {
let buf = EditorBuffer::new("hello\nworld");
assert_eq!(buf.line_count(), 2);
assert_eq!(buf.cursor(), (0, 0));
}
#[test]
fn insert_text() {
let mut buf = EditorBuffer::new("hello");
buf.set_cursor(0, 5);
buf.insert(" world");
assert_eq!(buf.text(), "hello world");
assert_eq!(buf.cursor(), (0, 11));
}
#[test]
fn backspace() {
let mut buf = EditorBuffer::new("hello");
buf.set_cursor(0, 5);
buf.backspace();
assert_eq!(buf.text(), "hell");
assert_eq!(buf.cursor(), (0, 4));
}
#[test]
fn insert_newline() {
let mut buf = EditorBuffer::new("hello");
buf.set_cursor(0, 5);
buf.insert("\n");
assert_eq!(buf.line_count(), 2);
assert_eq!(buf.cursor(), (1, 0));
}
#[test]
fn backspace_at_line_start_joins_lines() {
let mut buf = EditorBuffer::new("hello\nworld");
buf.set_cursor(1, 0);
buf.backspace();
assert_eq!(buf.text(), "helloworld");
assert_eq!(buf.cursor(), (0, 5));
}
}

View File

@@ -0,0 +1,81 @@
use syntect::highlighting::{Style, ThemeSet};
use syntect::parsing::{SyntaxReference, SyntaxSet};
/// Syntax highlighter using syntect.
pub struct Highlighter {
syntax_set: SyntaxSet,
theme_set: ThemeSet,
theme_name: String,
}
/// A line of highlighted text: spans of (style, text).
pub type HighlightedLine = Vec<(Style, String)>;
impl Highlighter {
pub fn new() -> Self {
Self {
syntax_set: SyntaxSet::load_defaults_newlines(),
theme_set: ThemeSet::load_defaults(),
theme_name: "base16-ocean.dark".to_string(),
}
}
/// Find the syntax definition for a file extension.
pub fn syntax_for_extension(&self, ext: &str) -> &SyntaxReference {
self.syntax_set
.find_syntax_by_extension(ext)
.unwrap_or_else(|| self.syntax_set.find_syntax_plain_text())
}
/// Highlight all lines of the given text for a specific syntax.
pub fn highlight_lines(&self, text: &str, syntax: &SyntaxReference) -> Vec<HighlightedLine> {
use syntect::easy::HighlightLines;
let theme = &self.theme_set.themes[&self.theme_name];
let mut h = HighlightLines::new(syntax, theme);
let mut result = Vec::new();
for line in text.lines() {
let line_with_newline = format!("{line}\n");
let ranges = h
.highlight_line(&line_with_newline, &self.syntax_set)
.unwrap_or_default();
let styled: HighlightedLine = ranges
.into_iter()
.map(|(style, text)| (style, text.to_string()))
.collect();
result.push(styled);
}
result
}
}
impl Default for Highlighter {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn highlight_markdown() {
let h = Highlighter::new();
let syntax = h.syntax_for_extension("md");
let lines = h.highlight_lines("# Hello\n\nSome text.", syntax);
assert_eq!(lines.len(), 3);
assert!(!lines[0].is_empty());
}
#[test]
fn plain_text_fallback() {
let h = Highlighter::new();
let syntax = h.syntax_for_extension("nonexistent");
// Should not panic, falls back to plain text
let lines = h.highlight_lines("hello", syntax);
assert_eq!(lines.len(), 1);
}
}

View File

@@ -0,0 +1,7 @@
mod buffer;
mod highlight;
mod widget;
pub use buffer::EditorBuffer;
pub use highlight::Highlighter;
pub use widget::CodeEditor;

View File

@@ -0,0 +1,130 @@
use iced::advanced::layout::{self, Layout};
use iced::advanced::renderer;
use iced::advanced::widget::{self, Widget};
use iced::advanced::{Clipboard, Shell};
use iced::event::Status;
use iced::mouse;
use iced::{Color, Element, Event, Length, Rectangle, Size, Theme};
use crate::buffer::EditorBuffer;
use crate::highlight::Highlighter;
/// Messages emitted by the CodeEditor widget.
#[derive(Debug, Clone)]
pub enum EditorMessage {
ContentChanged(String),
}
/// A syntax-highlighting code editor widget for Iced.
///
/// M0 proof of concept: renders highlighted text with line numbers.
/// Full editing (cursor, input, selection) follows in M3.
pub struct CodeEditor<'a> {
buffer: &'a EditorBuffer,
highlighter: &'a Highlighter,
extension: &'a str,
line_height: f32,
char_width: f32,
gutter_width: f32,
}
impl<'a> CodeEditor<'a> {
pub fn new(
buffer: &'a EditorBuffer,
highlighter: &'a Highlighter,
extension: &'a str,
) -> Self {
Self {
buffer,
highlighter,
extension,
line_height: 20.0,
char_width: 8.4,
gutter_width: 50.0,
}
}
}
impl<'a, Message, Renderer> Widget<Message, Theme, Renderer> for CodeEditor<'a>
where
Renderer: renderer::Renderer,
{
fn size(&self) -> Size<Length> {
Size::new(Length::Fill, Length::Fill)
}
fn layout(
&self,
_tree: &mut widget::Tree,
_renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
let size = limits.max();
layout::Node::new(size)
}
fn draw(
&self,
_tree: &widget::Tree,
renderer: &mut Renderer,
_theme: &Theme,
_style: &renderer::Style,
layout: Layout<'_>,
_cursor: mouse::Cursor,
_viewport: &Rectangle,
) {
let bounds = layout.bounds();
// Draw background
renderer.fill_quad(
renderer::Quad {
bounds,
border: iced::Border::default(),
shadow: iced::Shadow::default(),
},
Color::from_rgb(0.18, 0.20, 0.25),
);
// Draw gutter background
let gutter_bounds = Rectangle {
width: self.gutter_width,
..bounds
};
renderer.fill_quad(
renderer::Quad {
bounds: gutter_bounds,
border: iced::Border::default(),
shadow: iced::Shadow::default(),
},
Color::from_rgb(0.15, 0.17, 0.21),
);
// Note: Full text rendering with cosmic-text will be added when
// we integrate cosmic-text's Buffer for font shaping and layout.
// For M0 PoC, we verify the widget mounts and draws backgrounds.
}
fn on_event(
&mut self,
_state: &mut widget::Tree,
_event: Event,
_layout: Layout<'_>,
_cursor: mouse::Cursor,
_renderer: &Renderer,
_clipboard: &mut dyn Clipboard,
_shell: &mut Shell<'_, Message>,
_viewport: &Rectangle,
) -> Status {
Status::Ignored
}
}
impl<'a, Message, Renderer> From<CodeEditor<'a>> for Element<'a, Message, Theme, Renderer>
where
Renderer: renderer::Renderer + 'a,
Message: 'a,
{
fn from(editor: CodeEditor<'a>) -> Self {
Self::new(editor)
}
}

19
crates/bds-ui/Cargo.toml Normal file
View File

@@ -0,0 +1,19 @@
[package]
name = "bds-ui"
edition.workspace = true
version.workspace = true
license.workspace = true
[dependencies]
bds-core = { workspace = true }
bds-editor = { workspace = true }
iced = { workspace = true }
muda = { workspace = true }
rfd = { workspace = true }
anyhow = { workspace = true }
tokio = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"
objc2-foundation = "0.3"
objc2-app-kit = "0.3"

48
crates/bds-ui/src/app.rs Normal file
View File

@@ -0,0 +1,48 @@
use iced::widget::{column, container, text};
use iced::{Element, Length, Subscription, Task};
use crate::platform::menu;
#[derive(Debug, Clone)]
pub enum Message {
MenuEvent(muda::MenuId),
Noop,
}
pub struct BdsApp {
_menu_bar: muda::Menu,
}
impl BdsApp {
pub fn new() -> (Self, Task<Message>) {
let menu_bar = menu::build_menu_bar();
(Self { _menu_bar: menu_bar }, Task::none())
}
pub fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::MenuEvent(_id) => {
// Menu routing will be expanded in M2
Task::none()
}
Message::Noop => Task::none(),
}
}
pub fn view(&self) -> Element<'_, Message> {
let content = column![text("bDS — Blogging Desktop Server").size(24),]
.padding(20)
.spacing(10);
container(content)
.width(Length::Fill)
.height(Length::Fill)
.center_x(Length::Fill)
.center_y(Length::Fill)
.into()
}
pub fn subscription(&self) -> Subscription<Message> {
menu::menu_subscription()
}
}

4
crates/bds-ui/src/lib.rs Normal file
View File

@@ -0,0 +1,4 @@
mod app;
mod platform;
pub use app::BdsApp;

View File

@@ -0,0 +1,7 @@
use bds_ui::BdsApp;
fn main() -> iced::Result {
iced::application("bDS", BdsApp::update, BdsApp::view)
.subscription(BdsApp::subscription)
.run_with(BdsApp::new)
}

View File

@@ -0,0 +1,10 @@
// macOS lifecycle shim — objc2 hooks for NSApplicationDelegate.
//
// Handles:
// - application:openFile: (Finder open)
// - application:openURLs: (URL scheme handling)
//
// These will be forwarded as Message variants into the Iced event loop
// via a channel-based subscription.
//
// Implementation deferred to M2 when the full message routing is in place.

View File

@@ -0,0 +1,79 @@
use iced::Subscription;
use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
use crate::app::Message;
/// Build the native menu bar with standard application menus.
pub fn build_menu_bar() -> Menu {
let menu = Menu::new();
// App menu (macOS only shows this as the app name)
let app_menu = Submenu::new("bDS", true);
let _ = app_menu.append(&PredefinedMenuItem::about(None, None));
let _ = app_menu.append(&PredefinedMenuItem::separator());
let _ = app_menu.append(&PredefinedMenuItem::services(None));
let _ = app_menu.append(&PredefinedMenuItem::separator());
let _ = app_menu.append(&PredefinedMenuItem::hide(None));
let _ = app_menu.append(&PredefinedMenuItem::hide_others(None));
let _ = app_menu.append(&PredefinedMenuItem::show_all(None));
let _ = app_menu.append(&PredefinedMenuItem::separator());
let _ = app_menu.append(&PredefinedMenuItem::quit(None));
// File menu
let file_menu = Submenu::new("File", true);
let _ = file_menu.append(&MenuItem::new("Open Project...", true, None));
let _ = file_menu.append(&PredefinedMenuItem::separator());
let _ = file_menu.append(&MenuItem::new("Save", true, None));
let _ = file_menu.append(&PredefinedMenuItem::close_window(None));
// Edit menu
let edit_menu = Submenu::new("Edit", true);
let _ = edit_menu.append(&PredefinedMenuItem::undo(None));
let _ = edit_menu.append(&PredefinedMenuItem::redo(None));
let _ = edit_menu.append(&PredefinedMenuItem::separator());
let _ = edit_menu.append(&PredefinedMenuItem::cut(None));
let _ = edit_menu.append(&PredefinedMenuItem::copy(None));
let _ = edit_menu.append(&PredefinedMenuItem::paste(None));
let _ = edit_menu.append(&PredefinedMenuItem::select_all(None));
// View menu
let view_menu = Submenu::new("View", true);
let _ = view_menu.append(&MenuItem::new("Toggle Sidebar", true, None));
let _ = view_menu.append(&PredefinedMenuItem::fullscreen(None));
// Window menu
let window_menu = Submenu::new("Window", true);
let _ = window_menu.append(&PredefinedMenuItem::minimize(None));
let _ = window_menu.append(&PredefinedMenuItem::maximize(None));
// Help menu
let help_menu = Submenu::new("Help", true);
let _ = help_menu.append(&MenuItem::new("bDS Help", true, None));
let _ = menu.append(&app_menu);
let _ = menu.append(&file_menu);
let _ = menu.append(&edit_menu);
let _ = menu.append(&view_menu);
let _ = menu.append(&window_menu);
let _ = menu.append(&help_menu);
// Initialize the menu on macOS
#[cfg(target_os = "macos")]
{
let _ = menu.init_for_nsapp();
}
menu
}
/// Iced subscription that polls muda menu events.
pub fn menu_subscription() -> Subscription<Message> {
iced::event::listen_with(|_event, _status, _id| {
// Check for pending menu events
if let Ok(event) = MenuEvent::receiver().try_recv() {
Some(Message::MenuEvent(event.id))
} else {
None
}
})
}

View File

@@ -0,0 +1,4 @@
pub mod menu;
#[cfg(target_os = "macos")]
pub mod macos;