Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/story/src/stories/input_story.rs
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,7 @@ impl InputStory {
}
InputEvent::Focus => println!("Focus"),
InputEvent::Blur => println!("Blur"),
InputEvent::BreakpointToggled(line) => println!("BreakpointToggled: {}", line),
};
}

Expand Down
1 change: 1 addition & 0 deletions crates/story/src/stories/number_input_story.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ impl NumberInputStory {
}
InputEvent::Focus => println!("Focus"),
InputEvent::Blur => println!("Blur"),
InputEvent::BreakpointToggled(line) => println!("BreakpointToggled: {}", line),
}
}

Expand Down
110 changes: 110 additions & 0 deletions crates/ui/src/input/element.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ pub(super) const RIGHT_MARGIN: Pixels = px(10.);
pub(super) const LINE_NUMBER_RIGHT_MARGIN: Pixels = px(10.);
const FOLD_ICON_WIDTH: Pixels = px(14.);
const FOLD_ICON_HITBOX_WIDTH: Pixels = px(18.);
const BREAKPOINT_DOT_WIDTH: Pixels = px(14.);
const MAX_HIGHLIGHT_LINE_LENGTH: usize = 10_000;

fn compose_decorations(
Expand Down Expand Up @@ -322,6 +323,14 @@ struct FoldIconLayout {
icons: Vec<(usize, bool, gpui::AnyElement)>,
}

/// Layout information for the clickable breakpoint gutter.
struct BreakpointLayout {
/// Hitbox for the gutter area (used for hover detection).
hitbox: Option<Hitbox>,
/// `(is_set, dot_element)` per visible buffer line.
dots: Vec<(bool, gpui::AnyElement)>,
}

pub(super) struct TextElement {
pub(crate) state: Entity<InputState>,
placeholder: SharedString,
Expand Down Expand Up @@ -920,6 +929,11 @@ impl TextElement {
line_number_width += FOLD_ICON_HITBOX_WIDTH
}

if state.breakpoints_enabled {
// Reserve space at the left of the gutter for the breakpoint dots.
line_number_width += BREAKPOINT_DOT_WIDTH;
}

(line_number_width, line_number_len)
}

Expand Down Expand Up @@ -1216,6 +1230,95 @@ impl TextElement {
}
}

/// Prepaint a clickable dot at the far left of the gutter for every visible
/// line, coloured for set breakpoints. Mirrors [`Self::layout_fold_icons`].
fn layout_breakpoints(
&self,
origin_x: Pixels,
bounds: &Bounds<Pixels>,
last_layout: &LastLayout,
window: &mut Window,
cx: &mut App,
) -> BreakpointLayout {
let mut layout = BreakpointLayout {
hitbox: None,
dots: vec![],
};

let (breakpoints, enabled) = {
let state = self.state.read(cx);
(state.breakpoints.clone(), state.breakpoints_enabled)
};
if !enabled {
return layout;
}

layout.hitbox = Some(window.insert_hitbox(
Bounds::new(
point(origin_x, bounds.origin.y + last_layout.visible_top),
size(BREAKPOINT_DOT_WIDTH, bounds.size.height),
),
HitboxBehavior::Normal,
));

let line_height = last_layout.line_height;
let dot = px(8.);
let mut offset_y = last_layout.visible_top;
for (line, &buffer_line) in last_layout
.lines
.iter()
.zip(last_layout.visible_buffer_lines.iter())
{
let is_set = breakpoints.contains(&buffer_line);
let color = if is_set {
cx.theme().danger
} else {
cx.theme().muted_foreground.opacity(0.4)
};
let dot_bounds = Bounds::new(
point(
origin_x + (BREAKPOINT_DOT_WIDTH - dot).half(),
bounds.origin.y + offset_y + (line_height - dot).half(),
),
size(dot, dot),
);

let mut element = gpui::div()
.w(dot)
.h(dot)
.rounded_full()
.bg(color)
.on_mouse_down(MouseButton::Left, {
let state = self.state.clone();
move |_, _: &mut Window, cx: &mut App| {
cx.stop_propagation();
state.update(cx, |state, cx| state.toggle_breakpoint(buffer_line, cx));
}
})
.into_any_element();
element.prepaint_as_root(dot_bounds.origin, dot_bounds.size.into(), window, cx);

layout.dots.push((is_set, element));
offset_y += line.wrapped_lines.len() * line_height;
}

layout
}

/// Paint breakpoint dots: set lines always, unset lines only while the
/// gutter is hovered (so any line can be clicked to add one).
fn paint_breakpoints(&mut self, layout: &mut BreakpointLayout, window: &mut Window, cx: &mut App) {
let is_hovered = layout
.hitbox
.as_ref()
.is_some_and(|hitbox| hitbox.is_hovered(window));
for (is_set, dot) in &mut layout.dots {
if *is_set || is_hovered {
dot.paint(window, cx);
}
}
}

