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>
This commit is contained in:
Krzysztof Wiśniewski 2025-05-04 12:57:25 +02:00 committed by GitHub
parent 7fed896a77
commit 67135b433d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 189 additions and 13 deletions

View File

@ -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;

View File

@ -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);

View File

@ -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();
}

View File

@ -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 = &copyIp}));
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")));

View File

@ -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 = &copyIp}));
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);

View File

@ -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});

View File

@ -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;