From 67135b433d18328c3d19b0450a95f688dd2842ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Wi=C5=9Bniewski?= Date: Sun, 4 May 2025 12:57:25 +0200 Subject: [PATCH] Add chat history accessible with up/down arrows (#1244) * Add up down message history * Deduplicate messages when inserting into history * Add dedicated function for inserting strings into TextInput * Fix formatting issues * Change history behavior * Rename inputString to setString * Move clearing to setString * Apply suggestions from code review Co-authored-by: IntegratedQuantum <43880493+IntegratedQuantum@users.noreply.github.com> * Remove unused cursor capture * Use FixedSizeCircularBuffer for history * Restore large queue size * Allow navigation to empty entry * self.len must never be bigger than capacity * Move optional callbacks into struct * Use enum for moveCursorVertically return value * WA attempt #1 * Fix edge case from review * Update src/gui/windows/chat.zig Co-authored-by: IntegratedQuantum <43880493+IntegratedQuantum@users.noreply.github.com> * Remove isEmpty and isFull * Allow for empty history entry * Change empty message handling some more * Remove unused methods * Go to hell with all of those edge cases <3 * WA for 4b * Remove CircularBufferQueue methods * Fix queue thing --------- Co-authored-by: IntegratedQuantum <43880493+IntegratedQuantum@users.noreply.github.com> --- src/gui/components/TextInput.zig | 38 +++++++++-- src/gui/windows/change_name.zig | 2 +- src/gui/windows/chat.zig | 103 +++++++++++++++++++++++++++++- src/gui/windows/invite.zig | 2 +- src/gui/windows/multiplayer.zig | 2 +- src/gui/windows/save_creation.zig | 2 +- src/utils.zig | 53 ++++++++++++++- 7 files changed, 189 insertions(+), 13 deletions(-) diff --git a/src/gui/components/TextInput.zig b/src/gui/components/TextInput.zig index ea89b17c..318dc4cd 100644 --- a/src/gui/components/TextInput.zig +++ b/src/gui/components/TextInput.zig @@ -33,6 +33,7 @@ maxHeight: f32, textSize: Vec2f = undefined, scrollBar: *ScrollBar, onNewline: gui.Callback, +optional: OptionalCallbacks, pub fn __init() void { texture = Texture.initFromFile("assets/cubyz/ui/text_input.png"); @@ -42,7 +43,12 @@ pub fn __deinit() void { texture.deinit(); } -pub fn init(pos: Vec2f, maxWidth: f32, maxHeight: f32, text: []const u8, onNewline: gui.Callback) *TextInput { +const OptionalCallbacks = struct { + onUp: ?gui.Callback = null, + onDown: ?gui.Callback = null, +}; + +pub fn init(pos: Vec2f, maxWidth: f32, maxHeight: f32, text: []const u8, onNewline: gui.Callback, optional: OptionalCallbacks) *TextInput { const scrollBar = ScrollBar.init(undefined, scrollBarWidth, maxHeight - 2*border, 0); const self = main.globalAllocator.create(TextInput); self.* = TextInput{ @@ -54,6 +60,7 @@ pub fn init(pos: Vec2f, maxWidth: f32, maxHeight: f32, text: []const u8, onNewli .maxHeight = maxHeight, .scrollBar = scrollBar, .onNewline = onNewline, + .optional = optional, }; self.currentString.appendSlice(text); self.textSize = self.textBuffer.calculateLineBreaks(fontSize, maxWidth - 2*border - scrollBarWidth); @@ -239,8 +246,13 @@ pub fn right(self: *TextInput, mods: main.Window.Key.Modifiers) void { } } -fn moveCursorVertically(self: *TextInput, relativeLines: f32) void { - self.cursor = self.textBuffer.mousePosToIndex(self.textBuffer.indexToCursorPos(self.cursor.?) + Vec2f{0, 16*relativeLines}, self.currentString.items.len); +fn moveCursorVertically(self: *TextInput, relativeLines: f32) enum {changed, same} { + const newCursor = self.textBuffer.mousePosToIndex(self.textBuffer.indexToCursorPos(self.cursor.?) + Vec2f{0, 16*relativeLines}, self.currentString.items.len); + self.cursor = newCursor; + if(self.cursor != newCursor) { + return .changed; + } + return .same; } pub fn down(self: *TextInput, mods: main.Window.Key.Modifiers) void { @@ -249,7 +261,7 @@ pub fn down(self: *TextInput, mods: main.Window.Key.Modifiers) void { if(self.selectionStart == null) { self.selectionStart = cursor.*; } - self.moveCursorVertically(1); + _ = self.moveCursorVertically(1); if(self.selectionStart == self.cursor) { self.selectionStart = null; } @@ -258,7 +270,9 @@ pub fn down(self: *TextInput, mods: main.Window.Key.Modifiers) void { cursor.* = @max(cursor.*, selectionStart); self.selectionStart = null; } else { - self.moveCursorVertically(1); + if(self.moveCursorVertically(1) == .same) { + if(self.optional.onDown) |cb| cb.run(); + } } } self.ensureCursorVisibility(); @@ -271,7 +285,7 @@ pub fn up(self: *TextInput, mods: main.Window.Key.Modifiers) void { if(self.selectionStart == null) { self.selectionStart = cursor.*; } - self.moveCursorVertically(-1); + _ = self.moveCursorVertically(-1); if(self.selectionStart == self.cursor) { self.selectionStart = null; } @@ -280,7 +294,9 @@ pub fn up(self: *TextInput, mods: main.Window.Key.Modifiers) void { cursor.* = @min(cursor.*, selectionStart); self.selectionStart = null; } else { - self.moveCursorVertically(-1); + if(self.moveCursorVertically(-1) == .same) { + if(self.optional.onUp) |cb| cb.run(); + } } } self.ensureCursorVisibility(); @@ -393,6 +409,14 @@ pub fn inputCharacter(self: *TextInput, character: u21) void { } } +pub fn setString(self: *TextInput, utf8EncodedString: []const u8) void { + self.clear(); + self.currentString.insertSlice(0, utf8EncodedString); + self.reloadText(); + if(self.cursor != null) self.cursor = @intCast(utf8EncodedString.len); + self.ensureCursorVisibility(); +} + pub fn selectAll(self: *TextInput, mods: main.Window.Key.Modifiers) void { if(mods.control) { self.selectionStart = 0; diff --git a/src/gui/windows/change_name.zig b/src/gui/windows/change_name.zig index 2904e24a..127b866e 100644 --- a/src/gui/windows/change_name.zig +++ b/src/gui/windows/change_name.zig @@ -49,7 +49,7 @@ pub fn onOpen() void { list.add(Label.init(.{0, 0}, width, "\\**italic*\\* \\*\\***bold**\\*\\* \\_\\___underlined__\\_\\_ \\~\\~~~strike-through~~\\~\\~", .center)); list.add(Label.init(.{0, 0}, width, "Even colors are possible, using the hexadecimal color code:", .center)); list.add(Label.init(.{0, 0}, width, "\\##ff0000ff#ffffff00#ffffff00#ff0000red#ffffff \\##ff0000ff#00770077#ffffff00#ff7700orange#ffffff \\##ffffff00#00ff00ff#ffffff00#00ff00green#ffffff \\##ffffff00#ffffff00#0000ffff#0000ffblue", .center)); - textComponent = TextInput.init(.{0, 0}, width, 32, if(settings.playerName.len == 0) "quanturmdoelvloper" else settings.playerName, .{.callback = &apply}); + textComponent = TextInput.init(.{0, 0}, width, 32, if(settings.playerName.len == 0) "quanturmdoelvloper" else settings.playerName, .{.callback = &apply}, .{}); list.add(textComponent); list.add(Button.initText(.{0, 0}, 100, "Apply", .{.callback = &apply})); list.finish(.center); diff --git a/src/gui/windows/chat.zig b/src/gui/windows/chat.zig index 970f8cee..04d1399a 100644 --- a/src/gui/windows/chat.zig +++ b/src/gui/windows/chat.zig @@ -11,6 +11,7 @@ const Label = GuiComponent.Label; const MutexComponent = GuiComponent.MutexComponent; const TextInput = GuiComponent.TextInput; const VerticalList = @import("../components/VerticalList.zig"); +const FixedSizeCircularBuffer = main.utils.FixedSizeCircularBuffer; pub var window: GuiWindow = GuiWindow{ .relativePosition = .{ @@ -29,6 +30,7 @@ pub var window: GuiWindow = GuiWindow{ const padding: f32 = 8; const messageTimeout: i32 = 10000; const messageFade = 1000; +const reusableHistoryMaxSize = 8192; var history: main.List(*Label) = undefined; var messageQueue: main.utils.ConcurrentQueue([]const u8) = undefined; @@ -37,9 +39,79 @@ var historyStart: u32 = 0; var fadeOutEnd: u32 = 0; pub var input: *TextInput = undefined; var hideInput: bool = true; +var messageHistory: History = undefined; + +pub const History = struct { + up: FixedSizeCircularBuffer([]const u8, reusableHistoryMaxSize), + down: FixedSizeCircularBuffer([]const u8, reusableHistoryMaxSize), + + fn init() History { + return .{ + .up = .init(main.globalAllocator), + .down = .init(main.globalAllocator), + }; + } + fn deinit(self: *History) void { + self.clear(); + self.up.deinit(main.globalAllocator); + self.down.deinit(main.globalAllocator); + } + fn clear(self: *History) void { + while(self.up.dequeue()) |msg| { + main.globalAllocator.free(msg); + } + while(self.down.dequeue()) |msg| { + main.globalAllocator.free(msg); + } + } + fn flushUp(self: *History) void { + while(self.down.dequeueFront()) |msg| { + if(msg.len == 0) { + continue; + } + + if(self.up.forceEnqueueFront(msg)) |old| { + main.globalAllocator.free(old); + } + } + } + pub fn isDuplicate(self: *History, new: []const u8) bool { + if(new.len == 0) return true; + if(self.down.peekFront()) |msg| { + if(std.mem.eql(u8, msg, new)) return true; + } + if(self.up.peekFront()) |msg| { + if(std.mem.eql(u8, msg, new)) return true; + } + return false; + } + pub fn pushDown(self: *History, new: []const u8) void { + if(self.down.forceEnqueueFront(new)) |old| { + main.globalAllocator.free(old); + } + } + pub fn pushUp(self: *History, new: []const u8) void { + if(self.up.forceEnqueueFront(new)) |old| { + main.globalAllocator.free(old); + } + } + pub fn cycleUp(self: *History) bool { + if(self.down.dequeueFront()) |msg| { + self.pushUp(msg); + return true; + } + return false; + } + pub fn cycleDown(self: *History) void { + if(self.up.dequeueFront()) |msg| { + self.pushDown(msg); + } + } +}; pub fn init() void { history = .init(main.globalAllocator); + messageHistory = .init(); expirationTime = .init(main.globalAllocator); messageQueue = .init(main.globalAllocator, 16); } @@ -52,6 +124,7 @@ pub fn deinit() void { while(messageQueue.dequeue()) |msg| { main.globalAllocator.free(msg); } + messageHistory.deinit(); messageQueue.deinit(); expirationTime.deinit(); } @@ -87,10 +160,32 @@ fn refresh() void { } pub fn onOpen() void { - input = TextInput.init(.{0, 0}, 256, 32, "", .{.callback = &sendMessage}); + input = TextInput.init(.{0, 0}, 256, 32, "", .{.callback = &sendMessage}, .{.onUp = .{.callback = loadNextHistoryEntry}, .onDown = .{.callback = loadPreviousHistoryEntry}}); refresh(); } +pub fn loadNextHistoryEntry(_: usize) void { + const isSuccess = messageHistory.cycleUp(); + if(messageHistory.isDuplicate(input.currentString.items)) { + if(isSuccess) messageHistory.cycleDown(); + messageHistory.cycleDown(); + } else { + messageHistory.pushDown(main.globalAllocator.dupe(u8, input.currentString.items)); + messageHistory.cycleDown(); + } + const msg = messageHistory.down.peekFront() orelse ""; + input.setString(msg); +} + +pub fn loadPreviousHistoryEntry(_: usize) void { + _ = messageHistory.cycleUp(); + if(messageHistory.isDuplicate(input.currentString.items)) {} else { + messageHistory.pushUp(main.globalAllocator.dupe(u8, input.currentString.items)); + } + const msg = messageHistory.down.peekFront() orelse ""; + input.setString(msg); +} + pub fn onClose() void { while(history.popOrNull()) |label| { label.deinit(); @@ -98,6 +193,7 @@ pub fn onClose() void { while(messageQueue.dequeue()) |msg| { main.globalAllocator.free(msg); } + messageHistory.clear(); expirationTime.clearRetainingCapacity(); historyStart = 0; fadeOutEnd = 0; @@ -156,6 +252,11 @@ pub fn sendMessage(_: usize) void { if(data.len > 10000 or main.graphics.TextBuffer.Parser.countVisibleCharacters(data) > 1000) { std.log.err("Chat message is too long with {}/{} characters. Limits are 1000/10000", .{main.graphics.TextBuffer.Parser.countVisibleCharacters(data), data.len}); } else { + messageHistory.flushUp(); + if(!messageHistory.isDuplicate(data)) { + messageHistory.pushUp(main.globalAllocator.dupe(u8, data)); + } + main.network.Protocols.chat.send(main.game.world.?.conn, data); input.clear(); } diff --git a/src/gui/windows/invite.zig b/src/gui/windows/invite.zig index 2ab9b42c..4bb5257d 100644 --- a/src/gui/windows/invite.zig +++ b/src/gui/windows/invite.zig @@ -70,7 +70,7 @@ pub fn onOpen() void { ipAddressLabel = Label.init(.{0, 0}, width, " ", .center); list.add(ipAddressLabel); list.add(Button.initText(.{0, 0}, 100, "Copy IP", .{.callback = ©Ip})); - ipAddressEntry = TextInput.init(.{0, 0}, width, 32, settings.lastUsedIPAddress, .{.callback = &invite}); + ipAddressEntry = TextInput.init(.{0, 0}, width, 32, settings.lastUsedIPAddress, .{.callback = &invite}, .{}); list.add(ipAddressEntry); list.add(Button.initText(.{0, 0}, 100, "Invite", .{.callback = &invite})); list.add(Button.initText(.{0, 0}, 100, "Manage Players", gui.openWindowCallback("manage_players"))); diff --git a/src/gui/windows/multiplayer.zig b/src/gui/windows/multiplayer.zig index c7046bdf..c2fa5915 100644 --- a/src/gui/windows/multiplayer.zig +++ b/src/gui/windows/multiplayer.zig @@ -92,7 +92,7 @@ pub fn onOpen() void { ipAddressLabel = Label.init(.{0, 0}, width, " ", .center); list.add(ipAddressLabel); list.add(Button.initText(.{0, 0}, 100, "Copy IP", .{.callback = ©Ip})); - ipAddressEntry = TextInput.init(.{0, 0}, width, 32, settings.lastUsedIPAddress, .{.callback = &join}); + ipAddressEntry = TextInput.init(.{0, 0}, width, 32, settings.lastUsedIPAddress, .{.callback = &join}, .{}); list.add(ipAddressEntry); list.add(Button.initText(.{0, 0}, 100, "Join", .{.callback = &join})); list.finish(.center); diff --git a/src/gui/windows/save_creation.zig b/src/gui/windows/save_creation.zig index 2a9a0d60..a2abb988 100644 --- a/src/gui/windows/save_creation.zig +++ b/src/gui/windows/save_creation.zig @@ -109,7 +109,7 @@ pub fn onOpen() void { } const name = std.fmt.allocPrint(main.stackAllocator.allocator, "Save{}", .{num}) catch unreachable; defer main.stackAllocator.free(name); - textInput = TextInput.init(.{0, 0}, 128, 22, name, .{.callback = &createWorld}); + textInput = TextInput.init(.{0, 0}, 128, 22, name, .{.callback = &createWorld}, .{}); list.add(textInput); gamemodeInput = Button.initText(.{0, 0}, 128, @tagName(gamemode), .{.callback = &gamemodeCallback}); diff --git a/src/utils.zig b/src/utils.zig index a5315e82..f5d3f4d9 100644 --- a/src/utils.zig +++ b/src/utils.zig @@ -338,12 +338,53 @@ pub fn FixedSizeCircularBuffer(T: type, capacity: comptime_int) type { // MARK: allocator.destroy(self.mem); } - pub fn enqueue(self: *Self, elem: T) !void { + pub fn peekFront(self: Self) ?T { + if(self.len == 0) return null; + return self.mem[self.startIndex + self.len - 1 & mask]; + } + + pub fn peekBack(self: Self) ?T { + if(self.len == 0) return null; + return self.mem[self.startIndex]; + } + + pub fn enqueueFront(self: *Self, elem: T) !void { if(self.len >= capacity) return error.OutOfMemory; + self.enqueueFrontAssumeCapacity(elem); + } + + pub fn forceEnqueueFront(self: *Self, elem: T) ?T { + const result = if(self.len >= capacity) self.dequeueBack() else null; + self.enqueueFrontAssumeCapacity(elem); + return result; + } + + pub fn enqueueFrontAssumeCapacity(self: *Self, elem: T) void { self.mem[self.startIndex + self.len & mask] = elem; self.len += 1; } + pub fn enqueue(self: *Self, elem: T) !void { + return self.enqueueFront(elem); + } + + pub fn enqueueBack(self: *Self, elem: T) !void { + if(self.len >= capacity) return error.OutOfMemory; + self.enqueueBackAssumeCapacity(elem); + } + + pub fn enqueueBackAssumeCapacity(self: *Self, elem: T) void { + self.startIndex = (self.startIndex -% 1) & mask; + self.mem[self.startIndex] = elem; + self.len += 1; + } + + pub fn forceEnqueueBack(self: *Self, elem: T) ?T { + const result = if(self.len >= capacity) self.dequeueFront() else null; + self.enqueueBackAssumeCapacity(elem); + return result; + } + pub fn enqueueSlice(self: *Self, elems: []const T) !void { if(elems.len + self.len > capacity) { return error.OutOfMemory; @@ -377,6 +418,16 @@ pub fn FixedSizeCircularBuffer(T: type, capacity: comptime_int) type { // MARK: } pub fn dequeue(self: *Self) ?T { + return self.dequeueBack(); + } + + pub fn dequeueFront(self: *Self) ?T { + if(self.len == 0) return null; + self.len -= 1; + return self.mem[self.startIndex + self.len & mask]; + } + + pub fn dequeueBack(self: *Self) ?T { if(self.len == 0) return null; const result = self.mem[self.startIndex]; self.startIndex = (self.startIndex + 1) & mask;