diff --git a/.gitignore b/.gitignore index f80fa88..3bbddbf 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,10 @@ repo .claude/ # Fabric version backup -FabricVersion/ \ No newline at end of file +FabricVersion/ + +# External libs +libs/ + +# Logs +*.log \ No newline at end of file diff --git a/src/main/java/com/opencode/minecraft/client/OpenCodeClient.java b/src/main/java/com/opencode/minecraft/client/OpenCodeClient.java index 4262304..a05b757 100644 --- a/src/main/java/com/opencode/minecraft/client/OpenCodeClient.java +++ b/src/main/java/com/opencode/minecraft/client/OpenCodeClient.java @@ -30,6 +30,8 @@ public class OpenCodeClient { private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); private volatile boolean initialized = false; + private volatile java.util.function.Consumer guiMessageListener = null; + private volatile Runnable guiResponseCompleteListener = null; public OpenCodeClient(ModConfig config, PauseController pauseController) { this.config = config; @@ -99,6 +101,10 @@ private void handleEvent(SseEvent event) { if ("idle".equals(statusType)) { sessionManager.onSessionIdle(); messageRenderer.sendSystemMessage("Ready for input"); + // Notify GUI that response is complete + if (guiResponseCompleteListener != null) { + guiResponseCompleteListener.run(); + } } else if ("busy".equals(statusType)) { sessionManager.onSessionBusy(); messageRenderer.sendSystemMessage("Processing..."); @@ -108,7 +114,8 @@ private void handleEvent(SseEvent event) { handlePartUpdated(event); } case "message.created" -> { - messageRenderer.startNewMessage(); + // Don't clutter chat with message creation events + // messageRenderer.startNewMessage(); } case "session.error" -> { messageRenderer.sendErrorMessage("Session error occurred"); @@ -138,7 +145,13 @@ private void handlePartUpdated(SseEvent event) { pauseController.onDeltaReceived(); String delta = event.getDelta(); if (delta != null && !delta.isEmpty()) { - messageRenderer.appendDelta(delta); + // Don't send AI text to chat - only show in GUI + // messageRenderer.appendDelta(delta); + + // Notify GUI listener if present + if (guiMessageListener != null) { + guiMessageListener.accept(delta); + } } } } @@ -267,6 +280,35 @@ public SessionStatus getStatus() { return sessionManager.getStatus(); } + /** + * Gets the message history for a session + */ + public CompletableFuture getSessionMessages(String sessionId) { + return httpClient.getSessionMessages(sessionId); + } + + /** + * Sets a listener for real-time message updates (for GUI) + */ + public void setGuiMessageListener(java.util.function.Consumer listener) { + this.guiMessageListener = listener; + } + + /** + * Sets a listener for when a response completes (for GUI) + */ + public void setGuiResponseCompleteListener(Runnable listener) { + this.guiResponseCompleteListener = listener; + } + + /** + * Removes the GUI message listeners + */ + public void clearGuiMessageListener() { + this.guiMessageListener = null; + this.guiResponseCompleteListener = null; + } + /** * Returns true if connected and initialized */ diff --git a/src/main/java/com/opencode/minecraft/client/http/OpenCodeHttpClient.java b/src/main/java/com/opencode/minecraft/client/http/OpenCodeHttpClient.java index ace63fb..aaefeac 100644 --- a/src/main/java/com/opencode/minecraft/client/http/OpenCodeHttpClient.java +++ b/src/main/java/com/opencode/minecraft/client/http/OpenCodeHttpClient.java @@ -75,10 +75,11 @@ public CompletableFuture checkHealth() { public CompletableFuture createSession() { JsonObject body = new JsonObject(); + // Don't send x-opencode-directory header - let OpenCode use its default directory + // (The mod's config directory contains a config file that OpenCode doesn't understand) HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(baseUrl + "/session")) .header("Content-Type", "application/json") - .header("x-opencode-directory", directory) .timeout(Duration.ofSeconds(10)) .POST(HttpRequest.BodyPublishers.ofString(body.toString())) .build(); @@ -99,7 +100,6 @@ public CompletableFuture createSession() { public CompletableFuture> listSessions() { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(baseUrl + "/session")) - .header("x-opencode-directory", directory) .timeout(Duration.ofSeconds(10)) .GET() .build(); @@ -124,7 +124,6 @@ public CompletableFuture> listSessions() { public CompletableFuture getSession(String sessionId) { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(baseUrl + "/session/" + sessionId)) - .header("x-opencode-directory", directory) .timeout(Duration.ofSeconds(10)) .GET() .build(); @@ -188,7 +187,6 @@ public CompletableFuture sendPrompt(String sessionId, String text) { public CompletableFuture abortSession(String sessionId) { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(baseUrl + "/session/" + sessionId + "/abort")) - .header("x-opencode-directory", directory) .timeout(Duration.ofSeconds(10)) .POST(HttpRequest.BodyPublishers.noBody()) .build(); @@ -201,6 +199,30 @@ public CompletableFuture abortSession(String sessionId) { }); } + /** + * Gets the message history for a session + */ + public CompletableFuture getSessionMessages(String sessionId) { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + "/session/" + sessionId + "/message")) + .timeout(Duration.ofSeconds(10)) + .GET() + .build(); + + return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()) + .thenApply(response -> { + if (response.statusCode() != 200) { + OpenCodeMod.LOGGER.warn("Failed to get messages: {}", response.statusCode()); + return new JsonArray(); + } + return JsonParser.parseString(response.body()).getAsJsonArray(); + }) + .exceptionally(e -> { + OpenCodeMod.LOGGER.warn("Failed to get messages: {}", e.getMessage()); + return new JsonArray(); + }); + } + /** * Subscribes to the global event stream (SSE) */ @@ -222,7 +244,6 @@ private void runSseLoop() { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(baseUrl + "/global/event")) .header("Accept", "text/event-stream") - .header("x-opencode-directory", directory) .GET() .build(); diff --git a/src/main/java/com/opencode/minecraft/command/OpenCodeCommand.java b/src/main/java/com/opencode/minecraft/command/OpenCodeCommand.java index d5945bf..0e6d070 100644 --- a/src/main/java/com/opencode/minecraft/command/OpenCodeCommand.java +++ b/src/main/java/com/opencode/minecraft/command/OpenCodeCommand.java @@ -6,7 +6,9 @@ import com.opencode.minecraft.OpenCodeMod; import com.opencode.minecraft.client.OpenCodeClient; import com.opencode.minecraft.client.session.SessionInfo; +import com.opencode.minecraft.gui.OpenCodeGuiScreen; import net.minecraft.ChatFormatting; +import net.minecraft.client.Minecraft; import net.minecraft.commands.CommandSourceStack; import net.minecraft.commands.Commands; import net.minecraft.network.chat.Component; @@ -20,6 +22,7 @@ * Commands: * - /oc - Show help * - /oc help - Show help + * - /oc gui - Open ModernUI GUI interface * - /oc - Send a prompt to OpenCode * - /oc status - Show connection and session status * - /oc session new - Create a new session @@ -42,6 +45,10 @@ public static void register(CommandDispatcher dispatcher) { .then(Commands.literal("help") .executes(OpenCodeCommand::executeHelp)) + // /oc gui + .then(Commands.literal("gui") + .executes(OpenCodeCommand::executeGui)) + // /oc status .then(Commands.literal("status") .executes(OpenCodeCommand::executeStatus)) @@ -93,6 +100,8 @@ private static int executeHelp(CommandContext context) { source.sendSystemMessage(Component.literal("=== OpenCode Commands ===").withStyle(ChatFormatting.AQUA, ChatFormatting.BOLD)); source.sendSystemMessage(Component.literal("/oc ").withStyle(ChatFormatting.GREEN) .append(Component.literal(" - Send a prompt").withStyle(ChatFormatting.GRAY))); + source.sendSystemMessage(Component.literal("/oc gui").withStyle(ChatFormatting.GREEN) + .append(Component.literal(" - Open GUI interface").withStyle(ChatFormatting.GRAY))); source.sendSystemMessage(Component.literal("/oc status").withStyle(ChatFormatting.GREEN) .append(Component.literal(" - Show status").withStyle(ChatFormatting.GRAY))); source.sendSystemMessage(Component.literal("/oc session new").withStyle(ChatFormatting.GREEN) @@ -111,6 +120,14 @@ private static int executeHelp(CommandContext context) { return 1; } + private static int executeGui(CommandContext context) { + // Open terminal GUI on client thread + Minecraft.getInstance().execute(() -> { + Minecraft.getInstance().setScreen(new OpenCodeGuiScreen()); + }); + return 1; + } + private static int executeStatus(CommandContext context) { CommandSourceStack source = context.getSource(); OpenCodeClient client = OpenCodeMod.getClient(); diff --git a/src/main/java/com/opencode/minecraft/gui/OpenCodeGuiScreen.java b/src/main/java/com/opencode/minecraft/gui/OpenCodeGuiScreen.java new file mode 100644 index 0000000..696bdfe --- /dev/null +++ b/src/main/java/com/opencode/minecraft/gui/OpenCodeGuiScreen.java @@ -0,0 +1,458 @@ +package com.opencode.minecraft.gui; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.opencode.minecraft.OpenCodeMod; +import com.opencode.minecraft.client.session.SessionInfo; +import com.opencode.minecraft.gui.markdown.FormattedLine; +import com.opencode.minecraft.gui.markdown.MarkdownParser; +import com.opencode.minecraft.gui.markdown.TextSegment; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import org.lwjgl.glfw.GLFW; + +import java.util.ArrayList; +import java.util.List; + +/** + * Terminal-style GUI screen for OpenCode chat interface + * Inspired by classic command-line terminals + */ +public class OpenCodeGuiScreen extends Screen { + + // Expanded autumn theme colors - vibrant reds, oranges, golds, and coppers + private static final int BACKGROUND_COLOR = 0xE01a1210; // Dark warm brown (semi-transparent) + private static final int BORDER_COLOR = 0xFFff8c42; // Burnt orange border + private static final int TEXT_COLOR = 0xFFfff8dc; // Warm white (cornsilk) + private static final int INPUT_COLOR = 0xFFfff8dc; // Warm white input + private static final int PROMPT_COLOR = 0xFFff8c42; // Burnt orange prompt + + private EditBox inputField; + private List messageHistory; + private int scrollOffset = 0; + private StringBuilder currentAssistantMessage = new StringBuilder(); + private boolean receivingResponse = false; + + public OpenCodeGuiScreen() { + super(Component.literal("OpenCode Terminal")); + this.messageHistory = new ArrayList<>(); + } + + @Override + protected void init() { + super.init(); + + // Load message history from current session + if (messageHistory.isEmpty()) { + loadMessageHistory(); + } + + // Register for real-time message updates + OpenCodeMod.getClient().setGuiMessageListener(this::onMessageDelta); + OpenCodeMod.getClient().setGuiResponseCompleteListener(this::onResponseComplete); + + // Calculate dimensions for terminal window + int terminalWidth = this.width - 40; + int terminalHeight = this.height - 40; + int terminalX = 20; + int terminalY = 20; + + // Input field at bottom + int inputY = terminalY + terminalHeight - 30; + int inputX = terminalX + 30; // Leave space for prompt ">" + + this.inputField = new EditBox( + this.font, + inputX, + inputY, + terminalWidth - 40, + 20, + Component.literal("Input") + ); + this.inputField.setMaxLength(1000); + this.inputField.setBordered(false); + this.inputField.setTextColor(INPUT_COLOR); + this.inputField.setValue(""); + this.addRenderableWidget(this.inputField); + this.setInitialFocus(this.inputField); + } + + @Override + public void removed() { + super.removed(); + // Unregister listener when GUI is closed + OpenCodeMod.getClient().clearGuiMessageListener(); + } + + /** + * Called when a message delta arrives via SSE + */ + private void onMessageDelta(String delta) { + // This is called from the main thread via OpenCodeClient + if (!receivingResponse) { + // Start of a new response + receivingResponse = true; + currentAssistantMessage.setLength(0); + } + + // Append delta to current message + currentAssistantMessage.append(delta); + + // Update the last message line with the accumulated text + updateLastMessage("[OPENCODE] " + currentAssistantMessage.toString()); + + // Auto-scroll to bottom when receiving messages + scrollOffset = 0; + } + + /** + * Called when a response completes (session goes to idle) + */ + private void onResponseComplete() { + if (receivingResponse) { + // Add spacing after completed response + addMessage("", 0xFFffbf00); // Amber + receivingResponse = false; + } + } + + /** + * Updates the last message in the history (used for streaming updates) + */ + private void updateLastMessage(String newText) { + // Find where the last assistant message starts by looking backwards + int lastMessageStart = messageHistory.size(); + + // Look for the last [OPENCODE] message + for (int i = messageHistory.size() - 1; i >= 0; i--) { + FormattedLine line = messageHistory.get(i); + String plainText = line.getPlainText(); + if (plainText.trim().startsWith("[OPENCODE]")) { + lastMessageStart = i; + break; + } + // Stop at empty lines or other message types + if (plainText.trim().isEmpty() || plainText.trim().startsWith("[YOU]") || plainText.trim().startsWith("[SYSTEM]")) { + break; + } + } + + // Remove old assistant message lines + while (messageHistory.size() > lastMessageStart) { + messageHistory.remove(messageHistory.size() - 1); + } + + // Parse markdown and add the new text + List parsedLines = MarkdownParser.parse(newText, 0xFFff8c00); // Dark orange for responses + messageHistory.addAll(parsedLines); + } + + private void loadMessageHistory() { + // Add header + addMessage("[SYSTEM] OpenCode Terminal v1.0", 0xFFff8c42); // Burnt orange + addMessage("", 0xFFffbf00); + + // Get current session and load messages + SessionInfo session = OpenCodeMod.getClient().getCurrentSession(); + if (session != null) { + addMessage("[SYSTEM] Loading session: " + session.getId(), 0xFFffbf00); // Amber + addMessage("", 0xFFffbf00); + + // Fetch messages asynchronously + OpenCodeMod.getClient().getCurrentSession(); + // Access the HTTP client through reflection or add a getter + // For now, we'll need to add a method to OpenCodeClient to expose this + loadSessionMessages(session.getId()); + } else { + addMessage("[SYSTEM] No active session", 0xFFffbf00); // Amber + addMessage("[SYSTEM] Run '/oc session new' to create a session", 0xFFffbf00); + addMessage("", 0xFFffbf00); + } + } + + private void loadSessionMessages(String sessionId) { + OpenCodeMod.getClient().getSessionMessages(sessionId) + .thenAccept(messages -> { + // Process on main thread + net.minecraft.client.Minecraft.getInstance().execute(() -> { + if (messages.size() == 0) { + addMessage("[SYSTEM] No messages in session yet", 0xFFffbf00); // Amber + addMessage("[SYSTEM] Type your prompt below to start", 0xFFffbf00); + addMessage("", 0xFFffbf00); + } else { + addMessage("[SYSTEM] Loaded " + messages.size() + " messages", 0xFFffbf00); // Amber + addMessage("", 0xFFffbf00); + + // Parse and display each message + for (JsonElement msgElement : messages) { + parseAndDisplayMessage(msgElement.getAsJsonObject()); + } + } + }); + }) + .exceptionally(e -> { + net.minecraft.client.Minecraft.getInstance().execute(() -> { + addMessage("[ERROR] Failed to load messages: " + e.getMessage(), 0xFFdc143c); // Crimson red + addMessage("", 0xFFffbf00); + }); + return null; + }); + } + + private void parseAndDisplayMessage(JsonObject message) { + // Get message info + JsonObject info = message.has("info") ? message.getAsJsonObject("info") : null; + if (info == null) return; + + String role = info.has("role") ? info.get("role").getAsString() : "unknown"; + + // Get message parts + JsonArray parts = message.has("parts") ? message.getAsJsonArray("parts") : new JsonArray(); + + // Display based on role + for (JsonElement partElement : parts) { + JsonObject part = partElement.getAsJsonObject(); + String type = part.has("type") ? part.get("type").getAsString() : ""; + + // Only display text parts + if ("text".equals(type) && part.has("text")) { + String text = part.get("text").getAsString(); + + if ("user".equals(role)) { + addMessage("[YOU] " + text, 0xFFffa07a); // Light salmon + } else if ("assistant".equals(role)) { + addMessage("[OPENCODE] " + text, 0xFFff8c00); // Dark orange + } + } + } + + // Add spacing after each message exchange + addMessage("", 0xFFffbf00); + } + + @Override + public void render(GuiGraphics guiGraphics, int mouseX, int mouseY, float partialTick) { + // Render dark background to prevent blur + renderBackground(guiGraphics, mouseX, mouseY, partialTick); + + // Calculate dimensions with proper margins + int terminalWidth = this.width - 40; + int terminalHeight = this.height - 40; // Balanced margins + int terminalX = 20; + int terminalY = 20; + + // Draw terminal background (warm dark brown) + guiGraphics.fill(0, 0, this.width, this.height, 0xFF1a1210); // Full warm brown background + guiGraphics.fill(terminalX, terminalY, terminalX + terminalWidth, terminalY + terminalHeight, BACKGROUND_COLOR); + + // Draw border (clean graphical rectangles) + drawTerminalBorder(guiGraphics, terminalX, terminalY, terminalWidth, terminalHeight); + + // Draw title bar (centered, with spacing from top border) + String title = "OpenCode Terminal"; + int titleX = terminalX + (terminalWidth - this.font.width(title)) / 2; + guiGraphics.drawString(this.font, title, titleX, terminalY + 8, BORDER_COLOR, false); + + // Draw message history with proper margins + int borderThickness = 2; + int messageX = terminalX + borderThickness + 8; // Left margin inside border + int messageY = terminalY + 24; // Below title + int maxX = terminalX + terminalWidth - borderThickness - 8; // Right margin + int maxY = terminalY + terminalHeight - 40; + int messageWidth = maxX - messageX; + int lineHeight = this.font.lineHeight + 2; + + // Calculate how many lines can actually fit on screen + int availableHeight = maxY - messageY; + int maxVisibleLines = Math.max(1, availableHeight / lineHeight); + + int startIndex = Math.max(0, messageHistory.size() - maxVisibleLines - scrollOffset); + int endIndex = Math.min(messageHistory.size(), startIndex + maxVisibleLines); + + // Enable scissor (clipping) to prevent text overflow + guiGraphics.enableScissor(messageX, messageY, maxX, maxY); + + for (int i = startIndex; i < endIndex; i++) { + if (messageY >= maxY) break; + + FormattedLine line = messageHistory.get(i); + renderFormattedLine(guiGraphics, line, messageX, messageY, messageWidth); + messageY += lineHeight; + } + + // Disable scissor + guiGraphics.disableScissor(); + + // Draw input prompt + int inputY = terminalY + terminalHeight - 30; + guiGraphics.drawString(this.font, ">", terminalX + 10, inputY + 6, PROMPT_COLOR, false); + + // Draw input field border (darker copper) + int inputBoxX = terminalX + 25; + int inputBoxY = inputY + 2; + int inputBoxWidth = terminalWidth - 35; + guiGraphics.fill(inputBoxX, inputBoxY, inputBoxX + inputBoxWidth, inputBoxY + 18, 0xFF4a2f1e); + + // Render widgets (input field) + super.render(guiGraphics, mouseX, mouseY, partialTick); + + // Draw scroll indicator if needed (inside terminal, above input field) + if (messageHistory.size() > maxVisibleLines) { + String scrollInfo = String.format("[↑↓ scroll %d/%d]", + Math.max(0, messageHistory.size() - maxVisibleLines - scrollOffset), + messageHistory.size() - maxVisibleLines); + // Position above input field, bottom right + int scrollX = terminalX + terminalWidth - this.font.width(scrollInfo) - 12; + int scrollY = terminalY + terminalHeight - 40; // Above input, inside border + guiGraphics.drawString(this.font, scrollInfo, scrollX, scrollY, 0xFFffbf00, false); // Amber + } + } + + private void drawTerminalBorder(GuiGraphics guiGraphics, int x, int y, int width, int height) { + int borderThickness = 2; + + // Draw solid border rectangles + // Top border + guiGraphics.fill(x, y, x + width, y + borderThickness, BORDER_COLOR); + + // Bottom border + guiGraphics.fill(x, y + height - borderThickness, x + width, y + height, BORDER_COLOR); + + // Left border + guiGraphics.fill(x, y, x + borderThickness, y + height, BORDER_COLOR); + + // Right border + guiGraphics.fill(x + width - borderThickness, y, x + width, y + height, BORDER_COLOR); + } + + @Override + public boolean keyPressed(int keyCode, int scanCode, int modifiers) { + if (keyCode == GLFW.GLFW_KEY_ENTER || keyCode == GLFW.GLFW_KEY_KP_ENTER) { + handleSubmit(); + return true; + } + + // Scroll with arrow keys + if (keyCode == GLFW.GLFW_KEY_UP) { + int availableHeight = (this.height - 40 - 40 - 24); + int lineHeight = this.font.lineHeight + 2; + int maxVisibleLines = Math.max(1, availableHeight / lineHeight); + scrollOffset = Math.min(scrollOffset + 1, Math.max(0, messageHistory.size() - maxVisibleLines)); + return true; + } + if (keyCode == GLFW.GLFW_KEY_DOWN) { + scrollOffset = Math.max(0, scrollOffset - 1); + return true; + } + + if (keyCode == GLFW.GLFW_KEY_ESCAPE) { + this.onClose(); + return true; + } + + return this.inputField.keyPressed(keyCode, scanCode, modifiers) || super.keyPressed(keyCode, scanCode, modifiers); + } + + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double deltaX, double deltaY) { + // Scroll with mouse wheel + int availableHeight = (this.height - 40 - 40 - 24); + int lineHeight = this.font.lineHeight + 2; + int maxVisibleLines = Math.max(1, availableHeight / lineHeight); + + if (deltaY > 0) { + scrollOffset = Math.min(scrollOffset + 1, Math.max(0, messageHistory.size() - maxVisibleLines)); + } else if (deltaY < 0) { + scrollOffset = Math.max(0, scrollOffset - 1); + } + return true; + } + + private void handleSubmit() { + String text = this.inputField.getValue().trim(); + if (!text.isEmpty()) { + // Check if we have an active session + SessionInfo session = OpenCodeMod.getClient().getCurrentSession(); + if (session == null) { + addMessage("[ERROR] No active session. Run '/oc session new' first", 0xFFdc143c); // Crimson red + this.inputField.setValue(""); + return; + } + + addMessage("[YOU] " + text, 0xFFffa07a); // Light salmon for user input + addMessage("", 0xFFffbf00); // Empty line for spacing + this.inputField.setValue(""); + + // Reset response tracking + receivingResponse = false; + currentAssistantMessage.setLength(0); + + // Send to OpenCode server + OpenCodeMod.getClient().sendPrompt(text) + .exceptionally(e -> { + net.minecraft.client.Minecraft.getInstance().execute(() -> { + addMessage("[ERROR] Failed to send message: " + e.getMessage(), 0xFFdc143c); // Crimson red + }); + return null; + }); + + // Auto-scroll to bottom + scrollOffset = 0; + } + } + + public void addMessage(String message, int color) { + // Parse markdown and add formatted lines + List parsedLines = MarkdownParser.parse(message, color); + messageHistory.addAll(parsedLines); + + // Keep scroll at bottom for new messages + if (scrollOffset == 0) { + // Already at bottom, stay there + } + } + + /** + * Renders a formatted line with multiple colored segments, clipped to max width + */ + private void renderFormattedLine(GuiGraphics guiGraphics, FormattedLine line, int x, int y, int maxWidth) { + int currentX = x; + + // Add indentation for code blocks and lists + for (int i = 0; i < line.getIndentLevel(); i++) { + currentX += this.font.width(" "); + } + + // Draw each segment with boundary checking + for (TextSegment segment : line.getSegments()) { + String text = segment.getText(); + int color = segment.getEffectiveColor(); + + // Check if we have space left + int remainingWidth = (x + maxWidth) - currentX; + if (remainingWidth <= 0) break; + + // Truncate text if it's too long + int textWidth = this.font.width(text); + if (textWidth > remainingWidth) { + // Find how much of the text fits + String truncated = text; + while (this.font.width(truncated + "...") > remainingWidth && truncated.length() > 0) { + truncated = truncated.substring(0, truncated.length() - 1); + } + text = truncated + "..."; + } + + guiGraphics.drawString(this.font, text, currentX, y, color, false); + currentX += this.font.width(text); + } + } + + @Override + public boolean isPauseScreen() { + return false; // Don't pause the game + } +} diff --git a/src/main/java/com/opencode/minecraft/gui/markdown/FormattedLine.java b/src/main/java/com/opencode/minecraft/gui/markdown/FormattedLine.java new file mode 100644 index 0000000..64f62f4 --- /dev/null +++ b/src/main/java/com/opencode/minecraft/gui/markdown/FormattedLine.java @@ -0,0 +1,64 @@ +package com.opencode.minecraft.gui.markdown; + +import java.util.ArrayList; +import java.util.List; + +/** + * Represents a line with multiple styled text segments + */ +public class FormattedLine { + private final List segments; + private final boolean codeBlock; + private final int indentLevel; + + public FormattedLine() { + this(new ArrayList<>(), false, 0); + } + + public FormattedLine(List segments, boolean codeBlock, int indentLevel) { + this.segments = segments; + this.codeBlock = codeBlock; + this.indentLevel = indentLevel; + } + + public List getSegments() { + return segments; + } + + public void addSegment(TextSegment segment) { + segments.add(segment); + } + + public void addSegment(String text, int color) { + segments.add(new TextSegment(text, color)); + } + + public boolean isCodeBlock() { + return codeBlock; + } + + public int getIndentLevel() { + return indentLevel; + } + + public boolean isEmpty() { + return segments.isEmpty() || + (segments.size() == 1 && segments.get(0).getText().trim().isEmpty()); + } + + /** + * Gets the plain text content (for width calculations) + */ + public String getPlainText() { + StringBuilder sb = new StringBuilder(); + // Add indent + for (int i = 0; i < indentLevel; i++) { + sb.append(" "); + } + // Add text + for (TextSegment segment : segments) { + sb.append(segment.getText()); + } + return sb.toString(); + } +} diff --git a/src/main/java/com/opencode/minecraft/gui/markdown/MarkdownParser.java b/src/main/java/com/opencode/minecraft/gui/markdown/MarkdownParser.java new file mode 100644 index 0000000..5751962 --- /dev/null +++ b/src/main/java/com/opencode/minecraft/gui/markdown/MarkdownParser.java @@ -0,0 +1,217 @@ +package com.opencode.minecraft.gui.markdown; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Parses markdown text into formatted lines with styled segments + */ +public class MarkdownParser { + // Expanded autumn theme color palette - vibrant and varied + private static final int COLOR_TEXT = 0xFFfff8dc; // Warm white (cornsilk) + private static final int COLOR_BOLD = 0xFFb7410e; // Rust red for bold/headers + private static final int COLOR_CODE = 0xFFffd700; // Golden yellow for inline code + private static final int COLOR_CODE_BLOCK = 0xFFd2691e; // Copper for code blocks + private static final int COLOR_LINK = 0xFF800020; // Burgundy for links + + // Regex patterns for inline markdown + private static final Pattern BOLD_PATTERN = Pattern.compile("\\*\\*(.+?)\\*\\*|__(.+?)__"); + private static final Pattern ITALIC_PATTERN = Pattern.compile("\\*(.+?)\\*|_(.+?)_"); + private static final Pattern CODE_PATTERN = Pattern.compile("`([^`]+?)`"); + private static final Pattern LINK_PATTERN = Pattern.compile("\\[([^\\]]+?)\\]\\(([^\\)]+?)\\)"); + + /** + * Parse markdown text into formatted lines with word wrapping + */ + public static List parse(String markdownText, int baseColor) { + return parse(markdownText, baseColor, 1200); // Default max width + } + + /** + * Parse markdown text into formatted lines with specified max width + */ + public static List parse(String markdownText, int baseColor, int maxWidth) { + List lines = new ArrayList<>(); + String[] rawLines = markdownText.split("\n"); + + boolean inCodeBlock = false; + String codeBlockLang = ""; + + for (String line : rawLines) { + // Check for code block start/end + if (line.trim().startsWith("```")) { + if (!inCodeBlock) { + // Starting code block + inCodeBlock = true; + codeBlockLang = line.trim().substring(3).trim(); + // Add a separator line + FormattedLine separator = new FormattedLine(); + separator.addSegment("[ " + (codeBlockLang.isEmpty() ? "code" : codeBlockLang) + " ]", COLOR_CODE); + lines.add(separator); + } else { + // Ending code block + inCodeBlock = false; + FormattedLine separator = new FormattedLine(); + separator.addSegment("", COLOR_CODE); // Empty separator + lines.add(separator); + } + continue; + } + + if (inCodeBlock) { + // Code block line - wrap if too long + if (line.length() > 120) { // Wrap very long code lines + int chunkSize = 120; + for (int i = 0; i < line.length(); i += chunkSize) { + String chunk = line.substring(i, Math.min(i + chunkSize, line.length())); + FormattedLine codeLine = new FormattedLine(new ArrayList<>(), true, 1); + codeLine.addSegment(chunk, COLOR_CODE_BLOCK); + lines.add(codeLine); + } + } else { + FormattedLine codeLine = new FormattedLine(new ArrayList<>(), true, 1); + codeLine.addSegment(line, COLOR_CODE_BLOCK); + lines.add(codeLine); + } + } else { + // Regular line - parse inline markdown + FormattedLine formattedLine = parseInlineMarkdown(line, baseColor); + lines.add(formattedLine); + } + } + + return lines; + } + + /** + * Parse inline markdown within a single line + */ + private static FormattedLine parseInlineMarkdown(String line, int baseColor) { + FormattedLine formattedLine = new FormattedLine(); + + // Check for header + int headerLevel = 0; + String trimmed = line.trim(); + while (trimmed.startsWith("#")) { + headerLevel++; + trimmed = trimmed.substring(1); + } + if (headerLevel > 0 && trimmed.startsWith(" ")) { + // This is a header + trimmed = trimmed.trim(); + formattedLine.addSegment(trimmed, COLOR_BOLD); + return formattedLine; + } + + // Check for list items + int indentLevel = 0; + if (line.trim().startsWith("- ") || line.trim().startsWith("* ")) { + formattedLine = new FormattedLine(new ArrayList<>(), false, 1); + line = "• " + line.trim().substring(2); + } else if (line.trim().matches("^\\d+\\.\\s+.*")) { + formattedLine = new FormattedLine(new ArrayList<>(), false, 1); + } + + // Parse inline formatting (bold, italic, code, links) + parseInlineFormatting(line, baseColor, formattedLine); + + return formattedLine; + } + + /** + * Parse inline formatting like **bold**, *italic*, `code`, [links] + */ + private static void parseInlineFormatting(String text, int baseColor, FormattedLine line) { + List spans = new ArrayList<>(); + + // Find all formatting spans + Matcher boldMatcher = BOLD_PATTERN.matcher(text); + while (boldMatcher.find()) { + String content = boldMatcher.group(1) != null ? boldMatcher.group(1) : boldMatcher.group(2); + spans.add(new FormatSpan(boldMatcher.start(), boldMatcher.end(), content, FormatType.BOLD)); + } + + Matcher codeMatcher = CODE_PATTERN.matcher(text); + while (codeMatcher.find()) { + spans.add(new FormatSpan(codeMatcher.start(), codeMatcher.end(), codeMatcher.group(1), FormatType.CODE)); + } + + Matcher linkMatcher = LINK_PATTERN.matcher(text); + while (linkMatcher.find()) { + spans.add(new FormatSpan(linkMatcher.start(), linkMatcher.end(), linkMatcher.group(1), FormatType.LINK)); + } + + // Sort spans by start position + spans.sort((a, b) -> Integer.compare(a.start, b.start)); + + // Build segments + int currentPos = 0; + for (FormatSpan span : spans) { + // Add text before this span + if (span.start > currentPos) { + String before = text.substring(currentPos, span.start); + if (!before.isEmpty()) { + line.addSegment(before, baseColor); + } + } + + // Add formatted span + int color = baseColor; + boolean isBold = false; + boolean isCode = false; + + switch (span.type) { + case BOLD: + color = COLOR_BOLD; + isBold = true; + break; + case CODE: + color = COLOR_CODE; + isCode = true; + break; + case LINK: + color = COLOR_LINK; + break; + } + + line.addSegment(new TextSegment(span.content, color, isBold, false, isCode, false)); + currentPos = span.end; + } + + // Add remaining text + if (currentPos < text.length()) { + String remaining = text.substring(currentPos); + if (!remaining.isEmpty()) { + line.addSegment(remaining, baseColor); + } + } + + // If no formatting found, add the whole line + if (line.getSegments().isEmpty()) { + line.addSegment(text, baseColor); + } + } + + /** + * Helper class to track format spans + */ + private static class FormatSpan { + int start; + int end; + String content; + FormatType type; + + FormatSpan(int start, int end, String content, FormatType type) { + this.start = start; + this.end = end; + this.content = content; + this.type = type; + } + } + + private enum FormatType { + BOLD, ITALIC, CODE, LINK + } +} diff --git a/src/main/java/com/opencode/minecraft/gui/markdown/TextSegment.java b/src/main/java/com/opencode/minecraft/gui/markdown/TextSegment.java new file mode 100644 index 0000000..9a63643 --- /dev/null +++ b/src/main/java/com/opencode/minecraft/gui/markdown/TextSegment.java @@ -0,0 +1,68 @@ +package com.opencode.minecraft.gui.markdown; + +/** + * Represents a styled segment of text with color and formatting + */ +public class TextSegment { + private final String text; + private final int color; + private final boolean bold; + private final boolean italic; + private final boolean code; + private final boolean header; + + public TextSegment(String text, int color) { + this(text, color, false, false, false, false); + } + + public TextSegment(String text, int color, boolean bold, boolean italic, boolean code, boolean header) { + this.text = text; + this.color = color; + this.bold = bold; + this.italic = italic; + this.code = code; + this.header = header; + } + + public String getText() { + return text; + } + + public int getColor() { + return color; + } + + public boolean isBold() { + return bold; + } + + public boolean isItalic() { + return italic; + } + + public boolean isCode() { + return code; + } + + public boolean isHeader() { + return header; + } + + public int getEffectiveColor() { + // Apply brightness adjustments for styling + if (bold || header) { + // Make bold/headers brighter + int r = (color >> 16) & 0xFF; + int g = (color >> 8) & 0xFF; + int b = color & 0xFF; + + // Increase brightness by 20% + r = Math.min(255, (int)(r * 1.2)); + g = Math.min(255, (int)(g * 1.2)); + b = Math.min(255, (int)(b * 1.2)); + + return 0xFF000000 | (r << 16) | (g << 8) | b; + } + return color; + } +}