Compare commits

...

14 Commits

Author SHA1 Message Date
Piotr Osiewicz
06f4f46504 Merge branch 'main' into xcode-style-breakpoint-indicator 2025-06-29 00:42:17 +02:00
Nate Butler
fb877ddf4c Update breakpoint_indicator.rs 2025-05-28 11:33:51 -04:00
Nate Butler
5135326294 wip 2025-05-27 09:09:15 -04:00
Nate Butler
4acdb447cf Cleanup 2025-05-23 11:22:52 -04:00
Nate Butler
1e91d68e08 wip 2025-05-23 11:02:44 -04:00
Nate Butler
4276901e28 Remove now unused vectors 2025-05-23 09:05:25 -04:00
Nate Butler
44152c412f Almost there 2025-05-22 09:43:25 -04:00
Nate Butler
c19ff51465 wip 2025-05-22 08:52:26 -04:00
Nate Butler
f41747b422 wip 2025-05-22 08:27:01 -04:00
Nate Butler
f8d20986a1 wip 2025-05-22 07:58:19 -04:00
Nate Butler
82fa6d7e53 Use the actual longest line in px for indicator width
Co-authored-by: Cole Miller <m@cole-miller.net>
2025-05-21 11:22:16 -04:00
Nate Butler
d5392cf53f wip 2025-05-15 09:20:18 +02:00
Nate Butler
a07a090b5a wip 2025-05-12 14:42:46 +02:00
Nate Butler
046dbba964 wip 2025-05-12 10:07:36 +02:00
8 changed files with 588 additions and 164 deletions

3
Cargo.lock generated
View File

