Index: ps/trunk/binaries/data/mods/public/gui/common/AlignmentHelper.js =================================================================== --- ps/trunk/binaries/data/mods/public/gui/common/AlignmentHelper.js (nonexistent) +++ ps/trunk/binaries/data/mods/public/gui/common/AlignmentHelper.js (revision 26349) @@ -0,0 +1,51 @@ +/** + * This is a helper class to align edges of a set of GUIObjects. + * The class is designed to either vertically or horizontally align the GUIObjects. + */ +class AlignmentHelper +{ + /** + * @param {string} direction - Either min or max. Deciding whether we should move all objects to the minimal (left or top most) or maximal (right or bottom most) position. + */ + constructor(direction) + { + if (direction != "max" && direction != "min") + error("Invalid alignment direction."); + + this.direction = direction; + // An Object of Objects containing the GUIObjects and their requested alignment details. + this.objectData = {}; + + this.defaultValue = this.direction == "max" ? -Infinity : Infinity; + } + + /** + * @param {Object} GUIObject - A GUIObject to be aligned. + * @param {string} edge - One of left, right, top and bottom. Determining the edge to change position for this object. + * @param {number} wantedPosition - The requested position of the edge. + */ + setObject(GUIObject, edge, wantedPosition = this.defaultValue) + { + this.objectData[GUIObject.name] = { + "GUIObject": GUIObject, + "edge": edge, + "wantedPosition": wantedPosition + }; + + this.align(); + } + + align() + { + let value = this.defaultValue; + for (const objectName in this.objectData) + value = Math[this.direction](value, this.objectData[objectName].wantedPosition); + + for (const objectName in this.objectData) + { + const objectSize = this.objectData[objectName].GUIObject.size; + objectSize[this.objectData[objectName].edge] = value; + this.objectData[objectName].GUIObject.size = objectSize; + } + } +} Index: ps/trunk/binaries/data/mods/public/gui/common/functions_utility.js =================================================================== --- ps/trunk/binaries/data/mods/public/gui/common/functions_utility.js (revision 26348) +++ ps/trunk/binaries/data/mods/public/gui/common/functions_utility.js (revision 26349) @@ -1,240 +1,263 @@ /** * Used for acoustic GUI notifications. * Define the soundfile paths and specific time thresholds (avoid spam). * And store the timestamp of last interaction for each notification. */ var g_SoundNotifications = { "nick": { "soundfile": "audio/interface/ui/chat_alert.ogg", "threshold": 3000 }, "gamesetup.join": { "soundfile": "audio/interface/ui/gamesetup_join.ogg", "threshold": 0 } }; /** * These events are fired when the user has closed the options page. * The handlers are provided a Set storing which config values have changed. * TODO: This should become a GUI event sent by the engine. */ var g_ConfigChangeHandlers = new Set(); function registerConfigChangeHandler(handler) { g_ConfigChangeHandlers.add(handler); } /** * @param changes - a Set of config names */ function fireConfigChangeHandlers(changes) { for (let handler of g_ConfigChangeHandlers) handler(changes); } /** * Returns translated history and gameplay data of all civs, optionally including a mock gaia civ. */ function loadCivData(selectableOnly, gaia) { let civData = loadCivFiles(selectableOnly); translateObjectKeys(civData, ["Name", "Description", "History", "Special"]); if (gaia) civData.gaia = { "Code": "gaia", "Name": translate("Gaia") }; return deepfreeze(civData); } // A sorting function for arrays of objects with 'name' properties, ignoring case function sortNameIgnoreCase(x, y) { let lowerX = x.name.toLowerCase(); let lowerY = y.name.toLowerCase(); if (lowerX < lowerY) return -1; if (lowerX > lowerY) return 1; return 0; } /** * Escape tag start and escape characters, so users cannot use special formatting. */ function escapeText(text) { return text.replace(/\\/g, "\\\\").replace(/\[/g, "\\["); } function unescapeText(text) { return text.replace(/\\\\/g, "\\").replace(/\\\[/g, "\["); } /** * Prepends a backslash to all quotation marks. */ function escapeQuotation(text) { return text.replace(/"/g, "\\\""); } /** * Merge players by team to remove duplicate Team entries, thus reducing the packet size of the lobby report. */ function playerDataToStringifiedTeamList(playerData) { let teamList = {}; for (let pData of playerData) { let team = pData.Team === undefined ? -1 : pData.Team; if (!teamList[team]) teamList[team] = []; teamList[team].push(pData); delete teamList[team].Team; } return escapeText(JSON.stringify(teamList)); } function stringifiedTeamListToPlayerData(stringifiedTeamList) { let teamList = {}; try { teamList = JSON.parse(unescapeText(stringifiedTeamList)); } catch (e) { // Ignore invalid input from remote users return []; } let playerData = []; for (let team in teamList) for (let pData of teamList[team]) { pData.Team = team; playerData.push(pData); } return playerData; } function removeDupes(array) { // loop backwards to make splice operations cheaper let i = array.length; while (i--) if (array.indexOf(array[i]) != i) array.splice(i, 1); } function singleplayerName() { return Engine.ConfigDB_GetValue("user", "playername.singleplayer") || Engine.GetSystemUsername(); } function multiplayerName() { return Engine.ConfigDB_GetValue("user", "playername.multiplayer") || Engine.GetSystemUsername(); } function tryAutoComplete(text, autoCompleteList) { if (!text.length) return text; var wordSplit = text.split(/\s/g); if (!wordSplit.length) return text; var lastWord = wordSplit.pop(); if (!lastWord.length) return text; for (var word of autoCompleteList) { if (word.toLowerCase().indexOf(lastWord.toLowerCase()) != 0) continue; text = wordSplit.join(" "); if (text.length > 0) text += " "; text += word; break; } return text; } function autoCompleteText(guiObject, words) { let text = guiObject.caption; if (!text.length) return; let bufferPosition = guiObject.buffer_position; let textTillBufferPosition = text.substring(0, bufferPosition); let newText = tryAutoComplete(textTillBufferPosition, words); guiObject.caption = newText + text.substring(bufferPosition); guiObject.buffer_position = bufferPosition + (newText.length - textTillBufferPosition.length); } /** * Manage acoustic GUI notifications. * * @param {string} type - Notification type. */ function soundNotification(type) { if (Engine.ConfigDB_GetValue("user", "sound.notify." + type) != "true") return; let notificationType = g_SoundNotifications[type]; let timeNow = Date.now(); if (!notificationType.lastInteractionTime || timeNow > notificationType.lastInteractionTime + notificationType.threshold) Engine.PlayUISound(notificationType.soundfile, false); notificationType.lastInteractionTime = timeNow; } /** * Horizontally spaces objects within a parent * * @param margin The gap, in px, between the objects */ function horizontallySpaceObjects(parentName, margin = 0) { let objects = Engine.GetGUIObjectByName(parentName).children; for (let i = 0; i < objects.length; ++i) { let size = objects[i].size; let width = size.right - size.left; size.left = i * (width + margin) + margin; size.right = (i + 1) * (width + margin); objects[i].size = size; } } /** + * Change the width of a GUIObject to make the caption fits nicely. + * @param object - The GUIObject to consider. + * @param direction - Direction to change the side either "left" or "right". + * @param margin - Margin to be added to the width (can be negative). + */ +function resizeGUIObjectToCaption(object, align, margin = 0) +{ + const objectSize = object.size; + const width = Engine.GetTextWidth(object.font, object.caption) + margin; + switch (align) + { + case "right": + objectSize.right = object.size.left + width; + break; + case "left": + objectSize.left = object.size.right - width; + break; + default: + } + object.size = objectSize; +} + +/** * Hide all children after a certain index */ function hideRemaining(parentName, start = 0) { let objects = Engine.GetGUIObjectByName(parentName).children; for (let i = start; i < objects.length; ++i) objects[i].hidden = true; } function getBuildString() { return sprintf(translate("Build: %(buildDate)s (%(revision)s)"), { "buildDate": Engine.GetBuildDate(), "revision": Engine.GetBuildRevision() }); } Index: ps/trunk/binaries/data/mods/public/gui/session/chat/Chat.js =================================================================== --- ps/trunk/binaries/data/mods/public/gui/session/chat/Chat.js (revision 26348) +++ ps/trunk/binaries/data/mods/public/gui/session/chat/Chat.js (revision 26349) @@ -1,97 +1,99 @@ /** * This class is only concerned with owning the helper classes and linking them. * The class is not dealing with specific GUI objects and doesn't provide own handlers. */ class Chat { constructor(playerViewControl, cheats) { this.ChatWindow = new ChatWindow(); this.ChatOverlay = new ChatOverlay(); this.ChatHistory = new ChatHistory(); this.ChatHistory.registerSelectionChangeHandler(this.ChatWindow.onSelectionChange.bind(this.ChatWindow)); - this.ChatInput = new ChatInput(); + const alignmentHelper = new AlignmentHelper("max"); + + this.ChatInput = new ChatInput(alignmentHelper); this.ChatInput.registerChatSubmitHandler(executeNetworkCommand); this.ChatInput.registerChatSubmitHandler(cheats.executeCheat.bind(cheats)); this.ChatInput.registerChatSubmitHandler(this.submitChat.bind(this)); this.ChatInput.registerChatSubmittedHandler(this.closePage.bind(this)); - this.ChatAddressees = new ChatAddressees(); + this.ChatAddressees = new ChatAddressees(alignmentHelper); this.ChatAddressees.registerSelectionChangeHandler(this.ChatInput.onSelectionChange.bind(this.ChatInput)); this.ChatAddressees.registerSelectionChangeHandler(this.ChatWindow.onSelectionChange.bind(this.ChatWindow)); this.ChatMessageHandler = new ChatMessageHandler(); this.ChatMessageHandler.registerMessageFormatClass(ChatMessageFormatNetwork); this.ChatMessageHandler.registerMessageFormatClass(ChatMessageFormatSimulation); this.ChatMessageFormatPlayer = new ChatMessageFormatPlayer(); this.ChatMessageFormatPlayer.registerAddresseeTypes(this.ChatAddressees.AddresseeTypes); this.ChatMessageHandler.registerMessageFormat("message", this.ChatMessageFormatPlayer); this.ChatMessageHandler.registerMessageHandler(this.ChatOverlay.onChatMessage.bind(this.ChatOverlay)); this.ChatMessageHandler.registerMessageHandler(this.ChatHistory.onChatMessage.bind(this.ChatHistory)); this.ChatMessageHandler.registerMessageHandler(() => { if (this.ChatWindow.isOpen() && this.ChatWindow.isExtended()) this.ChatHistory.displayChatHistory(); }); let updater = this.onUpdatePlayers.bind(this); registerPlayersFinishedHandler(updater); registerPlayerAssignmentsChangeHandler(updater); playerViewControl.registerViewedPlayerChangeHandler(updater); Engine.SetGlobalHotkey("chat", "Press", this.openPage.bind(this)); Engine.SetGlobalHotkey("privatechat", "Press", this.openPage.bind(this)); Engine.SetGlobalHotkey("teamchat", "Press", () => { this.openPage(g_IsObserver ? "/observers" : "/allies"); }); } /** * Called by the owner whenever g_PlayerAssignments or g_Players changed. */ onUpdatePlayers() { this.ChatAddressees.onUpdatePlayers(); } openPage(command = "") { if (g_Disconnected) return; closeOpenDialogs(); this.ChatAddressees.select(command); this.ChatHistory.displayChatHistory(); this.ChatWindow.openPage(command); } closePage() { this.ChatWindow.closePage(); } getOpenHotkeyTooltip() { return this.ChatInput.getOpenHotkeyTooltip(); } /** * Send the given chat message. */ submitChat(text, command = "") { if (command.startsWith("/msg ")) Engine.SetGlobalHotkey("privatechat", "Press", () => { this.openPage(command); }); let msg = command ? command + " " + text : text; if (Engine.HasNetClient()) Engine.SendNetworkChat(msg); else this.ChatMessageHandler.handleMessage({ "type": "message", "guid": "local", "text": msg }); } } Index: ps/trunk/binaries/data/mods/public/gui/session/chat/ChatAddressees.js =================================================================== --- ps/trunk/binaries/data/mods/public/gui/session/chat/ChatAddressees.js (revision 26348) +++ ps/trunk/binaries/data/mods/public/gui/session/chat/ChatAddressees.js (revision 26349) @@ -1,127 +1,135 @@ /** * This class is concerned with building and propagating the chat addressee selection. */ class ChatAddressees { - constructor() + constructor(alignmentHelper) { this.selectionChangeHandlers = []; + this.chatAddresseeCaption = Engine.GetGUIObjectByName("chatAddresseeCaption"); + resizeGUIObjectToCaption(this.chatAddresseeCaption, "right", this.CaptionMargin); + this.chatAddressee = Engine.GetGUIObjectByName("chatAddressee"); this.chatAddressee.onSelectionChange = this.onSelectionChange.bind(this); + + alignmentHelper.setObject(this.chatAddressee, "left", this.chatAddresseeCaption.size.right + this.DropdownMargin); } registerSelectionChangeHandler(handler) { this.selectionChangeHandlers.push(handler); } onSelectionChange() { let selection = this.getSelection(); for (let handler of this.selectionChangeHandlers) handler(selection); } getSelection() { return this.chatAddressee.list_data[this.chatAddressee.selected] || ""; } select(command) { this.chatAddressee.selected = this.chatAddressee.list_data.indexOf(command); } onUpdatePlayers() { // Remember previously selected item let selectedName = this.getSelection(); selectedName = selectedName.startsWith("/msg ") && selectedName.substr("/msg ".length); let addressees = this.AddresseeTypes.filter( addresseeType => addresseeType.isSelectable()).map( addresseeType => ({ "label": translateWithContext("chat addressee", addresseeType.label), "cmd": addresseeType.command })); // Add playernames for private messages let guids = sortGUIDsByPlayerID(); for (let guid of guids) { if (guid == Engine.GetPlayerGUID()) continue; let playerID = g_PlayerAssignments[guid].player; // Don't provide option for PM from observer to player if (g_IsObserver && !isPlayerObserver(playerID)) continue; let colorBox = isPlayerObserver(playerID) ? "" : colorizePlayernameHelper("■", playerID) + " "; addressees.push({ "cmd": "/msg " + g_PlayerAssignments[guid].name, "label": colorBox + g_PlayerAssignments[guid].name }); } // Select mock item if the selected addressee went offline if (selectedName && guids.every(guid => g_PlayerAssignments[guid].name != selectedName)) addressees.push({ "cmd": "/msg " + selectedName, "label": sprintf(translate("\\[OFFLINE] %(player)s"), { "player": selectedName }) }); let oldChatAddressee = this.getSelection(); this.chatAddressee.list = addressees.map(adressee => adressee.label); this.chatAddressee.list_data = addressees.map(adressee => adressee.cmd); this.chatAddressee.selected = Math.max(0, this.chatAddressee.list_data.indexOf(oldChatAddressee)); } } ChatAddressees.prototype.AddresseeTypes = [ { "command": "", "isSelectable": () => true, "label": markForTranslationWithContext("chat addressee", "Everyone"), "isAddressee": () => true }, { "command": "/allies", "isSelectable": () => !g_IsObserver, "label": markForTranslationWithContext("chat addressee", "Allies"), "context": markForTranslationWithContext("chat message context", "Ally"), "isAddressee": senderID => g_Players[senderID] && g_Players[Engine.GetPlayerID()] && g_Players[senderID].isMutualAlly[Engine.GetPlayerID()], }, { "command": "/enemies", "isSelectable": () => !g_IsObserver, "label": markForTranslationWithContext("chat addressee", "Enemies"), "context": markForTranslationWithContext("chat message context", "Enemy"), "isAddressee": senderID => g_Players[senderID] && g_Players[Engine.GetPlayerID()] && g_Players[senderID].isEnemy[Engine.GetPlayerID()], }, { "command": "/observers", "isSelectable": () => true, "label": markForTranslationWithContext("chat addressee", "Observers"), "context": markForTranslationWithContext("chat message context", "Observer"), "isAddressee": senderID => g_IsObserver }, { "command": "/msg", "isSelectable": () => false, "label": undefined, "context": markForTranslationWithContext("chat message context", "Private"), "isAddressee": (senderID, addresseeGUID) => addresseeGUID == Engine.GetPlayerGUID() } ]; + +ChatAddressees.prototype.CaptionMargin = 10; +ChatAddressees.prototype.DropdownMargin = 5; Index: ps/trunk/binaries/data/mods/public/gui/session/chat/ChatHistory.js =================================================================== --- ps/trunk/binaries/data/mods/public/gui/session/chat/ChatHistory.js (revision 26348) +++ ps/trunk/binaries/data/mods/public/gui/session/chat/ChatHistory.js (revision 26349) @@ -1,134 +1,144 @@ /** * The objective of this class is to build a message type filter selection and * to store and display the chat history according to that selection. */ class ChatHistory { constructor() { /** * All unparsed chat messages received since connect, including timestamp. */ this.chatMessages = []; this.selectionChangeHandlers = []; + this.chatHistoryFilterCaption = Engine.GetGUIObjectByName("chatHistoryFilterCaption"); + resizeGUIObjectToCaption(this.chatHistoryFilterCaption, "right", this.CaptionMargin); + this.chatHistoryFilter = Engine.GetGUIObjectByName("chatHistoryFilter"); let filters = prepareForDropdown(this.Filters.filter(chatFilter => !chatFilter.hidden)); this.chatHistoryFilter.list = filters.text.map(text => translateWithContext("chat history filter", text)); this.chatHistoryFilter.list_data = filters.key; this.chatHistoryFilter.selected = 0; this.chatHistoryFilter.onSelectionChange = this.onSelectionChange.bind(this); + const chatHistoryFilterSize = this.chatHistoryFilter.size; + chatHistoryFilterSize.left = this.chatHistoryFilterCaption.size.right + this.FilterMargin; + this.chatHistoryFilter.size = chatHistoryFilterSize; + this.chatHistoryText = Engine.GetGUIObjectByName("chatHistoryText"); } registerSelectionChangeHandler(handler) { this.selectionChangeHandlers.push(handler); } /** * Called each time the history filter changes. */ onSelectionChange() { this.displayChatHistory(); for (let handler of this.selectionChangeHandlers) handler(); } displayChatHistory() { let selected = this.chatHistoryFilter.list_data[this.chatHistoryFilter.selected]; this.chatHistoryText.caption = this.chatMessages.filter(msg => msg.filter[selected]).map(msg => Engine.ConfigDB_GetValue("user", "chat.timestamp") == "true" ? sprintf(translate("%(time)s %(message)s"), { "time": msg.timePrefix, "message": msg.txt }) : msg.txt).join("\n"); } onChatMessage(msg, formatted) { // Save to chat history let historical = { "txt": formatted.text, "timePrefix": sprintf(translate("\\[%(time)s]"), { "time": Engine.FormatMillisecondsIntoDateStringLocal(Date.now(), translate("HH:mm")) }), "filter": {} }; // Apply the filters now before diplomacies or playerstates change let senderID = msg.guid && g_PlayerAssignments[msg.guid] ? g_PlayerAssignments[msg.guid].player : 0; for (let filter of this.Filters) historical.filter[filter.key] = filter.filter(msg, senderID); this.chatMessages.push(historical); } } /** * Notice only messages will be filtered that are visible to the player in the first place. */ ChatHistory.prototype.Filters = [ { "key": "all", "text": markForTranslationWithContext("chat history filter", "Chat and notifications"), "filter": (msg, senderID) => true }, { "key": "chat", "text": markForTranslationWithContext("chat history filter", "Chat messages"), "filter": (msg, senderID) => msg.type == "message" }, { "key": "player", "text": markForTranslationWithContext("chat history filter", "Players chat"), "filter": (msg, senderID) => msg.type == "message" && senderID > 0 && !isPlayerObserver(senderID) }, { "key": "ally", "text": markForTranslationWithContext("chat history filter", "Ally chat"), "filter": (msg, senderID) => msg.type == "message" && msg.cmd && msg.cmd == "/allies" }, { "key": "enemy", "text": markForTranslationWithContext("chat history filter", "Enemy chat"), "filter": (msg, senderID) => msg.type == "message" && msg.cmd && msg.cmd == "/enemies" }, { "key": "observer", "text": markForTranslationWithContext("chat history filter", "Observer chat"), "filter": (msg, senderID) => msg.type == "message" && msg.cmd && msg.cmd == "/observers" }, { "key": "private", "text": markForTranslationWithContext("chat history filter", "Private chat"), "filter": (msg, senderID) => !!msg.isVisiblePM }, { "key": "gamenotifications", "text": markForTranslationWithContext("chat history filter", "Game notifications"), "filter": (msg, senderID) => msg.type != "message" && msg.guid === undefined }, { "key": "chatnotifications", "text": markForTranslationWithContext("chat history filter", "Network notifications"), "filter": (msg, senderID) => msg.type != "message" && msg.guid !== undefined, "hidden": !Engine.HasNetClient() } ]; + +ChatHistory.prototype.CaptionMargin = 10; +ChatHistory.prototype.FilterMargin = 5; Index: ps/trunk/binaries/data/mods/public/gui/session/chat/ChatInput.js =================================================================== --- ps/trunk/binaries/data/mods/public/gui/session/chat/ChatInput.js (revision 26348) +++ ps/trunk/binaries/data/mods/public/gui/session/chat/ChatInput.js (revision 26349) @@ -1,90 +1,98 @@ /** * This class is concerned with setting up the text input field and the send button. */ class ChatInput { - constructor() + constructor(alignmentHelper) { this.selectedCommand = ""; this.chatSubmitHandlers = []; this.chatSubmittedHandlers = []; + this.chatInputCaption = Engine.GetGUIObjectByName("chatInputCaption"); + resizeGUIObjectToCaption(this.chatInputCaption, "right", this.CaptionMargin); + this.chatInput = Engine.GetGUIObjectByName("chatInput"); this.chatInput.onPress = this.submitChatInput.bind(this); this.chatInput.onTab = this.autoComplete.bind(this); + alignmentHelper.setObject(this.chatInput, "left", this.chatInputCaption.size.right + this.InputMargin); + this.sendChat = Engine.GetGUIObjectByName("sendChat"); this.sendChat.onPress = this.submitChatInput.bind(this); registerHotkeyChangeHandler(this.onHotkeyChange.bind(this)); } onHotkeyChange() { let tooltip = this.getInputHotkeyTooltip() + this.getOpenHotkeyTooltip(); this.chatInput.tooltip = tooltip; this.sendChat.tooltip = tooltip; } getInputHotkeyTooltip() { return translateWithContext("chat input", "Type the message to send.") + "\n" + colorizeAutocompleteHotkey(); } getOpenHotkeyTooltip() { return colorizeHotkey("\n" + translate("Press %(hotkey)s to open the public chat."), "chat") + colorizeHotkey( "\n" + (g_IsObserver ? translate("Press %(hotkey)s to open the observer chat.") : translate("Press %(hotkey)s to open the ally chat.")), "teamchat") + colorizeHotkey("\n" + translate("Press %(hotkey)s to open the previously selected private chat."), "privatechat"); } /** * The functions registered using this function will be called sequentially * when the user submits chat, until one of them returns true. */ registerChatSubmitHandler(handler) { this.chatSubmitHandlers.push(handler); } /** * The functions registered using this function will be called after the user submitted chat input. */ registerChatSubmittedHandler(handler) { this.chatSubmittedHandlers.push(handler); } /** * Called each time the addressee dropdown changes selection. */ onSelectionChange(command) { this.selectedCommand = command; } autoComplete() { let playernames = []; for (let player in g_PlayerAssignments) playernames.push(g_PlayerAssignments[player].name); autoCompleteText(this.chatInput, playernames); } submitChatInput() { let text = this.chatInput.caption; if (!text.length) return; this.chatSubmitHandlers.some(handler => handler(text, this.selectedCommand)); for (let handler of this.chatSubmittedHandlers) handler(); } } + +ChatInput.prototype.CaptionMargin = 10; +ChatInput.prototype.InputMargin = 5; Index: ps/trunk/binaries/data/mods/public/gui/session/chat/chat_window.xml =================================================================== --- ps/trunk/binaries/data/mods/public/gui/session/chat/chat_window.xml (revision 26348) +++ ps/trunk/binaries/data/mods/public/gui/session/chat/chat_window.xml (revision 26349) @@ -1,95 +1,95 @@