This commit is contained in:
@@ -0,0 +1,486 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui::{
|
||||
App, Bounds, ClipboardItem, Context, Element, ElementId, ElementInputHandler, Entity,
|
||||
EntityInputHandler, FocusHandle, GlobalElementId, LayoutId, Pixels, Point, Role, Style,
|
||||
UTF16Selection, Window, WindowBounds, WindowOptions, div, prelude::*, px, relative, rgb, size,
|
||||
text,
|
||||
};
|
||||
use gpui_platform::application;
|
||||
|
||||
const WORKSPACE_LABEL: &str = "Lumbridge workspace";
|
||||
const PANE_LABEL: &str = "Local terminal pane";
|
||||
const PANE_DESCRIPTION: &str = "Needs input: choose whether to run the proposed command";
|
||||
const PANE_ACCESSIBILITY_ID: &str = "lumbridge.pane.local-terminal";
|
||||
|
||||
struct AccessibilityProbe {
|
||||
pane_focus: FocusHandle,
|
||||
input: Entity<ImeTextReceiver>,
|
||||
}
|
||||
|
||||
impl AccessibilityProbe {
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let pane_focus = cx.focus_handle().tab_stop(true);
|
||||
window.focus(&pane_focus, cx);
|
||||
Self {
|
||||
pane_focus,
|
||||
input: cx.new(|_| ImeTextReceiver::default()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum BoundaryBias {
|
||||
Floor,
|
||||
Ceil,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
struct TextBuffer {
|
||||
text: String,
|
||||
selection: Range<usize>,
|
||||
marked: Option<Range<usize>>,
|
||||
}
|
||||
|
||||
impl TextBuffer {
|
||||
fn utf16_to_utf8(text: &str, offset: usize, bias: BoundaryBias) -> usize {
|
||||
let mut utf16_offset = 0;
|
||||
|
||||
for (utf8_offset, character) in text.char_indices() {
|
||||
if utf16_offset == offset {
|
||||
return utf8_offset;
|
||||
}
|
||||
|
||||
let next_utf16 = utf16_offset + character.len_utf16();
|
||||
if offset < next_utf16 {
|
||||
return match bias {
|
||||
BoundaryBias::Floor => utf8_offset,
|
||||
BoundaryBias::Ceil => utf8_offset + character.len_utf8(),
|
||||
};
|
||||
}
|
||||
utf16_offset = next_utf16;
|
||||
}
|
||||
|
||||
text.len()
|
||||
}
|
||||
|
||||
fn utf8_to_utf16(text: &str, offset: usize) -> usize {
|
||||
text[..offset].encode_utf16().count()
|
||||
}
|
||||
|
||||
fn range_from_utf16(text: &str, range: &Range<usize>) -> Range<usize> {
|
||||
if range.is_empty() {
|
||||
let offset = Self::utf16_to_utf8(text, range.start, BoundaryBias::Floor);
|
||||
return offset..offset;
|
||||
}
|
||||
|
||||
Self::utf16_to_utf8(text, range.start, BoundaryBias::Floor)
|
||||
..Self::utf16_to_utf8(text, range.end, BoundaryBias::Ceil)
|
||||
}
|
||||
|
||||
fn range_to_utf16(&self, range: &Range<usize>) -> Range<usize> {
|
||||
Self::utf8_to_utf16(&self.text, range.start)..Self::utf8_to_utf16(&self.text, range.end)
|
||||
}
|
||||
|
||||
fn selection_utf16(&self) -> Range<usize> {
|
||||
self.range_to_utf16(&self.selection)
|
||||
}
|
||||
|
||||
fn marked_utf16(&self) -> Option<Range<usize>> {
|
||||
self.marked.as_ref().map(|range| self.range_to_utf16(range))
|
||||
}
|
||||
|
||||
fn set_selection_utf16(&mut self, range: Range<usize>) {
|
||||
self.selection = Self::range_from_utf16(&self.text, &range);
|
||||
}
|
||||
|
||||
fn replacement_range(&self, range_utf16: Option<&Range<usize>>) -> Range<usize> {
|
||||
range_utf16
|
||||
.map(|range| Self::range_from_utf16(&self.text, range))
|
||||
.or_else(|| self.marked.clone())
|
||||
.unwrap_or_else(|| self.selection.clone())
|
||||
}
|
||||
|
||||
fn replace_text_in_range(&mut self, range_utf16: Option<Range<usize>>, new_text: &str) {
|
||||
let range = self.replacement_range(range_utf16.as_ref());
|
||||
let caret = range.start + new_text.len();
|
||||
self.text.replace_range(range, new_text);
|
||||
self.selection = caret..caret;
|
||||
self.marked = None;
|
||||
}
|
||||
|
||||
fn replace_and_mark_text_in_range(
|
||||
&mut self,
|
||||
range_utf16: Option<Range<usize>>,
|
||||
new_text: &str,
|
||||
new_selection_utf16: Option<Range<usize>>,
|
||||
) {
|
||||
let range = self.replacement_range(range_utf16.as_ref());
|
||||
let insertion_start = range.start;
|
||||
self.text.replace_range(range, new_text);
|
||||
|
||||
let inserted = insertion_start..insertion_start + new_text.len();
|
||||
self.marked = (!new_text.is_empty()).then(|| inserted.clone());
|
||||
self.selection = new_selection_utf16
|
||||
.map(|selection| Self::range_from_utf16(new_text, &selection))
|
||||
.map(|selection| insertion_start + selection.start..insertion_start + selection.end)
|
||||
.unwrap_or(inserted.end..inserted.end);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ImeTextReceiver {
|
||||
buffer: TextBuffer,
|
||||
}
|
||||
|
||||
impl EntityInputHandler for ImeTextReceiver {
|
||||
fn text_for_range(
|
||||
&mut self,
|
||||
range_utf16: Range<usize>,
|
||||
adjusted_range: &mut Option<Range<usize>>,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Option<String> {
|
||||
let range = TextBuffer::range_from_utf16(&self.buffer.text, &range_utf16);
|
||||
adjusted_range.replace(self.buffer.range_to_utf16(&range));
|
||||
Some(self.buffer.text[range].to_owned())
|
||||
}
|
||||
|
||||
fn selected_text_range(
|
||||
&mut self,
|
||||
_ignore_disabled_input: bool,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Option<UTF16Selection> {
|
||||
Some(UTF16Selection {
|
||||
range: self.buffer.selection_utf16(),
|
||||
reversed: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn marked_text_range(
|
||||
&self,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Option<Range<usize>> {
|
||||
self.buffer.marked_utf16()
|
||||
}
|
||||
|
||||
fn unmark_text(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.buffer.marked = None;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn paste(&mut self, item: ClipboardItem, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(text) = item.text() {
|
||||
self.replace_text_in_range(None, &text, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_text_in_range(
|
||||
&mut self,
|
||||
range_utf16: Option<Range<usize>>,
|
||||
text: &str,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.buffer.replace_text_in_range(range_utf16, text);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn replace_and_mark_text_in_range(
|
||||
&mut self,
|
||||
range_utf16: Option<Range<usize>>,
|
||||
new_text: &str,
|
||||
new_selected_range_utf16: Option<Range<usize>>,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.buffer
|
||||
.replace_and_mark_text_in_range(range_utf16, new_text, new_selected_range_utf16);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn bounds_for_range(
|
||||
&mut self,
|
||||
_range_utf16: Range<usize>,
|
||||
element_bounds: Bounds<Pixels>,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Option<Bounds<Pixels>> {
|
||||
Some(element_bounds)
|
||||
}
|
||||
|
||||
fn character_index_for_point(
|
||||
&mut self,
|
||||
_point: Point<Pixels>,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Option<usize> {
|
||||
Some(self.buffer.selection_utf16().end)
|
||||
}
|
||||
|
||||
fn set_selected_text_range(
|
||||
&mut self,
|
||||
range_utf16: Range<usize>,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.buffer.set_selection_utf16(range_utf16);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn text_length_utf16(
|
||||
&mut self,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Option<usize> {
|
||||
Some(self.buffer.text.encode_utf16().count())
|
||||
}
|
||||
}
|
||||
|
||||
struct InputCaptureElement {
|
||||
input: Entity<ImeTextReceiver>,
|
||||
focus: FocusHandle,
|
||||
}
|
||||
|
||||
impl IntoElement for InputCaptureElement {
|
||||
type Element = Self;
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for InputCaptureElement {
|
||||
type RequestLayoutState = ();
|
||||
type PrepaintState = ();
|
||||
|
||||
fn id(&self) -> Option<ElementId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
_inspector_id: Option<&gpui::InspectorElementId>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> (LayoutId, Self::RequestLayoutState) {
|
||||
let mut style = Style::default();
|
||||
style.size.width = relative(1.).into();
|
||||
style.size.height = px(32.).into();
|
||||
(window.request_layout(style, [], cx), ())
|
||||
}
|
||||
|
||||
fn prepaint(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
_inspector_id: Option<&gpui::InspectorElementId>,
|
||||
_bounds: Bounds<Pixels>,
|
||||
_request_layout: &mut Self::RequestLayoutState,
|
||||
_window: &mut Window,
|
||||
_cx: &mut App,
|
||||
) -> Self::PrepaintState {
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
_inspector_id: Option<&gpui::InspectorElementId>,
|
||||
bounds: Bounds<Pixels>,
|
||||
_request_layout: &mut Self::RequestLayoutState,
|
||||
_prepaint: &mut Self::PrepaintState,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
window.handle_input(
|
||||
&self.focus,
|
||||
ElementInputHandler::new(bounds, self.input.clone()),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for AccessibilityProbe {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let input_text = self.input.read(cx).buffer.text.clone();
|
||||
|
||||
div()
|
||||
.id("lumbridge-application")
|
||||
.role(Role::Application)
|
||||
.aria_label("Lumbridge accessibility probe")
|
||||
.size_full()
|
||||
.p_4()
|
||||
.bg(rgb(0x0c0e13))
|
||||
.text_color(rgb(0xd9e2f2))
|
||||
.child(
|
||||
div()
|
||||
.id("workspace")
|
||||
.accessibility_id("lumbridge.workspace.primary")
|
||||
.role(Role::Region)
|
||||
.aria_label(WORKSPACE_LABEL)
|
||||
.size_full()
|
||||
.p_3()
|
||||
.border_1()
|
||||
.border_color(rgb(0x293244))
|
||||
.rounded_md()
|
||||
.child(
|
||||
div()
|
||||
.id("pane-local-terminal")
|
||||
.accessibility_id(PANE_ACCESSIBILITY_ID)
|
||||
.role(Role::Pane)
|
||||
.aria_label(PANE_LABEL)
|
||||
.aria_description(PANE_DESCRIPTION)
|
||||
.aria_selected(true)
|
||||
.focusable()
|
||||
.track_focus(&self.pane_focus)
|
||||
.size_full()
|
||||
.p_3()
|
||||
.bg(rgb(0x121722))
|
||||
.rounded_md()
|
||||
.child(
|
||||
div()
|
||||
.id("terminal-content")
|
||||
.role(Role::Terminal)
|
||||
.aria_label("Terminal output")
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.child(text!("codex is waiting for approval"))
|
||||
.child(
|
||||
div()
|
||||
.id("ime-input-status")
|
||||
.role(Role::Status)
|
||||
.aria_label("Composed input")
|
||||
.child(if input_text.is_empty() {
|
||||
"Type composed Unicode here…".to_owned()
|
||||
} else {
|
||||
input_text
|
||||
}),
|
||||
)
|
||||
.child(InputCaptureElement {
|
||||
input: self.input.clone(),
|
||||
focus: self.pane_focus.clone(),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
application().run(|cx: &mut App| {
|
||||
let bounds = Bounds::centered(None, size(px(760.0), px(420.0)), cx);
|
||||
cx.open_window(
|
||||
WindowOptions {
|
||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||
titlebar: Some(gpui::TitlebarOptions {
|
||||
title: Some("Lumbridge · GPUI accessibility probe".into()),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
|window, cx| cx.new(|cx| AccessibilityProbe::new(window, cx)),
|
||||
)
|
||||
.expect("GPUI accessibility probe window should open");
|
||||
|
||||
cx.activate(true);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn workspace_and_focused_pane_map_to_an_accesskit_tree() {
|
||||
use gpui::accesskit::{Node, NodeId, Tree, TreeId, TreeUpdate};
|
||||
|
||||
let workspace_id = NodeId(1);
|
||||
let pane_id = NodeId(2);
|
||||
|
||||
let mut workspace = Node::new(Role::Region);
|
||||
workspace.set_label(WORKSPACE_LABEL.to_owned());
|
||||
workspace.set_children(vec![pane_id]);
|
||||
|
||||
let mut pane = Node::new(Role::Pane);
|
||||
pane.set_author_id(PANE_ACCESSIBILITY_ID.to_owned());
|
||||
pane.set_label(PANE_LABEL.to_owned());
|
||||
pane.set_description(PANE_DESCRIPTION.to_owned());
|
||||
pane.set_selected(true);
|
||||
|
||||
let update = TreeUpdate {
|
||||
nodes: vec![(workspace_id, workspace), (pane_id, pane)],
|
||||
tree: Some(Tree::new(workspace_id)),
|
||||
tree_id: TreeId::ROOT,
|
||||
focus: pane_id,
|
||||
};
|
||||
let pane = &update.nodes[1].1;
|
||||
|
||||
assert_eq!(
|
||||
update.tree.as_ref().map(|tree| tree.root),
|
||||
Some(workspace_id)
|
||||
);
|
||||
assert_eq!(update.focus, pane_id);
|
||||
assert_eq!(update.nodes[0].1.role(), Role::Region);
|
||||
assert_eq!(update.nodes[0].1.label(), Some(WORKSPACE_LABEL));
|
||||
assert_eq!(pane.role(), Role::Pane);
|
||||
assert_eq!(pane.author_id(), Some(PANE_ACCESSIBILITY_ID));
|
||||
assert_eq!(pane.label(), Some(PANE_LABEL));
|
||||
assert_eq!(pane.description(), Some(PANE_DESCRIPTION));
|
||||
assert_eq!(pane.is_selected(), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_selected_composed_accent_without_splitting_utf8() {
|
||||
let mut buffer = TextBuffer {
|
||||
text: "Cafe\u{301}".to_owned(),
|
||||
selection: 0..0,
|
||||
marked: None,
|
||||
};
|
||||
buffer.set_selection_utf16(3..5);
|
||||
|
||||
buffer.replace_text_in_range(None, "é");
|
||||
|
||||
assert_eq!(buffer.text, "Café");
|
||||
assert_eq!(buffer.selection_utf16(), 4..4);
|
||||
assert!(buffer.text.is_char_boundary(buffer.selection.start));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composed_ime_and_multi_codepoint_grapheme_preserve_selection() {
|
||||
let grapheme = "👩🏽💻";
|
||||
let grapheme_utf16_len = grapheme.encode_utf16().count();
|
||||
let mut buffer = TextBuffer::default();
|
||||
|
||||
buffer.replace_and_mark_text_in_range(Some(0..0), "e\u{301}", Some(2..2));
|
||||
assert_eq!(buffer.text, "e\u{301}");
|
||||
assert_eq!(buffer.marked_utf16(), Some(0..2));
|
||||
assert_eq!(buffer.selection_utf16(), 2..2);
|
||||
|
||||
buffer.replace_text_in_range(None, "é");
|
||||
assert_eq!(buffer.text, "é");
|
||||
assert_eq!(buffer.marked_utf16(), None);
|
||||
assert_eq!(buffer.selection_utf16(), 1..1);
|
||||
|
||||
buffer.set_selection_utf16(0..1);
|
||||
buffer.replace_text_in_range(None, grapheme);
|
||||
|
||||
assert_eq!(buffer.text, grapheme);
|
||||
assert_eq!(
|
||||
buffer.selection_utf16(),
|
||||
grapheme_utf16_len..grapheme_utf16_len
|
||||
);
|
||||
assert!(buffer.text.is_char_boundary(buffer.selection.start));
|
||||
|
||||
buffer.set_selection_utf16(0..grapheme_utf16_len);
|
||||
buffer.replace_text_in_range(None, "done");
|
||||
assert_eq!(buffer.text, "done");
|
||||
assert_eq!(buffer.selection_utf16(), 4..4);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user