Merge pull request #1990 from Joansitoh/fix/server-text-caret

🤖 Agent: Gate check by Ludovic's AI agent (Claude). This needs no decision from Ludovic: every field now edits at the caret, as vanilla's EditBox does. rdbt's line-break review: the book page is a MultiLineEditBox in 26.3 (javap), so I pushed MultilineTextField's own word rule. getPreviousWord/getNextWord split words at any Character.isWhitespace and start from the cursor clamped to the last character. The single-line EditBox.getWordPosition keeps counting spaces only. I also merged main in twice (multiplayer.rs labels, stations.rs). Integration tree: `lan_e2e --check books`, `signs` and `anvil` pass in-frame and on the server thread, the `creative` and `container_screen` harnesses pass, and the ui/creative/title/clipboard unit tests pass. Thanks @Joansitoh!
This commit is contained in:
Ludovic Marie
2026-10-26 16:15:00 +01:00
committed by GitHub
11 changed files with 939 additions and 400 deletions
+5
View File
@@ -346,6 +346,11 @@ mod tests {
assert_eq!(filter_text(s, true), "abc\nde\u{80}");
assert_eq!(strip_formatting("§aRed§r §zx§"), "Red §zx§");
assert_eq!(helper_clipboard_contents("§lone\r\ntwo"), "one\ntwo");
// TextFieldHelper.getClipboardContents runs before insertText's filter.
assert_eq!(
filter_text(&helper_clipboard_contents("§aHello"), false),
"Hello"
);
}
struct Field {
+40 -15
View File
@@ -1,7 +1,7 @@
//! Creative inventory (CreativeModeInventoryScreen): vanilla's tabs and their
//! contents as built by the game's own display generators
//! (`creative-inventory.json`), a 9x5 scrolling grid and the search tab.
use crate::{Result, items::ItemStack};
use crate::{Result, items::ItemStack, ui::EditBox};
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path};
@@ -35,10 +35,8 @@ pub struct Creative {
pub scroll: f32,
/// The scrollbar is held (scrolling): dragging moves it.
pub scrolling: bool,
pub search: String,
/// When the search box took the focus (`EditBox.setFocused`): its
/// cursor blinks from then.
pub search_focused: std::time::Instant,
/// CreativeModeInventoryScreen's search box: an unbordered `EditBox` of 50.
pub search: EditBox,
/// The search tab's stacks, in its display order.
search_items: Vec<ItemStack>,
search_index: Vec<SearchEntry>,
@@ -284,8 +282,13 @@ impl Creative {
selected: 0,
scroll: 0.0,
scrolling: false,
search: String::new(),
search_focused: std::time::Instant::now(),
search: {
let mut search = EditBox::new(0.0, 0.0, 80.0, 9.0);
search.bordered = false;
search.max_length = 50;
search.text_color = u32::MAX;
search
},
search_items,
search_index,
}
@@ -306,12 +309,13 @@ impl Creative {
/// selectTab: a different tab empties the search box; the scrollbar goes to the top.
pub fn select(&mut self, tab: usize) {
if tab != self.selected {
self.search.clear();
self.search.set_value("");
}
self.selected = tab;
self.scroll = 0.0;
if self.tab().search {
self.search_focused = std::time::Instant::now();
self.search.focused = true;
self.search.reset_blink();
}
}
/// The search tab's stacks.
@@ -385,7 +389,7 @@ impl Creative {
let tab = self.tab();
if tab.search {
return self
.search_results(&self.search, names)
.search_results(&self.search.value, names)
.into_iter()
.map(|i| self.search_items[i].clone())
.collect();
@@ -754,13 +758,13 @@ mod tests {
(1, false, true),
(2, true, false),
] {
c.search = "anvil".into();
c.search.set_value("anvil");
c.scroll = 0.5;
c.select(selected);
assert_eq!(c.selected, selected);
assert_eq!(c.tab().inventory, inventory);
assert_eq!(c.tab().search, search);
assert!(c.search.is_empty());
assert!(c.search.value.is_empty());
assert_eq!(c.scroll, 0.0);
}
}
@@ -780,12 +784,12 @@ mod tests {
assert_eq!(c.tabs[0].items[0].item.as_ref(), "minecraft:anvil");
let mut c = c;
c.select(1);
c.search = "anvil".into();
c.search.set_value("anvil");
c.scroll = 0.5;
c.select(1);
assert_eq!((c.search.as_str(), c.scroll), ("anvil", 0.0));
assert_eq!((c.search.value.as_str(), c.scroll), ("anvil", 0.0));
c.select(0);
assert_eq!(c.search, "");
assert_eq!(c.search.value, "");
}
#[test]
@@ -899,4 +903,25 @@ mod tests {
c.scroll = 1.0;
assert_eq!(c.scroller_offset(), 95.0);
}
#[test]
fn search_edits_at_its_cursor() {
use winit::keyboard::KeyCode;
let mut c = creative(0);
c.select(1);
c.search.insert_text("hello world");
for _ in 0..5 {
assert!(c.search.key(KeyCode::ArrowLeft, false, false));
}
c.search.insert_text("X");
assert_eq!(c.search.value, "hello Xworld");
c.search.key(KeyCode::Home, false, true);
c.search.insert_text("Y");
assert_eq!(c.search.value, "Yworld");
// Ctrl+Left jumps a word; one step right and Backspace deletes before the cursor.
c.search.key(KeyCode::ArrowLeft, true, false);
c.search.key(KeyCode::ArrowRight, false, false);
c.search.key(KeyCode::Backspace, false, false);
assert_eq!(c.search.value, "world");
}
}
+48 -18
View File
@@ -5719,8 +5719,9 @@ impl Game<'_> {
if self.creative_screen.tab().search {
// CreativeModeInventoryScreen.slotClicked: moveCursorToEnd, then
// setHighlightPos(0), so typing replaces the whole query.
let search = &self.creative_screen.search;
self.text_selection.select(("creative.search", 0), search);
let search = &mut self.creative_screen.search;
search.move_cursor_to_end(false);
search.highlight = 0;
}
let items = self.creative_screen.items(&self.pack.names);
let first = self.creative_screen.first_row(&self.pack.names);
@@ -9846,20 +9847,16 @@ impl Game<'_> {
);
}
if tab.search {
// The search box: an unbordered EditBox of 80 at (82, 6), white.
let search = &self.creative_screen.search;
horizon_client::ui::append_field(
batch,
atlas,
search,
(ox + 82.0, oy + 6.0, 80.0),
(u32::MAX, true),
clipboard::CREATIVE_SEARCH_CHARS,
self.text_selection
.covers(clipboard::CREATIVE_SEARCH, search),
true,
self.creative_screen.search_focused.elapsed().as_millis(),
);
// CreativeModeInventoryScreen: an unbordered EditBox of 50 at (82, 6).
let search = &mut self.creative_screen.search;
search.x = ox + 82.0;
search.y = oy + 6.0;
search.width = 80.0;
search.bordered = false;
search.focused = true;
search.text_color = u32::MAX;
search.max_length = clipboard::CREATIVE_SEARCH_CHARS;
search.draw(batch, atlas, None);
}
let mut hovered: Option<ItemStack> = None;
if tab.inventory {
@@ -10020,6 +10017,9 @@ impl Game<'_> {
}
return;
}
if !right && self.creative_search_click(ox, oy) {
return;
}
// Holding the scrollbar scrolls as the mouse moves (mouseDragged).
if !right
&& self
@@ -10032,6 +10032,24 @@ impl Game<'_> {
self.inventory_press(right);
}
}
/// A click on the search tab's box (`EditBox.onClick`).
fn creative_search_click(&mut self, ox: f32, oy: f32) -> bool {
if !self.creative_screen.tab().search {
return false;
}
let (x, y, w, h) = (ox + 82.0, oy + 6.0, 80.0, 9.0);
let (mx, my) = self.cursor;
if mx < x || my < y || mx >= x + w || my >= y + h {
return false;
}
let shift = self.has_shift_down();
let search = &mut self.creative_screen.search;
search.x = x;
search.y = y;
search.width = w;
search.click(&self.gui.atlas, mx, shift, false);
true
}
/// Copy the live player and world state into the `Level` that gets saved.
pub(super) fn sync_level(&mut self) {
// Level.write saves the game rules (game_rules.dat) with the rest.
@@ -11677,8 +11695,17 @@ impl ApplicationHandler for Game<'_> {
// The hovered slot took the key (`ignoreTextInput`).
} else if let Some(shortcut) = self.edit_shortcut(&event) {
self.creative_search_shortcut(shortcut);
} else if event.physical_key == PhysicalKey::Code(KeyCode::Backspace) {
self.creative_search_backspace(self.has_control_down());
} else if let PhysicalKey::Code(key) = event.physical_key {
// EditBox.keyPressed: arrows, Home, End, Delete and Backspace.
let before = self.creative_screen.search.value.clone();
let (ctrl, shift) = (self.has_control_down(), self.has_shift_down());
if self.creative_screen.search.key(key, ctrl, shift) {
if self.creative_screen.search.value != before {
self.creative_screen.scroll = 0.0;
}
} else {
self.creative_search_edit(event.text.as_deref());
}
} else {
self.creative_search_edit(event.text.as_deref());
}
@@ -11699,6 +11726,9 @@ impl ApplicationHandler for Game<'_> {
// The anvil rename box takes every key but Escape (AnvilScreen.keyPressed).
if let Some(shortcut) = self.edit_shortcut(&event) {
self.station_shortcut(shortcut);
} else if let PhysicalKey::Code(key) = event.physical_key
&& self.station_key(key)
{
} else {
let backspace = event.physical_key == PhysicalKey::Code(KeyCode::Backspace);
self.station_text(event.text.as_deref(), backspace);
+346 -151
View File
@@ -17,7 +17,6 @@ use horizon_client::{
use horizon_core::position::BlockPos;
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
use horizon_world::level::flags as level_flags;
use std::time::Instant;
use winit::{
event::{ElementState, KeyEvent},
keyboard::{KeyCode, PhysicalKey},
@@ -27,8 +26,24 @@ const BLACK: u32 = 0xFF00_0000;
const PAGE_CHARS: usize = 1024;
const MAX_PAGES: usize = 100;
const LINES: usize = 14;
/// `BookSignScreen`'s title box.
const BOOK_TITLE: horizon_client::clipboard::FieldId = ("book.title", 0);
fn book_title_box() -> ui::EditBox {
let mut edit = ui::EditBox::new(0.0, 0.0, 114.0, 9.0);
edit.bordered = false;
edit.max_length = 15;
edit.text_color = BLACK;
edit.focused = true;
edit
}
fn book_page_box() -> ui::EditBox {
let mut edit = ui::EditBox::new(0.0, 0.0, 114.0, 9.0);
edit.bordered = false;
edit.multiline = true;
edit.max_length = PAGE_CHARS;
edit.text_color = BLACK;
edit.focused = true;
edit
}
pub(super) enum BookScreen {
Edit {
@@ -38,7 +53,10 @@ pub(super) enum BookScreen {
page: usize,
signing: bool,
title: String,
opened: Instant,
/// BookSignScreen's title box (`EditBox` of 15).
title_edit: ui::EditBox,
/// BookEditScreen's page (`MultiLineEditBox`).
page_edit: ui::EditBox,
},
View {
pages: Vec<String>,
@@ -84,13 +102,16 @@ impl Game<'_> {
if pages.is_empty() {
pages.push(String::new());
}
let mut page_edit = book_page_box();
page_edit.set_value(&pages[0]);
BookScreen::Edit {
slot: self.held_slot(),
pages,
page: 0,
signing: false,
title: String::new(),
opened: Instant::now(),
title_edit: book_title_box(),
page_edit,
}
}
"minecraft:written_book" => BookScreen::View {
@@ -239,34 +260,24 @@ impl Game<'_> {
Some(KeyCode::PageDown) => self.turn_page(true),
_ => {}
},
Some(BookScreen::Edit {
signing: true,
title,
..
}) => match key {
Some(BookScreen::Edit { signing: true, .. }) => match key {
Some(KeyCode::Escape) => {
if let Some(BookScreen::Edit { signing, .. }) = self.book.as_mut() {
*signing = false;
}
}
Some(KeyCode::Backspace) => {
if !self.text_selection.take(BOOK_TITLE, title) {
title.pop();
}
}
Some(KeyCode::Enter | KeyCode::NumpadEnter) => {
if !title.trim().is_empty() {
let blank = self.book.as_ref().is_some_and(
|b| matches!(b, BookScreen::Edit { title, .. } if title.trim().is_empty()),
);
if !blank {
self.save_book(true);
}
}
Some(key) if self.book_title_key(key) => {}
_ => {
if let Some(typed) = text {
self.text_selection.take(BOOK_TITLE, title);
for c in typed.chars().filter(|c| !c.is_control()) {
if title.chars().count() < 15 {
title.push(c);
}
}
self.book_title_insert(typed);
}
}
},
@@ -274,24 +285,13 @@ impl Game<'_> {
Some(KeyCode::Escape) => self.save_book(false),
Some(KeyCode::PageUp) => self.turn_page(false),
Some(KeyCode::PageDown) => self.turn_page(true),
Some(KeyCode::Backspace) => {
if !self.take_page_selection() {
self.edit_page(|p| {
p.pop();
})
}
}
Some(KeyCode::Enter | KeyCode::NumpadEnter) => {
self.take_page_selection();
self.type_into_page("\n")
}
Some(KeyCode::ArrowUp) => self.book_move_line(-1),
Some(KeyCode::ArrowDown) => self.book_move_line(1),
Some(KeyCode::Enter | KeyCode::NumpadEnter) => self.page_insert("\n"),
Some(key) if self.book_page_key(key) => {}
_ => {
if let Some(typed) = text {
let typed: String = typed.chars().filter(|c| !c.is_control()).collect();
if !typed.is_empty() {
self.take_page_selection();
}
self.type_into_page(&typed);
self.page_insert(typed);
}
}
},
@@ -302,99 +302,258 @@ impl Game<'_> {
pub(super) fn book_type(&mut self, text: &str) {
let text = text.replace("\\n", "\n");
match self.book.as_mut() {
Some(BookScreen::Edit {
signing: true,
title,
..
}) => {
for c in text.chars().filter(|c| !c.is_control()) {
if title.chars().count() < 15 {
title.push(c);
}
}
}
Some(BookScreen::Edit { .. }) => self.type_into_page(&text),
Some(BookScreen::Edit { signing: true, .. }) => self.book_title_insert(&text),
Some(BookScreen::Edit { .. }) => self.page_insert(&text),
_ => {}
}
}
/// Before typing on a page: Select All's selection is cleared (true).
fn take_page_selection(&mut self) -> bool {
match self.book.as_mut() {
Some(BookScreen::Edit { pages, page, .. }) => self
.text_selection
.take(("book.page", *page), &mut pages[*page]),
_ => false,
/// `BookSignScreen.keyPressed` on the title box.
fn book_title_key(&mut self, key: KeyCode) -> bool {
let (ctrl, shift) = (self.has_control_down(), self.has_shift_down());
let Some(BookScreen::Edit {
title, title_edit, ..
}) = self.book.as_mut()
else {
return false;
};
if !title_edit.key(key, ctrl, shift) {
return false;
}
title.clone_from(&title_edit.value);
true
}
/// Ctrl+A/C/V/X: the title box (an `EditBox` of 15) while signing, else
/// the page (`MultilineTextField.insertText`: line breaks kept, cut to
/// the 1024 characters left, rejected when it overflows the 14 lines).
pub(super) fn book_shortcut(&mut self, shortcut: horizon_client::clipboard::EditShortcut) {
let font = &self.gui.atlas.font;
match self.book.as_mut() {
Some(BookScreen::Edit {
signing: true,
title,
..
}) => {
Self::append_shortcut(
&mut self.text_selection,
&mut self.clipboard,
BOOK_TITLE,
title,
shortcut,
15,
);
}
Some(BookScreen::Edit { pages, page, .. }) => {
// truncateInsertionText counts the whole value, selection included.
let room = PAGE_CHARS.saturating_sub(pages[*page].chars().count());
self.text_selection.apply(
("book.page", *page),
&mut pages[*page],
shortcut,
&mut self.clipboard,
|value, pasted| {
let text: String = horizon_client::clipboard::filter_text(pasted, true)
.chars()
.take(room)
.collect();
let next = format!("{value}{text}");
font.split(&Text::literal(&next), 114.0).len() <= LINES && {
*value = next;
true
}
},
);
}
_ => {}
}
}
fn edit_page(&mut self, f: impl FnOnce(&mut String)) {
if let Some(BookScreen::Edit { pages, page, .. }) = self.book.as_mut() {
f(&mut pages[*page]);
}
}
/// MultiLineEditBox: characters that keep the page within 14 lines and 1024 characters.
fn type_into_page(&mut self, typed: &str) {
let Some(BookScreen::Edit { pages, page, .. }) = self.book.as_ref() else {
fn book_title_insert(&mut self, text: &str) {
let Some(BookScreen::Edit {
title, title_edit, ..
}) = self.book.as_mut()
else {
return;
};
let mut text = pages[*page].clone();
for c in typed.chars() {
let mut next = text.clone();
next.push(c);
if next.chars().count() <= PAGE_CHARS && self.book_lines(&next).len() <= LINES {
text = next;
title_edit.insert_text(text);
title.clone_from(&title_edit.value);
}
/// `BookEditScreen.keyPressed` on the page, apart from the line moves.
fn book_page_key(&mut self, key: KeyCode) -> bool {
let (ctrl, shift) = (self.has_control_down(), self.has_shift_down());
let before = match self.book.as_ref() {
Some(BookScreen::Edit { page_edit, .. }) => Some((
page_edit.value.clone(),
page_edit.cursor,
page_edit.highlight,
)),
_ => return false,
};
let handled = if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() {
page_edit.key(key, ctrl, shift)
} else {
false
};
if handled {
self.page_keep(before);
}
handled
}
/// One character at a time, so a page stops at the last character that fits.
fn page_insert(&mut self, text: &str) {
for c in text.chars() {
self.page_change(|edit| edit.insert_text(&c.to_string()));
}
}
/// Ctrl+A/C/V/X: the title box while signing, else the page.
pub(super) fn book_shortcut(&mut self, shortcut: horizon_client::clipboard::EditShortcut) {
let signing = matches!(self.book, Some(BookScreen::Edit { signing: true, .. }));
if signing {
if let Some(BookScreen::Edit {
title, title_edit, ..
}) = self.book.as_mut()
{
title_edit.shortcut(shortcut, &mut self.clipboard);
title.clone_from(&title_edit.value);
}
return;
}
let before = match self.book.as_ref() {
Some(BookScreen::Edit { page_edit, .. }) => Some((
page_edit.value.clone(),
page_edit.cursor,
page_edit.highlight,
)),
_ => None,
};
if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() {
page_edit.shortcut(shortcut, &mut self.clipboard);
}
self.page_keep(before);
}
/// Edit the page box, then keep it only within 14 lines and 1024 characters.
fn page_change(&mut self, f: impl FnOnce(&mut ui::EditBox)) -> bool {
let before = match self.book.as_ref() {
Some(BookScreen::Edit { page_edit, .. }) => Some((
page_edit.value.clone(),
page_edit.cursor,
page_edit.highlight,
)),
_ => return false,
};
let ran = if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() {
f(page_edit);
true
} else {
false
};
ran && self.page_keep(before)
}
/// Write the page, or put the previous text back when it no longer fits.
fn page_keep(&mut self, before: Option<(String, usize, usize)>) -> bool {
let next = match self.book.as_ref() {
Some(BookScreen::Edit { page_edit, .. }) => page_edit.value.clone(),
_ => return false,
};
let ok = next.chars().count() <= PAGE_CHARS && self.book_lines(&next).len() <= LINES;
let Some(BookScreen::Edit {
pages,
page,
page_edit,
..
}) = self.book.as_mut()
else {
return false;
};
if ok {
pages[*page].clone_from(&page_edit.value);
} else if let Some((value, cursor, highlight)) = before {
page_edit.value = value;
page_edit.cursor = cursor;
page_edit.highlight = highlight;
}
ok
}
/// ArrowUp and ArrowDown move a visual line, keeping the column.
fn book_move_line(&mut self, dir: isize) {
let shift = self.has_shift_down();
let (text, cursor) = match self.book.as_ref() {
Some(BookScreen::Edit {
pages,
page,
page_edit,
..
}) => (pages[*page].clone(), page_edit.cursor),
_ => return,
};
let rows = self.page_rows(&text);
let (x, line) = self.caret_on(&text, &rows, cursor);
let next = (line as isize + dir).clamp(0, rows.len() as isize - 1) as usize;
let (start, end) = rows[next];
let chars: Vec<char> = text.chars().collect();
let slice: String = chars[start..end].iter().collect();
let pos = start + self.gui.atlas.font.fit(&slice, x);
if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() {
page_edit.set_cursor(pos, shift);
}
}
/// Wrapped rows of a page as character ranges, the same breaks as `font.split`.
fn page_rows(&self, page: &str) -> Vec<(usize, usize)> {
let font = &self.gui.atlas.font;
let chars: Vec<char> = page.chars().collect();
let mut rows = Vec::new();
let mut start = 0;
while start < chars.len() {
let mut end = chars.len();
let mut next = chars.len();
let mut last_space = None;
for i in start..chars.len() {
if chars[i] == '\n' {
end = i;
next = i + 1;
break;
}
if chars[i] == ' ' {
last_space = Some(i);
}
let slice: String = chars[start..=i].iter().collect();
if font.width(&slice) > 114.0 {
match last_space {
Some(s) if s > start => {
end = s;
next = s + 1;
}
_ => {
end = i.max(start + 1);
next = end;
}
}
break;
}
}
rows.push((start, end));
start = next;
if start == chars.len() && chars.last() == Some(&'\n') {
rows.push((chars.len(), chars.len()));
}
}
self.edit_page(|p| *p = text);
if rows.is_empty() {
rows.push((0, 0));
}
rows
}
/// GUI x of the caret and the row it sits on.
fn caret_on(&self, page: &str, rows: &[(usize, usize)], cursor: usize) -> (f32, usize) {
let chars: Vec<char> = page.chars().collect();
for (i, &(start, end)) in rows.iter().enumerate() {
if cursor <= end || i + 1 == rows.len() {
let take = cursor.saturating_sub(start).min(end.saturating_sub(start));
let prefix: String = chars.iter().skip(start).take(take).collect();
return (self.gui.atlas.font.width(&prefix), i);
}
}
(0.0, 0)
}
fn page_index_at(&self, page: &str, local_x: f32, local_y: f32) -> usize {
let rows = self.page_rows(page);
let line = (local_y / 9.0).floor().max(0.0) as usize;
let line = line.min(rows.len().saturating_sub(1));
let (start, end) = rows[line];
let chars: Vec<char> = page.chars().collect();
let slice: String = chars[start..end].iter().collect();
start + self.gui.atlas.font.fit(&slice, local_x.max(0.0))
}
/// A click on the centred title (`EditBox.onClick`).
fn book_title_click(&mut self, field_x: f32, mx: f32) {
let shift = self.has_shift_down();
let Some(BookScreen::Edit {
title, title_edit, ..
}) = self.book.as_mut()
else {
return;
};
let tx = field_x + ((114.0 - self.gui.atlas.font.width(title)) / 2.0).floor();
let rel = (mx - tx).max(0.0);
let pos = self.gui.atlas.font.fit(title, rel);
title_edit.set_cursor(pos, shift);
}
/// A click on the open page.
fn book_page_click(&mut self, origin_x: f32, origin_y: f32, mx: f32, my: f32) {
let shift = self.has_shift_down();
let text = match self.book.as_ref() {
Some(BookScreen::Edit { pages, page, .. }) => pages[*page].clone(),
_ => return,
};
let pos = self.page_index_at(&text, mx - origin_x, my - origin_y);
if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() {
page_edit.set_cursor(pos, shift);
}
}
/// The page buttons (`pageForward` adds a page while editing).
fn turn_page(&mut self, forward: bool) {
let mut lectern_page = None;
match self.book.as_mut() {
Some(BookScreen::Edit { pages, page, .. }) => {
Some(BookScreen::Edit {
pages,
page,
page_edit,
..
}) => {
let before = *page;
if forward {
if *page + 1 >= pages.len() && pages.len() < MAX_PAGES {
pages.push(String::new());
@@ -405,6 +564,10 @@ impl Game<'_> {
} else if *page > 0 {
*page -= 1;
}
if *page != before {
let text = pages[*page].clone();
page_edit.set_value(&text);
}
}
Some(BookScreen::View {
lectern: Some(key), ..
@@ -764,6 +927,8 @@ impl Game<'_> {
if let Some(BookScreen::Edit { signing, .. }) = self.book.as_mut() {
*signing = false;
}
} else if inside(left + 36.0, 50.0, 114.0, 9.0) {
self.book_title_click(left + 36.0, mx);
}
}
Some(BookScreen::Edit { page, .. }) => {
@@ -780,6 +945,8 @@ impl Game<'_> {
} else if inside(w / 2.0 + 2.0, 196.0, 98.0, 20.0) {
click(self);
self.save_book(false);
} else if inside(left + 36.0, 32.0, 114.0, 14.0 * 9.0) {
self.book_page_click(left + 36.0, 32.0, mx, my);
}
}
Some(BookScreen::View {
@@ -865,7 +1032,7 @@ impl Game<'_> {
BookScreen::Edit {
signing: true,
title,
opened,
title_edit,
..
} => {
let edit = tr("book.editTitle", "Enter Book Title:");
@@ -881,19 +1048,35 @@ impl Game<'_> {
// The title box: an unbordered, centred EditBox of 114 with
// black text and no shadow; the value alone is centred, the
// cursor follows it.
let value = &title_edit.value;
let x = left + 36.0;
let tx = x + ((114.0 - HudBatch::text_width(atlas, title)) / 2.0).floor();
horizon_client::ui::append_field(
batch,
atlas,
title,
(tx, 50.0, x + 114.0 - tx),
(BLACK, false),
15,
self.text_selection.covers(BOOK_TITLE, title),
true,
opened.elapsed().as_millis(),
);
let tx = x + ((114.0 - HudBatch::text_width(atlas, value)) / 2.0).floor();
batch.text(atlas, value, tx, 50.0, BLACK, false);
if title_edit.cursor != title_edit.highlight {
let (a, b) = (
title_edit.cursor.min(title_edit.highlight),
title_edit.cursor.max(title_edit.highlight),
);
let pre: String = value.chars().take(a).collect();
let sel: String = value.chars().skip(a).take(b - a).collect();
let sx = tx + HudBatch::text_width(atlas, &pre);
batch.text_highlight(
atlas,
(sx, 49.0, HudBatch::text_width(atlas, &sel), 11.0),
true,
);
}
if title_edit.cursor_visible() {
let pre: String = value.chars().take(title_edit.cursor).collect();
batch.fill(
atlas,
tx + HudBatch::text_width(atlas, &pre),
49.0,
1.0,
9.0,
BLACK,
);
}
let by = tr("book.byAuthor", "by %1$s").replace("%1$s", super::chat::PLAYER_NAME);
let tw = HudBatch::text_width(atlas, &by);
batch.text(
@@ -941,12 +1124,12 @@ impl Game<'_> {
BookScreen::Edit {
pages,
page,
opened,
page_edit,
..
} => {
indicator(batch, *page, pages.len());
let blink = (opened.elapsed().as_millis() / 300) % 2 == 0;
let lines = self.book_lines(&pages[*page]);
let text = &pages[*page];
let lines = self.book_lines(text);
for (i, line) in lines.iter().take(LINES).enumerate() {
batch.text_styled(
atlas,
@@ -957,27 +1140,39 @@ impl Game<'_> {
false,
);
}
if blink {
// The cursor after the last character.
let last = lines
.last()
.map(|l| atlas.font.width_text(l))
.unwrap_or(0.0);
let row = lines.len().saturating_sub(1) as f32
+ if pages[*page].ends_with('\n') {
1.0
} else {
0.0
};
let x = if pages[*page].ends_with('\n') {
0.0
} else {
last
};
let rows = self.page_rows(text);
let (sel_a, sel_b) = (
page_edit.cursor.min(page_edit.highlight),
page_edit.cursor.max(page_edit.highlight),
);
if sel_a != sel_b {
let chars: Vec<char> = text.chars().collect();
for (i, &(start, end)) in rows.iter().enumerate().take(LINES) {
let from = sel_a.max(start);
let to = sel_b.min(end);
if from >= to {
continue;
}
let pre: String = chars[start..from].iter().collect();
let sel: String = chars[from..to].iter().collect();
batch.text_highlight(
atlas,
(
left + 36.0 + atlas.font.width(&pre),
top + 29.0 + i as f32 * 9.0,
atlas.font.width(&sel),
9.0,
),
true,
);
}
}
if page_edit.cursor_visible() {
let (x, row) = self.caret_on(text, &rows, page_edit.cursor);
batch.fill(
atlas,
left + 36.0 + x,
top + 29.0 + row * 9.0,
top + 29.0 + row as f32 * 9.0,
1.0,
9.0,
BLACK,
+13 -24
View File
@@ -63,8 +63,8 @@ impl Game<'_> {
}
}
}
/// Typing or a backspace in a field of the title screens: in the server
/// screens' append-only fields Select All's selection is replaced first.
/// Typing or a backspace in a title-screen field. A field that still only
/// appends replaces a Select All selection before the edit.
pub(super) fn title_edit(&mut self, typed: Option<&str>, backspace: bool) {
let Some(title) = self.title.as_mut() else {
return;
@@ -86,42 +86,31 @@ impl Game<'_> {
}
/// Shortcuts in the creative inventory's search box (an `EditBox` of 50).
pub(super) fn creative_search_shortcut(&mut self, shortcut: EditShortcut) {
if Self::append_shortcut(
&mut self.text_selection,
&mut self.clipboard,
CREATIVE_SEARCH,
&mut self.creative_screen.search,
shortcut,
CREATIVE_SEARCH_CHARS,
) {
if self
.creative_screen
.search
.shortcut(shortcut, &mut self.clipboard)
{
self.creative_screen.scroll = 0.0;
}
}
/// A backspace in the creative search box: with Ctrl, the whole word
/// before the caret (`EditBox.deleteWords(-1)`), otherwise one character.
/// A Select All selection is what it deletes, as in `EditBox.keyPressed`.
/// A selection is what it deletes, as in `EditBox.keyPressed`.
pub(super) fn creative_search_backspace(&mut self, ctrl: bool) {
let search = &mut self.creative_screen.search;
if !self.text_selection.take(CREATIVE_SEARCH, search) {
if ctrl {
horizon_client::ui::delete_words(search, -1);
} else {
search.pop();
}
}
self.creative_screen
.search
.key(KeyCode::Backspace, ctrl, false);
self.creative_screen.scroll = 0.0;
}
/// Typing in the creative search box.
pub(super) fn creative_search_edit(&mut self, typed: Option<&str>) {
let search = &mut self.creative_screen.search;
self.text_selection.take(CREATIVE_SEARCH, search);
if let Some(text) = typed {
clipboard::append_limited(search, text, CREATIVE_SEARCH_CHARS);
self.creative_screen.search.insert_text(text);
self.creative_screen.scroll = 0.0;
}
self.creative_screen.scroll = 0.0;
}
}
/// `CreativeModeInventoryScreen`'s search box: `setMaxLength(50)`.
pub(super) const CREATIVE_SEARCH_CHARS: usize = 50;
pub(super) const CREATIVE_SEARCH: horizon_client::clipboard::FieldId = ("creative.search", 0);
+225 -89
View File
@@ -6,9 +6,9 @@ use horizon_client::{
containers::BlockEntity,
hud::{HudBatch, rgba},
signs::wood_type,
ui::EditBox,
};
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
use std::time::Instant;
use winit::{
event::{ElementState, KeyEvent},
keyboard::{KeyCode, PhysicalKey},
@@ -18,7 +18,16 @@ pub(super) struct SignEditor {
pub pos: (i32, i32, i32),
pub front: bool,
pub line: usize,
pub opened: Instant,
/// The line being edited (`AbstractSignEditScreen`'s text field).
edit: EditBox,
}
fn sign_box() -> EditBox {
let mut edit = EditBox::new(0.0, 0.0, 90.0, 9.0);
edit.bordered = false;
edit.max_length = 384;
edit.focused = true;
edit
}
impl Game<'_> {
@@ -28,8 +37,9 @@ impl Game<'_> {
pos,
front,
line: 0,
opened: Instant::now(),
edit: sign_box(),
});
self.sign_load();
self.keys.clear();
self.right = false;
self.capture(false);
@@ -140,97 +150,154 @@ impl Game<'_> {
/// A key and the text it typed on the sign being edited (the keyboard,
/// or a controller's on-screen keyboard).
pub(super) fn sign_input(&mut self, key: Option<KeyCode>, typed: Option<&str>) {
let Some(edit) = self.sign_edit.as_mut() else {
if self.sign_edit.is_none() {
return;
};
let front = edit.front;
let id = sign_line_id(front, edit.line);
}
match key {
Some(KeyCode::Escape) => self.close_sign_editor(),
Some(KeyCode::ArrowUp) => edit.line = (edit.line + 3) & 3,
Some(KeyCode::ArrowUp) => self.sign_move_line(3),
Some(KeyCode::ArrowDown | KeyCode::Enter | KeyCode::NumpadEnter) => {
edit.line = (edit.line + 1) & 3
self.sign_move_line(1)
}
Some(KeyCode::Backspace) => {
let (line, key) = (edit.line, edit.pos);
if let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get_mut(&key) {
let text = if front {
&mut sign.front
} else {
&mut sign.back
};
let value = &mut text.lines[line];
if !self.text_selection.take(id, value) {
value.pop();
}
}
Some(
key @ (KeyCode::ArrowLeft
| KeyCode::ArrowRight
| KeyCode::Home
| KeyCode::End
| KeyCode::Backspace
| KeyCode::Delete),
) => {
let (ctrl, shift) = (self.has_control_down(), self.has_shift_down());
self.sign_change(|edit| {
edit.key(key, ctrl, shift);
});
}
_ => {
let Some(typed) = typed else {
return;
};
let line = edit.line;
let atlas = &self.gui.atlas;
let width = |s: &str| HudBatch::text_width(atlas, s);
let Some(key) = self.sign_edit.as_ref().map(|e| e.pos) else {
return;
};
if let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get_mut(&key) {
let max = sign.max_line_width();
let text = if front {
&mut sign.front
} else {
&mut sign.back
};
self.text_selection.take(id, &mut text.lines[line]);
for ch in typed.chars().filter(|c| !c.is_control()) {
let mut next = text.lines[line].clone();
next.push(ch);
// TextFieldHelper filter: font.width(s) <= maxTextLineWidth.
if width(&next) <= max {
text.lines[line] = next;
}
}
}
self.sign_change(|edit| edit.insert_text(typed));
}
}
}
/// `TextFieldHelper.keyPressed`'s shortcuts on the edited line: a paste
/// (formatting and carriage returns removed) is taken whole or not at
/// all, as the line must stay within the sign's width.
fn sign_shortcut(&mut self, shortcut: horizon_client::clipboard::EditShortcut) {
/// Up or down a line: the box writes the line it leaves and loads the next.
fn sign_move_line(&mut self, by: usize) {
self.sign_write();
if let Some(edit) = self.sign_edit.as_mut() {
edit.line = (edit.line + by) & 3;
}
self.sign_load();
}
/// The edited line's text.
fn sign_current(&self) -> Option<String> {
let edit = self.sign_edit.as_ref()?;
let BlockEntity::Sign(sign) = self.world.block_entities.get(&edit.pos)? else {
return None;
};
let side = if edit.front { &sign.front } else { &sign.back };
Some(side.lines[edit.line].clone())
}
/// `EditBox.setValue` from the line now being edited.
fn sign_load(&mut self) {
let Some(text) = self.sign_current() else {
return;
};
if let Some(edit) = self.sign_edit.as_mut() {
edit.edit.set_value(&text);
}
}
/// The box's text onto the sign, when it still fits the line.
fn sign_write(&mut self) {
let Some(edit) = self.sign_edit.as_ref() else {
return;
};
let (key, front, line) = (edit.pos, edit.front, edit.line);
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get_mut(&key) else {
let (pos, front, line, value) = (edit.pos, edit.front, edit.line, edit.edit.value.clone());
if !self.sign_fits(&value) {
return;
}
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get_mut(&pos) else {
return;
};
let max = sign.max_line_width();
let text = if front {
let side = if front {
&mut sign.front
} else {
&mut sign.back
};
let atlas = &self.gui.atlas;
self.text_selection.apply(
sign_line_id(front, line),
&mut text.lines[line],
shortcut,
&mut self.clipboard,
|value, pasted| {
let next = format!(
"{value}{}",
horizon_client::clipboard::helper_clipboard_contents(pasted)
);
HudBatch::text_width(atlas, &next) <= max && {
*value = next;
true
}
},
);
side.lines[line] = value;
}
/// Done button (`this.width / 2 - 100, this.height / 4 + 144`, 200x20).
/// TextFieldHelper: `font.width(s) <= maxTextLineWidth`.
fn sign_fits(&self, value: &str) -> bool {
let Some(edit) = self.sign_edit.as_ref() else {
return false;
};
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get(&edit.pos) else {
return false;
};
HudBatch::text_width(&self.gui.atlas, value) <= sign.max_line_width()
}
/// Edit the line, then keep the result only when it still fits.
fn sign_change(&mut self, f: impl FnOnce(&mut EditBox)) {
let before = self
.sign_edit
.as_ref()
.map(|e| (e.edit.value.clone(), e.edit.cursor, e.edit.highlight));
if let Some(edit) = self.sign_edit.as_mut() {
f(&mut edit.edit);
let clean = horizon_client::clipboard::strip_formatting(&edit.edit.value);
if clean != edit.edit.value {
let n = horizon_client::ui::char_len(&clean);
edit.edit.value = clean;
edit.edit.cursor = edit.edit.cursor.min(n);
edit.edit.highlight = edit.edit.highlight.min(n);
}
}
self.sign_keep(before);
}
/// Write the box when the line still fits; otherwise put the old text back.
fn sign_keep(&mut self, before: Option<(String, usize, usize)>) {
let value = self
.sign_edit
.as_ref()
.map(|e| e.edit.value.clone())
.unwrap_or_default();
if self.sign_fits(&value) {
self.sign_write();
} else if let (Some(edit), Some((value, cursor, highlight))) =
(self.sign_edit.as_mut(), before)
{
edit.edit.value = value;
edit.edit.cursor = cursor;
edit.edit.highlight = highlight;
}
}
/// `TextFieldHelper.keyPressed`'s shortcuts on the edited line. Paste
/// reads `getClipboardContents` before `insertText`. A paste that would
/// pass the sign's width is refused whole.
fn sign_shortcut(&mut self, shortcut: horizon_client::clipboard::EditShortcut) {
if shortcut == horizon_client::clipboard::EditShortcut::Paste {
let raw = horizon_client::clipboard::Clipboard::get(&mut self.clipboard);
let text = horizon_client::clipboard::helper_clipboard_contents(&raw);
self.sign_change(|edit| edit.insert_text(&text));
return;
}
let before = self
.sign_edit
.as_ref()
.map(|e| (e.edit.value.clone(), e.edit.cursor, e.edit.highlight));
if let Some(edit) = self.sign_edit.as_mut() {
edit.edit.shortcut(shortcut, &mut self.clipboard);
let clean = horizon_client::clipboard::strip_formatting(&edit.edit.value);
if clean != edit.edit.value {
let n = horizon_client::ui::char_len(&clean);
edit.edit.value = clean;
edit.edit.cursor = edit.edit.cursor.min(n);
edit.edit.highlight = edit.edit.highlight.min(n);
}
}
self.sign_keep(before);
}
/// Done button (`this.width / 2 - 100, this.height / 4 + 144`, 200x20),
/// or a click on a line (`EditBox.onClick`).
pub(super) fn sign_edit_click(&mut self, size: [u32; 2]) {
let b = HudBatch::new(size);
let (x, y) = (b.width / 2.0 - 100.0, b.height / 4.0 + 144.0);
@@ -238,7 +305,56 @@ impl Game<'_> {
if mx >= x && mx < x + 200.0 && my >= y && my < y + 20.0 {
self.audio.play_button_click();
self.close_sign_editor();
return;
}
let Some((cx, top, line_height, max_width, lines)) = self.sign_lines(b.width) else {
return;
};
let shift = self.has_shift_down();
for i in 0..lines.len() {
let ly = top + i as f32 * line_height;
let left = cx - max_width / 2.0;
if mx < left || mx >= left + max_width || my < ly || my >= ly + line_height {
continue;
}
let current = self.sign_edit.as_ref().map(|e| e.line);
if current != Some(i) {
self.sign_move_line((i + 4 - current.unwrap_or(0)) & 3);
}
let value = self
.sign_edit
.as_ref()
.map(|e| e.edit.value.clone())
.unwrap_or_default();
let lw = HudBatch::text_width(&self.gui.atlas, &value);
let text_x = cx - (lw as i32 / 2) as f32;
if let Some(edit) = self.sign_edit.as_mut() {
edit.edit.x = text_x;
edit.edit.y = ly;
edit.edit.width = max_width;
edit.edit.click(&self.gui.atlas, mx, shift, false);
}
self.sign_write();
return;
}
}
/// Centre x, the first line's y, the line height, the width limit and the four lines.
fn sign_lines(&self, width: f32) -> Option<(f32, f32, f32, f32, Vec<String>)> {
let edit = self.sign_edit.as_ref()?;
let BlockEntity::Sign(sign) = self.world.block_entities.get(&edit.pos)? else {
return None;
};
let text = if edit.front { &sign.front } else { &sign.back };
let line_height = sign.line_height();
let y = if sign.hanging { 125.0 } else { 90.0 };
let top = y - (4.0 * line_height / 2.0).floor();
Some((
width / 2.0,
top,
line_height,
sign.max_line_width(),
text.lines.to_vec(),
))
}
pub(super) fn draw_sign_editor(&self, batch: &mut HudBatch) {
let Some(edit) = &self.sign_edit else { return };
@@ -304,23 +420,48 @@ impl Game<'_> {
let color = rgba((color >> 16) as u8, (color >> 8) as u8, color as u8, 255);
let line_height = sign.line_height();
let midpoint = (4.0 * line_height / 2.0).floor();
let blink = (edit.opened.elapsed().as_millis() / 300) % 2 == 0;
for (i, line) in text.lines.iter().enumerate() {
let lw = HudBatch::text_width(atlas, line);
let shown = if i == edit.line {
edit.edit.value.as_str()
} else {
line.as_str()
};
let lw = HudBatch::text_width(atlas, shown);
let x = cx - (lw as i32 / 2) as f32;
let ly = y + i as f32 * line_height - midpoint;
batch.text(atlas, line, x, ly, color, false);
if i == edit.line
&& self
.text_selection
.covers(sign_line_id(edit.front, i), line)
{
// AbstractSignEditScreen: the selection from its start to its end.
batch.text_highlight(atlas, (x, ly, lw, line_height), true);
batch.text(atlas, shown, x, ly, color, false);
if i != edit.line {
continue;
}
if i == edit.line && blink {
// TextCursorUtils.extractAppendCursor: an underscore after the text.
batch.text(atlas, "_", x + lw, ly, color, false);
let value = &edit.edit.value;
if edit.edit.cursor != edit.edit.highlight {
let (a, b) = (
edit.edit.cursor.min(edit.edit.highlight),
edit.edit.cursor.max(edit.edit.highlight),
);
let pre: String = value.chars().take(a).collect();
let sel: String = value.chars().skip(a).take(b - a).collect();
batch.text_highlight(
atlas,
(
x + HudBatch::text_width(atlas, &pre),
ly,
HudBatch::text_width(atlas, &sel),
line_height,
),
true,
);
}
if edit.edit.cursor_visible() {
let pre: String = value.chars().take(edit.edit.cursor).collect();
batch.fill(
atlas,
x + HudBatch::text_width(atlas, &pre),
ly,
1.0,
line_height,
color,
);
}
}
let (bx, by) = (w / 2.0 - 100.0, h / 4.0 + 144.0);
@@ -344,8 +485,3 @@ impl Game<'_> {
);
}
}
/// The sign line being edited, for Select All.
fn sign_line_id(front: bool, line: usize) -> horizon_client::clipboard::FieldId {
(if front { "sign.front" } else { "sign.back" }, line)
}
+83 -34
View File
@@ -135,8 +135,10 @@ pub(super) struct Station {
pub(super) offers: EnchantOffers,
bookcases: i32,
pub(super) anvil: AnvilResult,
/// Anvil rename box text.
/// Anvil rename box text (`AnvilMenu.itemName`).
pub(super) name: String,
/// The box itself (`AnvilScreen`'s `EditBox` of 50, unbordered).
pub(super) rename: horizon_client::ui::EditBox,
/// Stonecutter: the menu's recipe list (horizon_client::menu), its
/// `selectedRecipeIndex` and the screen's first shown index.
pub(super) stone: horizon_client::menu::Stonecutter,
@@ -146,8 +148,6 @@ pub(super) struct Station {
anvil_last: Option<ItemStack>,
/// Anvil on a server: the name last sent (the client menu's `itemName`).
pub(super) anvil_sent: Option<String>,
/// When the screen opened: the rename box has the focus from then.
opened: std::time::Instant,
pub(super) last_sound: u64,
/// Villager trading state.
pub(super) merchant: Option<Box<super::trading::MerchantState>>,
@@ -169,12 +169,12 @@ impl Station {
bookcases: 0,
anvil: AnvilResult::default(),
name: String::new(),
rename: anvil_box(),
stone: Default::default(),
selected: None,
start: 0,
anvil_last: None,
anvil_sent: None,
opened: std::time::Instant::now(),
last_sound: 0,
merchant: None,
beacon: [None; 2],
@@ -868,6 +868,9 @@ impl Game<'_> {
if kind == Kind::Stonecutter && self.stonecutter_click(local) {
return true;
}
if kind == Kind::Anvil && !right && self.anvil_name_click(ox, oy) {
return true;
}
}
let Some(slot) = self.slot_at(&batch, self.cursor.0, self.cursor.1) else {
return true;
@@ -894,6 +897,8 @@ impl Game<'_> {
.as_ref()
.map(|i| stations::hover_name(&|id| self.pack.names.display_item(id), i))
.unwrap_or_default();
let name = s.name.clone();
s.rename.set_value(&name);
}
self.station_changed();
}
@@ -960,21 +965,29 @@ impl Game<'_> {
let Some(s) = self.station.as_mut() else {
return;
};
let cleared = self.text_selection.take(ANVIL_NAME, &mut s.name);
if backspace {
if !cleared {
s.name.pop();
}
s.rename.delete_chars(-1);
} else if let Some(t) = text {
for c in t.chars().filter(|c| !c.is_control()) {
if s.name.chars().count() < ANVIL_NAME_CHARS {
s.name.push(c);
}
}
s.rename.insert_text(t);
}
s.name.clone_from(&s.rename.value);
self.station_changed();
self.remote_anvil_rename();
}
/// `AnvilScreen.keyPressed`: the rename box handles the key.
pub(super) fn station_key(&mut self, key: winit::keyboard::KeyCode) -> bool {
let (ctrl, shift) = (self.has_control_down(), self.has_shift_down());
let Some(s) = self.station.as_mut() else {
return false;
};
if !s.rename.key(key, ctrl, shift) {
return false;
}
s.name.clone_from(&s.rename.value);
self.station_changed();
self.remote_anvil_rename();
true
}
/// Ctrl+A/C/V/X in the anvil's rename box (an `EditBox` of 50) or the
/// recipe book's search box.
pub(super) fn station_shortcut(&mut self, shortcut: horizon_client::clipboard::EditShortcut) {
@@ -985,18 +998,52 @@ impl Game<'_> {
let Some(s) = self.station.as_mut() else {
return;
};
if Self::append_shortcut(
&mut self.text_selection,
&mut self.clipboard,
ANVIL_NAME,
&mut s.name,
shortcut,
ANVIL_NAME_CHARS,
) {
if s.rename.shortcut(shortcut, &mut self.clipboard) {
s.name.clone_from(&s.rename.value);
self.station_changed();
self.remote_anvil_rename();
}
}
/// A click in the anvil's name box (`EditBox.onClick`).
fn anvil_name_click(&mut self, ox: f32, oy: f32) -> bool {
let open = self
.station
.as_ref()
.is_some_and(|s| s.kind == Kind::Anvil && s.inputs[0].is_some());
if !open {
return false;
}
let (x, y, w, h) = (ox + 62.0, oy + 24.0, 103.0, 12.0);
let (mx, my) = self.cursor;
if mx < x || my < y || mx >= x + w || my >= y + h {
return false;
}
let shift = self.has_shift_down();
let Some(s) = self.station.as_mut() else {
return false;
};
s.rename.x = x;
s.rename.y = y;
s.rename.width = w;
s.rename.click(&self.gui.atlas, mx, shift, false);
true
}
/// AnvilScreen's name box, drawn after the station picture.
fn draw_anvil_name(&mut self, batch: &mut HudBatch, ox: f32, oy: f32) {
let Some(s) = self.station.as_mut() else {
return;
};
let edit = &mut s.rename;
edit.x = ox + 62.0;
edit.y = oy + 24.0;
edit.width = 103.0;
edit.height = 12.0;
edit.bordered = false;
edit.focused = true;
edit.text_color = u32::MAX;
edit.max_length = ANVIL_NAME_CHARS;
edit.draw(batch, &self.gui.atlas, None);
}
// ---------------------------------------------------------------- drawing
@@ -1094,6 +1141,7 @@ impl Game<'_> {
let (ox, oy) = self.inventory_origin(batch);
let local = (self.cursor.0 - ox, self.cursor.1 - oy);
let mut tooltip: Option<Vec<(horizon_client::text::Text, u32)>> = None;
let mut anvil_name = false;
// Keep item tooltips as stacks until drawing: text alone loses the
// bundle contents component (including in the player's inventory).
let mut tooltip_stack: Option<ItemStack> = None;
@@ -1431,18 +1479,8 @@ impl Game<'_> {
);
}
if s.inputs[0].is_some() {
// AnvilScreen's name box: white, 103 wide, no inverted highlight.
horizon_client::ui::append_field(
batch,
atlas,
&s.name,
(ox + 62.0, oy + 24.0, 103.0),
(u32::MAX, true),
ANVIL_NAME_CHARS,
self.text_selection.covers(ANVIL_NAME, &s.name),
false,
s.opened.elapsed().as_millis(),
);
// Drawn once this borrow of the station ends.
anvil_name = true;
}
let cost = s.anvil.cost;
if cost > 0 {
@@ -1589,6 +1627,9 @@ impl Game<'_> {
}
}
}
if anvil_name {
self.draw_anvil_name(batch, ox, oy);
}
let drag = self.drag_view();
for i in self.station_slots().unwrap_or_default() {
let Some((sx, sy)) = self.slot_position_any(i) else {
@@ -2204,5 +2245,13 @@ impl Game<'_> {
}
/// The anvil's rename box (`AnvilScreen`: `setMaxLength(50)`).
const ANVIL_NAME: horizon_client::clipboard::FieldId = ("anvil.name", 0);
const ANVIL_NAME_CHARS: usize = 50;
fn anvil_box() -> horizon_client::ui::EditBox {
let mut edit = horizon_client::ui::EditBox::new(0.0, 0.0, 103.0, 12.0);
edit.bordered = false;
edit.max_length = ANVIL_NAME_CHARS;
edit.text_color = u32::MAX;
edit.focused = true;
edit
}
+66 -54
View File
@@ -11,9 +11,15 @@ use std::{
collections::HashMap,
path::{Path, PathBuf},
sync::{Arc, Mutex},
time::Duration,
time::{Duration, Instant},
};
const SERVER_NAME: crate::clipboard::FieldId = ("server.name", 0);
const SERVER_ADDRESS: crate::clipboard::FieldId = ("server.address", 0);
const SERVER_DIRECT: crate::clipboard::FieldId = ("server.direct", 0);
const FRIEND_CODE: crate::clipboard::FieldId = ("friend.code", 0);
const FRIEND_RELAY: crate::clipboard::FieldId = ("friend.relay", 0);
/// The result of pinging one address.
#[derive(Clone, Debug)]
pub enum Ping {
@@ -587,18 +593,21 @@ impl Screen {
}
}
/// An EditBox and its label: the label at `label_y` (one pixel in, in
/// 0xA0A0A0), the 200x20 box at `box_y`.
/// 0xA0A0A0), the 200x20 box at `box_y`. The box is the same `EditBox`
/// the world screens edit (`Caret`): cursor, selection and scrolling.
fn field(
atlas: &HudAtlas,
batch: &mut HudBatch,
caret: &mut crate::ui::Caret,
focused_at: Instant,
label: &str,
value: &str,
id: crate::clipboard::FieldId,
max: usize,
(label_y, box_y): (f32, f32),
focused: bool,
blink: bool,
) {
let x = (batch.width.ceil() / 2.0).floor() - 100.0;
crate::widgets::target_as([x, box_y, 200.0, 20.0], crate::widgets::Kind::Field);
batch.text(
atlas,
label,
@@ -607,35 +616,25 @@ impl Screen {
rgba(160, 160, 160, 255),
true,
);
batch.fill(
caret.draw(
batch,
atlas,
x,
box_y,
200.0,
20.0,
if focused {
u32::MAX
} else {
rgba(160, 160, 160, 255)
},
);
batch.fill(atlas, x + 1.0, box_y + 1.0, 198.0, 18.0, rgba(0, 0, 0, 255));
let shown = if focused && blink {
format!("{value}_")
} else {
value.to_owned()
};
batch.text(
atlas,
&shown,
x + 4.0,
box_y + 6.0,
rgba(224, 224, 224, 255),
true,
(id, value, max),
[x, box_y, 200.0, 20.0],
focused,
focused_at,
);
}
pub fn draw(&self, atlas: &HudAtlas, batch: &mut HudBatch, blink: bool, cursor: (f32, f32)) {
pub fn draw(
&self,
atlas: &HudAtlas,
batch: &mut HudBatch,
caret: &mut crate::ui::Caret,
focused_at: Instant,
cursor: (f32, f32),
) {
let (w, h) = (batch.width, batch.height);
let max = self.max_length();
crate::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, false);
let centered = |batch: &mut HudBatch, text: &str, y: f32, color: u32| {
let tw = HudBatch::text_width(atlas, text);
@@ -888,20 +887,26 @@ impl Screen {
Self::field(
atlas,
batch,
caret,
focused_at,
&crate::lang::get_or("manageServer.enterName", "Server Name"),
name,
SERVER_NAME,
max,
(53.0, 66.0),
*focus == 0,
blink,
);
Self::field(
atlas,
batch,
caret,
focused_at,
&crate::lang::get_or("manageServer.enterIp", "Server Address"),
address,
SERVER_ADDRESS,
max,
(94.0, 106.0),
*focus == 1,
blink,
);
}
Self::Direct { address } => {
@@ -916,11 +921,14 @@ impl Screen {
Self::field(
atlas,
batch,
caret,
focused_at,
&crate::lang::get_or("manageServer.enterIp", "Server Address"),
address,
SERVER_DIRECT,
max,
(100.0, 116.0),
true,
blink,
);
}
Self::ConfirmDelete { name, lines, .. } => {
@@ -954,20 +962,26 @@ impl Screen {
Self::field(
atlas,
batch,
caret,
focused_at,
"Invite Code",
code,
FRIEND_CODE,
max,
(53.0, 64.0),
*focus == 0,
blink,
);
Self::field(
atlas,
batch,
caret,
focused_at,
"Relay (optional)",
relay,
FRIEND_RELAY,
max,
(94.0, 105.0),
*focus == 1,
blink,
);
}
Self::Disconnected { reason, retry } => {
@@ -1261,6 +1275,19 @@ impl Screen {
scroll.release();
}
}
/// The focused box, in GUI pixels: name at 66, address at 106
/// (`ManageServerScreen`), direct address at 116, friend fields at 64 and 105.
pub fn focused_box(&self, (w, _h): (f32, f32)) -> Option<[f32; 4]> {
let y = match self {
Self::Edit { focus: 0, .. } => 66.0,
Self::Edit { .. } => 106.0,
Self::Direct { .. } => 116.0,
Self::Friend { focus: 0, .. } => 64.0,
Self::Friend { .. } => 105.0,
_ => return None,
};
Some([(w / 2.0).floor() - 100.0, y, 200.0, 20.0])
}
fn focused_field(&mut self) -> Option<&mut String> {
match self {
Self::Edit {
@@ -1297,11 +1324,11 @@ impl Screen {
/// The focused text field, its id and maximum length (copy and paste).
pub fn focused_text(&mut self) -> Option<(crate::clipboard::FieldId, &mut String, usize)> {
let id = match self {
Self::Edit { focus: 0, .. } => ("server.name", 0),
Self::Edit { .. } => ("server.address", 0),
Self::Direct { .. } => ("server.direct", 0),
Self::Friend { focus: 0, .. } => ("friend.code", 0),
Self::Friend { .. } => ("friend.relay", 0),
Self::Edit { focus: 0, .. } => SERVER_NAME,
Self::Edit { .. } => SERVER_ADDRESS,
Self::Direct { .. } => SERVER_DIRECT,
Self::Friend { focus: 0, .. } => FRIEND_CODE,
Self::Friend { .. } => FRIEND_RELAY,
_ => return None,
};
let max = self.max_length();
@@ -1315,21 +1342,6 @@ impl Screen {
128
}
}
pub fn type_text(&mut self, text: &str) {
let max = self.max_length();
if let Some(field) = self.focused_field() {
for c in text.chars().filter(|c| !c.is_control()) {
if field.chars().count() < max {
field.push(c);
}
}
}
}
pub fn backspace(&mut self) {
if let Some(field) = self.focused_field() {
field.pop();
}
}
pub fn tab(&mut self) {
if let Self::Edit { focus, .. } | Self::Friend { focus, .. } = self {
*focus = (*focus + 1) % 2;
+8 -11
View File
@@ -1119,7 +1119,8 @@ impl Title {
Mode::Multiplayer(screen) => screen.draw(
atlas,
batch,
(self.started.elapsed().as_millis() / 300).is_multiple_of(2),
&mut self.caret.borrow_mut(),
self.started,
cursor,
),
}
@@ -1414,8 +1415,7 @@ impl Title {
mode => caret_field(mode, self.search_focused, &mut self.search),
}
}
/// Whether the focused field edits at its cursor (every field but the
/// server screens', which still append).
/// Whether the focused field edits at its cursor.
pub fn has_caret_field(&mut self) -> bool {
caret_field(&mut self.mode, self.search_focused, &mut self.search).is_some()
}
@@ -1435,15 +1435,9 @@ impl Title {
}
/// EditBox.charTyped / insertText at the cursor.
pub fn type_text(&mut self, text: &str) {
if let Mode::Multiplayer(screen) = &mut self.mode {
return screen.type_text(text);
}
self.edit_field(|edit| edit.insert_text(text));
}
pub fn backspace(&mut self) {
if let Mode::Multiplayer(screen) = &mut self.mode {
return screen.backspace();
}
self.field_key(winit::keyboard::KeyCode::Backspace, false, false);
}
/// EditBox.keyPressed: moving and deleting in the focused field.
@@ -1471,6 +1465,7 @@ impl Title {
Mode::Create { focus, options, .. } => create_world::field_rect(w, h, options.tab)
.filter(|(id, _)| id == focus)
.map(|(_, rect)| rect),
Mode::Multiplayer(screen) => screen.focused_box((w, h)),
_ => None,
}
}
@@ -1623,8 +1618,9 @@ const SEARCH: crate::clipboard::FieldId = ("world.search", 0);
const EDIT_NAME: crate::clipboard::FieldId = ("world.edit-name", 0);
const WORLD_NAME: crate::clipboard::FieldId = ("world.name", 0);
const WORLD_SEED: crate::clipboard::FieldId = ("world.seed", 0);
/// The focused field of the world screens: the list's search, the world
/// name being edited, or Create New World's name or seed.
/// The focused field of the world screens and the server screens: the list's
/// search, a world name or seed, or Add Server, Direct Connection and Join a
/// Friend.
fn caret_field<'a>(
mode: &'a mut Mode,
search_focused: bool,
@@ -1644,6 +1640,7 @@ fn caret_field<'a>(
1 => Some((WORLD_SEED, seed, 32)),
_ => None,
},
Mode::Multiplayer(screen) => screen.focused_text(),
_ => None,
}
}
+47
View File
@@ -824,3 +824,50 @@ fn world_fields_edit_at_their_cursor() {
f.title.field_key(KeyCode::Backspace, false, false);
assert_eq!(f.title.search, "world");
}
#[test]
fn server_fields_edit_at_their_cursor() {
use winit::keyboard::KeyCode;
let mut f = Fixture::new();
f.title.mode = Mode::Multiplayer(crate::multiplayer::Screen::Edit {
index: None,
name: "Minecraft Server".into(),
address: String::new(),
focus: 1,
packs: None,
});
f.title.type_text("localhost");
for _ in 0..4 {
assert!(f.title.field_key(KeyCode::ArrowLeft, false, false));
}
f.title.type_text("X");
let Mode::Multiplayer(crate::multiplayer::Screen::Edit { address, .. }) = &f.title.mode else {
panic!("add server");
};
assert_eq!(address, "localXhost");
// Shift+Home selects back to the start; typing replaces the selection.
f.title.field_key(KeyCode::Home, false, true);
f.title.type_text("Y");
let Mode::Multiplayer(crate::multiplayer::Screen::Edit { address, .. }) = &f.title.mode else {
panic!("add server");
};
assert_eq!(address, "Yhost");
// Tab moves to the name, which edits at its own cursor.
f.title.tab();
f.title.field_key(KeyCode::ArrowLeft, false, false);
f.title.type_text("X");
let Mode::Multiplayer(crate::multiplayer::Screen::Edit { name, .. }) = &f.title.mode else {
panic!("add server");
};
assert_eq!(name, "Minecraft ServeXr");
f.title.mode = Mode::Multiplayer(crate::multiplayer::Screen::Direct {
address: String::new(),
});
f.title.type_text("one two");
// Ctrl+Backspace deletes the word before the caret.
f.title.field_key(KeyCode::Backspace, true, false);
let Mode::Multiplayer(crate::multiplayer::Screen::Direct { address }) = &f.title.mode else {
panic!("direct");
};
assert_eq!(address, "one ");
}
+58 -4
View File
@@ -658,6 +658,8 @@ pub struct EditBox {
display: usize,
pub max_length: usize,
pub bordered: bool,
/// `MultiLineEditBox`: a book page keeps line breaks. A single-line box strips them.
pub multiline: bool,
pub x: f32,
pub y: f32,
pub width: f32,
@@ -669,7 +671,7 @@ pub struct EditBox {
pub text_color: u32,
}
/// The focused text field of a screen that keeps its values as `String`s
/// (the world list's search, world names, seeds): the `EditBox` state that
/// (the world list's search, world names, seeds, and the server screens): the `EditBox` state that
/// outlives a frame, its cursor, selection and scroll.
#[derive(Clone, Debug)]
pub struct Caret {
@@ -853,6 +855,38 @@ pub fn word_position(value: &str, dir: isize, from: usize) -> usize {
}
pos
}
/// `MultilineTextField.getPreviousWord` / `getNextWord`'s `beginIndex` (a book page): any
/// whitespace, line breaks included, separates words, and the search starts from the cursor
/// clamped to the last character, as vanilla's does.
pub fn multiline_word_position(value: &str, dir: isize, from: usize) -> usize {
let chars: Vec<char> = value.chars().collect();
if chars.is_empty() {
return 0;
}
let mut pos = from.min(chars.len() - 1);
if dir < 0 {
while pos > 0 && java_whitespace(chars[pos - 1]) {
pos -= 1;
}
while pos > 0 && !java_whitespace(chars[pos - 1]) {
pos -= 1;
}
} else {
while pos < chars.len() && !java_whitespace(chars[pos]) {
pos += 1;
}
while pos < chars.len() && java_whitespace(chars[pos]) {
pos += 1;
}
}
pos
}
/// `Character.isWhitespace`: Unicode spaces and line breaks but not the no-break spaces, plus
/// the ASCII separators U+001C to U+001F.
fn java_whitespace(c: char) -> bool {
(c.is_whitespace() && !matches!(c, '\u{85}' | '\u{a0}' | '\u{2007}' | '\u{202f}'))
|| ('\u{1c}'..='\u{1f}').contains(&c)
}
/// `EditBox.deleteWords(dir)` for an append-only field, whose caret is at the
/// end: removes the word in that direction. True when the value changed.
pub fn delete_words(value: &mut String, dir: isize) -> bool {
@@ -887,6 +921,7 @@ impl EditBox {
display: 0,
max_length: 32,
bordered: true,
multiline: false,
x,
y,
width,
@@ -917,6 +952,10 @@ impl EditBox {
pub fn reset_blink(&mut self) {
self.focused_at = Instant::now();
}
/// The caret is showing (`EditBox`'s blink, on for 300 ms of every 600).
pub fn cursor_visible(&self) -> bool {
self.focused && (self.focused_at.elapsed().as_millis() / 300).is_multiple_of(2)
}
pub fn set_value(&mut self, value: &str) {
self.value = value.chars().take(self.max_length).collect();
self.move_cursor_to_end(false);
@@ -934,7 +973,7 @@ impl EditBox {
/// EditBox.insertText: replace the selection, respecting the maximum length.
pub fn insert_text(&mut self, text: &str) {
let (a, b) = self.selected();
let filtered = crate::clipboard::filter_text(text, false);
let filtered = crate::clipboard::filter_text(text, self.multiline);
let room = self
.max_length
.saturating_sub(char_len(&self.value) - (b - a));
@@ -960,9 +999,13 @@ impl EditBox {
pub fn word_position(&self, dir: isize) -> usize {
self.word_position_from(dir, self.cursor)
}
/// EditBox.getWordPosition(dir, from).
/// EditBox.getWordPosition(dir, from), or MultilineTextField's word for a book page.
fn word_position_from(&self, dir: isize, from: usize) -> usize {
word_position(&self.value, dir, from)
if self.multiline {
multiline_word_position(&self.value, dir, from)
} else {
word_position(&self.value, dir, from)
}
}
pub fn set_cursor(&mut self, pos: usize, select: bool) {
self.cursor = pos.min(char_len(&self.value));
@@ -1492,6 +1535,17 @@ mod tests {
assert_eq!(word_position("hi", 1, 99), 2);
}
#[test]
fn multiline_words_split_at_any_whitespace() {
// Ctrl+Backspace at the end of `one\ntwo` deletes `two` only.
assert_eq!(multiline_word_position("one\ntwo", -1, 7), 4);
// From the end the search starts at the last character, as vanilla's does.
assert_eq!(multiline_word_position("one\n", -1, 4), 0);
assert_eq!(multiline_word_position("a b", -1, 3), 0);
assert_eq!(multiline_word_position("one\n\ntwo", 1, 0), 5);
assert_eq!(multiline_word_position("one\u{a0}two", 1, 0), 7);
assert_eq!(multiline_word_position("", -1, 0), 0);
}
#[test]
fn delete_words_removes_the_word_before_the_end() {
let mut v = "stone bricks".to_string();
assert!(delete_words(&mut v, -1));