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:
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ");
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user