From 86191ea19490ffdb9a9a0d10c9fe8a4e9d565f32 Mon Sep 17 00:00:00 2001 From: Webster Sheets Date: Sat, 3 Feb 2024 02:27:06 -0500 Subject: [PATCH 01/14] pigui: Add UI.Module helper class - Module implements an Elm-style model-view-update pattern which allows maintaining a clear separation between widget state (and state mutations) and display code. - It provides an easy way to move certain high-runtime-cost logic to only be run when needed, without requiring a significant amount of boilerplate code. - The module performs an early check for missing message handlers when dispatching the message to aid in generating debuggable callstacks --- data/pigui/libs/module.lua | 51 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 data/pigui/libs/module.lua diff --git a/data/pigui/libs/module.lua b/data/pigui/libs/module.lua new file mode 100644 index 0000000000..f41fd45c7e --- /dev/null +++ b/data/pigui/libs/module.lua @@ -0,0 +1,51 @@ +-- Copyright © 2008-2024 Pioneer Developers. See AUTHORS.txt for details +-- Licensed under the terms of the GPL v3. See licenses/GPL-3.txt + +local utils = require 'utils' + +-- Elm-style model-view-update pattern +-- The render function should treat self as immutable and instead process all +-- data updates through the message() system +---@class UI.Module +local Module = utils.class("UI.Module") + +function Module:Constructor() + self.__messages = {} +end + +function Module:message(id, ...) + -- Early check for message handler - produces more debuggable callstacks + if not self[id] then + error(string.format("No message handler for %s on object %s", id, getmetatable(self).class), 2) + end + + table.insert(self.__messages, { id = id, args = table.pack(...) }) +end + +function Module:hookMessage(id, fun, receiver) + local oldHandler = self[id] + + self[id] = function(self, ...) + if oldHandler then oldHandler(self, ...) end + return fun(receiver, ...) + end +end + +function Module:update() + if #self.__messages == 0 then + return + end + + local messages = self.__messages + self.__messages = {} + + for i, msg in ipairs(messages) do + self[msg.id](self, table.unpack(msg.args, 1, msg.args.n)) + end +end + +function Module:render() + return +end + +return Module From 7abec8fb9f83eb382a94a01cb8a4ab3543494e95 Mon Sep 17 00:00:00 2001 From: Webster Sheets Date: Wed, 17 Jul 2024 22:31:04 -0400 Subject: [PATCH 02/14] LuaPiGui: better support for text wrapping - ui.addText supports wrap width - ui.calcTextSize supports maximum wrap width - Add missing ImGuiWindowFlags_NoDecoration - Add missing ImGuiHoveredFlags_AllowWhenOverlappedByItem - Deprecate ui.calc*Size overloads which took separate font name and size parameters --- data/pigui/libs/buttons.lua | 11 +++++------ data/pigui/libs/forwarded.lua | 2 +- data/pigui/libs/text.lua | 11 +++-------- src/lua/LuaPiGui.cpp | 9 +++++++-- 4 files changed, 16 insertions(+), 17 deletions(-) diff --git a/data/pigui/libs/buttons.lua b/data/pigui/libs/buttons.lua index 142054582f..08b167ca07 100644 --- a/data/pigui/libs/buttons.lua +++ b/data/pigui/libs/buttons.lua @@ -112,19 +112,18 @@ end -- -- Parameters: -- label - string, label of the button to calculate size for --- font - Font|string, optional font to use when calculating button size --- size - number|nil, optional font size to use +-- font - Font, optional font to use when calculating button size -- -function ui.calcButtonSize(label, font, size) - return ui.calcTextSize(label, font, size) + ui.theme.styles.ButtonPadding * 2 +function ui.calcButtonSize(label, font) + return ui.calcTextSize(label, font) + ui.theme.styles.ButtonPadding * 2 end function ui.getButtonHeight(font) return (font and font.size or ui.getTextLineHeight()) + ui.theme.styles.ButtonPadding.y * 2 end -function ui.getButtonHeightWithSpacing() - return ui.getTextLineHeightWithSpacing() + ui.theme.styles.ButtonPadding.y * 2.0 +function ui.getButtonHeightWithSpacing(font) + return ui.getButtonHeight(font) + ui.getItemSpacing().y end -- diff --git a/data/pigui/libs/forwarded.lua b/data/pigui/libs/forwarded.lua index 66a8f2d139..c83f065c81 100644 --- a/data/pigui/libs/forwarded.lua +++ b/data/pigui/libs/forwarded.lua @@ -55,7 +55,7 @@ ui.addCircleFilled = pigui.AddCircleFilled ui.addRect = pigui.AddRect ---@type fun(a: Vector2, b: Vector2, col: Color, rounding: number, edges: integer, thickness: number) ui.addRectFilled = pigui.AddRectFilled ---@type fun(a: Vector2, b: Vector2, col: Color, rounding: number, edges: integer) ui.addLine = pigui.AddLine ---@type fun(a: Vector2, b: Vector2, col: Color, thickness: number) -ui.addText = pigui.AddText ---@type fun(pos: Vector2, col: Color, text: string) +ui.addText = pigui.AddText ---@type fun(pos: Vector2, col: Color, text: string, wrapWidth: number?) ui.pathArcTo = pigui.PathArcTo ui.pathStroke = pigui.PathStroke ui.setCursorPos = pigui.SetCursorPos ---@type fun(pos: Vector2) diff --git a/data/pigui/libs/text.lua b/data/pigui/libs/text.lua index 2e106c79a5..10eed36a92 100644 --- a/data/pigui/libs/text.lua +++ b/data/pigui/libs/text.lua @@ -79,15 +79,10 @@ ui.fonts = { -- Returns: -- size - Vector2, the size of the text when rendered -- -function ui.calcTextSize(text, font, size) - if size == nil and type(font) == "table" then - size = font.size - font = font.name - end - +function ui.calcTextSize(text, font, wrapWidth) local pushed = false - if font then pushed = pigui:PushFont(font, size) end - local ret = pigui.CalcTextSize(text) + if font then pushed = pigui:PushFont(font.name, font.size) end + local ret = pigui.CalcTextSize(text, wrapWidth) if pushed then pigui:PopFont() end return ret diff --git a/src/lua/LuaPiGui.cpp b/src/lua/LuaPiGui.cpp index 7a97831712..66892b47ba 100644 --- a/src/lua/LuaPiGui.cpp +++ b/src/lua/LuaPiGui.cpp @@ -382,6 +382,7 @@ static LuaFlags imguiWindowFlagsTable = { { "NoBackground", ImGuiWindowFlags_NoBackground }, { "NoSavedSettings", ImGuiWindowFlags_NoSavedSettings }, { "NoInputs", ImGuiWindowFlags_NoInputs }, + { "NoDecoration", ImGuiWindowFlags_NoDecoration }, { "MenuBar", ImGuiWindowFlags_MenuBar }, { "HorizontalScrollbar", ImGuiWindowFlags_HorizontalScrollbar }, { "NoFocusOnAppearing", ImGuiWindowFlags_NoFocusOnAppearing }, @@ -411,6 +412,7 @@ static LuaFlags imguiHoveredFlagsTable = { { "AnyWindow", ImGuiHoveredFlags_AnyWindow }, { "AllowWhenBlockedByPopup", ImGuiHoveredFlags_AllowWhenBlockedByPopup }, { "AllowWhenBlockedByActiveItem", ImGuiHoveredFlags_AllowWhenBlockedByActiveItem }, + { "AllowWhenOverlappedByItem", ImGuiHoveredFlags_AllowWhenOverlappedByItem }, { "AllowWhenOverlapped", ImGuiHoveredFlags_AllowWhenOverlapped }, { "AllowWhenDisabled", ImGuiHoveredFlags_AllowWhenDisabled }, { "RectOnly", ImGuiHoveredFlags_RectOnly }, @@ -1616,7 +1618,8 @@ static int l_pigui_add_text(lua_State *l) ImVec2 center = LuaPull(l, 1); ImU32 color = ImGui::GetColorU32(LuaPull(l, 2).Value); std::string text = LuaPull(l, 3); - draw_list->AddText(center, color, text.c_str()); + double wrapWidth = LuaPull(l, 4, 0.0); + draw_list->AddText(nullptr, 0.0f, center, color, text.c_str(), nullptr, wrapWidth); return 0; } @@ -1996,6 +1999,7 @@ static int l_pigui_pop_font(lua_State *l) * Parameters: * * text - The text we want dimensions of + * wrapWidth - The maximum length of a line of text before wrapping. Defaults to -1. * * Returns: * @@ -2006,7 +2010,8 @@ static int l_pigui_calc_text_size(lua_State *l) { PROFILE_SCOPED() std::string text = LuaPull(l, 1); - ImVec2 size = ImGui::CalcTextSize(text.c_str()); + double wrapWidth = LuaPull(l, 2, -1.0); + ImVec2 size = ImGui::CalcTextSize(text.c_str(), nullptr, false, wrapWidth); LuaPush(l, vector2d(size.x, size.y)); return 1; } From 83b3a325de29bf9ade217529ad4b2dc9cb94c18d Mon Sep 17 00:00:00 2001 From: Webster Sheets Date: Wed, 17 Jul 2024 23:01:14 -0400 Subject: [PATCH 03/14] LuaPiGui: add internal BringWindowToDisplayFront function - Used for windows that need to stay visible above modals. - Not recommended for general-purpose use. --- data/pigui/libs/forwarded.lua | 2 ++ src/lua/LuaPiGui.cpp | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/data/pigui/libs/forwarded.lua b/data/pigui/libs/forwarded.lua index c83f065c81..fd77c5d122 100644 --- a/data/pigui/libs/forwarded.lua +++ b/data/pigui/libs/forwarded.lua @@ -17,6 +17,8 @@ ui.pointOnClock = pigui.pointOnClock ui.screenWidth = pigui.screen_width ui.screenHeight = pigui.screen_height +ui.bringWindowToDisplayFront = pigui.bringWindowToDisplayFront ---@type fun() + -- Return the size of the specified window's contents from last frame (without padding/decoration) -- Returns {0,0} if the window hasn't been submitted during the lifetime of the program ui.getWindowContentSize = pigui.GetWindowContentSize ---@type fun(name: string): Vector2 diff --git a/src/lua/LuaPiGui.cpp b/src/lua/LuaPiGui.cpp index 66892b47ba..76588d7191 100644 --- a/src/lua/LuaPiGui.cpp +++ b/src/lua/LuaPiGui.cpp @@ -664,6 +664,31 @@ static int l_pigui_begin(lua_State *l) return 1; } +/* + * Function: bringWindowToDisplayFront + * + * Internal call to move the window to the top of the display order. + * + * Note that the window still follows regular interaction rules, and may be + * blocked by a modal window rendering underneath it. To render a window on top + * of a modal, it must be submitted within that modal's begin()/end() block. + * (See data/pigui/libs/modal-win.lua). + * + * Availability: + * + * 2024-07 + * + * Status: + * + * experimental + */ +static int l_pigui_bring_window_to_display_front(lua_State *l) +{ + PROFILE_SCOPED() + ImGui::BringWindowToDisplayFront(ImGui::GetCurrentWindow()); + return 0; +} + /* * Function: GetTime * @@ -3439,6 +3464,7 @@ void LuaObject::RegisterClass() static const luaL_Reg l_methods[] = { { "Begin", l_pigui_begin }, { "End", l_pigui_end }, + { "BringWindowToDisplayFront", l_pigui_bring_window_to_display_front }, { "GetTime", l_pigui_get_time }, { "PushClipRectFullScreen", l_pigui_push_clip_rect_full_screen }, { "PopClipRect", l_pigui_pop_clip_rect }, From d67f3c1800dfe8f4c5343bf01070aa8f1f97670c Mon Sep 17 00:00:00 2001 From: Webster Sheets Date: Wed, 17 Jul 2024 23:17:16 -0400 Subject: [PATCH 04/14] ui: add notification framework - Communicate important limited-duration events to the player in a feed - Supports three semantic types of notifications - informational popups, game events, and program errors - Can display a title line, details line, and optional icon --- data/pigui/init.lua | 2 +- data/pigui/libs/modal-win.lua | 6 + data/pigui/libs/notification.lua | 256 +++++++++++++++++++++++++++++++ data/pigui/themes/default.lua | 5 + 4 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 data/pigui/libs/notification.lua diff --git a/data/pigui/init.lua b/data/pigui/init.lua index 3f3ba17331..be7e5aa51c 100644 --- a/data/pigui/init.lua +++ b/data/pigui/init.lua @@ -14,6 +14,6 @@ require 'pigui.libs.icons' require 'pigui.libs.buttons' require 'pigui.libs.radial-menu' require 'pigui.libs.gauge' - +require 'pigui.libs.notification' return ui diff --git a/data/pigui/libs/modal-win.lua b/data/pigui/libs/modal-win.lua index 2e1446f87a..e064811adf 100644 --- a/data/pigui/libs/modal-win.lua +++ b/data/pigui/libs/modal-win.lua @@ -64,6 +64,12 @@ local function drawModals(idx) end end) end + + if idx == #modalStack + 1 then + for _,v in ipairs(ui.getModules('notification')) do + v.draw() + end + end end ui.registerModule('modal', function() diff --git a/data/pigui/libs/notification.lua b/data/pigui/libs/notification.lua new file mode 100644 index 0000000000..545f8efa97 --- /dev/null +++ b/data/pigui/libs/notification.lua @@ -0,0 +1,256 @@ +-- Copyright © 2008-2024 Pioneer Developers. See AUTHORS.txt for details +-- Licensed under the terms of the GPL v3. See licenses/GPL-3.txt + +local Engine = require 'Engine' +local pigui = Engine.pigui +local Vector2 = _G.Vector2 + +local ui = require 'pigui.baseui' + +local lui = require 'Lang'.GetResource("ui-core") + +local colors = ui.theme.colors +local icons = ui.theme.icons + +local titleFont = ui.fonts.pionillium.body +local detailsFont = ui.fonts.pionillium.details + +local style = ui.rescaleUI { + padding = Vector2(20, 20), + innerPadding = Vector2(20, 12), + innerSpacing = Vector2(4, 4), + closeButtonSize = Vector2(24, 24), + rounding = 8, + barWidth = 6, + border = 2, +} + +--=============================================================================- + +local Notification = {} + +Notification.dismissAnimLen = 0.7 +Notification.expiryTime = 7 +Notification.expiryTimeShort = 3 +Notification.queue = {} + +Notification.Type = { + Info = "INFO", -- General informational notification + Game = "GAME", -- For an in-game event that triggered a notification + Error = "ERROR", -- A section of code has encountered an error +} + +local notificationColor = { + [Notification.Type.Info] = colors.notificationInfo, + [Notification.Type.Game] = colors.notificationGame, + [Notification.Type.Error] = colors.notificationError +} + +-- Function: add +-- +-- Add a new notification to be displayed on screen +-- +---@param type string The type of notification, one of Notification.Type +---@param title string The title text of the notification. Can wrap if needed. +---@param body string? Additional text explaining the notification. Optional. +---@param icon any? The icon to display on the notification. Optional. +---@param noAutoExpire boolean? If true, the notification must be manually dismissed. +function Notification.add(type, title, body, icon, noAutoExpire) + local expiryTime = body and Notification.expiryTime or Notification.expiryTimeShort + + table.insert(Notification.queue, { + expiry = not noAutoExpire and ui.getTime() + expiryTime, + title = title or "", + body = body, + icon = icon, + type = type + }) +end + +-- Function: dismissAll +-- +-- Dismiss all current notifications. If the force parameter is passed, resets +-- the notification queue. +function Notification.dismissAll(force) + if force then + Notification.queue = {} + else + for _, notif in ipairs(Notification.queue) do + notif.closing = notif.closing or 0.0 + end + end +end + +--============================================================================== + +-- Pre-compute the size of a given notification and store it for later +local function calcNotificationSize(notif, wrapWidth) + local contentSize = Vector2(0, 0) + local spacing = style.innerSpacing + + -- Compute total size of notification widget + if notif.icon then + contentSize.x = titleFont.size + spacing.x + end + + local titleSize = ui.calcTextSize(notif.title, titleFont, wrapWidth - contentSize.x) + + contentSize.x = contentSize.x + titleSize.x + contentSize.y = titleSize.y + + -- If we have body notification text, just make this a max-width notification + if notif.body then + local textSize = ui.calcTextSize(notif.body or "", detailsFont, wrapWidth) + + contentSize.x = wrapWidth + contentSize.y = contentSize.y + spacing.y + textSize.y + end + + local badgeSize = contentSize + style.innerPadding * 2 + + notif.size = badgeSize + notif.titleHeight = titleSize.y +end + +-- Draw a notification card +-- Expects the size and titleHeight variables to have been set in the notification from +local function drawNotification(notif, wrapWidth) + local badgeSize = notif.size + local spacing = style.innerSpacing + + local startPos = ui.getCursorScreenPos() + local buttonArea = Vector2(style.closeButtonSize.x, 0) + local pos = startPos + buttonArea + + ui.beginGroup() + + -- Handle interaction with the notification widget + ui.dummy(badgeSize + buttonArea) + local hovered = ui.isItemHovered({ "AllowWhenOverlappedByItem", "AllowWhenBlockedByActiveItem" }) + + local round = style.rounding + local barCol = notificationColor[notif.type] or colors.notificationInfo + + -- Draw background + ui.addRectFilled(pos, pos + badgeSize, colors.notificationBackground, round, 0xF) + -- Draw border + ui.addRect(pos, pos + badgeSize, colors.windowFrame, round, 0xF, style.border) + -- Draw left bar + ui.addRectFilled(pos, pos + Vector2(style.barWidth, badgeSize.y), barCol, round, 0x5) + + pos = pos + style.innerPadding + + -- Draw the leading icon for the title line + if notif.icon then + ui.addIconSimple(pos, notif.icon, Vector2(titleFont.size), colors.font) + end + + -- Draw the title line, optionally offset by the icon + ui.withFont(titleFont, function() + local titlePos = notif.icon and (pos + Vector2(titleFont.size + spacing.x, 0)) or pos + ui.addText(titlePos, colors.font, notif.title, wrapWidth) + end) + + -- Draw the notification body text + if notif.body then + ui.withFont(detailsFont, function() + local bodyPos = pos + Vector2(0, notif.titleHeight + spacing.y) + ui.addText(bodyPos, colors.fontDim, notif.body, wrapWidth) + end) + end + + -- Draw the close button to the left of the notification in empty space + if hovered and not notif.closing then + ui.setCursorScreenPos(startPos) + local clicked = ui.iconButton(icons.retrograde, style.closeButtonSize, "##DismissNotification", ui.theme.buttonColors.transparent) + + if clicked then + notif.closing = 0.0 + end + end + + ui.endGroup() + + return hovered +end + +--============================================================================== + +local windowFlags = ui.WindowFlags { "NoDecoration", "NoBackground", "NoMove" } + +ui.registerModule('notification', function() + if #Notification.queue == 0 then + return + end + + local maxWidth = ui.screenWidth / 4.0 + local maxHeight = 0.0 + + local badgeWidth = maxWidth - style.padding.x - style.closeButtonSize.x + local wrapWidth = badgeWidth - style.innerPadding.x * 2 + local vSpacing = style.innerPadding.y + + -- Compute total vertical height of the notifications on-screen + -- We also remove expired notifications here + for i = #Notification.queue, 1, -1 do + local notif = Notification.queue[i] + + -- Start the close animation + if notif.expiry and notif.expiry < ui.getTime() and not notif.closing then + notif.closing = 0.0 + end + + -- Remove finished notifications + if notif.closing and notif.closing >= 1.0 then + table.remove(Notification.queue, i) + else + -- TODO(screen-resize): this has to be re-calculated if the screen width changes + if not notif.size then + calcNotificationSize(notif, wrapWidth) + end + + maxHeight = maxHeight + notif.size.y + (i == 1 and 0.0 or vSpacing) + end + end + + -- Grow vertically, but fix horizontal size + local windowHeight = math.min(maxHeight, ui.screenHeight) + ui.setNextWindowSize(Vector2(maxWidth, windowHeight), "Always") + ui.setNextWindowPos(ui.screenSize() - Vector2(maxWidth, windowHeight + style.padding.y), "Always") + + ui.withStyleVars({ WindowPadding = Vector2(0, 0) }, function() + pigui.Begin("##notifications", windowFlags) + end) + + -- Ensure the notification stack renders on top of everything + -- Notifications are rendered inside of the modal window begin()/end() stack to remain interactable + pigui.BringWindowToDisplayFront() + + -- Draw notifications bottom-up from newest to oldest + local currentHeight = windowHeight + + for i = #Notification.queue, 1, -1 do + local notif = Notification.queue[i] + + -- Increase animation progress + if notif.closing then + notif.closing = notif.closing + Engine.frameTime / Notification.dismissAnimLen + end + + local offset = badgeWidth - notif.size.x + maxWidth * (notif.closing or 0.0) + + ui.setCursorPos(Vector2(offset, currentHeight - notif.size.y)) + local hovered = drawNotification(notif, wrapWidth) + + -- Prevent this notification from expiring while hovered + if hovered then + notif.expiry = notif.expiry + Engine.frameTime + end + + currentHeight = currentHeight - notif.size.y - vSpacing + end + + pigui.End() +end) + +return Notification diff --git a/data/pigui/themes/default.lua b/data/pigui/themes/default.lua index 150af1d7e0..372399123d 100644 --- a/data/pigui/themes/default.lua +++ b/data/pigui/themes/default.lua @@ -198,6 +198,7 @@ theme.colors = { lightBlackBackground = styleColors.panel_900:opacity(0.80), --black:opacity(0.40), windowBackground = styleColors.panel_900, windowFrame = styleColors.panel_700, + notificationBackground = styleColors.panel_800, modalBackground = styleColors.panel_900, tableBackground = styleColors.primary_900, tableHighlight = styleColors.primary_800, @@ -241,6 +242,10 @@ theme.colors = { alertRed = styleColors.danger_500, hyperspaceInfo = styleColors.success_300, + notificationInfo = styleColors.gray_500, + notificationGame = styleColors.primary_500, + notificationError = styleColors.danger_700, + econProfit = styleColors.success_500, econLoss = styleColors.danger_300, econMajorExport = styleColors.success_300, From 3acf10381f1791088b3e24ab3c6c61cfc4665009 Mon Sep 17 00:00:00 2001 From: Webster Sheets Date: Wed, 17 Jul 2024 23:19:17 -0400 Subject: [PATCH 05/14] modal-win: inherit from module - Rename innerHandler() to render() - Support message-based update pattern in modal window widgets --- data/pigui/libs/modal-win.lua | 37 ++++++++++++++----- .../modules/station-view/02-bulletinBoard.lua | 2 +- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/data/pigui/libs/modal-win.lua b/data/pigui/libs/modal-win.lua index e064811adf..01dfda3ad8 100644 --- a/data/pigui/libs/modal-win.lua +++ b/data/pigui/libs/modal-win.lua @@ -3,21 +3,24 @@ local ui = require 'pigui' +local Module = require 'pigui.libs.module' +local utils = require 'utils' + local modalStack = {} -local ModalWindow = {} +---@class UI.ModalWindow : UI.Module +local ModalWindow = utils.class("UI.ModalWindow", Module) + local defaultModalFlags = ui.WindowFlags {"NoTitleBar", "NoResize", "AlwaysAutoResize", "NoMove"} -function ModalWindow.New(name, innerHandler, outerHandler, flags) +function ModalWindow.New(name, render, outerHandler, flags) local modalWin = { name = name, flags = flags or defaultModalFlags, stackIdx = -1, isOpen = false, - innerHandler = innerHandler, - outerHandler = outerHandler or function(_, drawPopupFn) - drawPopupFn() - end, + render = render, + outerHandler = outerHandler, } setmetatable(modalWin, { @@ -25,23 +28,37 @@ function ModalWindow.New(name, innerHandler, outerHandler, flags) class = "UI.ModalWindow", }) + Module.Constructor(modalWin) + return modalWin end -function ModalWindow:open() +function ModalWindow:open(...) if self.stackIdx < 0 then table.insert(modalStack, self) self.stackIdx = #modalStack end + + self:message("onOpen", ...) end -function ModalWindow:close() +function ModalWindow:close(...) for i=#modalStack, self.stackIdx, -1 do modalStack[i].stackIdx = -1 modalStack[i].isOpen = false ui.closeCurrentPopup() table.remove(modalStack, i) end + + self:message("onClose", ...) +end + +function ModalWindow:onOpen() end + +function ModalWindow:onClose() end + +function ModalWindow:outerHandler(innerFn) + innerFn() end local function drawModals(idx) @@ -53,9 +70,11 @@ local function drawModals(idx) ui.openPopup(win.name) end + win:update() + win:outerHandler(function () if ui.beginPopupModal(win.name, win.flags) then - win:innerHandler() + win:render() -- modal could close in handler if win.isOpen then drawModals(idx+1) diff --git a/data/pigui/modules/station-view/02-bulletinBoard.lua b/data/pigui/modules/station-view/02-bulletinBoard.lua index 86f0c6389f..f576376acb 100644 --- a/data/pigui/modules/station-view/02-bulletinBoard.lua +++ b/data/pigui/modules/station-view/02-bulletinBoard.lua @@ -213,7 +213,7 @@ bulletinBoard = Table.New("BulletinBoardTable", false, { chatForm = ChatForm.New(chatFunc, removeFunc, closeFunc, resizeFunc, ref, StationView, {buttonSize = widgetSizes.chatButtonSize}) station:LockAdvert(ref) - chatWin.innerHandler = function() chatForm:render() end + chatWin.render = function() chatForm:render() end chatForm.resizeFunc() chatWin:open() end, From 47355da027523c0110303cb37865c6b8897ea64a Mon Sep 17 00:00:00 2001 From: Webster Sheets Date: Wed, 17 Jul 2024 23:20:08 -0400 Subject: [PATCH 06/14] utils: add find_if, contains_if helpers - Used to find a value in a table which matches the given predicate --- data/libs/utils.lua | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/data/libs/utils.lua b/data/libs/utils.lua index ded83fa8ce..717a46ab28 100644 --- a/data/libs/utils.lua +++ b/data/libs/utils.lua @@ -208,6 +208,27 @@ utils.to_array = function(t, predicate) return out end +-- +-- Function: find_if +-- +-- Returns the first value in the passed table which matches the predicate. +-- +-- Iteration order is undefined (uses pairs() internally). +-- +---@generic K, V +---@param t table +---@param predicate fun(k: K, v: V): boolean +---@return V? +utils.find_if = function(t, predicate) + for k, v in pairs(t) do + if predicate(k, v) then + return v + end + end + + return nil +end + -- -- Function: stable_sort -- @@ -543,7 +564,7 @@ end -- -- Function: contains -- --- Return true if the function contains the given value under any key. +-- Return true if the table contains the given value under any key. -- utils.contains = function(t, val) for _, v in pairs(t) do @@ -553,6 +574,23 @@ utils.contains = function(t, val) return false end +-- +-- Function: contains +-- +-- Return true if the table contains a value that passes the given predicate. +-- +---@generic K, V +---@param t table +---@param predicate fun(v: V): boolean +---@return boolean +utils.contains_if = function(t, predicate) + for _, v in pairs(t) do + if predicate(v) then return true end + end + + return false +end + -- -- Function: utils.indexOf -- From 463c7209891b38457576cab226c9868fe738a2db Mon Sep 17 00:00:00 2001 From: Webster Sheets Date: Thu, 18 Jul 2024 01:33:12 -0400 Subject: [PATCH 07/14] ui-timer: cleanup and add type information - Update copyright header - Cleanup some unnecessary logic - Ensure the ui.*Timer methods are visible to the language server --- data/pigui/libs/ui-timer.lua | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/data/pigui/libs/ui-timer.lua b/data/pigui/libs/ui-timer.lua index 533b0fb072..4ab62c2f1a 100644 --- a/data/pigui/libs/ui-timer.lua +++ b/data/pigui/libs/ui-timer.lua @@ -1,15 +1,16 @@ --- Copyright © 2008-2023 Pioneer Developers. See AUTHORS.txt for details +-- Copyright © 2008-2024 Pioneer Developers. See AUTHORS.txt for details -- Licensed under the terms of the GPL v3. See licenses/GPL-3.txt +---@class ui local ui = require 'pigui' local timers = {} function ui.createTimer(name, endTime, callback) - timers[name] = {} - local timer = timers[name] + local timer = {} timer.endTime = ui.getTime() + endTime timer.callback = callback + timers[name] = timer end function ui.deleteTimer(name) @@ -22,11 +23,9 @@ function ui.deleteTimer(name) end local function updateTimers() - if next(timers) then - for name, timer in pairs(timers) do - if ui.getTime() > timer.endTime then - ui.deleteTimer(name) - end + for name, timer in pairs(timers) do + if ui.getTime() > timer.endTime then + ui.deleteTimer(name) end end end From 25e982257a5c9331f483e8aad267e67a03ff8929 Mon Sep 17 00:00:00 2001 From: Webster Sheets Date: Thu, 18 Jul 2024 02:08:02 -0400 Subject: [PATCH 08/14] ui.ModalWindow: only close popups that are being currently drawn - This allows ModalWindow:close() to be called outside of the modal's draw function --- data/pigui/libs/modal-win.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/data/pigui/libs/modal-win.lua b/data/pigui/libs/modal-win.lua index 01dfda3ad8..8958b679a3 100644 --- a/data/pigui/libs/modal-win.lua +++ b/data/pigui/libs/modal-win.lua @@ -46,7 +46,6 @@ function ModalWindow:close(...) for i=#modalStack, self.stackIdx, -1 do modalStack[i].stackIdx = -1 modalStack[i].isOpen = false - ui.closeCurrentPopup() table.remove(modalStack, i) end @@ -78,6 +77,8 @@ local function drawModals(idx) -- modal could close in handler if win.isOpen then drawModals(idx+1) + else + ui.closeCurrentPopup() end ui.endPopup() end From 7b6a79700682124d29351fb83562778eaf5a2da4 Mon Sep 17 00:00:00 2001 From: Webster Sheets Date: Thu, 18 Jul 2024 03:13:46 -0400 Subject: [PATCH 09/14] Rewrite save/load window - Implement design with additional save data displayed - Convert save/load window to utilize model-view-update pattern - Use notification system rather than drawing text in the save window box --- data/lang/core/en.json | 4 + data/lang/ui-core/en.json | 18 +- data/meta/CoreObject/Game.meta.lua | 1 + data/pigui/modules/saveloadgame.lua | 665 +++++++++++++++++++--------- 4 files changed, 467 insertions(+), 221 deletions(-) diff --git a/data/lang/core/en.json b/data/lang/core/en.json index c016a1cdf8..3bfc590864 100644 --- a/data/lang/core/en.json +++ b/data/lang/core/en.json @@ -1751,6 +1751,10 @@ "description": "Distance unit: an astronomical unit, radius of Earth's orbit", "message": "AU" }, + "UNIT_CREDITS": { + "description": "Unit of money, used when $ isn't specific enough", + "message": "Cr" + }, "UNIT_CUBIC_METERS": { "description": "Volume unit: cubic meter", "message": "m³" diff --git a/data/lang/ui-core/en.json b/data/lang/ui-core/en.json index e078fbdf8b..6cb765abaa 100644 --- a/data/lang/ui-core/en.json +++ b/data/lang/ui-core/en.json @@ -288,9 +288,17 @@ "message": "Could not find system body for frame" }, "COULD_NOT_LOAD_GAME": { - "description": "", + "description": "Label indicating a specific file could not be loaded", "message": "Could not load game: " }, + "COULD_NOT_LOAD_SAVE_FILES": { + "description": "Label indicating the list of save files could not be loaded", + "message": "Could not load save files" + }, + "COULD_NOT_SAVE_GAME": { + "description": "Label indicating a specific file could not be saved", + "message": "Could not save game: " + }, "COULD_NOT_READ_THE_SAVE_VERSION": { "description": "Message when restoring savegame", "message": "Could not read the save version" @@ -2027,6 +2035,14 @@ "description": "", "message": "Save" }, + "SAVED_GAMES": { + "description": "Header for save/load window", + "message": "Saved Games" + }, + "SAVE_AS": { + "description": "", + "message": "Save As" + }, "SAVE_DELETED_SUCCESSFULLY": { "description": "", "message": "Save deleted successfully" diff --git a/data/meta/CoreObject/Game.meta.lua b/data/meta/CoreObject/Game.meta.lua index 0bbea54a9e..65fe3fca13 100644 --- a/data/meta/CoreObject/Game.meta.lua +++ b/data/meta/CoreObject/Game.meta.lua @@ -50,6 +50,7 @@ function Game.SaveGame(filename) end --- Delete savefile with specified filename. ---@param filename string +---@return boolean success function Game.DeleteSave(filename) end --- End the current game and return to the main menu. diff --git a/data/pigui/modules/saveloadgame.lua b/data/pigui/modules/saveloadgame.lua index 1deb1d2a60..f142ef5e1d 100644 --- a/data/pigui/modules/saveloadgame.lua +++ b/data/pigui/modules/saveloadgame.lua @@ -1,302 +1,527 @@ -- Copyright © 2008-2024 Pioneer Developers. See AUTHORS.txt for details -- Licensed under the terms of the GPL v3. See licenses/GPL-3.txt -local Engine = require 'Engine' -local Game = require 'Game' -local ShipDef = require 'ShipDef' +local Game = require 'Game' local FileSystem = require 'FileSystem' -local Format = require 'Format' +local Lang = require 'Lang' +local ShipDef = require 'ShipDef' +local Vector2 = _G.Vector2 -local Lang = require 'Lang' -local lc = Lang.GetResource("core") +local utils = require 'utils' + +local lc = Lang.GetResource("core") local lui = Lang.GetResource("ui-core") -local Vector2 = _G.Vector2 -local Color = _G.Color local ui = require 'pigui' + +local Notification = require 'pigui.libs.notification' local ModalWindow = require 'pigui.libs.modal-win' local MessageBox = require 'pigui.libs.message-box' -local UITimer = require 'pigui.libs.ui-timer' local NewGameWindow = require 'pigui.modules.new-game-window.class' -local msgbox = require 'pigui.libs.message-box' - -local optionButtonSize = ui.rescaleUI(Vector2(100, 32)) -local winSize = Vector2(ui.screenWidth * 0.4, ui.screenHeight * 0.6) -local pionillium = ui.fonts.pionillium - -local searchText = lc.SEARCH .. ':' -local saveText = lui.SAVE .. ':' -local errText = lui.ERROR .. ': ' -local caseSensitiveText = lui.CASE_SENSITIVE - -local saveFileCache = {} -local selectedSave -local saveIsValid = true -local saveInList -local showDeleteResult = false -local successDeleteSaveResult = false -local timerName = "displaySuccessfulResult" -local dissapearTime = 3.0 local minSearchTextLength = 1 -local searchSave = "" -local caseSensitive = false -local function optionTextButton(label, enabled, callback) +local headingFont = ui.fonts.orbiteer.title +local mainButtonFont = ui.fonts.pionillium.body +local bodyFont = ui.fonts.pionillium.body +local detailsFont = ui.fonts.pionillium.details + +local colors = ui.theme.colors +local icons = ui.theme.icons +local cardBackgroundCol = ui.theme.buttonColors.card +local cardSelectedCol = ui.theme.buttonColors.card_selected + +local style = ui.rescaleUI { + windowPadding = Vector2(16, 16), + popupPadding = Vector2(120, 90), + itemSpacing = Vector2(8, 16), + mainButtonSize = Vector2(100, 0) -- button height will be calculated from the font height +} + +local iconColor = colors.fontDim + +--============================================================================== + +local SaveGameEntry = utils.proto() + +SaveGameEntry.name = "" +SaveGameEntry.character = lc.UNKNOWN +SaveGameEntry.shipName = "" +SaveGameEntry.shipClass = lc.UNKNOWN +SaveGameEntry.credits = 0 +SaveGameEntry.locationName = lc.UNKNOWN +SaveGameEntry.gameTime = 0 +SaveGameEntry.duration = 0 +SaveGameEntry.timestamp = 0 +SaveGameEntry.isAutosave = false +SaveGameEntry.compatible = true + +--============================================================================== + +local SaveLoadWindow = ModalWindow.New("SaveGameWindow") + +SaveLoadWindow.Modes = { + Load = "LOAD", + Save = "SAVE" +} + +SaveLoadWindow.mode = SaveLoadWindow.Modes.Load +SaveLoadWindow.selectedFile = nil +SaveLoadWindow.searchStr = "" +SaveLoadWindow.savePath = "" +SaveLoadWindow.entryCache = {} + +--============================================================================== + +local function mainButton(label, enabled, tooltip) local variant = not enabled and ui.theme.buttonColors.disabled or nil - local button - ui.withFont(pionillium.medium, function() - button = ui.button(label, optionButtonSize, variant) - end) - if button then - if enabled and callback then - callback(button) + local ret = ui.button(label, style.mainButtonSize, variant, tooltip) + return enabled and ret +end + +local function drawSaveEntryDetails(entry) + local iconSize = Vector2(detailsFont.size) + + -- Ship and current credits + ui.tableSetColumnIndex(1) + ui.withFont(detailsFont, function() + ui.alignTextToLineHeight(bodyFont.size) + ui.icon(icons.ships_no_orbits, iconSize, iconColor) + ui.sameLine() + if #entry.shipName > 0 then + ui.text(entry.shipClass .. ": " .. entry.shipName) + else + ui.text(entry.shipClass) end - end + + ui.icon(icons.money, iconSize, iconColor) + ui.sameLine() + ui.text(ui.Format.Number(entry.credits, 0) .. " " .. lc.UNIT_CREDITS) + end) + + -- Location and time + ui.tableSetColumnIndex(2) + ui.withFont(detailsFont, function() + ui.alignTextToLineHeight(bodyFont.size) + ui.icon(icons.navtarget, iconSize, iconColor) + ui.sameLine() + ui.text(entry.locationName) + + ui.icon(icons.eta, iconSize, iconColor) + ui.sameLine() + ui.text(ui.Format.Datetime(entry.gameTime)) + end) + + -- Time spent playing the savefile and last played date + ui.tableSetColumnIndex(3) + ui.withFont(detailsFont, function() + ui.alignTextToLineHeight(bodyFont.size) + ui.icon(icons.eta, iconSize, iconColor) + ui.sameLine() + ui.text(ui.Format.Duration(entry.duration)) + + ui.icon(icons.new, iconSize, iconColor) + ui.sameLine() + ui.text(ui.Format.Datetime(entry.timestamp)) + end) end -local function getSaveTooltip(name) - local ret - local stats - if not saveFileCache[name] then - _, saveFileCache[name] = pcall(Game.SaveGameStats, name) - end - stats = saveFileCache[name] - if (type(stats) == "string") then -- file could not be loaded, this is the error - return stats - end - ret = lui.GAME_TIME..": " .. Format.Date(stats.time) - local ship = stats.ship and ShipDef[stats.ship] +local function drawSaveEntryRow(entry, selected) + local pos = ui.getCursorScreenPos() + local size = ui.getContentRegion() + local padding = ui.theme.styles.ButtonPadding + local spacing = ui.theme.styles.ItemInnerSpacing - if stats.system then ret = ret .. "\n"..lc.SYSTEM..": " .. stats.system end - if stats.credits then ret = ret .. "\n"..lui.CREDITS..": " .. Format.Money(stats.credits) end + local contentHeight = spacing.y + ui.fonts.pionillium.body.size + ui.fonts.pionillium.details.size + size.y = padding.y * 2 + contentHeight - if ship then - ret = ret .. "\n"..lc.SHIP..": " .. ship.name - else - ret = ret .. "\n" .. lc.SHIP .. ": " .. lc.UNKNOWN - end + local clicked = ui.invisibleButton("##SaveGameEntry#" .. entry.name, size, { "PressedOnClickRelease", "PressedOnDoubleClick" }) + local hovered = ui.isItemHovered() + local active = ui.isItemActive() + + ui.setCursorScreenPos(pos) + + -- Draw the background of the whole save file + local backgroundColor = selected and cardSelectedCol or cardBackgroundCol + ui.addRectFilled(pos, pos + size, ui.getButtonColor(backgroundColor, hovered, active), 0, 0) + local indicatorColor = ui.theme.styleColors.danger_900 - if stats.flight_state then - ret = ret .. "\n"..lui.FLIGHT_STATE..": " - ret = ret .. (rawget(lc, string.upper(stats.flight_state)) or - rawget(lui, string.upper(stats.flight_state)) or - lc.UNKNOWN) + if entry.compatible then + indicatorColor = entry.isAutosave and ui.theme.styleColors.gray_400 or ui.theme.styleColors.primary_400 end - if stats.docked_at then ret = ret .. "\n"..lui.DOCKED_AT..": " .. stats.docked_at end - if stats.frame then ret = ret .. "\n"..lui.VICINITY_OF..": " .. stats.frame end + -- Indicator bar for compatible / autosave / incompatible saves + ui.addRectFilled(pos, pos + Vector2(spacing.x, size.y), indicatorColor, 0, 0) + + ui.withStyleVars({ CellPadding = padding, ItemSpacing = ui.theme.styles.ItemInnerSpacing }, function() + + if ui.beginTable("##SaveGameEntry", 4) then - saveFileCache[name].ret = ret - return ret + ui.tableNextRow() + + ui.tableSetColumnIndex(0) + + local iconStart = ui.getCursorScreenPos() + + -- Draw the leading icon spanning both rows of details + ui.addIconSimple(iconStart + Vector2(padding.x, 0), icons.medium_courier, Vector2(contentHeight, contentHeight), colors.font) + ui.dummy(Vector2(padding.x + contentHeight, contentHeight)) + ui.sameLine() + + -- Draw the save name and character name + ui.group(function() + ui.withFont(bodyFont, function() + ui.text(entry.name) + end) + + ui.withFont(detailsFont, function() + ui.icon(icons.personal, Vector2(detailsFont.size), iconColor) + ui.sameLine() + ui.text(entry.character) + end) + end) + + -- Draw all of the other details + drawSaveEntryDetails(entry) + + ui.endTable() + end + + end) + + return clicked end -local function shouldDisplayThisSave(f) - if(string.len(searchSave) < minSearchTextLength) then - return true +--============================================================================= + +local function makeEntryForSave(file) + local compatible, saveInfo = pcall(Game.SaveGameStats, file.name) + if not compatible then + saveInfo = {} end - return not caseSensitive and string.find(string.lower(f.name), string.lower(searchSave), 1, true) ~= nil or - string.find(f.name, searchSave, 1, true) ~= nil -end + local location = saveInfo.system or lc.UNKNOWN + + if saveInfo.docked_at then + location = location .. ", " .. saveInfo.docked_at + end + + local saveEntry = SaveGameEntry:clone({ + name = file.name, + compatible = compatible, + isAutosave = file.name:sub(1, 1) == "_", + -- character = lc.UNKNOWN -- No information about the pilot available + timestamp = file.mtime.timestamp, + gameTime = saveInfo.time, + -- duration = 0, -- No duration information available + locationName = location, + credits = saveInfo.credits, + -- shipName = lc.UNKNOWN, -- No information about the name of the ship available, either, + }) + + -- The saved ship name is the name of the *model* not the shipdef for some dumb reason + -- We treat the two as interchangeable for now + if saveInfo.ship then + local shipDef = ShipDef[saveInfo.ship] + + if shipDef then + saveEntry.shipClass = shipDef.name + end + end -local function wantRecovery() - return ui.ctrlHeld() or not saveIsValid + return saveEntry end -local function closeAndClearCache() - ui.saveLoadWindow:close() - ui.saveLoadWindow.mode = nil - saveFileCache = {} - popupOpened = false - saveInList = false - selectedSave = "" - searchSave = "" - UITimer.deleteTimer(timerName) - showDeleteResult = false +function SaveLoadWindow:getSaveEntry(filename) + return self.entryCache[filename] or SaveGameEntry end -local function closeAndLoadOrSave() - if selectedSave ~= nil and selectedSave ~= '' then - local success, err - if ui.saveLoadWindow.mode == "LOAD" then - if not wantRecovery() then - success, err = pcall(Game.LoadGame, selectedSave) - else - -- recover - local ok, report = NewGameWindow.restoreSaveGame(selectedSave) - if (ok) then - closeAndClearCache() - NewGameWindow.mode = 'RECOVER' - NewGameWindow:open() - end - msgbox.OK(report) - end - elseif ui.saveLoadWindow.mode == "SAVE" then - success, err = pcall(Game.SaveGame, selectedSave) - else - logWarning("Unknown saveLoadWindow mode: " .. ui.saveLoadWindow.mode) +--============================================================================= + +function SaveLoadWindow:makeFilteredList() + local shouldShow = function(f) + if #self.searchStr < minSearchTextLength then + return true end - if success ~= nil then - if not success then - MessageBox.OK(errText .. err) - else - closeAndClearCache() - end + + if self.caseSensitive then + return f.name:find(self.searchStr, 1, true) ~= nil + else + return f.name:lower():find(self.searchStr:lower(), 1, true) ~= nil end end -end + local isSelectedFile = function(f) return f.name == self.selectedFile end -local function displaySave(f) - if ui.selectable(f.name, f.name == selectedSave, {"SpanAllColumns", "DontClosePopups", "AllowDoubleClick"}) then - selectedSave = f.name - saveIsValid = pcall(Game.SaveGameStats, f.name) - if ui.isMouseDoubleClicked(0) then - closeAndLoadOrSave() - end - end + self.filteredFiles = utils.filter_array(self.files, shouldShow) - if ui.isItemHovered("ForTooltip") then - ui.setTooltip(getSaveTooltip(f.name)) + if not utils.contains_if(self.filteredFiles, isSelectedFile) then + self.selectedFile = nil end - - ui.nextColumn() - ui.text(Format.Date(f.mtime.timestamp)) - ui.nextColumn() end -local function showSaveFiles() - -- TODO: This is reading the files of disc every frame, think about refactoring to not do this. +function SaveLoadWindow:makeFileList() local ok, files, _ = pcall(FileSystem.ReadDirectory, "user://savefiles") + if not ok then - print('Error: ' .. files) - saveFileCache = {} + Notification.add(Notification.Type.Error, lui.COULD_NOT_LOAD_SAVE_FILES, files --[[@as string]]) + self.files = {} + end + + self.files = files + + table.sort(self.files, function(a, b) + return a.mtime.timestamp > b.mtime.timestamp + end) + + self:makeFilteredList() +end + +function SaveLoadWindow:loadSelectedSave() + local success, err = pcall(Game.LoadGame, self.selectedFile) + + if not success then + Notification.add(Notification.Type.Error, lui.COULD_NOT_LOAD_GAME .. self.selectedFile, err) else - table.sort(files, function(a,b) return (a.mtime.timestamp > b.mtime.timestamp) end) - ui.columns(2,"##saved_games",true) - local wasInList = false - for _,f in pairs(files) do - if(shouldDisplayThisSave(f)) then - displaySave(f) - if not wasInList and (f.name == selectedSave) then - wasInList = true - end - end - end - saveInList = wasInList + self:close() end end -local function deleteSave() - successDeleteSaveResult = Game.DeleteSave(selectedSave) - showDeleteResult = true - if successDeleteSaveResult then - UITimer.createTimer(timerName, dissapearTime, function() - showDeleteResult = false - end) +function SaveLoadWindow:recoverSelectedSave() + local ok, report = NewGameWindow.restoreSaveGame(self.selectedFile) + + if ok then + self:close() + NewGameWindow.mode = 'RECOVER' + NewGameWindow:open() + end + + MessageBox.OK(report) +end + +function SaveLoadWindow:saveToFilePath() + local success, err = pcall(Game.SaveGame, self.savePath) + + if not success then + Notification.add(Notification.Type.Error, lui.COULD_NOT_SAVE_GAME .. self.savePath, err) else - return + self:close() end - selectedSave = '' end -local function showDeleteSaveResult(saving) - if successDeleteSaveResult then - local textSize = ui.calcTextSize(lui.SAVE_DELETED_SUCCESSFULLY, pionillium.small) - if saving then - ui.sameLine(ui.getContentRegion().x - textSize.x) +function SaveLoadWindow:deleteSelectedSave() + local savefile = self.selectedFile + + MessageBox.OK_CANCEL(lui.DELETE_SAVE_CONFIRMATION, function() + if Game.DeleteSave(savefile) then + Notification.add(Notification.Type.Info, lui.SAVE_DELETED_SUCCESSFULLY) else - local txt_hshift = math.max(0, (optionButtonSize.y - ui.getFrameHeight() + textSize.y) / 2) - ui.sameLine(ui.getContentRegion().x - textSize.x - (ui.getItemSpacing().x + ui.getWindowPadding().x + optionButtonSize.x * 3.2)) - ui.addCursorPos(Vector2(0, txt_hshift)) + Notification.add(Notification.Type.Error, lui.COULD_NOT_DELETE_SAVE) end - ui.withFont(pionillium.small, function() - ui.text(lui.SAVE_DELETED_SUCCESSFULLY) - end) - else - MessageBox.OK(lui.COULD_NOT_DELETE_SAVE) - showDeleteResult = false + + -- List of files changed on disk, need to update our copy of it + self:makeFileList() + end) +end + +function SaveLoadWindow:update() + ModalWindow.update(self) + + -- Incrementally update cache until all files are up to date + -- We don't need to manually clear the cache, as changes to the list of + -- files will trigger the cache to be updated + local uncached = utils.find_if(self.files, function(_, file) + return not self.entryCache[file.name] or self.entryCache[file.name].timestamp ~= file.mtime.timestamp + end) + + if uncached then + self.entryCache[uncached.name] = makeEntryForSave(uncached) end end -local function showDeleteConfirmation() - MessageBox.OK_CANCEL(lui.DELETE_SAVE_CONFIRMATION, deleteSave) +--============================================================================= + +function SaveLoadWindow:onOpen() + self.selectedFile = nil + self.savePath = "" + self:makeFileList() +end + +function SaveLoadWindow:onFileSelected(filename) + self.selectedFile = filename + self.savePath = filename +end + +function SaveLoadWindow:onSavePathChanged(savePath) + self.savePath = savePath + self.selectedFile = nil end -local function drawSearchHeader(txt_width) - ui.withFont(pionillium.medium, function() - ui.text(searchText) - ui.nextItemWidth(txt_width, 0) - searchSave, _ = ui.inputText("##searchSave", searchSave, {}) +function SaveLoadWindow:onFilterChanged(filter) + self.searchStr = filter + self:makeFilteredList() +end + +function SaveLoadWindow:onSetCaseSensitive(cs) + self.caseSensitive = cs + self:makeFilteredList() +end + +--============================================================================= + +function SaveLoadWindow:drawSearchHeader() + ui.withFont(headingFont, function() + ui.text(lui.SAVED_GAMES) + end) + + -- Draw search bar icon + ui.sameLine(0, style.windowPadding.x) + ui.icon(icons.search_lens, Vector2(headingFont.size), colors.font, lc.SEARCH) + + -- Draw search bar text entry + ui.withFont(bodyFont, function() + local height = ui.getFrameHeight() + ui.sameLine() - local ch, value = ui.checkbox(caseSensitiveText, caseSensitive) - if ch then - caseSensitive = value + ui.addCursorPos(Vector2(0, (ui.getLineHeight() - height) / 2.0)) + + ui.nextItemWidth(ui.getContentRegion().x - (headingFont.size + ui.getItemSpacing().x)) + local searchStr, changed = ui.inputText("##searchStr", self.searchStr, {}) + + if changed then + self:message("onFilterChanged", searchStr) end end) -end -local function drawOptionButtons(txt_width, saving) - -- for vertical center alignment - local txt_hshift = math.max(0, (optionButtonSize.y - ui.getFrameHeight()) / 2) - local mode = saving and lui.SAVE or wantRecovery() and lui.RECOVER or lui.LOAD - ui.sameLine(txt_width + ui.getWindowPadding().x + ui.getItemSpacing().x) - ui.addCursorPos(Vector2(0, saving and -txt_hshift or txt_hshift)) - optionTextButton(mode, ((saving and (selectedSave ~= nil and selectedSave ~= '')) or (not saving and saveInList)), closeAndLoadOrSave) - ui.sameLine() - ui.addCursorPos(Vector2(0, saving and -txt_hshift or txt_hshift)) - optionTextButton(lui.DELETE, saveInList, showDeleteConfirmation) + -- Draw case-sensitive toggle + local color = self.caseSensitive and ui.theme.buttonColors.default or ui.theme.buttonColors.transparent ui.sameLine() - ui.addCursorPos(Vector2(0, saving and -txt_hshift or txt_hshift)) - optionTextButton(lui.CANCEL, true, closeAndClearCache) + if ui.iconButton(icons.autopilot_fly_to, Vector2(ui.getLineHeight()), lui.CASE_SENSITIVE, color) then + self:message("onSetCaseSensitive", not self.caseSensitive) + end end -ui.saveLoadWindow = ModalWindow.New("LoadGame", function() - local saving = ui.saveLoadWindow.mode == "SAVE" - local searchTextSize = ui.calcTextSize(searchText, pionillium.medium.name, pionillium.medium.size) +function SaveLoadWindow:drawSaveFooter() + local buttonWidths = (style.itemSpacing.x + style.mainButtonSize.x) * 3 - local txt_width = winSize.x - (ui.getWindowPadding().x + optionButtonSize.x + ui.getItemSpacing().x) * 2 + -- Draw savefile text entry + if self.mode == SaveLoadWindow.Modes.Save then + ui.alignTextToButtonPadding() + ui.text(lui.SAVE_AS .. ":") - drawSearchHeader(txt_width) + ui.sameLine() + ui.nextItemWidth(ui.getContentRegion().x - buttonWidths) - ui.separator() + local savePath, changed - local saveFilesSearchHeaderHeight = (searchTextSize.y * 2 + ui.getItemSpacing().y * 2 + ui.getWindowPadding().y * 2) - local saveFilesChildWindowHeight = (optionButtonSize.y + (saving and searchTextSize.y or 0) + ui.getItemSpacing().y * 2 + ui.getWindowPadding().y * 2) + ui.withStyleVars({ FramePadding = ui.theme.styles.ButtonPadding }, function() + savePath, changed = ui.inputText("##savePath", self.savePath, {}) + end) - local saveFilesChildWindowSize = Vector2(0, (winSize.y - saveFilesChildWindowHeight) - saveFilesSearchHeaderHeight) + if changed then + self:message("onSavePathChanged", savePath) + end + else + ui.dummy(Vector2(ui.getContentRegion().x - buttonWidths, 0)) + end - ui.child("savefiles", saveFilesChildWindowSize, function() - showSaveFiles() - end) + ui.sameLine() - ui.separator() + -- Draw load/recover button + if self.mode == SaveLoadWindow.Modes.Load then + local wantRecovery = ui.ctrlHeld() - -- a padding just before the window border, so that the cancel button will not be cut out - txt_width = txt_width / 1.38 - if saving then - ui.withFont(pionillium.medium, function() - ui.text(saveText) - if showDeleteResult then - showDeleteSaveResult(saving) - end - ui.nextItemWidth(txt_width, 0) - selectedSave = ui.inputText("##saveFileName", selectedSave or "", {}) - end) + if self.selectedFile then + wantRecovery = wantRecovery or not self:getSaveEntry(self.selectedFile).compatible + end + + if mainButton(wantRecovery and lui.RECOVER or lui.LOAD, self.selectedFile) then + self:message(wantRecovery and "recoverSelectedSave" or "loadSelectedSave") + end else - if showDeleteResult then - showDeleteSaveResult(saving) + local active = self.savePath and #self.savePath > 0 + + if mainButton(lui.SAVE, active) then + self:message("saveToFilePath") + end + end + + ui.sameLine() + if mainButton(lui.DELETE, self.selectedFile) then + self:message("deleteSelectedSave") + end + + ui.sameLine() + if ui.button(lui.CANCEL, style.mainButtonSize) then + self:message("close") + end +end + +function SaveLoadWindow:drawSaveList() + for _, f in ipairs(self.filteredFiles) do + local entry = self:getSaveEntry(f.name) + + if entry then + local selected = drawSaveEntryRow(entry, f.name == self.selectedFile) + + if selected then + self:message("onFileSelected", f.name) + + if ui.isMouseDoubleClicked(0) then + self:message("loadSelectedSave") + end + end end end - drawOptionButtons(txt_width, saving) -end, function (_, drawPopupFn) - ui.setNextWindowSize(winSize, "Always") +end + +function SaveLoadWindow:outerHandler(drawPopupFn) + local size = ui.screenSize() - style.popupPadding * 2 + ui.setNextWindowSize(size, "Always") ui.setNextWindowPosCenter('Always') - ui.withStyleColors({ PopupBg = ui.theme.colors.modalBackground }, drawPopupFn) -end) + ui.withStyleColorsAndVars({ PopupBg = ui.theme.colors.modalBackground }, { WindowPadding = style.windowPadding }, drawPopupFn) + + -- Debug reloading + if ui.ctrlHeld() and ui.isKeyReleased(ui.keys.delete) then + self:message("close") + package.reimport() + end +end + +function SaveLoadWindow:render() + local windowSpacing = Vector2(ui.theme.styles.ItemSpacing.x, style.windowPadding.y) + + ui.withStyleVars({ WindowPadding = ui.theme.styles.WindowPadding, ItemSpacing = windowSpacing }, function() + + self:drawSearchHeader() + + ui.separator() + + local bottomLineHeight = ui.getButtonHeight(mainButtonFont) + windowSpacing.y * 2 + local saveListSize = Vector2(0, ui.getContentRegion().y - bottomLineHeight) + + ui.child("##SaveFileList", saveListSize, function() + self:drawSaveList() + end) + + ui.separator() + + ui.withStyleVars({ ItemSpacing = style.itemSpacing }, function() + ui.withFont(mainButtonFont, function() + self:drawSaveFooter() + end) + end) + + end) + + if ui.isKeyReleased(ui.keys.escape) then + self:close() + end +end + +--============================================================================= -ui.saveLoadWindow.mode = "LOAD" +ui.saveLoadWindow = SaveLoadWindow return {} From 2d1de341187e2b01a55c807bb0a360dbcb9647c2 Mon Sep 17 00:00:00 2001 From: Webster Sheets Date: Thu, 18 Jul 2024 03:28:59 -0400 Subject: [PATCH 10/14] Fix copyright header dates --- data/meta/Color.lua | 2 +- data/meta/Space.lua | 2 +- data/modules/Common/ProximityQuery.lua | 2 +- data/modules/FlightLog/FlightLogEntries.lua | 12 ++++++------ data/modules/FlightLog/FlightLogExporter.lua | 4 ++-- data/shaders/opengl/rayleigh_accurate.frag | 2 +- data/shaders/opengl/rayleigh_accurate.shaderdef | 2 +- data/shaders/opengl/rayleigh_accurate.vert | 2 +- data/shaders/opengl/rayleigh_fast.frag | 2 +- data/shaders/opengl/rayleigh_fast.shaderdef | 2 +- data/shaders/opengl/rayleigh_fast.vert | 2 +- data/shaders/opengl/sRGB.glsl | 2 +- src/lua/LuaBody.h | 2 +- 13 files changed, 19 insertions(+), 19 deletions(-) diff --git a/data/meta/Color.lua b/data/meta/Color.lua index 4007b5c0a6..fa7f6dd653 100644 --- a/data/meta/Color.lua +++ b/data/meta/Color.lua @@ -1,4 +1,4 @@ --- Copyright © 2008-2023 Pioneer Developers. See AUTHORS.txt for details +-- Copyright © 2008-2024 Pioneer Developers. See AUTHORS.txt for details -- Licensed under the terms of the GPL v3. See licenses/GPL-3.txt -- This file implements type information about C++ classes for Lua static analysis diff --git a/data/meta/Space.lua b/data/meta/Space.lua index 5d3cb8621d..55db087d25 100644 --- a/data/meta/Space.lua +++ b/data/meta/Space.lua @@ -1,4 +1,4 @@ --- Copyright © 2008-2023 Pioneer Developers. See AUTHORS.txt for details +-- Copyright © 2008-2024 Pioneer Developers. See AUTHORS.txt for details -- Licensed under the terms of the GPL v3. See licenses/GPL-3.txt -- This file implements type information about C++ classes for Lua static analysis diff --git a/data/modules/Common/ProximityQuery.lua b/data/modules/Common/ProximityQuery.lua index 24cfcd249e..2d91cf6ddd 100644 --- a/data/modules/Common/ProximityQuery.lua +++ b/data/modules/Common/ProximityQuery.lua @@ -1,4 +1,4 @@ --- Copyright © 2008-2023 Pioneer Developers. See AUTHORS.txt for details +-- Copyright © 2008-2024 Pioneer Developers. See AUTHORS.txt for details -- Licensed under the terms of the GPL v3. See licenses/GPL-3.txt local Engine = require 'Engine' diff --git a/data/modules/FlightLog/FlightLogEntries.lua b/data/modules/FlightLog/FlightLogEntries.lua index acce3b2de8..f3664a2c8c 100644 --- a/data/modules/FlightLog/FlightLogEntries.lua +++ b/data/modules/FlightLog/FlightLogEntries.lua @@ -1,4 +1,4 @@ --- Copyright © 2008-2023 Pioneer Developers. See AUTHORS.txt for details +-- Copyright © 2008-2024 Pioneer Developers. See AUTHORS.txt for details -- Licensed under the terms of the GPL v3. See licenses/GPL-3.txt -- Entries for the FlightLog @@ -118,7 +118,7 @@ end --- Based on flight state, compose a reasonable string for location --- TODO: consider a class to represent, construct, store and format this ----@param location string[] Array of string info, the first one is the +---@param location string[] Array of string info, the first one is the ---@return string The formatted composite location. function FlightLogEntry.Base.composeLocationString(location) return string.interp(l["FLIGHTLOG_"..location[1]], @@ -166,7 +166,7 @@ end FlightLogEntry.System = utils.class("FlightLogEntry.System", FlightLogEntry.Base) ---@return string Description of this type -function FlightLogEntry.System:GetType() +function FlightLogEntry.System:GetType() return "System" end @@ -257,7 +257,7 @@ end FlightLogEntry.Custom = utils.class("FlightLogEntry.Custom", FlightLogEntry.Base) ---@return string Description of this type -function FlightLogEntry.Custom:GetType() +function FlightLogEntry.Custom:GetType() return "Custom" end @@ -314,7 +314,7 @@ end FlightLogEntry.Station = utils.class("FlightLogEntry.Station", FlightLogEntry.Base) ---@return string Description of this type -function FlightLogEntry.Station:GetType() +function FlightLogEntry.Station:GetType() return "Station" end @@ -359,4 +359,4 @@ function FlightLogEntry.Station:GetDataPairs( earliest_first ) } end -return FlightLogEntry \ No newline at end of file +return FlightLogEntry diff --git a/data/modules/FlightLog/FlightLogExporter.lua b/data/modules/FlightLog/FlightLogExporter.lua index 6ce195c253..c62bec665e 100644 --- a/data/modules/FlightLog/FlightLogExporter.lua +++ b/data/modules/FlightLog/FlightLogExporter.lua @@ -1,4 +1,4 @@ --- Copyright © 2008-2023 Pioneer Developers. See AUTHORS.txt for details +-- Copyright © 2008-2024 Pioneer Developers. See AUTHORS.txt for details -- Licensed under the terms of the GPL v3. See licenses/GPL-3.txt local FlightLog = require 'modules.FlightLog.FlightLog' @@ -136,4 +136,4 @@ function Exporter.Export( included_types, earliest_first, player_info, export_ht end -return Exporter \ No newline at end of file +return Exporter diff --git a/data/shaders/opengl/rayleigh_accurate.frag b/data/shaders/opengl/rayleigh_accurate.frag index 2902b4c4c5..40cdf4b187 100644 --- a/data/shaders/opengl/rayleigh_accurate.frag +++ b/data/shaders/opengl/rayleigh_accurate.frag @@ -1,4 +1,4 @@ -// Copyright © 2008-2023 Pioneer Developers. See AUTHORS.txt for details +// Copyright © 2008-2024 Pioneer Developers. See AUTHORS.txt for details // Licensed under the terms of the GPL v3. See licenses/GPL-3.txt #include "attributes.glsl" diff --git a/data/shaders/opengl/rayleigh_accurate.shaderdef b/data/shaders/opengl/rayleigh_accurate.shaderdef index e092bd09fd..3f1d544973 100644 --- a/data/shaders/opengl/rayleigh_accurate.shaderdef +++ b/data/shaders/opengl/rayleigh_accurate.shaderdef @@ -1,4 +1,4 @@ -## Copyright © 2008-2023 Pioneer Developers. See AUTHORS.txt for details +## Copyright © 2008-2024 Pioneer Developers. See AUTHORS.txt for details ## Licensed under the terms of the GPL v3. See licenses/GPL-3.txt Shader rayleigh_accurate diff --git a/data/shaders/opengl/rayleigh_accurate.vert b/data/shaders/opengl/rayleigh_accurate.vert index 58666a61ab..8678b519e7 100644 --- a/data/shaders/opengl/rayleigh_accurate.vert +++ b/data/shaders/opengl/rayleigh_accurate.vert @@ -1,4 +1,4 @@ -// Copyright © 2008-2023 Pioneer Developers. See AUTHORS.txt for details +// Copyright © 2008-2024 Pioneer Developers. See AUTHORS.txt for details // Licensed under the terms of the GPL v3. See licenses/GPL-3.txt #include "attributes.glsl" diff --git a/data/shaders/opengl/rayleigh_fast.frag b/data/shaders/opengl/rayleigh_fast.frag index afeb8ebf83..30b7df5a31 100644 --- a/data/shaders/opengl/rayleigh_fast.frag +++ b/data/shaders/opengl/rayleigh_fast.frag @@ -1,4 +1,4 @@ -// Copyright © 2008-2023 Pioneer Developers. See AUTHORS.txt for details +// Copyright © 2008-2024 Pioneer Developers. See AUTHORS.txt for details // Licensed under the terms of the GPL v3. See licenses/GPL-3.txt #include "attributes.glsl" diff --git a/data/shaders/opengl/rayleigh_fast.shaderdef b/data/shaders/opengl/rayleigh_fast.shaderdef index db12bd88a7..754ea4b506 100644 --- a/data/shaders/opengl/rayleigh_fast.shaderdef +++ b/data/shaders/opengl/rayleigh_fast.shaderdef @@ -1,4 +1,4 @@ -## Copyright © 2008-2023 Pioneer Developers. See AUTHORS.txt for details +## Copyright © 2008-2024 Pioneer Developers. See AUTHORS.txt for details ## Licensed under the terms of the GPL v3. See licenses/GPL-3.txt Shader rayleigh_fast diff --git a/data/shaders/opengl/rayleigh_fast.vert b/data/shaders/opengl/rayleigh_fast.vert index cca12f9124..851005b312 100644 --- a/data/shaders/opengl/rayleigh_fast.vert +++ b/data/shaders/opengl/rayleigh_fast.vert @@ -1,4 +1,4 @@ -// Copyright © 2008-2023 Pioneer Developers. See AUTHORS.txt for details +// Copyright © 2008-2024 Pioneer Developers. See AUTHORS.txt for details // Licensed under the terms of the GPL v3. See licenses/GPL-3.txt #include "attributes.glsl" diff --git a/data/shaders/opengl/sRGB.glsl b/data/shaders/opengl/sRGB.glsl index f94437f9da..abe2a765bb 100644 --- a/data/shaders/opengl/sRGB.glsl +++ b/data/shaders/opengl/sRGB.glsl @@ -1,4 +1,4 @@ -// Copyright © 2008-2023 Pioneer Developers. See AUTHORS.txt for details +// Copyright © 2008-2024 Pioneer Developers. See AUTHORS.txt for details // Licensed under the terms of the GPL v3. See licenses/GPL-3.txt #define GAMMA_FACTOR 2.2 diff --git a/src/lua/LuaBody.h b/src/lua/LuaBody.h index 025f31a3c3..f0451644a2 100644 --- a/src/lua/LuaBody.h +++ b/src/lua/LuaBody.h @@ -1,4 +1,4 @@ -// Copyright © 2008-2023 Pioneer Developers. See AUTHORS.txt for details +// Copyright © 2008-2024 Pioneer Developers. See AUTHORS.txt for details // Licensed under the terms of the GPL v3. See licenses/GPL-3.txt #include "Body.h" From ba98f8288a3a569aa650921f16592699fa0f6e0a Mon Sep 17 00:00:00 2001 From: Webster Sheets Date: Sun, 11 Aug 2024 18:25:12 -0400 Subject: [PATCH 11/14] Game: add duration, ship info, character name to savefile header - Add total playtime to save information - Add ship name and hull name to save information - Add player character's name to save information - All additions should be save-compatible with the current version --- src/Game.cpp | 35 ++++++++++++++++++++++++++++++++++- src/Game.h | 3 +++ src/Ship.h | 2 ++ src/lua/LuaGame.cpp | 9 +++++++++ 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/Game.cpp b/src/Game.cpp index 0a6d6bbbc5..5ee6571a7b 100644 --- a/src/Game.cpp +++ b/src/Game.cpp @@ -39,6 +39,7 @@ static const int s_saveVersion = 90; Game::Game(const SystemPath &path, const double startDateTime, const char *shipType) : m_galaxy(GalaxyGenerator::Create()), m_time(startDateTime), + m_playedDuration(0), m_state(State::NORMAL), m_wantHyperspace(false), m_hyperspaceProgress(0), @@ -107,6 +108,9 @@ Game::Game(const SystemPath &path, const double startDateTime, const char *shipT m_player->SetVelocity(randomOrthoDirection * orbitalVelocity); } + // Record when we started playing this save so we can determine how long it's been played this session + m_sessionStartTimestamp = std::chrono::steady_clock::now().time_since_epoch().count(); + CreateViews(); EmitPauseState(IsPaused()); @@ -189,6 +193,10 @@ Game::Game(const Json &jsonObj) : for (Uint32 i = 0; i < hyperspaceCloudArray.size(); i++) { m_hyperspaceClouds.push_back(static_cast(Body::FromJson(hyperspaceCloudArray[i], 0))); } + + const Json &gameInfo = jsonObj["game_info"]; + m_playedDuration = gameInfo.value("duration", 0.0); + } catch (Json::type_error &) { throw SavedGameCorruptException(); } @@ -205,6 +213,8 @@ Game::Game(const Json &jsonObj) : Pi::luaSerializer->UninitTableRefs(); + m_sessionStartTimestamp = std::chrono::steady_clock::now().time_since_epoch().count(); + EmitPauseState(IsPaused()); Pi::GetApp()->RequestProfileFrame("LoadGame"); @@ -267,9 +277,32 @@ void Game::ToJson(Json &jsonObj) Json gameInfo = Json::object(); float credits = LuaObject::CallMethod(Pi::player, "GetMoney"); + // Get the player's character name + // TODO: add an easier way to get the player's character object once player+ship are split more firmly + pi_lua_import(Lua::manager->GetLuaState(), "Character"); + LuaTable characters(Lua::manager->GetLuaState(), -1); + + std::string character_name = characters.Sub("persistent").Sub("player").Get("name"); + gameInfo["character"] = character_name; + + // Remove the Character table + lua_pop(Lua::manager->GetLuaState(), 1); + + // Determine how long we've been playing this save (since we created or loaded it) + std::chrono::steady_clock::duration start_time(m_sessionStartTimestamp); + auto playtime_duration = std::chrono::steady_clock::now().time_since_epoch() - start_time; + + auto playtime_this_session = std::chrono::duration_cast(playtime_duration).count(); + + gameInfo["duration"] = m_playedDuration + playtime_this_session; + + // Information about the player's ship + gameInfo["shipHull"] = Pi::player->GetShipType()->name; + gameInfo["shipName"] = Pi::player->GetShipName(); + gameInfo["system"] = Pi::game->GetSpace()->GetStarSystem()->GetName(); gameInfo["credits"] = credits; - gameInfo["ship"] = Pi::player->GetShipType()->modelName; + gameInfo["ship"] = Pi::player->GetShipType()->id; if (Pi::player->IsDocked()) { gameInfo["docked_at"] = Pi::player->GetDockedWith()->GetSystemBody()->GetName(); } diff --git a/src/Game.h b/src/Game.h index 61a34b69ad..4cd0b3093e 100644 --- a/src/Game.h +++ b/src/Game.h @@ -164,6 +164,9 @@ class Game { std::unique_ptr m_space; double m_time; + int64_t m_sessionStartTimestamp; + double m_playedDuration; + enum class State { NORMAL, HYPERSPACE, diff --git a/src/Ship.h b/src/Ship.h index a0ba488233..8f8f205b24 100644 --- a/src/Ship.h +++ b/src/Ship.h @@ -214,6 +214,8 @@ class Ship : public DynamicBody { void SetLabel(const std::string &label) override; void SetShipName(const std::string &shipName); + const std::string &GetShipName() const { return m_shipName; } + float GetAtmosphericPressureLimit() const; float GetPercentShields() const; float GetPercentHull() const; diff --git a/src/lua/LuaGame.cpp b/src/lua/LuaGame.cpp index 90673c823e..d1b5730518 100644 --- a/src/lua/LuaGame.cpp +++ b/src/lua/LuaGame.cpp @@ -112,6 +112,15 @@ static int l_game_savegame_stats(lua_State *l) t.Set("flight_state", gameInfo["flight_state"].get()); if (gameInfo["docked_at"].is_string()) t.Set("docked_at", gameInfo["docked_at"].get()); + + if (gameInfo.count("shipHull")) + t.Set("shipHull", gameInfo["shipHull"].get()); + if (gameInfo.count("shipName")) + t.Set("shipName", gameInfo["shipName"].get()); + if (gameInfo.count("duration")) + t.Set("duration", gameInfo["duration"].get()); + if (gameInfo.count("character")) + t.Set("character", gameInfo["character"].get()); } else { // this is an older saved game...try to show something useful Json shipNode = rootNode["space"]["bodies"][rootNode["player"].get() - 1]; From d48ef4e5bd8344289a8f0f1f179820b60e5feee7 Mon Sep 17 00:00:00 2001 From: Webster Sheets Date: Mon, 12 Aug 2024 14:53:22 -0400 Subject: [PATCH 12/14] Add filter to hide autosaves, load full data from save info - Make autosaves hidden by default in the list - makes it easier to see on-purpose saves - Fully populate the save entry structure with data from the saved game information; now we have character and ship names, and total playtime - Update for new ui.iconButton signature --- data/lang/ui-core/en.json | 4 +++ data/pigui/modules/saveloadgame.lua | 54 +++++++++++++++++++++-------- 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/data/lang/ui-core/en.json b/data/lang/ui-core/en.json index 6cb765abaa..2a151282f9 100644 --- a/data/lang/ui-core/en.json +++ b/data/lang/ui-core/en.json @@ -2171,6 +2171,10 @@ "description": "", "message": "Ship Type" }, + "SHOW_AUTOSAVES": { + "description": "Button tooltip indicating whether to show autosave files", + "message": "Show Autosaves" + }, "SIMULATING_UNIVERSE_EVOLUTION_N_BYEARS": { "description": "", "message": "Simulating evolution of the universe: {age} billion years ;-)" diff --git a/data/pigui/modules/saveloadgame.lua b/data/pigui/modules/saveloadgame.lua index f142ef5e1d..33db23747a 100644 --- a/data/pigui/modules/saveloadgame.lua +++ b/data/pigui/modules/saveloadgame.lua @@ -47,7 +47,7 @@ local SaveGameEntry = utils.proto() SaveGameEntry.name = "" SaveGameEntry.character = lc.UNKNOWN SaveGameEntry.shipName = "" -SaveGameEntry.shipClass = lc.UNKNOWN +SaveGameEntry.shipHull = lc.UNKNOWN SaveGameEntry.credits = 0 SaveGameEntry.locationName = lc.UNKNOWN SaveGameEntry.gameTime = 0 @@ -70,6 +70,8 @@ SaveLoadWindow.selectedFile = nil SaveLoadWindow.searchStr = "" SaveLoadWindow.savePath = "" SaveLoadWindow.entryCache = {} +SaveLoadWindow.caseSensitive = false +SaveLoadWindow.showAutosaves = false --============================================================================== @@ -89,9 +91,9 @@ local function drawSaveEntryDetails(entry) ui.icon(icons.ships_no_orbits, iconSize, iconColor) ui.sameLine() if #entry.shipName > 0 then - ui.text(entry.shipClass .. ": " .. entry.shipName) + ui.text(entry.shipHull .. ": " .. entry.shipName) else - ui.text(entry.shipClass) + ui.text(entry.shipHull) end ui.icon(icons.money, iconSize, iconColor) @@ -211,22 +213,23 @@ local function makeEntryForSave(file) name = file.name, compatible = compatible, isAutosave = file.name:sub(1, 1) == "_", - -- character = lc.UNKNOWN -- No information about the pilot available + character = saveInfo.character, timestamp = file.mtime.timestamp, gameTime = saveInfo.time, - -- duration = 0, -- No duration information available + duration = saveInfo.duration, locationName = location, credits = saveInfo.credits, - -- shipName = lc.UNKNOWN, -- No information about the name of the ship available, either, + shipName = saveInfo.shipName, + shipHull = saveInfo.shipHull, }) - -- The saved ship name is the name of the *model* not the shipdef for some dumb reason - -- We treat the two as interchangeable for now - if saveInfo.ship then + -- Old saves store only the name of the ship's *model* file for some dumb reason + -- Treat the model name as the ship id and otherwise ignore it if we have proper data + if not saveInfo.shipHull then local shipDef = ShipDef[saveInfo.ship] if shipDef then - saveEntry.shipClass = shipDef.name + saveEntry.shipHull = shipDef.name end end @@ -241,6 +244,10 @@ end function SaveLoadWindow:makeFilteredList() local shouldShow = function(f) + if not self.showAutosaves and f.name:sub(1, 1) == "_" then + return false + end + if #self.searchStr < minSearchTextLength then return true end @@ -365,6 +372,14 @@ end function SaveLoadWindow:onSetCaseSensitive(cs) self.caseSensitive = cs + + if #self.searchStr >= minSearchTextLength then + self:makeFilteredList() + end +end + +function SaveLoadWindow:onSetShowAutosaves(sa) + self.showAutosaves = sa self:makeFilteredList() end @@ -379,6 +394,9 @@ function SaveLoadWindow:drawSearchHeader() ui.sameLine(0, style.windowPadding.x) ui.icon(icons.search_lens, Vector2(headingFont.size), colors.font, lc.SEARCH) + local icon_size = Vector2(headingFont.size) + local icon_size_spacing = icon_size.x + ui.getItemSpacing().x + -- Draw search bar text entry ui.withFont(bodyFont, function() local height = ui.getFrameHeight() @@ -386,7 +404,7 @@ function SaveLoadWindow:drawSearchHeader() ui.sameLine() ui.addCursorPos(Vector2(0, (ui.getLineHeight() - height) / 2.0)) - ui.nextItemWidth(ui.getContentRegion().x - (headingFont.size + ui.getItemSpacing().x)) + ui.nextItemWidth(ui.getContentRegion().x - (icon_size_spacing * 2)) local searchStr, changed = ui.inputText("##searchStr", self.searchStr, {}) if changed then @@ -394,12 +412,20 @@ function SaveLoadWindow:drawSearchHeader() end end) - -- Draw case-sensitive toggle - local color = self.caseSensitive and ui.theme.buttonColors.default or ui.theme.buttonColors.transparent ui.sameLine() - if ui.iconButton(icons.autopilot_fly_to, Vector2(ui.getLineHeight()), lui.CASE_SENSITIVE, color) then + + -- Draw case-sensitive toggle + local case_sens_variant = not self.caseSensitive and ui.theme.buttonColors.transparent + if ui.iconButton("case_sensitive", icons.case_sensitive, lui.CASE_SENSITIVE, case_sens_variant, icon_size) then self:message("onSetCaseSensitive", not self.caseSensitive) end + + ui.sameLine() + + local autosave_variant = not self.showAutosaves and ui.theme.buttonColors.transparent + if ui.iconButton("show_autosaves", icons.view_internal, lui.SHOW_AUTOSAVES, autosave_variant, icon_size) then + self:message("onSetShowAutosaves", not self.showAutosaves) + end end function SaveLoadWindow:drawSaveFooter() From 0b9a251bff2d33a06b0f11115fb8c7928180d056 Mon Sep 17 00:00:00 2001 From: Webster Sheets Date: Mon, 12 Aug 2024 20:20:08 -0400 Subject: [PATCH 13/14] Save on pressing enter in filename box --- data/pigui/modules/saveloadgame.lua | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/data/pigui/modules/saveloadgame.lua b/data/pigui/modules/saveloadgame.lua index 33db23747a..6bc0ed7192 100644 --- a/data/pigui/modules/saveloadgame.lua +++ b/data/pigui/modules/saveloadgame.lua @@ -439,15 +439,19 @@ function SaveLoadWindow:drawSaveFooter() ui.sameLine() ui.nextItemWidth(ui.getContentRegion().x - buttonWidths) - local savePath, changed + local savePath, confirmed ui.withStyleVars({ FramePadding = ui.theme.styles.ButtonPadding }, function() - savePath, changed = ui.inputText("##savePath", self.savePath, {}) + savePath, confirmed = ui.inputText("##savePath", self.savePath, { "EnterReturnsTrue" }) end) - if changed then + if savePath ~= self.savePath then self:message("onSavePathChanged", savePath) end + + if confirmed then + self:message("saveToFilePath") + end else ui.dummy(Vector2(ui.getContentRegion().x - buttonWidths, 0)) end From a2b792315bf4c98a42a40ee0ff0398b0e2b8a73e Mon Sep 17 00:00:00 2001 From: Webster Sheets Date: Wed, 21 Aug 2024 20:24:32 -0400 Subject: [PATCH 14/14] Fix missing return value --- src/lua/LuaPiGui.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lua/LuaPiGui.cpp b/src/lua/LuaPiGui.cpp index 76588d7191..ca9dd5eee6 100644 --- a/src/lua/LuaPiGui.cpp +++ b/src/lua/LuaPiGui.cpp @@ -3400,6 +3400,7 @@ static int l_pigui_table_set_bg_color(lua_State *l) const int columnIndex = LuaPull(l, 3, -1); ImGui::TableSetBgColor(target, color, columnIndex); + return 0; } static Color4ub to_Color4ub(ImVec4 c)