#[allow(clippy::too_many_arguments)]
fn layout_lines(
state: &InputState,
Expand Down Expand Up @@ -1465,6 +1568,8 @@ pub(super) struct PrepaintState {
bounds: Bounds<Pixels>,
/// Fold icon layout data
fold_icon_layout: FoldIconLayout,
/// Breakpoint gutter layout data
breakpoint_layout: BreakpointLayout,
// Inline completion rendering data
/// Shaped ghost lines to paint after cursor row (completion lines 2+)
ghost_lines: Vec<ShapedLine>,
Expand Down Expand Up @@ -1933,6 +2038,8 @@ impl Element for TextElement {
)));
let fold_icon_layout =
self.layout_fold_icons(original_x, &bounds, &last_layout, window, cx);
let breakpoint_layout =
self.layout_breakpoints(original_x, &bounds, &last_layout, window, cx);

PrepaintState {
bounds,
Expand All @@ -1949,6 +2056,7 @@ impl Element for TextElement {
document_color_paths,
indent_guides_path,
fold_icon_layout,
breakpoint_layout,
ghost_first_line,
ghost_lines,
ghost_lines_height,
Expand Down Expand Up @@ -2232,6 +2340,8 @@ impl Element for TextElement {
cx,
);

self.paint_breakpoints(&mut prepaint.breakpoint_layout, window, cx);

self.state.update(cx, |state, cx| {
state.last_layout = Some(prepaint.last_layout.clone());
state.last_bounds = Some(bounds);
Expand Down
56 changes: 56 additions & 0 deletions crates/ui/src/input/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ pub enum InputEvent {
PressEnter { secondary: bool, shift: bool },
Focus,
Blur,
/// A gutter breakpoint was toggled; carries the 0-based buffer line.
BreakpointToggled(usize),
}

pub(super) const CONTEXT: &str = "Input";
Expand Down Expand Up @@ -452,6 +454,11 @@ pub struct InputState {
pub(super) inline_completion: InlineCompletion,

pub(super) auto_scroll: AutoScroll,
/// Gutter breakpoint lines (0-based buffer rows), shown as clickable dots
/// when [`Self::breakpoints_enabled`] is set.
pub(super) breakpoints: std::collections::HashSet<usize>,
/// Whether the clickable breakpoint gutter is drawn (requires line numbers).
pub(super) breakpoints_enabled: bool,
}

impl EventEmitter<InputEvent> for InputState {}
Expand Down Expand Up @@ -553,6 +560,8 @@ impl InputState {
inline_completion: InlineCompletion::default(),
cursor_line_end_affinity: false,
auto_scroll: AutoScroll::default(),
breakpoints: std::collections::HashSet::new(),
breakpoints_enabled: false,
}
}

Expand Down Expand Up @@ -669,6 +678,53 @@ impl InputState {
cx.notify();
}

/// Enable the clickable breakpoint gutter (dots left of the line numbers).
/// Requires line numbers to be shown.
#[must_use]
pub fn breakpoints_enabled(mut self, enabled: bool) -> Self {
self.breakpoints_enabled = enabled;
self
}

/// Toggle the clickable breakpoint gutter at runtime (dots left of the
/// line numbers). Requires line numbers to be shown.
pub fn set_breakpoints_enabled(&mut self, enabled: bool, cx: &mut Context<Self>) {
if self.breakpoints_enabled != enabled {
self.breakpoints_enabled = enabled;
cx.notify();
}
}

/// Whether the clickable breakpoint gutter is currently drawn.
pub fn is_breakpoints_enabled(&self) -> bool {
self.breakpoints_enabled
}

/// The current gutter breakpoint lines (0-based buffer rows).
pub fn breakpoints(&self) -> &std::collections::HashSet<usize> {
&self.breakpoints
}

/// Replace the gutter breakpoint lines (0-based buffer rows).
pub fn set_breakpoints(
&mut self,
lines: std::collections::HashSet<usize>,
cx: &mut Context<Self>,
) {
self.breakpoints = lines;
cx.notify();
}

/// Toggle a gutter breakpoint on a 0-based buffer line and emit
/// [`InputEvent::BreakpointToggled`].
pub fn toggle_breakpoint(&mut self, line: usize, cx: &mut Context<Self>) {
if !self.breakpoints.remove(&line) {
self.breakpoints.insert(line);
}
cx.emit(InputEvent::BreakpointToggled(line));
cx.notify();
}

/// Set the number of rows for the multi-line Textarea.
///
/// This is only used when `multi_line` is set to true.
Expand Down
Loading