Skip to content
8 changes: 8 additions & 0 deletions common/src/main/java/me/chrr/scribble/ScribbleConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ public class ScribbleConfig extends ReflectedConfig {
public Value<Boolean> copyFormattingCodes = value(true);
public Value<Integer> editHistorySize = value(32)
.range(8, 128, 1);
public Value<Boolean> overflowWhenTyping = value(false);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Like mentioned in the previous PR (#92 (comment)), I think it would be nice to have an enum here with three options:

  • Don't overflow ever.
  • Overflow only on enter / backspace (so you don't accidentally enter a new page while just typing / you can hold down something like - to fill up the last line).
  • Always overflow when running out of room.

public Value<PasteBehavior> pasteBehavior = value(PasteBehavior.DENY);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name "Paste behaviour" doesn't really indicate its connection to overflowing. Maybe "Overflow when pasting" to match the other option?


@Category("miscellaneous")
public Value<Boolean> openVanillaBookScreenOnShift = value(false);
Expand All @@ -43,4 +45,10 @@ public enum ShowActionButtons {
WHEN_EDITING,
NEVER,
}

public enum PasteBehavior {
DENY,
FIT_PAGE,
OVERFLOW,
}
}
13 changes: 13 additions & 0 deletions common/src/main/java/me/chrr/scribble/book/RichText.java
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,19 @@ public String getAsFormattedString() {
return out.toString();
}

/**
* Remove carriage return characters (\r) from the text. This is needed because
* Windows line endings (CRLF) contain \r characters that Minecraft books display
* as visible symbols instead of treating them as whitespace.
*
* @return a new RichText with all carriage returns removed.
*/
public RichText filterCarriageReturns() {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this just be filtered out the raw clipboard text when pasting, instead of after having processed everything already?

String plain = this.getPlainText();
if (!plain.contains("\r")) return this;
return RichText.fromFormattedString(this.getAsFormattedString().replace("\r", ""));
}

/**
* Get the rich text as a vanilla {@link MutableComponent}. Note that this text content is valid for
* this client only!
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package me.chrr.scribble.book;

import me.chrr.scribble.history.HistoryListener;
import me.chrr.scribble.history.command.OverflowCommand;
import net.minecraft.ChatFormatting;
import net.minecraft.client.gui.Font;
import net.minecraft.network.chat.Style;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;

import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.function.Consumer;

@NullMarked
public class TextOverflowHandler {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two things here:

  • Why isn't this just in ScribbleBookEditScreen?
  • The name TextOverflowHandler is really confusing, given there's the completely unrelated interface OverflowHandler.

private static final int PAGE_WIDTH = 114;
private static final int LINE_LIMIT = 14;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't these already exist in other places: textField.width and textField.lineLimit?


private final HistoryListener listener;
private final Font font;
private final Consumer<OverflowCommand> onCommand;

public TextOverflowHandler(HistoryListener listener, Font font, Consumer<OverflowCommand> onCommand) {
this.listener = listener;
this.font = font;
this.onCommand = onCommand;
}

public boolean insertWithOverflow(int page, RichText text, int cursor, RichText insert,
@Nullable ChatFormatting color, Set<ChatFormatting> modifiers) {
int maxPages = 100 - listener.getTotalPages() + 1;
if (cursor != text.getLength() || maxPages < 1) return false;

// Truncate insert to max pages × ~400 chars/page (conservative estimate)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI?

insert = insert.subText(0, Math.min(insert.getLength(), maxPages * 400));

// Combine text + insert first, then split with word-wrapping
RichText combined = text.insert(cursor, insert);

List<RichText> pages = new ArrayList<>();
RichText remaining = combined;
for (; remaining.getLength() > 0 && pages.size() < maxPages; ) {
int len = findFittingLength(remaining);
pages.add(remaining.subText(0, len));
remaining = remaining.subText(len, remaining.getLength());
}

// Reject only if no overflow pages were created (at page limit with no room)
// Allow truncation when overflow pages were successfully added
if (remaining.getLength() > 0 && pages.size() == 1) {
return false;
}

OverflowCommand cmd = new OverflowCommand(page, listener.getPageContent(page), pages);
cmd.execute(listener);
onCommand.accept(cmd);
return true;
}

private int findFittingLength(RichText text) {
if (!wouldOverflow(text)) return text.getLength();

// Binary search for max chars that fit on one page
int lo = 1, hi = text.getLength(), best = 1;
while (lo <= hi) {
int mid = (lo + hi) / 2;
if (!wouldOverflow(text.subText(0, mid))) { best = mid; lo = mid + 1; }
else hi = mid - 1;
}

// Find last space in the fitted text for word wrapping
String plain = text.subText(0, best).getPlainText();
int lastNewline = plain.lastIndexOf('\n');
int searchStart = Math.max(0, lastNewline); // Only look for space after last newline
int lastSpace = plain.lastIndexOf(' ', best - 1);

// Only use space if it's on the last visual line (after last newline)
if (lastSpace > searchStart) {
return lastSpace + 1;
}
return best;
}

private boolean wouldOverflow(RichText text) {
return font.getSplitter().splitLines(text, PAGE_WIDTH, Style.EMPTY).size() > LINE_LIMIT;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package me.chrr.scribble.gui.edit;

import me.chrr.scribble.book.RichText;
import net.minecraft.ChatFormatting;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;

import java.util.Set;

/**
* Interface for handling text overflow situations in the book editor.
* This is called when text operations would cause the page to overflow.
*/
@NullMarked
public interface OverflowHandler {
/**
* Called when typing or pasting would overflow the current page.
*
* @param currentText the current text on the page
* @param cursor the cursor position
* @param insert the text to insert
* @param color the current color
* @param modifiers the current modifiers
* @return true if the overflow was handled, false if the operation should be rejected
*/
boolean handleOverflow(RichText currentText, int cursor, RichText insert,
@Nullable ChatFormatting color, Set<ChatFormatting> modifiers);

/**
* Called when Enter is pressed at the end of a page.
*
* @return true if a new page was created
*/
boolean handleEnterAtEnd();

/**
* Called when Backspace is pressed on a completely empty page.
*
* @return true if the page was deleted
*/
boolean handleBackspaceOnEmpty();
}
123 changes: 114 additions & 9 deletions common/src/main/java/me/chrr/scribble/gui/edit/RichEditBox.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@

import com.mojang.blaze3d.platform.cursor.CursorTypes;
import com.mojang.datafixers.util.Pair;
import me.chrr.scribble.Scribble;
import me.chrr.scribble.ScribbleConfig;
import me.chrr.scribble.book.RichText;
import me.chrr.scribble.gui.TextArea;
import me.chrr.scribble.history.command.Command;
import me.chrr.scribble.history.command.EditCommand;
import net.minecraft.ChatFormatting;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.Font;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.MultiLineEditBox;
Expand All @@ -32,18 +35,21 @@
public class RichEditBox extends MultiLineEditBox implements TextArea<RichText> {
private final @Nullable Runnable onInvalidateFormat;
private final @Nullable Consumer<Command> onHistoryPush;
private final @Nullable OverflowHandler overflowHandler;

public @Nullable ChatFormatting color = ChatFormatting.BLACK;
public Set<ChatFormatting> modifiers = new HashSet<>();

private RichEditBox(Font font, int x, int y, int width, int height,
Component placeholder, Component message, int textColor, boolean textShadow, int cursorColor,
boolean hasBackground, boolean hasOverlay,
@Nullable Runnable onInvalidateFormat, @Nullable Consumer<Command> onHistoryPush) {
@Nullable Runnable onInvalidateFormat, @Nullable Consumer<Command> onHistoryPush,
@Nullable OverflowHandler overflowHandler) {
super(font, x, y, width, height, placeholder, message, textColor, textShadow, cursorColor, hasBackground, hasOverlay);

this.onInvalidateFormat = onInvalidateFormat;
this.onHistoryPush = onHistoryPush;
this.overflowHandler = overflowHandler;

this.textField = new RichMultiLineTextField(
font, width - this.totalInnerPadding(),
Expand Down Expand Up @@ -197,6 +203,30 @@ protected void extractContents(GuiGraphicsExtractor graphics, int mouseX, int mo
@Override
public boolean charTyped(CharacterEvent event) {
if (this.visible && this.isFocused() && event.isAllowedChatCharacter()) {
RichMultiLineTextField tf = this.getRichTextField();
RichText currentText = tf.getRichText();
int cursor = tf.cursor;

// Create the text to insert
RichText insert = new RichText(event.codepointAsString(),
Optional.ofNullable(color).orElse(ChatFormatting.BLACK), modifiers);

// Check if this would overflow
RichText result = tf.hasSelection()
? currentText.replace(tf.getSelected().beginIndex(), tf.getSelected().endIndex(), insert)
: currentText.insert(cursor, insert);

boolean wouldOverflow = tf.hasLineLimit() && tf.font.getSplitter()
.splitLines(result, tf.width, net.minecraft.network.chat.Style.EMPTY).size() > tf.lineLimit;

// If would overflow and we have an overflow handler, try to handle it
if (wouldOverflow && this.overflowHandler != null && cursor == currentText.getLength() && !tf.hasSelection()) {
if (this.overflowHandler.handleOverflow(currentText, cursor, insert, color, modifiers)) {
return true;
}
}

// Normal behavior
EditCommand command = new EditCommand(this,
(textField) -> textField.insertText(event.codepointAsString()));
command.executeEdit(this.getRichTextField());
Expand Down Expand Up @@ -226,17 +256,84 @@ public boolean keyPressed(KeyEvent event) {
}
}

// Wrap the operation with an edit command if it edits the text.
if (event.isCut() || event.isPaste() ||
List.of(GLFW.GLFW_KEY_ENTER, GLFW.GLFW_KEY_KP_ENTER,
GLFW.GLFW_KEY_BACKSPACE, GLFW.GLFW_KEY_DELETE).contains(event.key())) {
EditCommand command = new EditCommand(this,
(textField) -> textField.keyPressed(event));
command.executeEdit(this.getRichTextField());
RichMultiLineTextField tf = this.getRichTextField();
RichText currentText = tf.getRichText();
int cursor = tf.cursor;
boolean isAtEndOfText = cursor == currentText.getLength() && !tf.hasSelection();

// Handle Enter at end of full page - create new page if overflow handler exists
if ((event.key() == GLFW.GLFW_KEY_ENTER || event.key() == GLFW.GLFW_KEY_KP_ENTER) && isAtEndOfText) {
boolean pageFull = tf.hasLineLimit() && tf.font.getSplitter()
.splitLines(currentText, tf.width, net.minecraft.network.chat.Style.EMPTY).size() >= tf.lineLimit;
if (pageFull) {
if (this.overflowHandler != null && this.overflowHandler.handleEnterAtEnd()) {
return true;
}
return true; // Block enter on full page if overflow disabled
}
}

// Handle Backspace on empty page - delete page if overflow handler exists
if (event.key() == GLFW.GLFW_KEY_BACKSPACE && currentText.isEmpty() && !tf.hasSelection()) {
if (this.overflowHandler != null && this.overflowHandler.handleBackspaceOnEmpty()) {
return true;
}
}

// Handle Paste with different behaviors based on config
if (event.isPaste()) {
String clipboardText = Minecraft.getInstance().keyboardHandler.getClipboard().replace("\r", "");
boolean keepFormatting = Scribble.CONFIG.copyFormattingCodes.get() ^ event.hasShiftDown();
if (!keepFormatting) clipboardText = ChatFormatting.stripFormatting(clipboardText);

RichText insert = ChatFormatting.stripFormatting(clipboardText).equals(clipboardText)
? new RichText(clipboardText, Optional.ofNullable(color).orElse(ChatFormatting.BLACK), modifiers)
: RichText.fromFormattedString(clipboardText);

int start = tf.hasSelection() ? tf.getSelected().beginIndex() : cursor;
int end = tf.hasSelection() ? tf.getSelected().endIndex() : cursor;
RichText result = tf.hasSelection() ? currentText.replace(start, end, insert) : currentText.insert(cursor, insert);
boolean wouldOverflow = tf.hasLineLimit() && tf.font.getSplitter()
.splitLines(result, tf.width, net.minecraft.network.chat.Style.EMPTY).size() > tf.lineLimit;

if (wouldOverflow) {
ScribbleConfig.PasteBehavior behavior = Scribble.CONFIG.pasteBehavior.get();
if (behavior == ScribbleConfig.PasteBehavior.FIT_PAGE) {
int lo = 0, hi = insert.getLength(), best = 0;
while (lo <= hi) {
int mid = (lo + hi) / 2;
RichText partial = insert.subText(0, mid);
RichText partialResult = tf.hasSelection() ? currentText.replace(start, end, partial) : currentText.insert(cursor, partial);
if (tf.font.getSplitter().splitLines(partialResult, tf.width, net.minecraft.network.chat.Style.EMPTY).size() <= tf.lineLimit) {
best = mid; lo = mid + 1;
} else hi = mid - 1;
}
if (best > 0) {
String truncated = insert.subText(0, best).getAsFormattedString();
EditCommand cmd = new EditCommand(this, t -> t.insertText(truncated));
cmd.executeEdit(tf);
this.pushHistory(cmd);
}
return true;
} else if (behavior == ScribbleConfig.PasteBehavior.OVERFLOW && this.overflowHandler != null && isAtEndOfText) {
if (this.overflowHandler.handleOverflow(currentText, cursor, insert, color, modifiers)) return true;
}
}

EditCommand command = new EditCommand(this, t -> t.keyPressed(event));
command.executeEdit(tf);
this.pushHistory(command);
return true;
}

// Wrap the operation with an edit command if it edits the text.
if (event.isCut() || List.of(GLFW.GLFW_KEY_ENTER, GLFW.GLFW_KEY_KP_ENTER,
GLFW.GLFW_KEY_BACKSPACE, GLFW.GLFW_KEY_DELETE).contains(event.key())) {
EditCommand command = new EditCommand(this, t -> t.keyPressed(event));
command.executeEdit(tf);
this.pushHistory(command);
return true;
}

return super.keyPressed(event);
}
Expand Down Expand Up @@ -267,6 +364,8 @@ public static class Builder extends MultiLineEditBox.Builder {
private Runnable onInvalidateFormat = null;
@Nullable
private Consumer<Command> onHistoryPush = null;
@Nullable
private OverflowHandler overflowHandler = null;

public Builder onInvalidateFormat(Runnable onInvalidateFormat) {
this.onInvalidateFormat = onInvalidateFormat;
Expand All @@ -278,13 +377,19 @@ public Builder onHistoryPush(Consumer<Command> onHistoryPush) {
return this;
}

public Builder onOverflow(OverflowHandler overflowHandler) {
this.overflowHandler = overflowHandler;
return this;
}

@Override
public MultiLineEditBox build(Font font, int width, int height, Component message) {
return new RichEditBox(font,
this.x, this.y, width, height,
this.placeholder, message, this.textColor,
this.textShadow, this.cursorColor, this.showBackground,
this.showDecorations, this.onInvalidateFormat, this.onHistoryPush);
this.showDecorations, this.onInvalidateFormat, this.onHistoryPush,
this.overflowHandler);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,14 @@ public interface HistoryListener {

void insertPageAt(int page, @Nullable RichText content);

void deletePage(int page);
/** @param navigateDirection negative = go left, 0 or positive = stay/go right */
void deletePage(int page, int navigateDirection);

int getTotalPages();

RichText getPageContent(int page);

void setPageContent(int page, RichText content);

void refreshPages();

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most of these methods feel unneccessary, this can all be done using switchAndFocusPage, see also EditCommand:

  • getTotalPages feels misplaced? It's never used in history commands.
  • getPageContent -> textField.getRichText.
  • setPageContent -> textField.setValueWithoutUpdating or textField.setValue.
  • refreshPages -> textField.sendUpdateFormat

I might be missing something here, do tell me, but I'd rather avoid having multiple ways to do the same thing.

}
Loading
Loading