From 060a37dc8536328017f7759ee710a55481c18c42 Mon Sep 17 00:00:00 2001 From: Joansitoh Date: Mon, 26 Oct 2026 01:29:07 +0100 Subject: [PATCH 1/3] Edit the remaining text fields at the caret. Those fields only appended, so the cursor, selection and arrow keys did nothing. A sign paste strips formatting first, matching TextFieldHelper.getClipboardContents. --- crates/horizon-client/src/clipboard.rs | 5 + crates/horizon-client/src/creative.rs | 55 ++- crates/horizon-client/src/game.rs | 66 ++- crates/horizon-client/src/game/books.rs | 497 ++++++++++++++------ crates/horizon-client/src/game/clipboard.rs | 37 +- crates/horizon-client/src/game/sign_edit.rs | 314 +++++++++---- crates/horizon-client/src/game/stations.rs | 117 +++-- crates/horizon-client/src/multiplayer.rs | 120 ++--- crates/horizon-client/src/title.rs | 19 +- crates/horizon-client/src/title/tests.rs | 47 ++ crates/horizon-client/src/ui.rs | 11 +- 11 files changed, 890 insertions(+), 398 deletions(-) diff --git a/crates/horizon-client/src/clipboard.rs b/crates/horizon-client/src/clipboard.rs index c21460cdb..76872091c 100644 --- a/crates/horizon-client/src/clipboard.rs +++ b/crates/horizon-client/src/clipboard.rs @@ -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 { diff --git a/crates/horizon-client/src/creative.rs b/crates/horizon-client/src/creative.rs index 249e40b11..033b4daec 100644 --- a/crates/horizon-client/src/creative.rs +++ b/crates/horizon-client/src/creative.rs @@ -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, search_index: Vec, @@ -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"); + } } diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 1e6158bc4..e8a23e819 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -5548,8 +5548,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); @@ -9586,20 +9587,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 = None; if tab.inventory { @@ -9761,6 +9758,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 @@ -9773,6 +9773,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. @@ -11380,8 +11398,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()); } @@ -11402,6 +11429,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); diff --git a/crates/horizon-client/src/game/books.rs b/crates/horizon-client/src/game/books.rs index 9af1da70c..2f38dffd9 100644 --- a/crates/horizon-client/src/game/books.rs +++ b/crates/horizon-client/src/game/books.rs @@ -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, @@ -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 = 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 = 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 = 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 = 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 = 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, diff --git a/crates/horizon-client/src/game/clipboard.rs b/crates/horizon-client/src/game/clipboard.rs index c87aa3681..6e81bf3ee 100644 --- a/crates/horizon-client/src/game/clipboard.rs +++ b/crates/horizon-client/src/game/clipboard.rs @@ -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); diff --git a/crates/horizon-client/src/game/sign_edit.rs b/crates/horizon-client/src/game/sign_edit.rs index 2f50ad9ab..2d010f1f5 100644 --- a/crates/horizon-client/src/game/sign_edit.rs +++ b/crates/horizon-client/src/game/sign_edit.rs @@ -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, 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 { + 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)> { + 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) -} diff --git a/crates/horizon-client/src/game/stations.rs b/crates/horizon-client/src/game/stations.rs index 171f860f4..93a7cab34 100644 --- a/crates/horizon-client/src/game/stations.rs +++ b/crates/horizon-client/src/game/stations.rs @@ -134,8 +134,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, @@ -145,8 +147,6 @@ pub(super) struct Station { anvil_last: Option, /// Anvil on a server: the name last sent (the client menu's `itemName`). pub(super) anvil_sent: Option, - /// 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>, @@ -168,12 +168,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], @@ -867,6 +867,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; @@ -893,6 +896,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(); } @@ -959,21 +964,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) { @@ -984,18 +997,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 @@ -1093,6 +1140,7 @@ impl Game<'_> { let (ox, oy) = self.inventory_origin(batch); let local = (self.cursor.0 - ox, self.cursor.1 - oy); let mut tooltip: Option> = None; + let mut anvil_name = false; { let atlas = &self.gui.atlas; batch.fill( @@ -1424,18 +1472,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 { @@ -1582,6 +1620,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 { @@ -2037,5 +2078,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 +} diff --git a/crates/horizon-client/src/multiplayer.rs b/crates/horizon-client/src/multiplayer.rs index 07d8552ff..843e1d6f9 100644 --- a/crates/horizon-client/src/multiplayer.rs +++ b/crates/horizon-client/src/multiplayer.rs @@ -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, "Server Name", name, + SERVER_NAME, + max, (53.0, 66.0), *focus == 0, - blink, ); Self::field( atlas, batch, + caret, + focused_at, "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("addServer.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; diff --git a/crates/horizon-client/src/title.rs b/crates/horizon-client/src/title.rs index a6967816a..3da813557 100644 --- a/crates/horizon-client/src/title.rs +++ b/crates/horizon-client/src/title.rs @@ -1113,7 +1113,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, ), } @@ -1408,8 +1409,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() } @@ -1429,15 +1429,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. @@ -1465,6 +1459,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, } } @@ -1617,8 +1612,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, @@ -1638,6 +1634,7 @@ fn caret_field<'a>( 1 => Some((WORLD_SEED, seed, 32)), _ => None, }, + Mode::Multiplayer(screen) => screen.focused_text(), _ => None, } } diff --git a/crates/horizon-client/src/title/tests.rs b/crates/horizon-client/src/title/tests.rs index 572be2ef2..fcaf68225 100644 --- a/crates/horizon-client/src/title/tests.rs +++ b/crates/horizon-client/src/title/tests.rs @@ -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 "); +} diff --git a/crates/horizon-client/src/ui.rs b/crates/horizon-client/src/ui.rs index 8987020bf..c1ba51111 100644 --- a/crates/horizon-client/src/ui.rs +++ b/crates/horizon-client/src/ui.rs @@ -657,6 +657,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, @@ -668,7 +670,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 { @@ -886,6 +888,7 @@ impl EditBox { display: 0, max_length: 32, bordered: true, + multiline: false, x, y, width, @@ -916,6 +919,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); @@ -933,7 +940,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)); From fa8291fc974a2b1290b7538022440696c15726d9 Mon Sep 17 00:00:00 2001 From: Joansitoh Date: Mon, 26 Oct 2026 10:06:48 +0100 Subject: [PATCH 2/3] Treat a line break as a word boundary in text fields. Ctrl+Backspace on a book page with the caret after `two` in `one\ntwo` removed both lines because the word helper only knew spaces. Co-Authored-By: Claude Sonnet 5.5 --- crates/horizon-client/src/ui.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/ui.rs b/crates/horizon-client/src/ui.rs index c1ba51111..be92ed7d5 100644 --- a/crates/horizon-client/src/ui.rs +++ b/crates/horizon-client/src/ui.rs @@ -837,17 +837,25 @@ pub fn char_len(s: &str) -> usize { pub fn word_position(value: &str, dir: isize, from: usize) -> usize { let chars: Vec = value.chars().collect(); let mut pos = from.min(chars.len()); + // A line break is a word of its own, so Ctrl+Backspace in a book page + // stops at the end of the previous line. if dir < 0 { while pos > 0 && chars[pos - 1] == ' ' { pos -= 1; } - while pos > 0 && chars[pos - 1] != ' ' { + if pos > 0 && chars[pos - 1] == '\n' { + return pos - 1; + } + while pos > 0 && !matches!(chars[pos - 1], ' ' | '\n') { pos -= 1; } } else { - while pos < chars.len() && chars[pos] != ' ' { + while pos < chars.len() && !matches!(chars[pos], ' ' | '\n') { pos += 1; } + if pos < chars.len() && chars[pos] == '\n' { + return pos + 1; + } while pos < chars.len() && chars[pos] == ' ' { pos += 1; } @@ -1498,6 +1506,12 @@ mod tests { assert_eq!(word_position("hi", 1, 99), 2); } #[test] + fn word_position_stops_at_line_breaks() { + assert_eq!(word_position("one\ntwo", -1, 7), 4); + assert_eq!(word_position("one\n", -1, 4), 3); + assert_eq!(word_position("one\ntwo", 1, 0), 4); + } + #[test] fn delete_words_removes_the_word_before_the_end() { let mut v = "stone bricks".to_string(); assert!(delete_words(&mut v, -1)); From 0e4965845a2779a932c75ecc7c6c9803d9bd86ba Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:47:17 +0100 Subject: [PATCH 3/3] Book pages: move by words as MultilineTextField does BookEditScreen's page is a MultiLineEditBox. Its getPreviousWord and getNextWord split words at any whitespace (Character.isWhitespace, line breaks included) and start from the cursor clamped to the last character. The single-line EditBox.getWordPosition keeps looking at spaces only, so the line break rule moves to the multiline box alone. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/ui.rs | 65 +++++++++++++++++++++++++-------- 1 file changed, 49 insertions(+), 16 deletions(-) diff --git a/crates/horizon-client/src/ui.rs b/crates/horizon-client/src/ui.rs index be92ed7d5..c920e7e34 100644 --- a/crates/horizon-client/src/ui.rs +++ b/crates/horizon-client/src/ui.rs @@ -837,31 +837,55 @@ pub fn char_len(s: &str) -> usize { pub fn word_position(value: &str, dir: isize, from: usize) -> usize { let chars: Vec = value.chars().collect(); let mut pos = from.min(chars.len()); - // A line break is a word of its own, so Ctrl+Backspace in a book page - // stops at the end of the previous line. if dir < 0 { while pos > 0 && chars[pos - 1] == ' ' { pos -= 1; } - if pos > 0 && chars[pos - 1] == '\n' { - return pos - 1; - } - while pos > 0 && !matches!(chars[pos - 1], ' ' | '\n') { + while pos > 0 && chars[pos - 1] != ' ' { pos -= 1; } } else { - while pos < chars.len() && !matches!(chars[pos], ' ' | '\n') { + while pos < chars.len() && chars[pos] != ' ' { pos += 1; } - if pos < chars.len() && chars[pos] == '\n' { - return pos + 1; - } while pos < chars.len() && chars[pos] == ' ' { pos += 1; } } 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 = 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 { @@ -974,9 +998,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)); @@ -1506,10 +1534,15 @@ mod tests { assert_eq!(word_position("hi", 1, 99), 2); } #[test] - fn word_position_stops_at_line_breaks() { - assert_eq!(word_position("one\ntwo", -1, 7), 4); - assert_eq!(word_position("one\n", -1, 4), 3); - assert_eq!(word_position("one\ntwo", 1, 0), 4); + 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() {