mirror of
https://github.com/emilk/egui.git
synced 2026-09-03 15:20:05 -04:00
Improve robustness of text input handling for eframe/web (#8045)
<!--
Please read the "Making a PR" section of
[`CONTRIBUTING.md`](https://github.com/emilk/egui/blob/main/CONTRIBUTING.md)
before opening a Pull Request!
* Keep your PR:s small and focused.
* The PR title is what ends up in the changelog, so make it descriptive!
* If applicable, add a screenshot or gif.
* If it is a non-trivial addition, consider adding a demo for it to
`egui_demo_lib`, or a new example.
* Do NOT open PR:s from your `master` branch, as that makes it hard for
maintainers to test and add commits to your PR.
* Remember to run `cargo fmt` and `cargo clippy`.
* Open the PR as a draft until you have self-reviewed it and run
`./scripts/check.sh`.
* When you have addressed a PR comment, mark it as resolved.
Please be patient! I will review your PR, but my time is limited!
-->
* Fix [the Samsung Keyboard Korean Cheonjiin layout
bug](https://github.com/emilk/egui/pull/7967#issuecomment-4098503570)
* Partially fix (does not close) #8046
* Supersedes #7914
* Supersedes #8047
* Related: #8068
* Related: #7983
* Related: #8078
* [x] I have followed the instructions in the PR template
This PR reworks the text input handling logic in eframe's web
integration, primarily in `text_agent.rs`.
It also adds a new `ImeEvent::DeleteSurrounding` variant, along with the
corresponding handling logic in `egui` to support the changes.
## Fix: Samsung Keyboard Cheonjiin issue
This PR fixes a bug reported by @rustbasic when using Samsung Keyboard's
Cheonjiin Korean layout.
Since Samsung Keyboard is only available on Samsung devices, I wasn't
able to verify it myself. The fix is based on @rustbasic's testing and
confirmation.
The root cause is that the layout relies on the preceding text to
correctly handle batchim composition. Previously, the text agent eagerly
cleared the text input after every IME composition, removing the context
too early. This PR makes that cleanup more conservative, preserving the
text when it may still be needed.
My understanding is that this is a quirk specific to Samsung Keyboard:
The IME reports that composition has finished even though it is
effectively still active and the composed text may continue to change.
## Fix: Keystrokes resetting keyboard layout (numpad/symbols/etc.)
Partially fixes #8046.
Previously, keystrokes would cause the on-screen keyboard to switch back
to its primary layout.
One remaining issue is that tapping within the active `TextEdit` to
reposition the cursor still resets the keyboard to its primary layout.
## About text suggestions
I originally planned to include text suggestion support in this PR
because #8068 implemented it.
However, adding text suggestion support would broaden the scope of this
PR, so I think it is better addressed in a separate PR.
For reference, [the reverted
implementation](8a4f70859c)
works fine on Android (Gboard), but not on iOS (iPadOS 17 + SwiftKey).
This commit is contained in:
@@ -22,6 +22,15 @@ pub enum ImeEvent {
|
||||
/// The IME is considered dismissed after this event.
|
||||
Commit(String),
|
||||
|
||||
/// Notifies when the text surrounding the cursor should be deleted.
|
||||
///
|
||||
/// `before_chars` and `after_chars` are the number of characters (not
|
||||
/// bytes) to delete before and after the cursor, respectively.
|
||||
DeleteSurrounding {
|
||||
before_chars: usize,
|
||||
after_chars: usize,
|
||||
},
|
||||
|
||||
/// Notifies when the IME was disabled.
|
||||
#[deprecated = "No longer used by egui"]
|
||||
Disabled,
|
||||
|
||||
@@ -82,6 +82,9 @@ impl FullOutput {
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
|
||||
pub struct IMEOutput {
|
||||
/// IME's purpose.
|
||||
pub purpose: crate::IMEPurpose,
|
||||
|
||||
/// Where the [`crate::TextEdit`] is located on screen.
|
||||
pub rect: crate::Rect,
|
||||
|
||||
|
||||
@@ -5,9 +5,10 @@ use epaint::text::{Galley, LayoutJob, TextWrapMode, cursor::CCursor};
|
||||
|
||||
use crate::{
|
||||
Align, Align2, AsIdSalt, AtomExt as _, AtomKind, AtomLayout, Atoms, Color32, Context,
|
||||
CursorIcon, Event, EventFilter, FontSelection, Frame, Id, IdSalt, ImeEvent, IntoAtoms,
|
||||
IntoSizedResult, Key, KeyboardShortcut, Margin, Modifiers, NumExt as _, Response, Sense,
|
||||
SizedAtomKind, TextBuffer, TextStyle, Ui, Vec2, Widget, WidgetInfo, WidgetWithState, epaint,
|
||||
CursorIcon, Event, EventFilter, FontSelection, Frame, IMEPurpose, Id, IdSalt, ImeEvent,
|
||||
IntoAtoms, IntoSizedResult, Key, KeyboardShortcut, Margin, Modifiers, NumExt as _, Response,
|
||||
Sense, SizedAtomKind, TextBuffer, TextStyle, Ui, Vec2, Widget, WidgetInfo, WidgetWithState,
|
||||
epaint,
|
||||
os::OperatingSystem,
|
||||
output::OutputEvent,
|
||||
response,
|
||||
@@ -527,6 +528,19 @@ impl TextEdit<'_> {
|
||||
let mut cursor_range = None;
|
||||
let mut prev_cursor_range = None;
|
||||
|
||||
let owns_ime_events = ui.memory(|mem| mem.owns_ime_events(id));
|
||||
if !owns_ime_events {
|
||||
state.cursor_purpose = TextEditCursorPurpose::Selection;
|
||||
if !state.cursor.is_empty() {
|
||||
state.cursor.set_char_range(
|
||||
state
|
||||
.cursor
|
||||
.char_range()
|
||||
.map(|r| CCursorRange::one(r.primary)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let mut text_changed = false;
|
||||
let text_mutable = text.is_mutable();
|
||||
|
||||
@@ -547,14 +561,17 @@ impl TextEdit<'_> {
|
||||
text,
|
||||
galley,
|
||||
layouter,
|
||||
id,
|
||||
wrap_width,
|
||||
multiline,
|
||||
password,
|
||||
default_cursor_range,
|
||||
char_limit,
|
||||
event_filter,
|
||||
return_key,
|
||||
&EventsOptions {
|
||||
id,
|
||||
wrap_width,
|
||||
multiline,
|
||||
password,
|
||||
default_cursor_range,
|
||||
owns_ime_events,
|
||||
char_limit,
|
||||
event_filter,
|
||||
return_key,
|
||||
},
|
||||
);
|
||||
|
||||
if changed {
|
||||
@@ -772,6 +789,7 @@ impl TextEdit<'_> {
|
||||
|
||||
if did_interact || response.clicked() {
|
||||
ui.memory_mut(|mem| mem.request_focus(response.id));
|
||||
state.cursor_purpose = TextEditCursorPurpose::Selection;
|
||||
|
||||
state.last_interaction_time = ui.input(|i| i.time);
|
||||
}
|
||||
@@ -907,6 +925,11 @@ impl TextEdit<'_> {
|
||||
.unwrap_or_default();
|
||||
ui.output_mut(|o| {
|
||||
o.ime = Some(crate::output::IMEOutput {
|
||||
purpose: if password {
|
||||
IMEPurpose::Password
|
||||
} else {
|
||||
IMEPurpose::Normal
|
||||
},
|
||||
rect: to_global * inner_rect,
|
||||
cursor_rect: to_global * primary_cursor_rect,
|
||||
should_interrupt_composition: false,
|
||||
@@ -993,23 +1016,41 @@ fn mask_if_password(is_password: bool, text: &str) -> String {
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// Bundles parameters for [`events`] to avoid `clippy::too_many_arguments` and
|
||||
/// `clippy::fn_params_excessive_bools`.
|
||||
struct EventsOptions {
|
||||
id: Id,
|
||||
wrap_width: f32,
|
||||
multiline: bool,
|
||||
password: bool,
|
||||
default_cursor_range: CCursorRange,
|
||||
owns_ime_events: bool,
|
||||
char_limit: usize,
|
||||
event_filter: EventFilter,
|
||||
return_key: Option<KeyboardShortcut>,
|
||||
}
|
||||
|
||||
/// Check for (keyboard) events to edit the cursor and/or text.
|
||||
#[expect(clippy::too_many_arguments)]
|
||||
fn events(
|
||||
ui: &crate::Ui,
|
||||
state: &mut TextEditState,
|
||||
text: &mut dyn TextBuffer,
|
||||
galley: &mut Arc<Galley>,
|
||||
layouter: &mut dyn FnMut(&Ui, &dyn TextBuffer, f32) -> Arc<Galley>,
|
||||
id: Id,
|
||||
wrap_width: f32,
|
||||
multiline: bool,
|
||||
password: bool,
|
||||
default_cursor_range: CCursorRange,
|
||||
char_limit: usize,
|
||||
event_filter: EventFilter,
|
||||
return_key: Option<KeyboardShortcut>,
|
||||
opts: &EventsOptions,
|
||||
) -> (bool, CCursorRange) {
|
||||
let EventsOptions {
|
||||
id,
|
||||
wrap_width,
|
||||
multiline,
|
||||
password,
|
||||
default_cursor_range,
|
||||
owns_ime_events,
|
||||
char_limit,
|
||||
event_filter,
|
||||
return_key,
|
||||
} = *opts;
|
||||
|
||||
let os = ui.os();
|
||||
|
||||
let mut cursor_range = state.cursor.range(galley).unwrap_or(default_cursor_range);
|
||||
@@ -1031,9 +1072,13 @@ fn events(
|
||||
|
||||
let events = ui.input(|i| i.filtered_events(&event_filter));
|
||||
|
||||
let owns_ime_events = ui.memory(|mem| mem.owns_ime_events(id));
|
||||
if !owns_ime_events {
|
||||
state.cursor_purpose = TextEditCursorPurpose::Selection;
|
||||
enum CursorMutation {
|
||||
Selection(CCursorRange),
|
||||
ImeComposition {
|
||||
cursor_range: CCursorRange,
|
||||
active_range: Option<std::ops::Range<CCursor>>,
|
||||
},
|
||||
ImeCompositionCursorRange(CCursorRange),
|
||||
}
|
||||
|
||||
for event in &events {
|
||||
@@ -1052,7 +1097,9 @@ fn events(
|
||||
None
|
||||
} else {
|
||||
copy_if_not_password(ui, cursor_range.slice_str(text.as_str()).to_owned());
|
||||
Some(CCursorRange::one(text.delete_selected(&cursor_range)))
|
||||
Some(CursorMutation::Selection(CCursorRange::one(
|
||||
text.delete_selected(&cursor_range),
|
||||
)))
|
||||
}
|
||||
}
|
||||
Event::Paste(text_to_insert) => {
|
||||
@@ -1067,7 +1114,7 @@ fn events(
|
||||
text.insert_text_at(&mut ccursor, &single_line, char_limit);
|
||||
}
|
||||
|
||||
Some(CCursorRange::one(ccursor))
|
||||
Some(CursorMutation::Selection(CCursorRange::one(ccursor)))
|
||||
}
|
||||
}
|
||||
Event::Text(text_to_insert) => {
|
||||
@@ -1077,7 +1124,7 @@ fn events(
|
||||
|
||||
text.insert_text_at(&mut ccursor, text_to_insert, char_limit);
|
||||
|
||||
Some(CCursorRange::one(ccursor))
|
||||
Some(CursorMutation::Selection(CCursorRange::one(ccursor)))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -1095,7 +1142,7 @@ fn events(
|
||||
} else {
|
||||
text.insert_text_at(&mut ccursor, "\t", char_limit);
|
||||
}
|
||||
Some(CCursorRange::one(ccursor))
|
||||
Some(CursorMutation::Selection(CCursorRange::one(ccursor)))
|
||||
}
|
||||
Event::Key {
|
||||
key,
|
||||
@@ -1110,7 +1157,7 @@ fn events(
|
||||
let mut ccursor = text.delete_selected(&cursor_range);
|
||||
text.insert_text_at(&mut ccursor, "\n", char_limit);
|
||||
// TODO(emilk): if code editor, auto-indent by same leading tabs, + one if the lines end on an opening bracket
|
||||
Some(CCursorRange::one(ccursor))
|
||||
Some(CursorMutation::Selection(CCursorRange::one(ccursor)))
|
||||
} else {
|
||||
ui.memory_mut(|mem| mem.surrender_focus(id)); // End input with enter
|
||||
break;
|
||||
@@ -1132,7 +1179,7 @@ fn events(
|
||||
.redo(&(cursor_range, text.as_str().to_owned()))
|
||||
{
|
||||
text.replace_with(redo_txt);
|
||||
Some(*redo_ccursor_range)
|
||||
Some(CursorMutation::Selection(*redo_ccursor_range))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -1150,7 +1197,7 @@ fn events(
|
||||
.undo(&(cursor_range, text.as_str().to_owned()))
|
||||
{
|
||||
text.replace_with(undo_txt);
|
||||
Some(*undo_ccursor_range)
|
||||
Some(CursorMutation::Selection(*undo_ccursor_range))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -1161,8 +1208,8 @@ fn events(
|
||||
key,
|
||||
pressed: true,
|
||||
..
|
||||
} => check_for_mutating_key_press(os, &cursor_range, text, galley, modifiers, *key),
|
||||
|
||||
} => check_for_mutating_key_press(os, &cursor_range, text, galley, modifiers, *key)
|
||||
.map(CursorMutation::Selection),
|
||||
Event::Ime(ime_event) if owns_ime_events => {
|
||||
/// Both `ImeEvent::Preedit("")` and `ImeEvent::Commit("")`
|
||||
/// might be emitted from different integrations to signify that
|
||||
@@ -1235,22 +1282,20 @@ fn events(
|
||||
text: preedit_text,
|
||||
active_range_chars,
|
||||
} => {
|
||||
state.cursor_purpose = if preedit_text.is_empty() {
|
||||
TextEditCursorPurpose::Selection
|
||||
let mut ccursor = clear_preedit_text(text, &cursor_range);
|
||||
|
||||
if preedit_text.is_empty() {
|
||||
Some(CursorMutation::Selection(CCursorRange::one(ccursor)))
|
||||
} else {
|
||||
TextEditCursorPurpose::ImeComposition {
|
||||
let start_cursor = ccursor;
|
||||
text.insert_text_at(&mut ccursor, preedit_text, char_limit);
|
||||
Some(CursorMutation::ImeComposition {
|
||||
cursor_range: CCursorRange::two(start_cursor, ccursor),
|
||||
active_range: active_range_chars.clone().map(|range| {
|
||||
CCursor::new(range.start)..CCursor::new(range.end)
|
||||
}),
|
||||
}
|
||||
};
|
||||
let mut ccursor = clear_preedit_text(text, &cursor_range);
|
||||
|
||||
let start_cursor = ccursor;
|
||||
if !preedit_text.is_empty() {
|
||||
text.insert_text_at(&mut ccursor, preedit_text, char_limit);
|
||||
})
|
||||
}
|
||||
Some(CCursorRange::two(start_cursor, ccursor))
|
||||
}
|
||||
ImeEvent::Commit(commit_text) => {
|
||||
state.cursor_purpose = TextEditCursorPurpose::Selection;
|
||||
@@ -1260,22 +1305,43 @@ fn events(
|
||||
text.insert_text_at(&mut ccursor, commit_text, char_limit);
|
||||
}
|
||||
|
||||
Some(CCursorRange::one(ccursor))
|
||||
Some(CursorMutation::Selection(CCursorRange::one(ccursor)))
|
||||
}
|
||||
ImeEvent::DeleteSurrounding {
|
||||
before_chars,
|
||||
after_chars,
|
||||
} => Some(CursorMutation::ImeCompositionCursorRange(
|
||||
text.delete_surrounding_chars(cursor_range, *before_chars, *after_chars),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(new_ccursor_range) = did_mutate_text {
|
||||
if let Some(cursor_mutation) = did_mutate_text {
|
||||
any_change = true;
|
||||
|
||||
// Layout again to avoid frame delay, and to keep `text` and `galley` in sync.
|
||||
*galley = layouter(ui, text, wrap_width);
|
||||
|
||||
// Set cursor_range using new galley:
|
||||
cursor_range = new_ccursor_range;
|
||||
match cursor_mutation {
|
||||
CursorMutation::Selection(new_cursor_range) => {
|
||||
cursor_range = new_cursor_range;
|
||||
state.cursor_purpose = TextEditCursorPurpose::Selection;
|
||||
}
|
||||
CursorMutation::ImeComposition {
|
||||
cursor_range: new_cursor_range,
|
||||
active_range,
|
||||
} => {
|
||||
cursor_range = new_cursor_range;
|
||||
state.cursor_purpose = TextEditCursorPurpose::ImeComposition { active_range };
|
||||
}
|
||||
CursorMutation::ImeCompositionCursorRange(new_cursor_range) => {
|
||||
cursor_range = new_cursor_range;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -154,6 +154,30 @@ pub trait TextBuffer {
|
||||
self.delete_selected_ccursor_range([min_ccursor, max_ccursor])
|
||||
}
|
||||
|
||||
/// Deletes characters surrounding the current cursor range.
|
||||
///
|
||||
/// Removes `before_chars` characters before the selection start and
|
||||
/// `after_chars` characters after the selection end.
|
||||
/// The returned [`CCursorRange`] is adjusted to account for the removed
|
||||
/// characters before the selection.
|
||||
fn delete_surrounding_chars(
|
||||
&mut self,
|
||||
mut cursor_range: CCursorRange,
|
||||
before_chars: usize,
|
||||
after_chars: usize,
|
||||
) -> CCursorRange {
|
||||
let [min, max] = cursor_range.sorted_cursors();
|
||||
if after_chars > 0 {
|
||||
self.delete_selected_ccursor_range([max, max + after_chars]);
|
||||
}
|
||||
if before_chars > 0 {
|
||||
self.delete_selected_ccursor_range([min - before_chars, min]);
|
||||
cursor_range.primary -= before_chars;
|
||||
cursor_range.secondary -= before_chars;
|
||||
}
|
||||
cursor_range
|
||||
}
|
||||
|
||||
fn delete_paragraph_before_cursor(
|
||||
&mut self,
|
||||
galley: &Galley,
|
||||
@@ -320,3 +344,109 @@ impl TextBuffer for &str {
|
||||
std::any::TypeId::of::<&str>()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn txt_n_sel(input: &str) -> (String, CCursorRange) {
|
||||
assert!(
|
||||
input.matches('[').count() == 1 && input.matches(']').count() == 1,
|
||||
"`input` must contain exactly one `[` and one `]` to indicate the selection (cursor range)"
|
||||
);
|
||||
let mut primary_index = input.chars().position(|c| c == ']').unwrap();
|
||||
let mut secondary_index = input.chars().position(|c| c == '[').unwrap();
|
||||
let text = input.replace(['[', ']'], "");
|
||||
if primary_index > secondary_index {
|
||||
primary_index -= 1;
|
||||
} else {
|
||||
secondary_index -= 1;
|
||||
}
|
||||
let cursor_range = CCursorRange {
|
||||
primary: CCursor::new(primary_index),
|
||||
secondary: CCursor::new(secondary_index),
|
||||
h_pos: None,
|
||||
};
|
||||
(text, cursor_range)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_txt_n_sel() {
|
||||
assert_eq!(
|
||||
txt_n_sel("<<L[]R>>"),
|
||||
("<<LR>>".to_owned(), CCursorRange::one(CCursor::new(3)))
|
||||
);
|
||||
assert_eq!(
|
||||
txt_n_sel("<<L[_]R>>"),
|
||||
(
|
||||
"<<L_R>>".to_owned(),
|
||||
CCursorRange::two(CCursor::new(3), CCursor::new(4))
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
txt_n_sel("<<左[_]右>>"),
|
||||
(
|
||||
"<<左_右>>".to_owned(),
|
||||
CCursorRange::two(CCursor::new(3), CCursor::new(4))
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
txt_n_sel("<<L]_[R>>"),
|
||||
(
|
||||
"<<L_R>>".to_owned(),
|
||||
CCursorRange::two(CCursor::new(4), CCursor::new(3))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_surrounding_chars() {
|
||||
fn test_case(
|
||||
(mut input_text, input_cursor_range): (String, CCursorRange),
|
||||
before_chars: usize,
|
||||
after_chars: usize,
|
||||
(expected_text, expected_cursor_range): (String, CCursorRange),
|
||||
) {
|
||||
let new_cursor_range =
|
||||
input_text.delete_surrounding_chars(input_cursor_range, before_chars, after_chars);
|
||||
assert_eq!(input_text, expected_text);
|
||||
assert_eq!(new_cursor_range, expected_cursor_range);
|
||||
}
|
||||
|
||||
// 1 byte per char
|
||||
test_case(txt_n_sel("<<L[]R>>"), 1, 1, txt_n_sel("<<[]>>"));
|
||||
test_case(txt_n_sel("<<L[_]R>>"), 1, 0, txt_n_sel("<<[_]R>>"));
|
||||
test_case(txt_n_sel("<<L[_]R>>"), 0, 1, txt_n_sel("<<L[_]>>"));
|
||||
test_case(txt_n_sel("<<L[_]R>>"), 1, 1, txt_n_sel("<<[_]>>"));
|
||||
test_case(txt_n_sel("<<L[__]R>>"), 1, 1, txt_n_sel("<<[__]>>"));
|
||||
test_case(txt_n_sel("<<LL[_]RR>>"), 2, 2, txt_n_sel("<<[_]>>"));
|
||||
test_case(txt_n_sel("<<L]_[R>>"), 1, 0, txt_n_sel("<<]_[R>>"));
|
||||
test_case(txt_n_sel("<<L]_[R>>"), 0, 1, txt_n_sel("<<L]_[>>"));
|
||||
test_case(txt_n_sel("<<L]_[R>>"), 1, 1, txt_n_sel("<<]_[>>"));
|
||||
|
||||
// 2 bytes per char: `˻` = `0xCB 0xBB`, `˼` = `0xCB 0xBC`
|
||||
test_case(txt_n_sel("<<˻[]˼>>"), 1, 1, txt_n_sel("<<[]>>"));
|
||||
test_case(txt_n_sel("<<˻[_]˼>>"), 1, 0, txt_n_sel("<<[_]˼>>"));
|
||||
test_case(txt_n_sel("<<˻[_]˼>>"), 0, 1, txt_n_sel("<<˻[_]>>"));
|
||||
test_case(txt_n_sel("<<˻[_]˼>>"), 1, 1, txt_n_sel("<<[_]>>"));
|
||||
test_case(txt_n_sel("<<˻[__]˼>>"), 1, 1, txt_n_sel("<<[__]>>"));
|
||||
test_case(txt_n_sel("<<˻˻[_]˼˼>>"), 2, 2, txt_n_sel("<<[_]>>"));
|
||||
test_case(txt_n_sel("<<˻]_[˼>>"), 1, 0, txt_n_sel("<<]_[˼>>"));
|
||||
test_case(txt_n_sel("<<˻]_[˼>>"), 0, 1, txt_n_sel("<<˻]_[>>"));
|
||||
test_case(txt_n_sel("<<˻]_[˼>>"), 1, 1, txt_n_sel("<<]_[>>"));
|
||||
|
||||
// 3 bytes per char: `左` = `0xE5 0xB7 0xA6`, `右` = `0xE5 0x8F 0xB3`
|
||||
test_case(txt_n_sel("<<左[]右>>"), 1, 1, txt_n_sel("<<[]>>"));
|
||||
test_case(txt_n_sel("<<左[_]右>>"), 1, 0, txt_n_sel("<<[_]右>>"));
|
||||
test_case(txt_n_sel("<<左[_]右>>"), 0, 1, txt_n_sel("<<左[_]>>"));
|
||||
test_case(txt_n_sel("<<左[_]右>>"), 1, 1, txt_n_sel("<<[_]>>"));
|
||||
test_case(txt_n_sel("<<左[__]右>>"), 1, 1, txt_n_sel("<<[__]>>"));
|
||||
test_case(txt_n_sel("<<左左[_]右右>>"), 2, 2, txt_n_sel("<<[_]>>"));
|
||||
test_case(txt_n_sel("<<左]_[右>>"), 1, 0, txt_n_sel("<<]_[右>>"));
|
||||
test_case(txt_n_sel("<<左]_[右>>"), 0, 1, txt_n_sel("<<左]_[>>"));
|
||||
test_case(txt_n_sel("<<左]_[右>>"), 1, 1, txt_n_sel("<<]_[>>"));
|
||||
|
||||
// mixed
|
||||
test_case(txt_n_sel("<<L˻左[_]R˼右>>"), 3, 3, txt_n_sel("<<[_]>>"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user