@@ -4314,11 +4314,14 @@ dependencies = [
"client",
"collections",
"command_palette_hooks",
"component",
"dap",
"dap_adapters",
"db",
"debugger_tools",
"editor",
"env_logger 0.11.8",
"feature_flags",
"file_icons",
"futures 0.3.31",
"fuzzy",

View File

@@ -303,6 +303,7 @@ pub enum ComponentScope {
Collaboration,
#[strum(serialize = "Data Display")]
DataDisplay,
Debugger,
Editor,
#[strum(serialize = "Images & Icons")]
Images,

View File

@@ -32,10 +32,14 @@ bitflags.workspace = true
client.workspace = true
collections.workspace = true
command_palette_hooks.workspace = true
component.workspace = true
dap.workspace = true
dap_adapters = { workspace = true, optional = true }
db.workspace = true
debugger_tools = { workspace = true, optional = true }
editor.workspace = true
env_logger = { workspace = true, optional = true }
feature_flags.workspace = true
file_icons.workspace = true
futures.workspace = true
fuzzy.workspace = true
@@ -64,11 +68,10 @@ theme.workspace = true
tree-sitter.workspace = true
tree-sitter-json.workspace = true
ui.workspace = true
util.workspace = true
workspace.workspace = true
workspace-hack.workspace = true
debugger_tools = { workspace = true, optional = true }
unindent = { workspace = true, optional = true }
util.workspace = true
workspace-hack.workspace = true
workspace.workspace = true
zed_actions.workspace = true
[dev-dependencies]

View File

@@ -21,6 +21,7 @@ mod editor_settings;
mod editor_settings_controls;
mod element;
mod git;
mod gutter;
mod highlight_matching_bracket;
mod hover_links;
pub mod hover_popover;
@@ -201,8 +202,8 @@ use theme::{
observe_buffer_font_size_adjustment,
};
use ui::{
ButtonSize, ButtonStyle, ContextMenu, Disclosure, IconButton, IconButtonShape, IconName,
IconSize, Indicator, Key, Tooltip, h_flex, prelude::*,
ButtonSize, ContextMenu, Disclosure, IconButton, IconButtonShape, IconName, IconSize,
Indicator, Key, Tooltip, h_flex, prelude::*,
};
use util::{RangeExt, ResultExt, TryFutureExt, maybe, post_inc};
use workspace::{
@@ -7977,121 +7978,6 @@ impl Editor {
})
}
fn render_breakpoint(
&self,
position: Anchor,
row: DisplayRow,
breakpoint: &Breakpoint,
state: Option<BreakpointSessionState>,
cx: &mut Context<Self>,
) -> IconButton {
let is_rejected = state.is_some_and(|s| !s.verified);
// Is it a breakpoint that shows up when hovering over gutter?
let (is_phantom, collides_with_existing) = self.gutter_breakpoint_indicator.0.map_or(
(false, false),
|PhantomBreakpointIndicator {
is_active,
display_row,
collides_with_existing_breakpoint,
}| {
(
is_active && display_row == row,
collides_with_existing_breakpoint,
)
},
);
let (color, icon) = {
let icon = match (&breakpoint.message.is_some(), breakpoint.is_disabled()) {
(false, false) => ui::IconName::DebugBreakpoint,
(true, false) => ui::IconName::DebugLogBreakpoint,
(false, true) => ui::IconName::DebugDisabledBreakpoint,
(true, true) => ui::IconName::DebugDisabledLogBreakpoint,
};
let color = if is_phantom {
Color::Hint
} else if is_rejected {
Color::Disabled
} else {
Color::Debugger
};
(color, icon)
};
let breakpoint = Arc::from(breakpoint.clone());
let alt_as_text = gpui::Keystroke {
modifiers: Modifiers::secondary_key(),
..Default::default()
};
let primary_action_text = if breakpoint.is_disabled() {
"Enable breakpoint"
} else if is_phantom && !collides_with_existing {
"Set breakpoint"
} else {
"Unset breakpoint"
};
let focus_handle = self.focus_handle.clone();
let meta = if is_rejected {
SharedString::from("No executable code is associated with this line.")
} else if collides_with_existing && !breakpoint.is_disabled() {
SharedString::from(format!(
"{alt_as_text}-click to disable,\nright-click for more options."
))
} else {
SharedString::from("Right-click for more options.")
};
IconButton::new(("breakpoint_indicator", row.0 as usize), icon)
.icon_size(IconSize::XSmall)
.size(ui::ButtonSize::None)
.when(is_rejected, |this| {
this.indicator(Indicator::icon(Icon::new(IconName::Warning)).color(Color::Warning))
})
.icon_color(color)
.style(ButtonStyle::Transparent)
.on_click(cx.listener({
let breakpoint = breakpoint.clone();
move |editor, event: &ClickEvent, window, cx| {
let edit_action = if event.modifiers().platform || breakpoint.is_disabled() {
BreakpointEditAction::InvertState
} else {
BreakpointEditAction::Toggle
};
window.focus(&editor.focus_handle(cx));
editor.edit_breakpoint_at_anchor(
position,
breakpoint.as_ref().clone(),
edit_action,
cx,
);
}
}))
.on_right_click(cx.listener(move |editor, event: &ClickEvent, window, cx| {
editor.set_breakpoint_context_menu(
row,
Some(position),
event.down.position,
window,
cx,
);
}))
.tooltip(move |window, cx| {
Tooltip::with_meta_in(
primary_action_text,
Some(&ToggleBreakpoint),
meta.clone(),
&focus_handle,
window,
cx,
)
})
}
fn build_tasks_context(
project: &Entity<Project>,
buffer: &Entity<Buffer>,

View File

@@ -21,6 +21,7 @@ use crate::{
ScrollbarDiagnostics, ShowMinimap, ShowScrollbar,
},
git::blame::{BlameRenderer, GitBlame, GlobalBlameRenderer},
gutter::breakpoint_indicator::breakpoint_indicator_path,
hover_popover::{
self, HOVER_POPOVER_GAP, MIN_POPOVER_CHARACTER_WIDTH, MIN_POPOVER_LINE_HEIGHT,
POPOVER_RIGHT_OFFSET, hover_at,
@@ -30,6 +31,7 @@ use crate::{
mouse_context_menu::{self, MenuPosition},
scroll::{ActiveScrollbarState, ScrollbarThumbState, scroll_amount::ScrollAmount},
};
use buffer_diff::{DiffHunkStatus, DiffHunkStatusKind};
use collections::{BTreeMap, HashMap};
use file_icons::FileIcons;
@@ -42,12 +44,12 @@ use gpui::{
Action, Along, AnyElement, App, AppContext, AvailableSpace, Axis as ScrollbarAxis, BorderStyle,
Bounds, ClickEvent, ContentMask, Context, Corner, Corners, CursorStyle, DispatchPhase, Edges,
Element, ElementInputHandler, Entity, Focusable as _, FontId, GlobalElementId, Hitbox,
HitboxBehavior, Hsla, InteractiveElement, IntoElement, IsZero, Keystroke, Length,
HitboxBehavior, Hsla, InteractiveElement, IntoElement, IsZero, Keystroke, Length, Modifiers,
ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad,
ParentElement, Pixels, ScrollDelta, ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString,
Size, StatefulInteractiveElement, Style, Styled, TextRun, TextStyleRefinement, WeakEntity,
Window, anchored, deferred, div, fill, linear_color_stop, linear_gradient, outline, point, px,
quad, relative, size, solid_background, transparent_black,
Window, anchored, canvas, deferred, div, fill, linear_color_stop, linear_gradient, outline,
point, px, quad, relative, size, solid_background, transparent_black,
};
use itertools::Itertools;
use language::language_settings::{
@@ -61,7 +63,7 @@ use multi_buffer::{
use project::{
ProjectPath,
debugger::breakpoint_store::{Breakpoint, BreakpointSessionState},
debugger::breakpoint_store::{Breakpoint, BreakpointEditAction, BreakpointSessionState},
project_settings::{GitGutterSetting, GitHunkStyleSetting, ProjectSettings},
};
use settings::Settings;
@@ -2757,7 +2759,16 @@ impl EditorElement {
return None;
}
let button = editor.render_breakpoint(text_anchor, display_row, &bp, state, cx);
let button = self.render_breakpoint(
editor,
snapshot,
text_anchor,
display_row,
row,
&bp,
window,
cx,
);
let button = prepaint_gutter_button(
button,
@@ -2776,6 +2787,184 @@ impl EditorElement {
})
}
fn render_breakpoint(
&self,
editor: &Editor,
snapshot: &EditorSnapshot,
position: Anchor,
row: DisplayRow,
multibuffer_row: MultiBufferRow,
breakpoint: &Breakpoint,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
let element_id =
ElementId::Name(format!("breakpoint_indicator_{}", multibuffer_row.0).into());
// === Extract text style and calculate dimensions ===
let text_style = self.style.text.clone();
let font_size = text_style.font_size;
let font_size_px = font_size.to_pixels(window.rem_size());
let rem_size = window.rem_size();
// Calculate font scale relative to a baseline font size
const BASELINE_FONT_SIZE: f32 = 14.0; // Default editor font size
let font_scale = font_size_px / px(BASELINE_FONT_SIZE);
let line_height: Pixels = text_style.line_height.to_pixels(font_size, rem_size);
// Debug font metrics
dbg!(font_size);
dbg!(font_size_px);
dbg!(rem_size);
dbg!(BASELINE_FONT_SIZE);
dbg!(font_scale);
dbg!(line_height);
// Helper to scale pixel values based on font size
let scale_px = |value: f32| px(value) * font_scale;
const HORIZONTAL_OFFSET: f32 = 40.0;
const VERTICAL_OFFSET: f32 = 4.0;
let horizontal_offset = scale_px(HORIZONTAL_OFFSET);
let vertical_offset = px(VERTICAL_OFFSET);
let indicator_height = line_height - vertical_offset;
let line_number_width = self.max_line_number_width(snapshot, window, cx);
let indicator_width = dbg!(line_number_width) + scale_px(HORIZONTAL_OFFSET);
// Debug indicator dimensions
dbg!(horizontal_offset);
dbg!(vertical_offset);
dbg!(indicator_height);
dbg!(indicator_width);
let is_disabled = breakpoint.is_disabled();
let breakpoint_arc = Arc::from(breakpoint.clone());
let (is_hovered, collides_with_existing) = editor.gutter_breakpoint_indicator.0.map_or(
(false, false),
|PhantomBreakpointIndicator {
is_active,
display_row,
collides_with_existing_breakpoint,
}| {
(
is_active && display_row == row,
collides_with_existing_breakpoint,
)
},
);
let indicator_color = if is_hovered {
cx.theme().colors().ghost_element_hover
} else if is_disabled {
cx.theme().status().info.alpha(0.64)
} else {
cx.theme().status().info.alpha(0.48)
};
let primary_action = if is_disabled {
"enable"
} else if is_hovered && !collides_with_existing {
"set"
} else {
"unset"
};
let mut tooltip_text = format!("Click to {primary_action}");
if collides_with_existing && !is_disabled {
use std::fmt::Write;
let modifier_key = gpui::Keystroke {
modifiers: Modifiers::secondary_key(),
..Default::default()
};
write!(tooltip_text, ", {modifier_key}-click to disable").ok();
}
div()
.id(element_id)
.cursor_pointer()
.absolute()
.left_0()
.w(indicator_width)
.h(indicator_height)
.child(
canvas(
|_bounds, _cx, _style| {},
move |bounds, _cx, window, _style| {
// Debug canvas bounds
dbg!(&bounds);
// Adjust bounds to account for horizontal offset
let adjusted_bounds = Bounds {
origin: point(bounds.origin.x + horizontal_offset, bounds.origin.y),
size: size(bounds.size.width, indicator_height),
};
// Debug adjusted bounds
dbg!(&adjusted_bounds);
dbg!(font_scale);
// Generate the breakpoint indicator path
let path =
breakpoint_indicator_path(adjusted_bounds, font_scale, is_disabled);
// Paint the path with the calculated color
window.paint_path(path, indicator_color);
},
)
.size_full(),
)
.on_click({
let editor_weak = self.editor.downgrade();
let breakpoint = breakpoint_arc.clone();
move |event, window, cx| {
let action = if event.modifiers().platform || breakpoint.is_disabled() {
BreakpointEditAction::InvertState
} else {
BreakpointEditAction::Toggle
};
let Some(editor_strong) = editor_weak.upgrade() else {
return;
};
window.focus(&editor_strong.focus_handle(cx));
editor_strong.update(cx, |editor, cx| {
editor.edit_breakpoint_at_anchor(
position,
breakpoint.as_ref().clone(),
action,
cx,
);
});
}
})
.on_mouse_down(gpui::MouseButton::Right, {
let editor_weak = self.editor.downgrade();
let anchor_position = position.clone();
move |event, window, cx| {
let Some(editor_strong) = editor_weak.upgrade() else {
return;
};
editor_strong.update(cx, |editor, cx| {
editor.set_breakpoint_context_menu(
row,
Some(anchor_position.clone()),
event.position,
window,
cx,
);
});
}
})
.into_any_element()
}
#[allow(clippy::too_many_arguments)]
fn layout_run_indicators(
&self,
@@ -3034,7 +3223,7 @@ impl EditorElement {
scroll_position: gpui::Point<f32>,
rows: Range<DisplayRow>,
buffer_rows: &[RowInfo],
active_rows: &BTreeMap<DisplayRow, LineHighlightSpec>,
_active_rows: &BTreeMap<DisplayRow, LineHighlightSpec>,
newest_selection_head: Option<DisplayPoint>,
snapshot: &EditorSnapshot,
window: &mut Window,
@@ -3090,16 +3279,7 @@ impl EditorElement {
return None;
}
let color = active_rows
.get(&display_row)
.map(|spec| {
if spec.breakpoint {
cx.theme().colors().debugger_accent
} else {
cx.theme().colors().editor_active_line_number
}
})
.unwrap_or_else(|| cx.theme().colors().editor_line_number);
let color = cx.theme().colors().editor_active_line_number;
let shaped_line =
self.shape_line_number(SharedString::from(&line_number), color, window);
let scroll_top = scroll_position.y * line_height;
@@ -5577,6 +5757,9 @@ impl EditorElement {
cx: &mut App,
) {
window.paint_layer(layout.gutter_hitbox.bounds, |window| {
for breakpoint in layout.breakpoints.iter_mut() {
breakpoint.paint(window, cx);
}
window.with_element_namespace("crease_toggles", |window| {
for crease_toggle in layout.crease_toggles.iter_mut().flatten() {
crease_toggle.paint(window, cx);
@@ -5589,13 +5772,24 @@ impl EditorElement {
}
});
for breakpoint in layout.breakpoints.iter_mut() {
breakpoint.paint(window, cx);
}
for test_indicator in layout.test_indicators.iter_mut() {
test_indicator.paint(window, cx);
}
let show_git_gutter = layout
.position_map
.snapshot
.show_git_diff_gutter
.unwrap_or_else(|| {
matches!(
ProjectSettings::get_global(cx).git.git_gutter,
Some(GitGutterSetting::TrackedFiles)
)
});
if show_git_gutter {
Self::paint_gutter_diff_hunks(layout, window, cx)
}
});
}
@@ -5619,20 +5813,6 @@ impl EditorElement {
}
}
let show_git_gutter = layout
.position_map
.snapshot
.show_git_diff_gutter
.unwrap_or_else(|| {
matches!(
ProjectSettings::get_global(cx).git.git_gutter,
Some(GitGutterSetting::TrackedFiles)
)
});
if show_git_gutter {
Self::paint_gutter_diff_hunks(layout, window, cx)
}
let highlight_width = 0.275 * layout.position_map.line_height;
let highlight_corner_radii = Corners::all(0.05 * layout.position_map.line_height);
window.paint_layer(layout.gutter_hitbox.bounds, |window| {
@@ -6879,7 +7059,8 @@ impl EditorElement {
layout.width
}
fn max_line_number_width(
/// Get the width of the longest line number in the current editor in Pixels
pub(crate) fn max_line_number_width(
&self,
snapshot: &EditorSnapshot,
window: &mut Window,
@@ -6974,7 +7155,7 @@ impl AcceptEditPredictionBinding {
}
fn prepaint_gutter_button(
button: IconButton,
button: impl IntoElement,
row: DisplayRow,
line_height: Pixels,
gutter_dimensions: &GutterDimensions,
@@ -8959,6 +9140,10 @@ impl Element for EditorElement {
self.paint_background(layout, window, cx);
self.paint_indent_guides(layout, window, cx);
if layout.gutter_hitbox.size.width > Pixels::ZERO {
self.paint_gutter_highlights(layout, window, cx);
self.paint_gutter_indicators(layout, window, cx);
}
if layout.gutter_hitbox.size.width > Pixels::ZERO {
self.paint_blamed_display_rows(layout, window, cx);
self.paint_line_numbers(layout, window, cx);
@@ -8966,11 +9151,6 @@ impl Element for EditorElement {
self.paint_text(layout, window, cx);
if layout.gutter_hitbox.size.width > Pixels::ZERO {
self.paint_gutter_highlights(layout, window, cx);
self.paint_gutter_indicators(layout, window, cx);
}
if !layout.blocks.is_empty() {
window.with_element_namespace("blocks", |window| {
self.paint_blocks(layout, window, cx);

View File

@@ -0,0 +1,208 @@
use gpui::{Bounds, Path, PathBuilder, PathStyle, StrokeOptions, point};
use ui::{Pixels, px};
/// Draw the path for the breakpoint indicator.
///
/// Note: The indicator needs to be a minimum of MIN_WIDTH px wide.
/// wide to draw without graphical issues, so it will ignore narrower width.
pub(crate) fn breakpoint_indicator_path(
bounds: Bounds<Pixels>,
scale: f32,
stroke: bool,
) -> Path<Pixels> {
// Constants for the breakpoint shape dimensions
// The shape is designed based on a 50px wide by 15px high template
// and uses 9-slice style scaling to allow the shape to be stretched
// vertically and horizontally.
const SHAPE_BASE_HEIGHT: f32 = 15.0;
const SHAPE_FIXED_WIDTH: f32 = 32.0; // Width of non-stretchable parts (corners)
const SHAPE_MIN_WIDTH: f32 = 34.0; // Minimum width to render properly
const PIXEL_ROUNDING_FACTOR: f32 = 8.0; // Round to nearest 1/8 pixel
// Key points in the shape (in base coordinates)
const CORNER_RADIUS: f32 = 5.0;
const CENTER_Y: f32 = 7.5;
const TOP_Y: f32 = 0.0;
const BOTTOM_Y: f32 = 15.0;
const CURVE_CONTROL_OFFSET: f32 = 1.5;
const RIGHT_CORNER_START: f32 = 4.0;
const RIGHT_CORNER_WIDTH: f32 = 13.0;
// Helper function to round pixels to nearest 1/8
let round_to_pixel_grid = |value: Pixels| -> Pixels {
let value_f32: f32 = value.into();
px((value_f32 * PIXEL_ROUNDING_FACTOR).round() / PIXEL_ROUNDING_FACTOR)
};
// Calculate actual dimensions with scaling
let min_allowed_width = px(SHAPE_MIN_WIDTH * scale);
let actual_width = if bounds.size.width < min_allowed_width {
min_allowed_width
} else {
bounds.size.width
};
let actual_height = bounds.size.height;
// Debug input parameters and initial calculations
dbg!(&bounds);
dbg!(scale);
dbg!(stroke);
dbg!(min_allowed_width);
dbg!(actual_width);
dbg!(actual_height);
// Origin point for positioning
let origin_x = bounds.origin.x;
let origin_y = bounds.origin.y;
// Calculate the scale factor based on height and user scale
let shape_scale = (actual_height / px(SHAPE_BASE_HEIGHT)) * scale;
// Calculate the width of fixed and stretchable sections
let fixed_sections_width = px(SHAPE_FIXED_WIDTH) * shape_scale;
let stretchable_middle_width = actual_width - fixed_sections_width;
// Debug scaling calculations
dbg!(shape_scale);
dbg!(fixed_sections_width);
dbg!(stretchable_middle_width);
// Pre-calculate all the key x-coordinates
let left_edge_x = round_to_pixel_grid(origin_x);
let left_corner_end_x = round_to_pixel_grid(origin_x + px(CORNER_RADIUS) * shape_scale);
let middle_section_end_x =
round_to_pixel_grid(origin_x + px(CORNER_RADIUS) * shape_scale + stretchable_middle_width);
let right_corner_start_x = round_to_pixel_grid(
origin_x
+ px(CORNER_RADIUS) * shape_scale
+ stretchable_middle_width
+ px(RIGHT_CORNER_START) * shape_scale,
);
let right_edge_x = round_to_pixel_grid(
origin_x
+ px(CORNER_RADIUS) * shape_scale
+ stretchable_middle_width
+ px(RIGHT_CORNER_WIDTH) * shape_scale,
);
// Debug x-coordinates
dbg!(origin_x);
dbg!(left_edge_x);
dbg!(left_corner_end_x);
dbg!(middle_section_end_x);
dbg!(right_corner_start_x);
dbg!(right_edge_x);
// Pre-calculate all the key y-coordinates
let top_edge_y = round_to_pixel_grid(origin_y);
let center_y = round_to_pixel_grid(origin_y + px(CENTER_Y) * shape_scale);
let bottom_edge_y = round_to_pixel_grid(origin_y + px(BOTTOM_Y) * shape_scale);
// Y-coordinates for the left side curves
let left_upper_curve_start_y = round_to_pixel_grid(origin_y + px(CORNER_RADIUS) * shape_scale);
let left_lower_curve_end_y = round_to_pixel_grid(origin_y + px(10.0) * shape_scale);
// Y-coordinates for the right side curves
let right_upper_curve_control_y = round_to_pixel_grid(origin_y + px(6.0) * shape_scale);
let right_lower_curve_control_y = round_to_pixel_grid(origin_y + px(9.0) * shape_scale);
// Control point offsets
let control_offset = px(CURVE_CONTROL_OFFSET) * shape_scale;
let right_control_offset = px(9.0) * shape_scale;
// Debug y-coordinates
dbg!(origin_y);
dbg!(top_edge_y);
dbg!(center_y);
dbg!(bottom_edge_y);
dbg!(left_upper_curve_start_y);
dbg!(left_lower_curve_end_y);
dbg!(right_upper_curve_control_y);
dbg!(right_lower_curve_control_y);
// Create the path builder
let mut builder = if stroke {
let stroke_width = px(1.0 * scale);
let options = StrokeOptions::default().with_line_width(stroke_width.0);
PathBuilder::stroke(stroke_width).with_style(PathStyle::Stroke(options))
} else {
PathBuilder::fill()
};
// Build the path - starting from left center
builder.move_to(point(left_edge_x, center_y));
// === Upper half of the shape ===
// Move up to start of left upper curve
builder.line_to(point(left_edge_x, left_upper_curve_start_y));
// Top-left corner curve
builder.cubic_bezier_to(
point(left_corner_end_x, top_edge_y),
point(left_edge_x, round_to_pixel_grid(origin_y + control_offset)),
point(round_to_pixel_grid(origin_x + control_offset), top_edge_y),
);
// Top edge - stretchable middle section
builder.line_to(point(middle_section_end_x, top_edge_y));
// Top edge - right corner start
builder.line_to(point(right_corner_start_x, top_edge_y));
// Top-right corner curve
builder.cubic_bezier_to(
point(right_edge_x, center_y),
point(
round_to_pixel_grid(
origin_x
+ px(CORNER_RADIUS) * shape_scale
+ stretchable_middle_width
+ right_control_offset,
),
top_edge_y,
),
point(right_edge_x, right_upper_curve_control_y),
);
// === Lower half of the shape (mirrored) ===
// Bottom-right corner curve
builder.cubic_bezier_to(
point(right_corner_start_x, bottom_edge_y),
point(right_edge_x, right_lower_curve_control_y),
point(
round_to_pixel_grid(
origin_x
+ px(CORNER_RADIUS) * shape_scale
+ stretchable_middle_width
+ right_control_offset,
),
bottom_edge_y,
),
);
// Bottom edge - right corner to middle
builder.line_to(point(middle_section_end_x, bottom_edge_y));
// Bottom edge - stretchable middle section
builder.line_to(point(left_corner_end_x, bottom_edge_y));
// Bottom-left corner curve
builder.cubic_bezier_to(
point(left_edge_x, left_lower_curve_end_y),
point(
round_to_pixel_grid(origin_x + control_offset),
bottom_edge_y,
),
point(
left_edge_x,
round_to_pixel_grid(origin_y + px(13.5) * shape_scale),
),
);
// Close the path by returning to start
builder.line_to(point(left_edge_x, center_y));
builder.build().unwrap()
}

View File

@@ -0,0 +1 @@
pub mod breakpoint_indicator;

View File

@@ -1,7 +1,7 @@
use gpui::{
Application, Background, Bounds, ColorSpace, Context, MouseDownEvent, Path, PathBuilder,
PathStyle, Pixels, Point, Render, SharedString, StrokeOptions, Window, WindowOptions, canvas,
div, linear_color_stop, linear_gradient, point, prelude::*, px, rgb, size,
PathStyle, Pixels, Point, Render, SharedString, StrokeOptions, Window, WindowOptions, bounds,
canvas, div, linear_color_stop, linear_gradient, point, prelude::*, px, rgb, size,
};
struct PaintingViewer {
@@ -150,6 +150,14 @@ impl PaintingViewer {
let path = builder.build().unwrap();
lines.push((path, gpui::green().into()));
// draw the indicators (aligned and unaligned versions)
let aligned_indicator = breakpoint_indicator_path(
bounds(point(px(50.), px(250.)), size(px(60.), px(16.))),
1.0,
false,
);
lines.push((aligned_indicator, rgb(0x1e88e5).into()));
Self {
default_lines: lines.clone(),
lines: vec![],
@@ -306,3 +314,137 @@ fn main() {
cx.activate(true);
});
}
/// Draw the path for the breakpoint indicator.
///
/// Note: The indicator needs to be a minimum of MIN_WIDTH px wide.
/// wide to draw without graphical issues, so it will ignore narrower width.
fn breakpoint_indicator_path(bounds: Bounds<Pixels>, scale: f32, stroke: bool) -> Path<Pixels> {
static MIN_WIDTH: f32 = 31.;
// Apply user scale to the minimum width
let min_width = MIN_WIDTH * scale;
let width = if bounds.size.width.0 < min_width {
px(min_width)
} else {
bounds.size.width
};
let height = bounds.size.height;
// Position the indicator on the canvas
let base_x = bounds.origin.x;
let base_y = bounds.origin.y;
// Calculate the scaling factor for the height (SVG is 15px tall), incorporating user scale
let scale_factor = (height / px(15.0)) * scale;
// Calculate how much width to allocate to the stretchable middle section
// SVG has 32px of fixed elements (corners), so the rest is for the middle
let fixed_width = px(32.0) * scale_factor;
let middle_width = width - fixed_width;
// Helper function to round to nearest quarter pixel
let round_to_quarter = |value: Pixels| -> Pixels {
let value_f32: f32 = value.into();
px((value_f32 * 4.0).round() / 4.0)
};
// Create a new path - either fill or stroke based on the flag
let mut builder = if stroke {
// For stroke, we need to set appropriate line width and options
let stroke_width = px(1.0 * scale); // Apply scale to stroke width
let options = StrokeOptions::default().with_line_width(stroke_width.0);
PathBuilder::stroke(stroke_width).with_style(PathStyle::Stroke(options))
} else {
// For fill, use the original implementation
PathBuilder::fill()
};
// Upper half of the shape - Based on the provided SVG
// Start at bottom left (0, 8)
let start_x = round_to_quarter(base_x);
let start_y = round_to_quarter(base_y + px(7.5) * scale_factor);
builder.move_to(point(start_x, start_y));
// Vertical line to (0, 5)
let vert_y = round_to_quarter(base_y + px(5.0) * scale_factor);
builder.line_to(point(start_x, vert_y));
// Curve to (5, 0) - using cubic Bezier
let curve1_end_x = round_to_quarter(base_x + px(5.0) * scale_factor);
let curve1_end_y = round_to_quarter(base_y);
let curve1_ctrl1_x = round_to_quarter(base_x);
let curve1_ctrl1_y = round_to_quarter(base_y + px(1.5) * scale_factor);
let curve1_ctrl2_x = round_to_quarter(base_x + px(1.5) * scale_factor);
let curve1_ctrl2_y = round_to_quarter(base_y);
builder.cubic_bezier_to(
point(curve1_end_x, curve1_end_y),
point(curve1_ctrl1_x, curve1_ctrl1_y),
point(curve1_ctrl2_x, curve1_ctrl2_y),
);
// Horizontal line through the middle section to (37, 0)
let middle_end_x = round_to_quarter(base_x + px(5.0) * scale_factor + middle_width);
builder.line_to(point(middle_end_x, curve1_end_y));
// Horizontal line to (41, 0)
let right_section_x =
round_to_quarter(base_x + px(5.0) * scale_factor + middle_width + px(4.0) * scale_factor);
builder.line_to(point(right_section_x, curve1_end_y));
// Curve to (50, 7.5) - using cubic Bezier
let curve2_end_x =
round_to_quarter(base_x + px(5.0) * scale_factor + middle_width + px(13.0) * scale_factor);
let curve2_end_y = round_to_quarter(base_y + px(7.5) * scale_factor);
let curve2_ctrl1_x =
round_to_quarter(base_x + px(5.0) * scale_factor + middle_width + px(9.0) * scale_factor);
let curve2_ctrl1_y = round_to_quarter(base_y);
let curve2_ctrl2_x =
round_to_quarter(base_x + px(5.0) * scale_factor + middle_width + px(13.0) * scale_factor);
let curve2_ctrl2_y = round_to_quarter(base_y + px(6.0) * scale_factor);
builder.cubic_bezier_to(
point(curve2_end_x, curve2_end_y),
point(curve2_ctrl1_x, curve2_ctrl1_y),
point(curve2_ctrl2_x, curve2_ctrl2_y),
);
// Lower half of the shape - mirrored vertically
// Curve from (50, 7.5) to (41, 15)
let curve3_end_y = round_to_quarter(base_y + px(15.0) * scale_factor);
let curve3_ctrl1_x =
round_to_quarter(base_x + px(5.0) * scale_factor + middle_width + px(13.0) * scale_factor);
let curve3_ctrl1_y = round_to_quarter(base_y + px(9.0) * scale_factor);
let curve3_ctrl2_x =
round_to_quarter(base_x + px(5.0) * scale_factor + middle_width + px(9.0) * scale_factor);
let curve3_ctrl2_y = round_to_quarter(base_y + px(15.0) * scale_factor);
builder.cubic_bezier_to(
point(right_section_x, curve3_end_y),
point(curve3_ctrl1_x, curve3_ctrl1_y),
point(curve3_ctrl2_x, curve3_ctrl2_y),
);
// Horizontal line to (37, 15)
builder.line_to(point(middle_end_x, curve3_end_y));
// Horizontal line through the middle section to (5, 15)
builder.line_to(point(curve1_end_x, curve3_end_y));
// Curve to (0, 10)
let curve4_end_y = round_to_quarter(base_y + px(10.0) * scale_factor);
let curve4_ctrl1_x = round_to_quarter(base_x + px(1.5) * scale_factor);
let curve4_ctrl1_y = round_to_quarter(base_y + px(15.0) * scale_factor);
let curve4_ctrl2_x = round_to_quarter(base_x);
let curve4_ctrl2_y = round_to_quarter(base_y + px(13.5) * scale_factor);
builder.cubic_bezier_to(
point(start_x, curve4_end_y),
point(curve4_ctrl1_x, curve4_ctrl1_y),
point(curve4_ctrl2_x, curve4_ctrl2_y),
);
// Close the path
builder.line_to(point(start_x, start_y));
builder.build().unwrap()
}