Skip to content

Commit 737c7ee

Browse files
feat: Add border option to editor (#947)
* feat: Add border option to editor * lint * Coderabbit fixes * Sidebar toggle fixes + default border changes
1 parent ce505f2 commit 737c7ee

File tree

12 files changed

+294
-24
lines changed

12 files changed

+294
-24
lines changed

AGENTS.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Repository Guidelines
2+
3+
## Project Structure & Modules
4+
- Turborepo monorepo:
5+
- `apps/desktop` (Tauri v2 + SolidStart), `apps/web` (Next.js), `apps/cli` (Rust CLI).
6+
- `packages/*` shared libs (e.g., `database`, `ui`, `ui-solid`, `utils`, `web-*`).
7+
- `crates/*` Rust media/recording/rendering/camera crates.
8+
- `scripts/*`, `infra/`, and `packages/local-docker/` for tooling and local services.
9+
10+
## Build, Test, Develop
11+
- Install: `pnpm install`; setup: `pnpm env-setup` then `pnpm cap-setup`.
12+
- Dev: `pnpm dev` (web+desktop). Desktop only: `pnpm dev:desktop`. Web only: `pnpm dev:web` or `cd apps/web && pnpm dev`.
13+
- Build: `pnpm build` (Turbo). Desktop release: `pnpm tauri:build`.
14+
- DB: `pnpm db:generate``pnpm db:push``pnpm db:studio`.
15+
- Docker: `pnpm docker:up | docker:stop | docker:clean`.
16+
- Quality: `pnpm lint`, `pnpm format`, `pnpm typecheck`. Rust: `cargo build -p <crate>`, `cargo test -p <crate>`.
17+
18+
## Coding Style & Naming
19+
- TypeScript: 2‑space indent; Biome formats/lints (`pnpm format`).
20+
- Rust: `rustfmt` + workspace clippy lints.
21+
- Naming: files kebab‑case (`user-menu.tsx`); components PascalCase; Rust modules snake_case, crates kebab‑case.
22+
- Runtime: Node 20, pnpm 10.x, Rust 1.88+, Docker for MySQL/MinIO.
23+
24+
## Testing
25+
- TS/JS: Vitest where present (e.g., desktop). Name tests `*.test.ts(x)` near sources.
26+
- Rust: `cargo test` per crate; tests in `src` or `tests`.
27+
- Prefer unit tests for logic and light smoke tests for flows; no strict coverage yet.
28+
29+
## Commits & PRs
30+
- Conventional style: `feat:`, `fix:`, `chore:`, `improve:`, `refactor:`, `docs:` (e.g., `fix: hide watermark for pro users`).
31+
- PRs: clear description, linked issues, screenshots/GIFs for UI, env/migration notes. Keep scope tight and update docs when behavior changes.
32+
33+
## Agent‑Specific Practices (inspired by CLAUDE.md)
34+
- Do not start extra servers; use `pnpm dev:web` or `pnpm dev:desktop` as needed.
35+
- Never edit auto‑generated files: `**/tauri.ts`, `**/queries.ts`, `apps/desktop/src-tauri/gen/**`.
36+
- Prefer existing scripts and Turbo filters over ad‑hoc commands; clear `.turbo` only when necessary.
37+
- Database flow: always `db:generate``db:push` before relying on new schema.
38+
- Keep secrets out of VCS; configure via `.env` from `pnpm env-setup`.
39+
- macOS note: desktop permissions (screen/mic) apply to the terminal running `pnpm dev:desktop`.

apps/desktop/src/routes/editor/ConfigSidebar.tsx

Lines changed: 86 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -241,9 +241,9 @@ export function ConfigSidebar() {
241241
return (
242242
<KTabs
243243
value={state.selectedTab}
244-
class="flex flex-col shrink-0 flex-1 max-w-[26rem] overflow-hidden rounded-xl z-10 relative bg-gray-1 dark:bg-gray-2 border border-gray-3"
244+
class="flex flex-col min-h-0 shrink-0 flex-1 max-w-[26rem] overflow-hidden rounded-xl z-10 relative bg-gray-1 dark:bg-gray-2 border border-gray-3"
245245
>
246-
<KTabs.List class="flex overflow-hidden relative z-40 flex-row items-center h-16 text-lg border-b border-gray-3 shrink-0">
246+
<KTabs.List class="flex overflow-hidden sticky top-0 z-30 flex-row items-center h-16 text-lg border-b border-gray-3 shrink-0 bg-gray-1 dark:bg-gray-2">
247247
<For
248248
each={[
249249
{ id: TAB_IDS.background, icon: IconCapImage },
@@ -304,7 +304,7 @@ export function ConfigSidebar() {
304304
style={{
305305
"--margin-top-scroll": "5px",
306306
}}
307-
class="p-4 custom-scroll overflow-x-hidden overflow-y-scroll text-[0.875rem] h-full"
307+
class="p-4 custom-scroll overflow-x-hidden overflow-y-scroll text-[0.875rem] flex-1 min-h-0"
308308
>
309309
<BackgroundConfig scrollRef={scrollRef} />
310310
<CameraConfig scrollRef={scrollRef} />
@@ -1070,7 +1070,7 @@ function BackgroundConfig(props: { scrollRef: HTMLDivElement }) {
10701070
<KTabs class="overflow-hidden relative" value={backgroundTab()}>
10711071
<KTabs.List
10721072
ref={setBackgroundRef}
1073-
class="flex overflow-x-auto overscroll-contain relative z-40 flex-row gap-2 items-center mb-3 text-xs hide-scroll"
1073+
class="flex overflow-x-auto overscroll-contain relative z-10 flex-row gap-2 items-center mb-3 text-xs hide-scroll"
10741074
style={{
10751075
"-webkit-mask-image": `linear-gradient(to right, transparent, black ${
10761076
scrollX() > 0 ? "24px" : "0"
@@ -1510,6 +1510,86 @@ function BackgroundConfig(props: { scrollRef: HTMLDivElement }) {
15101510
formatTooltip="%"
15111511
/>
15121512
</Field>
1513+
<Field
1514+
name="Border"
1515+
icon={<IconCapSettings class="size-4" />}
1516+
value={
1517+
<Toggle
1518+
checked={project.background.border?.enabled ?? false}
1519+
onChange={(enabled) => {
1520+
const prev = project.background.border ?? {
1521+
enabled: false,
1522+
width: 5.0,
1523+
color: [0, 0, 0],
1524+
opacity: 50.0,
1525+
};
1526+
1527+
setProject("background", "border", {
1528+
...prev,
1529+
enabled,
1530+
});
1531+
}}
1532+
/>
1533+
}
1534+
/>
1535+
<Show when={project.background.border?.enabled}>
1536+
<Field name="Border Width" icon={<IconCapEnlarge class="size-4" />}>
1537+
<Slider
1538+
value={[project.background.border?.width ?? 5.0]}
1539+
onChange={(v) =>
1540+
setProject("background", "border", {
1541+
...(project.background.border ?? {
1542+
enabled: true,
1543+
width: 5.0,
1544+
color: [0, 0, 0],
1545+
opacity: 50.0,
1546+
}),
1547+
width: v[0],
1548+
})
1549+
}
1550+
minValue={1}
1551+
maxValue={20}
1552+
step={0.1}
1553+
formatTooltip="px"
1554+
/>
1555+
</Field>
1556+
<Field name="Border Color" icon={<IconCapImage class="size-4" />}>
1557+
<RgbInput
1558+
value={project.background.border?.color ?? [0, 0, 0]}
1559+
onChange={(color) =>
1560+
setProject("background", "border", {
1561+
...(project.background.border ?? {
1562+
enabled: true,
1563+
width: 5.0,
1564+
color: [0, 0, 0],
1565+
opacity: 50.0,
1566+
}),
1567+
color,
1568+
})
1569+
}
1570+
/>
1571+
</Field>
1572+
<Field name="Border Opacity" icon={<IconCapShadow class="size-4" />}>
1573+
<Slider
1574+
value={[project.background.border?.opacity ?? 50.0]}
1575+
onChange={(v) =>
1576+
setProject("background", "border", {
1577+
...(project.background.border ?? {
1578+
enabled: true,
1579+
width: 5.0,
1580+
color: [0, 0, 0],
1581+
opacity: 50.0,
1582+
}),
1583+
opacity: v[0],
1584+
})
1585+
}
1586+
minValue={0}
1587+
maxValue={100}
1588+
step={0.1}
1589+
formatTooltip="%"
1590+
/>
1591+
</Field>
1592+
</Show>
15131593
<Field name="Shadow" icon={<IconCapShadow class="size-4" />}>
15141594
<Slider
15151595
value={[project.background.shadow!]}
@@ -1839,9 +1919,7 @@ function ZoomSegmentPreview(props: {
18391919
const video = document.createElement("video");
18401920
createEffect(() => {
18411921
const path = convertFileSrc(
1842-
`${
1843-
editorInstance.path
1844-
}/content/segments/segment-${segmentIndex()}/display.mp4`,
1922+
`${editorInstance.path}/content/segments/segment-${segmentIndex()}/display.mp4`,
18451923
);
18461924
video.src = path;
18471925
video.preload = "auto";
@@ -2413,6 +2491,7 @@ function RgbInput(props: {
24132491
ref={colorInput}
24142492
type="color"
24152493
class="absolute left-0 bottom-0 w-[3rem] opacity-0"
2494+
value={rgbToHex(props.value)}
24162495
onChange={(e) => {
24172496
const value = hexToRgb(e.target.value);
24182497
if (value) props.onChange(value);

apps/desktop/src/routes/editor/Editor.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,11 +114,11 @@ function Inner() {
114114
<>
115115
<Header />
116116
<div
117-
class="flex overflow-y-hidden flex-col flex-1 gap-2 pb-4 w-full leading-5 animate-in fade-in"
117+
class="flex overflow-y-hidden flex-col flex-1 min-h-0 gap-2 pb-4 w-full leading-5 animate-in fade-in"
118118
data-tauri-drag-region
119119
>
120-
<div class="flex overflow-hidden flex-col flex-1">
121-
<div class="flex overflow-y-hidden flex-row flex-1 gap-2 px-2 pb-0.5">
120+
<div class="flex overflow-hidden flex-col flex-1 min-h-0">
121+
<div class="flex overflow-y-hidden flex-row flex-1 min-h-0 gap-2 px-2 pb-0.5">
122122
<Player />
123123
<ConfigSidebar />
124124
</div>

apps/desktop/src/utils/tauri.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -331,8 +331,9 @@ start_time?: number | null }
331331
export type AuthSecret = { api_key: string } | { token: string; expires: number }
332332
export type AuthStore = { secret: AuthSecret; user_id: string | null; plan: Plan | null; intercom_hash: string | null }
333333
export type AuthenticationInvalid = null
334-
export type BackgroundConfiguration = { source: BackgroundSource; blur: number; padding: number; rounding: number; inset: number; crop: Crop | null; shadow?: number; advancedShadow?: ShadowConfiguration | null }
334+
export type BackgroundConfiguration = { source: BackgroundSource; blur: number; padding: number; rounding: number; inset: number; crop: Crop | null; shadow?: number; advancedShadow?: ShadowConfiguration | null; border?: BorderConfiguration | null }
335335
export type BackgroundSource = { type: "wallpaper"; path: string | null } | { type: "image"; path: string | null } | { type: "color"; value: [number, number, number] } | { type: "gradient"; from: [number, number, number]; to: [number, number, number]; angle?: number }
336+
export type BorderConfiguration = { enabled: boolean; width: number; color: [number, number, number]; opacity: number }
336337
export type Camera = { hide: boolean; mirror: boolean; position: CameraPosition; size: number; zoom_size: number | null; rounding?: number; shadow?: number; advanced_shadow?: ShadowConfiguration | null; shape?: CameraShape }
337338
export type CameraInfo = { device_id: string; model_id: ModelIDType | null; display_name: string }
338339
export type CameraPosition = { x: CameraXPosition; y: CameraYPosition }

crates/project/src/configuration.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,15 @@ pub struct ShadowConfiguration {
193193
pub blur: f32, // Shadow blur amount (0-100)
194194
}
195195

196+
#[derive(Type, Serialize, Deserialize, Clone, Debug, PartialEq)]
197+
#[serde(rename_all = "camelCase")]
198+
pub struct BorderConfiguration {
199+
pub enabled: bool,
200+
pub width: f32, // Border width in pixels
201+
pub color: Color, // Border color (RGB)
202+
pub opacity: f32, // Border opacity (0-100)
203+
}
204+
196205
#[derive(Type, Serialize, Deserialize, Clone, Debug)]
197206
#[serde(rename_all = "camelCase")]
198207
pub struct BackgroundConfiguration {
@@ -206,6 +215,19 @@ pub struct BackgroundConfiguration {
206215
pub shadow: f32,
207216
#[serde(default)]
208217
pub advanced_shadow: Option<ShadowConfiguration>,
218+
#[serde(default)]
219+
pub border: Option<BorderConfiguration>,
220+
}
221+
222+
impl Default for BorderConfiguration {
223+
fn default() -> Self {
224+
Self {
225+
enabled: false,
226+
width: 5.0,
227+
color: [255, 255, 255], // White
228+
opacity: 80.0, // 80% opacity
229+
}
230+
}
209231
}
210232

211233
impl Default for BackgroundConfiguration {
@@ -219,6 +241,7 @@ impl Default for BackgroundConfiguration {
219241
crop: None,
220242
shadow: 73.6,
221243
advanced_shadow: Some(ShadowConfiguration::default()),
244+
border: None, // Border is disabled by default for backwards compatibility
222245
}
223246
}
224247
}

crates/rendering-skia/src/bin/test_background.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ fn test_color_background(
7070
let uniforms = SkiaProjectUniforms {
7171
output_size: (width, height),
7272
background: BackgroundSource::Color { value: color },
73+
border: None,
7374
};
7475

7576
let frame_data = FrameData {
@@ -133,6 +134,7 @@ fn test_gradient_background(
133134
to: [0, 0, 65535], // Blue
134135
angle: 45,
135136
},
137+
border: None,
136138
};
137139

138140
let frame_data = FrameData {
@@ -197,6 +199,7 @@ fn test_gradient_angles(
197199
to: [32768, 0, 65535], // Purple
198200
angle,
199201
},
202+
border: None,
200203
};
201204

202205
let frame_data = FrameData {
@@ -261,6 +264,7 @@ fn test_caching(
261264
to: [0, 0, 65535],
262265
angle: 90,
263266
},
267+
border: None,
264268
};
265269

266270
let frame_data = FrameData {
@@ -306,6 +310,7 @@ fn test_caching(
306310
to: [65535, 65535, 0],
307311
angle: 45,
308312
},
313+
border: None,
309314
};
310315

311316
let new_frame_data = FrameData {

crates/rendering-skia/src/layers/background.rs

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,11 @@ impl From<BackgroundSource> for Background {
5858
pub struct BackgroundLayer {
5959
// Current background configuration
6060
current_background: Option<Background>,
61+
current_border: Option<cap_project::BorderConfiguration>,
6162

6263
// Track what we rendered last to detect changes
6364
last_rendered_background: Option<Background>,
65+
last_rendered_border: Option<cap_project::BorderConfiguration>,
6466
last_rendered_size: (u32, u32),
6567

6668
// For image backgrounds
@@ -72,7 +74,9 @@ impl BackgroundLayer {
7274
pub fn new() -> Self {
7375
Self {
7476
current_background: None,
77+
current_border: None,
7578
last_rendered_background: None,
79+
last_rendered_border: None,
7680
last_rendered_size: (0, 0),
7781
image_path: None,
7882
loaded_image: None,
@@ -102,6 +106,41 @@ impl BackgroundLayer {
102106
}
103107
}
104108

109+
fn render_border(
110+
&self,
111+
canvas: &Canvas,
112+
bounds: Rect,
113+
border: &cap_project::BorderConfiguration,
114+
) {
115+
if !border.enabled || border.width <= 0.0 {
116+
return;
117+
}
118+
119+
let mut paint = Paint::default();
120+
paint.set_style(skia_safe::PaintStyle::Stroke);
121+
paint.set_stroke_width(border.width);
122+
paint.set_anti_alias(true);
123+
124+
let alpha = ((border.opacity / 100.0).clamp(0.0, 1.0) * 255.0) as u8;
125+
let border_color = Color::from_argb(
126+
alpha,
127+
(border.color[0] >> 8) as u8,
128+
(border.color[1] >> 8) as u8,
129+
(border.color[2] >> 8) as u8,
130+
);
131+
paint.set_color(border_color);
132+
133+
let inset = border.width / 2.0;
134+
let border_rect = Rect::from_xywh(
135+
bounds.left() + inset,
136+
bounds.top() + inset,
137+
(bounds.width() - border.width).max(0.0),
138+
(bounds.height() - border.width).max(0.0),
139+
);
140+
141+
canvas.draw_rect(border_rect, &paint);
142+
}
143+
105144
fn render_color(&self, canvas: &Canvas, color: &[u16; 3], _bounds: Rect) {
106145
// Convert from u16 (0-65535) to u8 (0-255)
107146
let skia_color = Color::from_argb(
@@ -121,7 +160,6 @@ impl BackgroundLayer {
121160
angle: u16,
122161
bounds: Rect,
123162
) {
124-
// Convert colors from u16 (0-65535) to u8 (0-255)
125163
let start_color = Color::from_argb(
126164
255, // Full opacity
127165
(from[0] >> 8) as u8,
@@ -208,19 +246,29 @@ impl RecordableLayer for BackgroundLayer {
208246
let canvas = recorder.begin_recording(bounds, None);
209247
self.render_background(canvas, bounds);
210248

249+
// Render border if enabled
250+
if let Some(border) = &uniforms.border {
251+
if border.enabled {
252+
self.render_border(canvas, bounds, border);
253+
}
254+
}
255+
211256
// Update what was last rendered
212257
self.last_rendered_background = self.current_background.clone();
258+
self.last_rendered_border = self.current_border.clone();
213259
self.last_rendered_size = uniforms.output_size;
214260

215261
recorder.finish_recording_as_picture(None)
216262
}
217263

218264
fn needs_update(&self, uniforms: &SkiaProjectUniforms) -> bool {
219265
let new_background = Background::from(uniforms.background.clone());
266+
let new_border = uniforms.border.clone();
220267
let new_size = uniforms.output_size;
221268

222269
// Check against what was last rendered, not what's currently prepared
223270
self.last_rendered_background.as_ref() != Some(&new_background)
271+
|| self.last_rendered_border != new_border
224272
|| self.last_rendered_size != new_size
225273
}
226274

@@ -261,6 +309,7 @@ impl RecordableLayer for BackgroundLayer {
261309

262310
// Update current state (but not last_rendered, that happens in record())
263311
self.current_background = Some(new_background);
312+
self.current_border = frame_data.uniforms.border.clone();
264313

265314
Ok(())
266315
}

0 commit comments

Comments
 (0)