diff --git a/README.md b/README.md index b62de85..32517ef 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,28 @@ Access the keyboard interface with `/jb` or `/jobbinds`: - `!` = Alt - `+` = Shift +### Global Bindings + +JobBinds now supports **Global Bindings** - keybinds that are active across all jobs and override job-specific bindings. + +- **File:** `Ashita/scripts/JobBinds.txt` +- **Visual Indicator:** Global bindings appear in **blue** on the keyboard interface +- **Priority:** Global bindings always override job-specific bindings +- **Restriction:** Keys with global bindings **cannot** have job-specific bindings (including modifiers) + - Example: If `M` has a global binding, you cannot create a job-specific binding for `M`, `Shift+M`, `Ctrl+M`, or `Alt+M` + - However, you **can** create additional **global** bindings on that key (e.g., `Shift+M` as global) +- **Usage:** + 1. Click a key in the interface + 2. Check the **Global** checkbox + 3. Configure the binding + 4. Click **Save** - the binding will be saved to `JobBinds.txt` +- **Auto-Load:** Global bindings are automatically loaded after job-specific bindings when you change jobs, ensuring they take precedence + +**Example Use Cases:** +- Map key (`/bind M /map`) - useful on all jobs +- Menu toggles - consistent across all characters +- Common macros - shared functionality + ### Blocked Keys Essential game keys are protected from being bound: diff --git a/jobbinds.lua b/jobbinds.lua index 259211c..10aeab4 100644 --- a/jobbinds.lua +++ b/jobbinds.lua @@ -13,31 +13,23 @@ addon.desc = 'Automatically loads keybind profile scripts based on current addon.link = 'https://github.com/seekey13/jobbinds'; require('common'); -local chat = require('chat') -local keyboard_ui = require('keyboard_ui'); -local blocked_keybinds = require('blocked_keybinds'); +local log = require('lib.log'); +local keyboard_ui = require('lib.keyboard_ui'); +local blocked_keybinds = require('lib.blocked_keybinds'); + +log.set_addon_name(addon.name) +local printf = log.printf +local errorf = log.errorf +local debugf = log.debugf -- Use the blocked_keybinds module for consistency local KEY_BLACKLIST = blocked_keybinds.blocked; --- Custom print functions for categorized output. -local function printf(fmt, ...) print(chat.header(addon.name) .. chat.message(fmt:format(...))) end -local function warnf(fmt, ...) print(chat.header(addon.name) .. chat.warning(fmt:format(...))) end -local function errorf(fmt, ...) print(chat.header(addon.name) .. chat.error (fmt:format(...))) end -local function debugf(fmt, ...) - if debug_mode then - print(chat.header(addon.name) .. chat.message('[DEBUG] ' .. fmt:format(...))) - end -end - -- Holds the last loaded job/subjob profile info local last_job = nil local last_subjob = nil local last_profile_keys = {} --- Debug mode flag (off by default) -local debug_mode = false - -- Helper: Get current job and subjob local function get_current_jobs() local ok, party = pcall(function() return AshitaCore:GetMemoryManager():GetParty() end) @@ -133,27 +125,69 @@ local function update_keyboard_ui(profile_filename, profile_path) keyboard_ui.load_profile(profile_path) end +-- Helper: Create an empty profile file for a job combination +local function create_empty_profile(jobid, subjobid, profile_path) + local job = get_job_shortname(jobid) + local subjob = get_job_shortname(subjobid) + local profile_filename = get_profile_filename(jobid, subjobid) + + debugf('Creating empty profile: %s', profile_path) + + local file = io.open(profile_path, "w") + if not file then + errorf('Failed to create profile file: %s', profile_path) + return false + end + + -- Write header comment + file:write(string.format('# JobBinds Profile: %s/%s\n', job, subjob)) + file:write('\n') + + file:close() + printf('Created empty profile: %s', profile_filename) + debugf('Empty profile created successfully at: %s', profile_path) + return true +end + -- Load new profile via /exec local function load_profile(jobid, subjobid) local profile_path = get_profile_path(jobid, subjobid) local profile_filename = get_profile_filename(jobid, subjobid) debugf('Attempting to load profile: %s', profile_path) - -- Ensure file exists + -- Ensure job profile file exists, create empty one if it doesn't local file = io.open(profile_path, "r") if not file then - errorf('Profile %s not found.', profile_path) + printf('Profile %s not found, creating empty profile.', profile_filename) debugf('Profile file does not exist at: %s', profile_path) - return false + if not create_empty_profile(jobid, subjobid, profile_path) then + return false + end + else + file:close() end - file:close() debugf('Profile file exists, executing: %s', profile_filename) + -- Load job-specific profile first local ok = pcall(function() AshitaCore:GetChatManager():QueueCommand(-1, string.format('/exec %s', profile_filename)) end) if ok then printf('Loaded jobbinds profile: %s', profile_filename) + + -- Then load global bindings (JobBinds.txt) to override job-specific ones + local global_path = string.format('%s/scripts/JobBinds.txt', AshitaCore:GetInstallPath()) + local global_file = io.open(global_path, "r") + if global_file then + global_file:close() + debugf('Loading global bindings from JobBinds.txt (overriding job-specific)') + pcall(function() + AshitaCore:GetChatManager():QueueCommand(-1, '/exec JobBinds.txt') + end) + else + debugf('No global bindings file found at: %s', global_path) + end + -- Update the keyboard UI with the current profile update_keyboard_ui(profile_filename, profile_path) debugf('Successfully loaded and updated UI with profile: %s', profile_filename) @@ -241,10 +275,11 @@ ashita.events.register('command', 'jobbinds_command', function(e) printf('Opening JobBinds keyboard interface.') elseif #args == 2 and args[2]:lower() == 'debug' then -- Toggle debug mode - debug_mode = not debug_mode - keyboard_ui.set_debug_mode(debug_mode) -- Update keyboard UI debug mode - printf('Debug mode %s.', debug_mode and 'enabled' or 'disabled') - if debug_mode then + local new_state = not log.is_debug() + log.set_debug(new_state) + keyboard_ui.set_debug_mode(new_state) -- Update keyboard UI debug mode + printf('Debug mode %s.', new_state and 'enabled' or 'disabled') + if new_state then debugf('Debug information will now be displayed.') debugf('Current state: last_job=%s, last_subjob=%s', get_safe_job_name(last_job), diff --git a/blocked_keybinds.lua b/lib/blocked_keybinds.lua similarity index 55% rename from blocked_keybinds.lua rename to lib/blocked_keybinds.lua index 46a144e..4c9103d 100644 --- a/blocked_keybinds.lua +++ b/lib/blocked_keybinds.lua @@ -64,8 +64,6 @@ M.blocked = { ['PAGEUP'] = true, ['PAGEDOWN'] = true, ['INSERT'] = true, - ['HOME'] = true, - ['END'] = true, } -- Keys that can be used alone or with Shift, but not with Ctrl or Alt @@ -89,92 +87,50 @@ M.blocked_with_modifiers = { ['9'] = true, } --- Consolidated list of all blocked keys for easy checking -M.all_blocked = {} - --- Function to populate the consolidated list -local function populate_all_blocked() - for key, _ in pairs(M.blocked) do - M.all_blocked[key:upper()] = true - end -end - --- Function to check if a key is blocked +-- Function to check if a key is fully blocked (any modifier combination). function M.is_key_blocked(key) if not key then return false end - return M.all_blocked[key:upper()] == true + return M.blocked[key:upper()] == true end --- Function to check if a key combination is blocked -function M.is_combination_blocked(key, modifiers) +-- Function to check if a key combination is blocked. +-- Returns: blocked (bool), reason (string|nil) +function M.is_combination_blocked(key, modifiers_string) if not key then return false end - + local base_key = key:upper() - - -- Check if base key is completely blocked + + -- Fully blocked base key if M.is_key_blocked(base_key) then return true, string.format("Key '%s' is protected and cannot be rebound", base_key) end - - -- Check for restricted modifier combinations - if M.blocked_with_modifiers[base_key] and modifiers then - local mod_string = modifiers:upper() - - -- Block Ctrl combinations for restricted keys + + local mod_string = modifiers_string and modifiers_string:upper() or '' + + -- Restricted modifier combinations for certain keys + if M.blocked_with_modifiers[base_key] then if mod_string:find('CTRL') then - return true, string.format("'%s' cannot be used with Ctrl modifier", base_key) + return true, string.format("'%s' cannot be used with Ctrl modifier (use alone or with Shift)", base_key) end - - -- Block Alt combinations for restricted keys if mod_string:find('ALT') then - return true, string.format("'%s' cannot be used with Alt modifier", base_key) + return true, string.format("'%s' cannot be used with Alt modifier (use alone or with Shift)", base_key) end end - - -- Special cases for certain combinations that should always be blocked - if modifiers then - local mod_string = modifiers:upper() - - -- Block Alt+F4 (close application) + + -- Special always-blocked combinations + if mod_string ~= '' then if base_key == 'F4' and mod_string:find('ALT') then return true, "Alt+F4 is protected (closes application)" end - - -- Block Ctrl+Alt+Delete equivalent combinations - if mod_string:find('CTRL') and mod_string:find('ALT') and base_key == 'DELETE' then + if base_key == 'DELETE' and mod_string:find('CTRL') and mod_string:find('ALT') then return true, "Ctrl+Alt+Delete combination is protected" end - - -- Block Windows key combinations if mod_string:find('WIN') then return true, "Windows key combinations are protected" end end - - return false, nil -end --- Function to get a user-friendly error message for blocked keys -function M.get_block_reason(key, modifiers) - if not key then return nil end - - local base_key = key:upper() - - -- Check for restricted modifier combinations first - if M.blocked_with_modifiers[base_key] and modifiers then - local mod_string = modifiers:upper() - if mod_string:find('CTRL') then - return string.format("'%s' cannot be used with Ctrl modifier (use alone or with Shift)", base_key) - elseif mod_string:find('ALT') then - return string.format("'%s' cannot be used with Alt modifier (use alone or with Shift)", base_key) - end - end - - -- Default message for completely blocked keys - return string.format("Key '%s' is protected and cannot be rebound", base_key) + return false, nil end --- Initialize the consolidated list -populate_all_blocked() - return M diff --git a/keyboard_ui.lua b/lib/keyboard_ui.lua similarity index 58% rename from keyboard_ui.lua rename to lib/keyboard_ui.lua index e345f46..62382c1 100644 --- a/keyboard_ui.lua +++ b/lib/keyboard_ui.lua @@ -1,13 +1,12 @@ require('common'); -local chat = require('chat'); local imgui = require('imgui'); -local vk_codes = require('vk_codes'); -local blocked_keybinds = require('blocked_keybinds'); -local ui_functions = require('ui_functions'); +local vk_codes = require('lib.vk_codes'); +local blocked_keybinds = require('lib.blocked_keybinds'); +local ui_functions = require('lib.ui_functions'); +local log = require('lib.log'); --- Custom print functions -local function printf(fmt, ...) print(chat.header('JobBinds') .. chat.message(fmt:format(...))) end -local function errorf(fmt, ...) print(chat.header('JobBinds') .. chat.error(fmt:format(...))) end +local printf = log.printf +local errorf = log.errorf -- UI state variables local keyboard_ui = {}; @@ -39,34 +38,117 @@ keyboard_ui.last_scripts_refresh = 0; keyboard_ui.current_profile = 'No Profile Loaded'; keyboard_ui.is_binding = false; -keyboard_ui.debug_mode = false; keyboard_ui.error_message = ''; +keyboard_ui.global = { false }; --- Current bindings loaded from profile file -local current_bindings = {}; +-- Current bindings loaded from profile files +local current_bindings = {}; -- Job-specific bindings +local global_bindings = {}; -- Global bindings from JobBinds.txt +local combined_bindings = {}; -- Merged bindings (global overrides job-specific) local current_profile_path = nil; +local global_profile_path = nil; + +-- ============================================================================ +-- GLOBAL BINDINGS HELPER FUNCTIONS (Must be defined early for use in render functions) +-- ============================================================================ + +-- Helper: Get path to global bindings file (JobBinds.txt) +local function get_global_bindings_path() + return string.format('%s/scripts/JobBinds.txt', AshitaCore:GetInstallPath()); +end + +-- Helper: Ensure global bindings file exists +local function ensure_global_bindings_file() + local global_path = get_global_bindings_path(); + local file = io.open(global_path, 'r'); + if not file then + -- Create empty global bindings file + file = io.open(global_path, 'w'); + if file then + file:write('# JobBinds Global Bindings\n'); + file:write('\n'); + file:close(); + end + else + file:close(); + end +end + +-- Helper: Load global bindings from JobBinds.txt +local function load_global_bindings() + ensure_global_bindings_file(); + local global_path = get_global_bindings_path(); + local bindings = ui_functions.load_bindings_from_profile(global_path); + + -- Mark all bindings as global + for _, binding in ipairs(bindings) do + binding.is_global = true; + end + + return bindings; +end + +-- Helper: Check if any global binding exists on a key (any modifier combination) +local function has_global_binding_on_key(key) + for _, binding in ipairs(global_bindings) do + if binding.key:upper() == key:upper() then + return true; + end + end + return false; +end + +-- Helper: Merge global and job-specific bindings (global overrides job-specific) +local function merge_bindings() + local merged = {}; + local added_keys = {}; -- Track key+modifier combinations + + -- Add global bindings first (they take precedence) + for _, binding in ipairs(global_bindings) do + local key_id = binding.key:upper() .. '|' .. (binding.modifiers or ''); + binding.is_global = true; -- Ensure global flag is set + merged[#merged + 1] = binding; + added_keys[key_id] = true; + end + + -- Add job-specific bindings that don't conflict with global + for _, binding in ipairs(current_bindings) do + local key_id = binding.key:upper() .. '|' .. (binding.modifiers or ''); + if not added_keys[key_id] then + binding.is_global = false; -- Explicitly ensure job-specific bindings are NOT global + merged[#merged + 1] = binding; + added_keys[key_id] = true; + end + end + + return merged; +end + +-- ============================================================================ +-- KEYBOARD LAYOUT AND UI FUNCTIONS +-- ============================================================================ -- Virtual keyboard layout definition local keyboard_layout = { -- Row 1: Number row { {'`', 26}, {'1', 26}, {'2', 26}, {'3', 26}, {'4', 26}, {'5', 26}, {'6', 26}, - {'7', 26}, {'8', 26}, {'9', 26}, {'0', 26}, {'-', 26}, {'=', 26}, {'<--', 51} + {'7', 26}, {'8', 26}, {'9', 26}, {'0', 26}, {'-', 26}, {'=', 26}, {'<--', 51}, {'INS', 51} }, -- Row 2: QWERTY row { {'TAB', 38}, {'Q', 26}, {'W', 26}, {'E', 26}, {'R', 26}, {'T', 26}, {'Y', 26}, - {'U', 26}, {'I', 26}, {'O', 26}, {'P', 26}, {'[', 26}, {']', 26}, {'\\', 38} + {'U', 26}, {'I', 26}, {'O', 26}, {'P', 26}, {'[', 26}, {']', 26}, {'\\', 38}, {'DEL', 51} }, -- Row 3: ASDF row { {'CAPS', 51}, {'A', 26}, {'S', 26}, {'D', 26}, {'F', 26}, {'G', 26}, {'H', 26}, - {'J', 26}, {'K', 26}, {'L', 26}, {';', 26}, {"'", 26}, {'ENTER', 58} + {'J', 26}, {'K', 26}, {'L', 26}, {';', 26}, {"'", 26}, {'ENTER', 58}, {'HOME', 51} }, -- Row 4: ZXCV row { {'SHIFT', 64}, {'Z', 26}, {'X', 26}, {'C', 26}, {'V', 26}, {'B', 26}, {'N', 26}, - {'M', 26}, {',', 26}, {'.', 26}, {'/', 26}, {'SHIFT', 78} + {'M', 26}, {',', 26}, {'.', 26}, {'/', 26}, {'SHIFT', 78}, {'END', 51} } } @@ -92,7 +174,7 @@ local function refresh_scripts_list() -- Helper function to check if file should be ignored local function is_ignored_file(filename) local lower = filename:lower(); - return lower == 'default.txt' or lower == 'launcher.txt'; + return lower == 'default.txt' or lower == 'launcher.txt' or lower == 'jobbinds.txt'; end -- Use Lua's lfs library if available, otherwise use a simple file list @@ -140,62 +222,107 @@ local function render_key_button(key, width) -- Check if this key is blocked local is_blocked = blocked_keybinds.blocked[key:upper()] or false - -- Check if this key is bound + -- Check if this key is bound (check combined bindings) local is_bound = false - for _, binding in ipairs(current_bindings) do + local is_global_bound = false + local binding_count = 0 + local global_binding_count = 0 + + for _, binding in ipairs(combined_bindings) do if binding.key:upper() == key:upper() then is_bound = true - break + binding_count = binding_count + 1 + -- Check if ANY binding on this key is global + -- If so, all bindings on the key should be global (per requirements) + if binding.is_global == true then -- Explicit check for true value + is_global_bound = true + global_binding_count = global_binding_count + 1 + end end end - -- Determine if we need to push custom colors - local push_colors = false - local push_alpha = false + -- Additional safety: double-check global status against global_bindings array + if is_global_bound then + local confirmed_global = false + for _, global_binding in ipairs(global_bindings) do + if global_binding.key:upper() == key:upper() then + confirmed_global = true + break + end + end + if not confirmed_global then + errorf('[BUG] Key %s marked as global but not in global_bindings', key) + is_global_bound = false -- Correct the error + end + end + + -- Debug: If we have mixed global/job bindings, that's a bug + if binding_count > 0 and global_binding_count > 0 and global_binding_count < binding_count then + errorf('[BUG] Key %s has mixed bindings: %d total, %d global', key, binding_count, global_binding_count) + end + + -- Style the button based on status: blocked > selected > global > bound > normal + -- Push colors, render button, pop colors immediately to prevent leaks + local button_clicked = false + local colors_pushed = 0 -- Track number of colors pushed - -- Style the button based on status: blocked > selected > bound > normal if is_blocked then -- Blocked keys: dark red/disabled appearance imgui.PushStyleColor(ImGuiCol_Button, { 0.4, 0.1, 0.1, 0.6 }); imgui.PushStyleColor(ImGuiCol_ButtonHovered, { 0.4, 0.1, 0.1, 0.6 }); imgui.PushStyleColor(ImGuiCol_ButtonActive, { 0.4, 0.1, 0.1, 0.6 }); imgui.PushStyleVar(ImGuiStyleVar_Alpha, 0.5); - push_colors = true - push_alpha = true + colors_pushed = 3 + button_clicked = imgui.Button(key, { width, 30 }); + imgui.PopStyleVar(); + imgui.PopStyleColor(3); + colors_pushed = 0 elseif keyboard_ui.binding_key[1] ~= '' and keyboard_ui.binding_key[1]:upper() == key:upper() then -- Selected key: green (active binding being edited) imgui.PushStyleColor(ImGuiCol_Button, { 0.1, 0.6, 0.1, 1.0 }); imgui.PushStyleColor(ImGuiCol_ButtonHovered, { 0.15, 0.7, 0.15, 1.0 }); imgui.PushStyleColor(ImGuiCol_ButtonActive, { 0.08, 0.5, 0.08, 1.0 }); - push_colors = true + colors_pushed = 3 + button_clicked = imgui.Button(key, { width, 30 }); + imgui.PopStyleColor(3); + colors_pushed = 0 + elseif is_global_bound then + -- Global bound keys: blue + imgui.PushStyleColor(ImGuiCol_Button, { 0.1, 0.3, 0.7, 1.0 }); + imgui.PushStyleColor(ImGuiCol_ButtonHovered, { 0.15, 0.4, 0.8, 1.0 }); + imgui.PushStyleColor(ImGuiCol_ButtonActive, { 0.08, 0.25, 0.6, 1.0 }); + colors_pushed = 3 + button_clicked = imgui.Button(key, { width, 30 }); + imgui.PopStyleColor(3); + colors_pushed = 0 elseif is_bound then - -- Bound keys: default ImGui styling (standard button color) - -- No custom styling - uses default ImGui button colors - push_colors = false + -- Bound keys: default ImGui styling (standard red button color) + button_clicked = imgui.Button(key, { width, 30 }); else -- Normal keys: gray imgui.PushStyleColor(ImGuiCol_Button, { 0.3, 0.3, 0.3, 1.0 }); imgui.PushStyleColor(ImGuiCol_ButtonHovered, { 0.4, 0.4, 0.4, 1.0 }); imgui.PushStyleColor(ImGuiCol_ButtonActive, { 0.2, 0.2, 0.2, 1.0 }); - push_colors = true + colors_pushed = 3 + button_clicked = imgui.Button(key, { width, 30 }); + imgui.PopStyleColor(3); + colors_pushed = 0 end - -- Only allow clicking if not blocked - local button_clicked = false - if is_blocked then - -- Disabled button - still render but don't handle clicks - imgui.Button(key, { width, 30 }); - else - button_clicked = imgui.Button(key, { width, 30 }); + -- Safety check: ensure no styles are leaked + if colors_pushed > 0 then + errorf('[STYLE LEAK] %d colors still pushed after rendering key %s', colors_pushed, key); + imgui.PopStyleColor(colors_pushed); end -- Show tooltip on hover if key has bindings if is_bound and imgui.IsItemHovered() then local tooltip_lines = {}; - for _, binding in ipairs(current_bindings) do + for _, binding in ipairs(combined_bindings) do if binding.key:upper() == key:upper() then local modifier_text = binding.modifiers ~= '' and (binding.modifiers .. ' + ') or ''; - table.insert(tooltip_lines, modifier_text .. binding.key .. ': ' .. binding.command); + local global_marker = binding.is_global and ' [GLOBAL]' or ''; + table.insert(tooltip_lines, modifier_text .. binding.key .. ': ' .. binding.command .. global_marker); end end if #tooltip_lines > 0 then @@ -204,114 +331,120 @@ local function render_key_button(key, width) end if button_clicked then - -- Handle key click - populate UI with existing bindings for all modifier combinations - keyboard_ui.binding_key[1] = key:upper(); - keyboard_ui.error_message = ''; - - -- Helper function to load binding data for a specific modifier combination - local function load_modifier_binding(has_ctrl, has_alt, has_shift) - local cmd_text, is_macro, macro_text + -- Use pcall to ensure style pop happens even if there's an error + local success, err = pcall(function() + -- Handle key click - populate UI with existing bindings for all modifier combinations + keyboard_ui.binding_key[1] = key:upper(); + keyboard_ui.error_message = ''; - -- Find existing binding for this key+modifier combination - local existing_binding = nil - for _, binding in ipairs(current_bindings) do - if binding.key:upper() == key:upper() then - local modifiers = binding.modifiers or '' - local bind_has_ctrl = modifiers:find('Ctrl') ~= nil - local bind_has_alt = modifiers:find('Alt') ~= nil - local bind_has_shift = modifiers:find('Shift') ~= nil - - if bind_has_ctrl == has_ctrl and bind_has_alt == has_alt and bind_has_shift == has_shift then - existing_binding = binding - break + -- Helper function to load binding data for a specific modifier combination + local function load_modifier_binding(has_ctrl, has_alt, has_shift) + local cmd_text, is_macro, macro_text + + -- Find existing binding for this key+modifier combination (check combined bindings) + local existing_binding = nil + for _, binding in ipairs(combined_bindings) do + if binding.key:upper() == key:upper() then + local modifiers = binding.modifiers or '' + local bind_has_ctrl = modifiers:find('Ctrl') ~= nil + local bind_has_alt = modifiers:find('Alt') ~= nil + local bind_has_shift = modifiers:find('Shift') ~= nil + + if bind_has_ctrl == has_ctrl and bind_has_alt == has_alt and bind_has_shift == has_shift then + existing_binding = binding + break + end end end - end - - if existing_binding then - cmd_text = existing_binding.command or '' - is_macro = existing_binding.is_macro or false - macro_text = '' - -- Load macro content if it's a macro - if existing_binding.is_macro and existing_binding.command:match('^/exec%s+(.+)$') then - local macro_name = existing_binding.command:match('^/exec%s+(.+)$') - local macro_path = string.format('%s/%s', ui_functions.get_scripts_path(), macro_name) - if not macro_path:match('%.txt$') then - macro_path = macro_path .. '.txt' - end + if existing_binding then + cmd_text = existing_binding.command or '' + is_macro = existing_binding.is_macro or false + macro_text = '' - local macro_file = io.open(macro_path, 'r') - if macro_file then - macro_text = macro_file:read('*all') or '' - macro_file:close() + -- Load macro content if it's a macro + if existing_binding.is_macro and existing_binding.command:match('^/exec%s+(.+)$') then + local macro_name = existing_binding.command:match('^/exec%s+(.+)$') + local macro_path = string.format('%s/%s', ui_functions.get_scripts_path(), macro_name) + if not macro_path:match('%.txt$') then + macro_path = macro_path .. '.txt' + end + + local macro_file = io.open(macro_path, 'r') + if macro_file then + macro_text = macro_file:read('*all') or '' + macro_file:close() + end + + -- Set command to just the macro name for display + cmd_text = macro_name:gsub('%.txt$', '') end - - -- Set command to just the macro name for display - cmd_text = macro_name:gsub('%.txt$', '') + else + -- No binding exists for this combination + cmd_text = '' + is_macro = false + macro_text = '' end + + return cmd_text, is_macro, macro_text + end + + -- Load all 4 modifier combinations + keyboard_ui.command_text_none[1], keyboard_ui.is_macro_none[1], keyboard_ui.macro_text_none[1] = + load_modifier_binding(false, false, false) + + -- Check if modifier combinations are valid for this key before loading + local is_valid_ctrl, _ = ui_functions.validate_key_binding(key:upper(), false, false, true) + local is_valid_alt, _ = ui_functions.validate_key_binding(key:upper(), false, true, false) + local is_valid_shift, _ = ui_functions.validate_key_binding(key:upper(), true, false, false) + + if is_valid_ctrl then + keyboard_ui.command_text_ctrl[1], keyboard_ui.is_macro_ctrl[1], keyboard_ui.macro_text_ctrl[1] = + load_modifier_binding(true, false, false) else - -- No binding exists for this combination - cmd_text = '' - is_macro = false - macro_text = '' + keyboard_ui.command_text_ctrl[1] = '' + keyboard_ui.is_macro_ctrl[1] = false + keyboard_ui.macro_text_ctrl[1] = '' end - return cmd_text, is_macro, macro_text - end - - -- Load all 4 modifier combinations - keyboard_ui.command_text_none[1], keyboard_ui.is_macro_none[1], keyboard_ui.macro_text_none[1] = - load_modifier_binding(false, false, false) - - -- Check if modifier combinations are valid for this key before loading - local is_valid_ctrl, _ = ui_functions.validate_key_binding(key:upper(), false, false, true) - local is_valid_alt, _ = ui_functions.validate_key_binding(key:upper(), false, true, false) - local is_valid_shift, _ = ui_functions.validate_key_binding(key:upper(), true, false, false) - - if is_valid_ctrl then - keyboard_ui.command_text_ctrl[1], keyboard_ui.is_macro_ctrl[1], keyboard_ui.macro_text_ctrl[1] = - load_modifier_binding(true, false, false) - else - keyboard_ui.command_text_ctrl[1] = '' - keyboard_ui.is_macro_ctrl[1] = false - keyboard_ui.macro_text_ctrl[1] = '' - end - - if is_valid_alt then - keyboard_ui.command_text_alt[1], keyboard_ui.is_macro_alt[1], keyboard_ui.macro_text_alt[1] = - load_modifier_binding(false, true, false) - else - keyboard_ui.command_text_alt[1] = '' - keyboard_ui.is_macro_alt[1] = false - keyboard_ui.macro_text_alt[1] = '' - end + if is_valid_alt then + keyboard_ui.command_text_alt[1], keyboard_ui.is_macro_alt[1], keyboard_ui.macro_text_alt[1] = + load_modifier_binding(false, true, false) + else + keyboard_ui.command_text_alt[1] = '' + keyboard_ui.is_macro_alt[1] = false + keyboard_ui.macro_text_alt[1] = '' + end + + if is_valid_shift then + keyboard_ui.command_text_shift[1], keyboard_ui.is_macro_shift[1], keyboard_ui.macro_text_shift[1] = + load_modifier_binding(false, false, true) + else + keyboard_ui.command_text_shift[1] = '' + keyboard_ui.is_macro_shift[1] = false + keyboard_ui.macro_text_shift[1] = '' + end + + -- Set Global checkbox based on whether key has any global bindings + keyboard_ui.global[1] = has_global_binding_on_key(key:upper()); + end) - if is_valid_shift then - keyboard_ui.command_text_shift[1], keyboard_ui.is_macro_shift[1], keyboard_ui.macro_text_shift[1] = - load_modifier_binding(false, false, true) - else - keyboard_ui.command_text_shift[1] = '' - keyboard_ui.is_macro_shift[1] = false - keyboard_ui.macro_text_shift[1] = '' + if not success then + -- Log error but don't crash + errorf('Error handling key click: %s', tostring(err)) end clicked = true end - -- Pop style colors only if we pushed them - if push_colors then - imgui.PopStyleColor(3); - end - if push_alpha then - imgui.PopStyleVar(); - end - return clicked end -- Function to render the virtual keyboard local function render_virtual_keyboard() + -- Get the initial style color stack depth (if available) + local initial_stack_depth = 0 + for row_index, row in ipairs(keyboard_layout) do local first_key = true for _, key_data in ipairs(row) do @@ -323,9 +456,15 @@ local function render_virtual_keyboard() end first_key = false + -- Render each key button render_key_button(key, width) end end + + -- Ensure no style colors are left on the stack after rendering keyboard + -- This is a safety check - all keys should pop their own colors + -- Note: ImGui doesn't provide a way to check stack depth in Lua, so we rely on + -- the per-key safety checks in render_key_button end -- Function to save current bindings for all modifier combinations @@ -335,16 +474,22 @@ local function save_current_binding() return false end + -- Check if trying to save job-specific binding on a key with global bindings + if not keyboard_ui.global[1] and has_global_binding_on_key(keyboard_ui.binding_key[1]) then + keyboard_ui.error_message = 'Cannot create job-specific binding: Key has global binding(s)'; + errorf('Cannot create job-specific binding on %s: Key has global binding(s)', keyboard_ui.binding_key[1]); + return false; + end + local all_success = true local last_error = '' + -- Determine which profile path and binding array to use + local target_profile_path = keyboard_ui.global[1] and global_profile_path or current_profile_path; + local target_bindings = keyboard_ui.global[1] and global_bindings or current_bindings; + -- Helper function to save a single modifier combination local function save_modifier_binding(cmd_text, is_macro, macro_text, has_ctrl, has_alt, has_shift) - -- Skip if no command is set - if cmd_text == '' then - return true - end - local binding_data = { key = keyboard_ui.binding_key[1], command = cmd_text, @@ -352,10 +497,22 @@ local function save_current_binding() macro_text = macro_text, shift_modifier = has_shift, alt_modifier = has_alt, - ctrl_modifier = has_ctrl + ctrl_modifier = has_ctrl, + is_global = keyboard_ui.global[1] } - local success, error_msg = ui_functions.save_current_binding(binding_data, current_bindings, current_profile_path, keyboard_ui.debug_mode) + -- If no command is set, delete the binding instead of saving + if cmd_text == '' then + local success, error_msg = ui_functions.delete_current_binding(binding_data, target_bindings, target_profile_path) + -- If no binding was found, that's okay (nothing to delete) + if not success and error_msg ~= 'No binding found for this key combination' then + last_error = error_msg + return false + end + return true + end + + local success, error_msg = ui_functions.save_current_binding(binding_data, target_bindings, target_profile_path) if not success then last_error = error_msg return false @@ -400,6 +557,16 @@ local function save_current_binding() return false end + -- Update the appropriate binding array + if keyboard_ui.global[1] then + global_bindings = target_bindings; + else + current_bindings = target_bindings; + end + + -- Re-merge bindings + combined_bindings = merge_bindings(); + keyboard_ui.error_message = '' return true end @@ -414,16 +581,21 @@ local function delete_current_binding() local all_success = true local last_error = '' + -- Determine which profile path and binding array to use + local target_profile_path = keyboard_ui.global[1] and global_profile_path or current_profile_path; + local target_bindings = keyboard_ui.global[1] and global_bindings or current_bindings; + -- Helper function to delete a single modifier combination local function delete_modifier_binding(has_ctrl, has_alt, has_shift) local binding_data = { key = keyboard_ui.binding_key[1], shift_modifier = has_shift, alt_modifier = has_alt, - ctrl_modifier = has_ctrl + ctrl_modifier = has_ctrl, + is_global = keyboard_ui.global[1] } - local success, error_msg = ui_functions.delete_current_binding(binding_data, current_bindings, current_profile_path, keyboard_ui.debug_mode) + local success, error_msg = ui_functions.delete_current_binding(binding_data, target_bindings, target_profile_path) if not success then last_error = error_msg return false @@ -437,6 +609,16 @@ local function delete_current_binding() delete_modifier_binding(false, true, false) delete_modifier_binding(false, false, true) + -- Update the appropriate binding array + if keyboard_ui.global[1] then + global_bindings = target_bindings; + else + current_bindings = target_bindings; + end + + -- Re-merge bindings + combined_bindings = merge_bindings(); + -- Clear UI keyboard_ui.binding_key[1] = '' keyboard_ui.command_text_none[1] = '' @@ -486,7 +668,7 @@ local function render_binding_editor() end -- Command text field (always editable, used for filename when macro mode) - imgui.SetNextItemWidth(330); -- Align Command Text Width + imgui.SetNextItemWidth(389); -- Align Command Text Width imgui.InputText('##cmd_' .. label, cmd_text, 256, ImGuiInputTextFlags_None); -- Show tooltip if invalid characters detected @@ -556,7 +738,7 @@ local function render_binding_editor() -- Macro text editor on the right imgui.SetNextItemWidth(400); - imgui.InputTextMultiline('##macro_' .. label .. '_text', macro_text, 2048, { 362, 100 }); -- Align Macro Text Width + imgui.InputTextMultiline('##macro_' .. label .. '_text', macro_text, 2048, { 421, 100 }); -- Align Macro Text Width end end @@ -565,11 +747,8 @@ local function render_binding_editor() -- Show prompt if no key is selected if keyboard_ui.binding_key[1] == '' then - imgui.Spacing(); imgui.Spacing(); imgui.Text('Click a button on the keyboard to apply a key binding'); - imgui.Spacing(); - imgui.Spacing(); return end @@ -588,12 +767,13 @@ local function render_binding_editor() show_shift = is_valid_shift -- Render the 4 binding rows with headers + imgui.Spacing(); imgui.Text('Binding'); imgui.SameLine(); imgui.SetCursorPosX(135); -- Above the X button imgui.Text('Command'); imgui.SameLine(); - imgui.SetCursorPosX(label_width + 314); -- Align Macro Text + imgui.SetCursorPosX(label_width + 373); -- Align Macro Text imgui.Text('Macro'); imgui.Spacing(); @@ -608,6 +788,16 @@ local function render_binding_editor() imgui.Spacing(); end + -- + Shift + if show_shift then + render_binding_row('+ Shift', + keyboard_ui.command_text_shift, + keyboard_ui.is_macro_shift, + keyboard_ui.macro_text_shift, + label_width); + imgui.Spacing(); + end + -- + Ctrl if show_ctrl then render_binding_row('+ Ctrl', @@ -628,18 +818,6 @@ local function render_binding_editor() imgui.Spacing(); end - -- + Shift - if show_shift then - render_binding_row('+ Shift', - keyboard_ui.command_text_shift, - keyboard_ui.is_macro_shift, - keyboard_ui.macro_text_shift, - label_width); - imgui.Spacing(); - end - - imgui.Spacing(); - imgui.Separator(); imgui.Spacing(); -- Check if any macro bindings have invalid filename characters @@ -696,15 +874,29 @@ local function render_binding_editor() end end + imgui.SameLine(); + imgui.Checkbox('Global', keyboard_ui.global); + if imgui.IsItemHovered() then + imgui.SetTooltip('Save binding to JobBinds.txt (global across all jobs).\nGlobal bindings override job-specific bindings.\nKeys with global bindings cannot have job-specific bindings.'); + end + -- Display current profile/job combination imgui.SameLine(); - imgui.Dummy({ 164, 0 }); -- Move right 164px + imgui.Dummy({ 128, 0 }); -- Move right 116px imgui.SameLine(); local profile_display = keyboard_ui.current_profile or 'No Profile Loaded'; -- Convert WAR_NIN.txt format to WAR/NIN display profile_display = profile_display:gsub('%.txt$', ''):gsub('_', '/'); imgui.Text(profile_display); + -- Display error message if present + if keyboard_ui.error_message ~= '' then + imgui.Spacing(); + imgui.PushStyleColor(ImGuiCol_Text, { 1.0, 0.3, 0.3, 1.0 }); + imgui.Text(keyboard_ui.error_message); + imgui.PopStyleColor(); + end + -- Key binding detection (simplified for demo) if keyboard_ui.is_binding then for key_code = 1, 255 do @@ -714,15 +906,11 @@ local function render_binding_editor() if vk_codes.is_known_key(key_code) then keyboard_ui.binding_key[1] = key_name; keyboard_ui.is_binding = false; - if keyboard_ui.debug_mode then - printf('[DEBUG] Detected key: %s (code: %d)', key_name, key_code); - end + log.debugf('Detected key: %s (code: %d)', key_name, key_code); else keyboard_ui.binding_key[1] = 'KEY_' .. key_code; keyboard_ui.is_binding = false; - if keyboard_ui.debug_mode then - printf('[DEBUG] Detected unknown key code: %d', key_code); - end + log.debugf('Detected unknown key code: %d', key_code); end break; end @@ -730,9 +918,7 @@ local function render_binding_editor() local ok, is_pressed = pcall(function() return imgui.IsKeyPressed(27) end) if ok and is_pressed then keyboard_ui.is_binding = false; - if keyboard_ui.debug_mode then - printf('[DEBUG] Escape pressed, canceling binding'); - end + log.debugf('Escape pressed, canceling binding'); end end end @@ -748,9 +934,6 @@ function keyboard_ui.render() -- Virtual keyboard on top render_virtual_keyboard(); - -- Horizontal divider - imgui.Separator(); - -- Binding editor on bottom render_binding_editor(); end @@ -778,16 +961,61 @@ function keyboard_ui.set_current_profile(profile_name) end function keyboard_ui.load_profile(profile_path) - current_bindings = ui_functions.load_bindings_from_profile(profile_path, keyboard_ui.debug_mode); + -- Load job-specific bindings + current_bindings = ui_functions.load_bindings_from_profile(profile_path); + + -- Ensure job-specific bindings are NOT marked as global + for _, binding in ipairs(current_bindings) do + binding.is_global = false; + end + current_profile_path = profile_path; + + -- Load global bindings + global_bindings = load_global_bindings(); + global_profile_path = get_global_bindings_path(); + + -- Merge bindings (global overrides job-specific) + combined_bindings = merge_bindings(); + + -- Debug: Count global vs total bindings + if log.is_debug() then + local global_count = 0; + for _, binding in ipairs(combined_bindings) do + if binding.is_global then + global_count = global_count + 1; + end + end + log.debugf('Bindings loaded - Total: %d, Global: %d, Job-specific: %d', + #combined_bindings, global_count, #combined_bindings - global_count); + end end function keyboard_ui.load_bindings(bindings) current_bindings = bindings or {}; + + -- Ensure job-specific bindings are NOT marked as global + for _, binding in ipairs(current_bindings) do + binding.is_global = false; + end + + -- Also reload global bindings and merge + global_bindings = load_global_bindings(); + combined_bindings = merge_bindings(); end function keyboard_ui.set_debug_mode(enabled) - keyboard_ui.debug_mode = enabled; + log.set_debug(enabled); +end + +-- Get global bindings path (for external access) +function keyboard_ui.get_global_bindings_path() + return get_global_bindings_path(); +end + +-- Check if key has global binding (for external access) +function keyboard_ui.has_global_binding_on_key(key) + return has_global_binding_on_key(key); end return keyboard_ui; diff --git a/lib/log.lua b/lib/log.lua new file mode 100644 index 0000000..9814459 --- /dev/null +++ b/lib/log.lua @@ -0,0 +1,47 @@ +--[[ +* Logging module for JobBinds +* Centralizes printf/warnf/errorf/debugf and the debug_mode flag. +--]] + +local chat = require('chat') + +local log = {} + +local addon_name = 'JobBinds' +local debug_mode = false + +local function emit(formatter, fmt, ...) + print(chat.header(addon_name) .. formatter(fmt:format(...))) +end + +function log.set_addon_name(name) + addon_name = name or addon_name +end + +function log.set_debug(enabled) + debug_mode = enabled and true or false +end + +function log.is_debug() + return debug_mode +end + +function log.printf(fmt, ...) + emit(chat.message, fmt, ...) +end + +function log.warnf(fmt, ...) + emit(chat.warning, fmt, ...) +end + +function log.errorf(fmt, ...) + emit(chat.error, fmt, ...) +end + +function log.debugf(fmt, ...) + if debug_mode then + emit(chat.message, '[DEBUG] ' .. fmt, ...) + end +end + +return log diff --git a/lib/modifiers.lua b/lib/modifiers.lua new file mode 100644 index 0000000..05749bd --- /dev/null +++ b/lib/modifiers.lua @@ -0,0 +1,71 @@ +--[[ +* Modifier helpers for JobBinds +* Single source of truth for Ctrl/Alt/Shift <-> ^/!/+ conversions. +--]] + +local modifiers = {} + +-- Canonical order: Ctrl, Alt, Shift (matches Ashita /bind prefix order ^!+) +modifiers.ORDER = { 'Ctrl', 'Alt', 'Shift' } + +-- Name -> single-char prefix used by Ashita's /bind command +modifiers.PREFIX = { + Ctrl = '^', + Alt = '!', + Shift = '+', +} + +-- Build the "Ctrl+Alt+Shift" style string from boolean flags. +function modifiers.string_from_flags(ctrl, alt, shift) + local parts = {} + if ctrl then parts[#parts + 1] = 'Ctrl' end + if alt then parts[#parts + 1] = 'Alt' end + if shift then parts[#parts + 1] = 'Shift' end + return table.concat(parts, '+') +end + +-- Build the "^!+" style prefix used in /bind commands and macro filenames. +function modifiers.prefix_from_flags(ctrl, alt, shift) + local s = '' + if ctrl then s = s .. modifiers.PREFIX.Ctrl end + if alt then s = s .. modifiers.PREFIX.Alt end + if shift then s = s .. modifiers.PREFIX.Shift end + return s +end + +-- Build the prefix from a "Ctrl+Alt+Shift" style string (case-insensitive). +function modifiers.prefix_from_string(mod_string) + if not mod_string or mod_string == '' then return '' end + local up = mod_string:upper() + return modifiers.prefix_from_flags( + up:find('CTRL') ~= nil, + up:find('ALT') ~= nil, + up:find('SHIFT') ~= nil + ) +end + +-- Parse leading ^!+ prefix off a key string. Returns key, ctrl, alt, shift. +function modifiers.strip_prefix(key) + local ctrl, alt, shift = false, false, false + while true do + local c = key:sub(1, 1) + if c == '^' then ctrl = true; key = key:sub(2) + elseif c == '!' then alt = true; key = key:sub(2) + elseif c == '+' then shift = true; key = key:sub(2) + else break end + end + return key, ctrl, alt, shift +end + +-- Convert a "Ctrl+Alt+Shift" style string into boolean flags. +function modifiers.flags_from_string(mod_string) + if not mod_string or mod_string == '' then + return false, false, false + end + local up = mod_string:upper() + return up:find('CTRL') ~= nil, + up:find('ALT') ~= nil, + up:find('SHIFT') ~= nil +end + +return modifiers diff --git a/ui_functions.lua b/lib/ui_functions.lua similarity index 52% rename from ui_functions.lua rename to lib/ui_functions.lua index 1788564..0932982 100644 --- a/ui_functions.lua +++ b/lib/ui_functions.lua @@ -5,13 +5,9 @@ --]] require('common'); -local chat = require('chat'); -local blocked_keybinds = require('blocked_keybinds'); - --- Custom print functions -local function printf(fmt, ...) print(chat.header('JobBinds') .. chat.message(fmt:format(...))) end -local function warnf(fmt, ...) print(chat.header('JobBinds') .. chat.warning(fmt:format(...))) end -local function errorf(fmt, ...) print(chat.header('JobBinds') .. chat.error(fmt:format(...))) end +local blocked_keybinds = require('lib.blocked_keybinds'); +local modifiers = require('lib.modifiers'); +local log = require('lib.log'); local ui_functions = {}; @@ -26,50 +22,12 @@ end -- Function to ensure directory exists local function ensure_directory_exists(path) - local ok, err = pcall(function() + local ok = pcall(function() AshitaCore:GetChatManager():QueueCommand(1, string.format('/mkdir "%s"', path)) end) return ok end --- ============================================================================ --- MACRO FILENAME GENERATION --- ============================================================================ - --- Helper: Generate macro filename based on profile + modifiers + key -function ui_functions.get_macro_filename(profile_base, key, shift, alt, ctrl) - if not profile_base or not key then - return '' - end - profile_base = profile_base:gsub('%.txt$', '') - local mod = '' - if ctrl then mod = mod .. '^' end - if alt then mod = mod .. '!' end - if shift then mod = mod .. '+' end - return string.format('%s_%s%s.txt', profile_base, mod, key) -end - --- Helper: Rename macro script file if the filename changes -function ui_functions.rename_macro_file(old_name, new_name) - if old_name == new_name then return end - local scripts_path = ui_functions.get_scripts_path() - local old_path = string.format('%s/%s', scripts_path, old_name) - local new_path = string.format('%s/%s', scripts_path, new_name) - local file = io.open(old_path, 'r') - if file then - file:close() - -- Only rename if new doesn't already exist - local newfile = io.open(new_path, 'r') - if not newfile then - local ok, err = pcall(function() os.rename(old_path, new_path) end) - return ok - else - newfile:close() - end - end - return false -end - -- ============================================================================ -- VALIDATION FUNCTIONS -- ============================================================================ @@ -79,19 +37,13 @@ function ui_functions.validate_key_binding(binding_key, shift_modifier, alt_modi if binding_key == '' then return true, '' end - - local modifiers = {} - if shift_modifier then table.insert(modifiers, 'Shift') end - if alt_modifier then table.insert(modifiers, 'Alt') end - if ctrl_modifier then table.insert(modifiers, 'Ctrl') end - local modifier_string = table.concat(modifiers, '+') - + + local modifier_string = modifiers.string_from_flags(ctrl_modifier, alt_modifier, shift_modifier) local is_blocked, error_msg = blocked_keybinds.is_combination_blocked(binding_key, modifier_string) if is_blocked then - local message = error_msg or blocked_keybinds.get_block_reason(binding_key, modifier_string) - return false, message + return false, error_msg or '' end - + return true, '' end @@ -101,35 +53,14 @@ end -- Function to generate bind command string from binding data function ui_functions.generate_bind_command(binding) - local key_part = binding.key - - if binding.modifiers and binding.modifiers ~= '' then - for modifier in binding.modifiers:gmatch('[^+]+') do - if modifier == 'Ctrl' then - key_part = '^' .. key_part - elseif modifier == 'Alt' then - key_part = '!' .. key_part - elseif modifier == 'Shift' then - key_part = '+' .. key_part - end - end - end - + local key_part = modifiers.prefix_from_string(binding.modifiers) .. binding.key + local command = binding.command if command:sub(1, 1) ~= '/' then command = '/' .. command end - - return string.format('/bind %s %s', key_part, command) -end --- Function to generate binding suffix for display -function ui_functions.generate_binding_suffix(shift_modifier, alt_modifier, ctrl_modifier) - local suffix = '' - if ctrl_modifier then suffix = suffix .. '^' end - if alt_modifier then suffix = suffix .. '!' end - if shift_modifier then suffix = suffix .. '+' end - return suffix + return string.format('/bind %s %s', key_part, command) end -- ============================================================================ @@ -137,150 +68,111 @@ end -- ============================================================================ -- Function to save bindings back to profile file -function ui_functions.save_bindings_to_profile(current_bindings, current_profile_path, debug_mode) +function ui_functions.save_bindings_to_profile(current_bindings, current_profile_path) if not current_profile_path then - if debug_mode then - errorf('[DEBUG] No profile path available for saving') - end + log.debugf('No profile path available for saving') return false end - + local file = io.open(current_profile_path, 'w') if not file then - if debug_mode then - errorf('[DEBUG] Could not open profile file for writing: %s', current_profile_path) - end + log.debugf('Could not open profile file for writing: %s', current_profile_path) return false end - - -- Write all bindings + for _, binding in ipairs(current_bindings) do local bind_command = ui_functions.generate_bind_command(binding) file:write(bind_command .. '\n') - if debug_mode then - printf('[DEBUG] Wrote binding: %s', bind_command) - end + log.debugf('Wrote binding: %s', bind_command) end - + file:close() - - if debug_mode then - printf('[DEBUG] Saved %d bindings to: %s', #current_bindings, current_profile_path) - end - + log.debugf('Saved %d bindings to: %s', #current_bindings, current_profile_path) return true end -- Function to parse a bind command line function ui_functions.parse_bind_line(line) - -- Pattern to match: /bind [modifiers+]key "command" or /bind [modifiers+]key command - -- Expanded pattern to include punctuation keys: -, =, [, ], \, ;, ', ,, ., /, `, etc. - local modifiers_key, command = line:match('^/bind%s+([!@#%%^+%w%-%=%[%]\\%;%\',%%.%/%`]+)%s+(.+)$') + -- Match: /bind + local modifiers_key, command = line:match('^/bind%s+(%S+)%s+(.+)$') if not modifiers_key or not command then return nil end - + -- Remove quotes from command if present command = command:match('^"(.*)"$') or command - + -- Check if this is a macro (exec command) local is_macro = false local macro_content = '' local exec_file = command:match('^/exec%s+(.+)$') if exec_file then is_macro = true - -- Load macro file content local macro_path = string.format('%s/%s', ui_functions.get_scripts_path(), exec_file) if not macro_path:match('%.txt$') then macro_path = macro_path .. '.txt' end - + local macro_file = io.open(macro_path, 'r') if macro_file then macro_content = macro_file:read('*all') or '' macro_file:close() end end - - -- Parse modifiers and key - local key = modifiers_key - local modifiers = {} - - -- Check for modifiers (order matters for parsing) - if key:match('^%^') then - table.insert(modifiers, 'Ctrl') - key = key:sub(2) - end - if key:match('^!') then - table.insert(modifiers, 'Alt') - key = key:sub(2) - end - if key:match('^%+') then - table.insert(modifiers, 'Shift') - key = key:sub(2) - end - + + -- Strip ^!+ prefix into modifier flags + local key, ctrl, alt, shift = modifiers.strip_prefix(modifiers_key) + return { - key = key:upper(), - modifiers = table.concat(modifiers, '+'), - command = command, - is_macro = is_macro, - macro_content = macro_content + key = key:upper(), + modifiers = modifiers.string_from_flags(ctrl, alt, shift), + command = command, + is_macro = is_macro, + macro_content = macro_content, } end -- Function to load bindings from profile file -function ui_functions.load_bindings_from_profile(profile_path, debug_mode) - local current_bindings = {} -- Clear existing bindings - +function ui_functions.load_bindings_from_profile(profile_path) + local current_bindings = {} + if not profile_path then - if debug_mode then - warnf('[DEBUG] No profile path provided') - end + log.debugf('No profile path provided') return current_bindings end - + local file = io.open(profile_path, 'r') if not file then - if debug_mode then - warnf('[DEBUG] Could not open profile file: %s', profile_path) - end + log.debugf('Could not open profile file: %s', profile_path) return current_bindings end - + local line_count = 0 local bind_count = 0 - + for line in file:lines() do line_count = line_count + 1 line = line:match('^%s*(.-)%s*$') -- Trim whitespace - + if line:match('^/bind%s+') then local binding = ui_functions.parse_bind_line(line) if binding then table.insert(current_bindings, binding) bind_count = bind_count + 1 - if debug_mode then - printf('[DEBUG] Parsed binding: %s%s -> %s%s', - binding.key, - binding.modifiers ~= '' and (' (' .. binding.modifiers .. ')') or '', - binding.command, - binding.is_macro and ' [MACRO]' or '') - end + log.debugf('Parsed binding: %s%s -> %s%s', + binding.key, + binding.modifiers ~= '' and (' (' .. binding.modifiers .. ')') or '', + binding.command, + binding.is_macro and ' [MACRO]' or '') else - if debug_mode then - warnf('[DEBUG] Failed to parse bind line: %s', line) - end + log.debugf('Failed to parse bind line: %s', line) end end end - + file:close() - - if debug_mode then - printf('[DEBUG] Loaded %d bindings from %d lines in: %s', bind_count, line_count, profile_path) - end - + log.debugf('Loaded %d bindings from %d lines in: %s', bind_count, line_count, profile_path) + return current_bindings end @@ -288,43 +180,16 @@ end -- MACRO FILE OPERATIONS -- ============================================================================ --- Function to generate profile name for macros -function ui_functions.generate_profile_name() - local ok, party = pcall(function() return AshitaCore:GetMemoryManager():GetParty() end) - if not ok or not party then - return 'unknown' - end - - local okj, job = pcall(function() return party:GetMemberMainJob(0) end) - local oksj, subjob = pcall(function() return party:GetMemberSubJob(0) end) - - if not okj or not oksj then - return 'unknown' - end - - local job_names = { - [1] = 'WAR', [2] = 'MNK', [3] = 'WHM', [4] = 'BLM', [5] = 'RDM', [6] = 'THF', - [7] = 'PLD', [8] = 'DRK', [9] = 'BST', [10] = 'BRD', [11] = 'RNG', [12] = 'SAM', - [13] = 'NIN', [14] = 'DRG', [15] = 'SMN', [16] = 'BLU', [17] = 'COR', [18] = 'PUP', - [19] = 'DNC', [20] = 'SCH', [21] = 'GEO', [22] = 'RUN' - } - - local job_name = job_names[job] or 'UNK' - local subjob_name = job_names[subjob] or 'UNK' - - return string.format('%s_%s', job_name, subjob_name) -end - -- Function to create macro file function ui_functions.create_macro_file(macro_name, content, existing_command) local scripts_path = ui_functions.get_scripts_path() ensure_directory_exists(scripts_path) - + local macro_path = string.format('%s/%s', scripts_path, macro_name) if not macro_path:match('%.txt$') then macro_path = macro_path .. '.txt' end - + -- Check if we need to handle existing commands that aren't exec commands if existing_command and existing_command ~= '' and not existing_command:match('^/exec%s+') then -- If there's an existing non-exec command, prepend it to the macro content @@ -334,14 +199,14 @@ function ui_functions.create_macro_file(macro_name, content, existing_command) content = existing_command end end - + local file = io.open(macro_path, 'w') if file then file:write(content or '') file:close() return true end - + return false end @@ -350,66 +215,62 @@ end -- ============================================================================ -- Function to save current binding -function ui_functions.save_current_binding(binding_data, current_bindings, current_profile_path, debug_mode) +function ui_functions.save_current_binding(binding_data, current_bindings, current_profile_path) -- Validate inputs if binding_data.key == '' then return false, 'Please select a key' end - + if binding_data.command == '' and not binding_data.is_macro then return false, 'Please enter a command' end - + if binding_data.is_macro and binding_data.macro_text == '' then return false, 'Please enter macro content' end - - -- Build modifiers string - local modifiers = {} - if binding_data.shift_modifier then table.insert(modifiers, 'Shift') end - if binding_data.alt_modifier then table.insert(modifiers, 'Alt') end - if binding_data.ctrl_modifier then table.insert(modifiers, 'Ctrl') end - local modifier_string = table.concat(modifiers, '+') - + + -- Build modifier string + local modifier_string = modifiers.string_from_flags( + binding_data.ctrl_modifier, + binding_data.alt_modifier, + binding_data.shift_modifier + ) + -- Check if key+modifiers are blocked - local is_valid, error_msg = ui_functions.validate_key_binding(binding_data.key, + local is_valid, error_msg = ui_functions.validate_key_binding(binding_data.key, binding_data.shift_modifier, binding_data.alt_modifier, binding_data.ctrl_modifier) if not is_valid then return false, error_msg end - + -- Create new binding local new_binding = { - key = binding_data.key, + key = binding_data.key, modifiers = modifier_string, - command = binding_data.command, - is_macro = binding_data.is_macro + command = binding_data.command, + is_macro = binding_data.is_macro, + is_global = binding_data.is_global or false, } - + -- Handle macro if binding_data.is_macro then - -- Use the user-specified filename from binding_data.command local macro_filename = binding_data.command if not macro_filename or macro_filename == '' then return false, 'Please enter a macro filename' end - - -- Ensure .txt extension if not macro_filename:match('%.txt$') then macro_filename = macro_filename .. '.txt' end - - -- Save macro content to file + if not ui_functions.create_macro_file(macro_filename, binding_data.macro_text) then return false, 'Failed to save macro file' end - - -- Set command to exec the macro + new_binding.command = '/exec ' .. macro_filename end - + -- Remove existing binding for this key+modifiers combination for i = #current_bindings, 1, -1 do local binding = current_bindings[i] @@ -418,13 +279,10 @@ function ui_functions.save_current_binding(binding_data, current_bindings, curre break end end - - -- Add new binding + table.insert(current_bindings, new_binding) - - -- Save to file - if ui_functions.save_bindings_to_profile(current_bindings, current_profile_path, debug_mode) then - -- Apply binding immediately + + if ui_functions.save_bindings_to_profile(current_bindings, current_profile_path) then local bind_command = ui_functions.generate_bind_command(new_binding) AshitaCore:GetChatManager():QueueCommand(1, bind_command) return true, '' @@ -434,18 +292,17 @@ function ui_functions.save_current_binding(binding_data, current_bindings, curre end -- Function to delete current binding -function ui_functions.delete_current_binding(binding_data, current_bindings, current_profile_path, debug_mode) +function ui_functions.delete_current_binding(binding_data, current_bindings, current_profile_path) if binding_data.key == '' then return false, 'Please select a key' end - - -- Build modifiers string - local modifiers = {} - if binding_data.shift_modifier then table.insert(modifiers, 'Shift') end - if binding_data.alt_modifier then table.insert(modifiers, 'Alt') end - if binding_data.ctrl_modifier then table.insert(modifiers, 'Ctrl') end - local modifier_string = table.concat(modifiers, '+') - + + local modifier_string = modifiers.string_from_flags( + binding_data.ctrl_modifier, + binding_data.alt_modifier, + binding_data.shift_modifier + ) + -- Find and remove binding local found = false for i = #current_bindings, 1, -1 do @@ -460,25 +317,23 @@ function ui_functions.delete_current_binding(binding_data, current_bindings, cur end os.remove(macro_path) end - + table.remove(current_bindings, i) found = true break end end - + if not found then return false, 'No binding found for this key combination' end - - -- Save to file - if ui_functions.save_bindings_to_profile(current_bindings, current_profile_path, debug_mode) then - -- Unbind the key - local key_part = binding_data.key - if binding_data.ctrl_modifier then key_part = '^' .. key_part end - if binding_data.alt_modifier then key_part = '!' .. key_part end - if binding_data.shift_modifier then key_part = '+' .. key_part end - + + if ui_functions.save_bindings_to_profile(current_bindings, current_profile_path) then + local key_part = modifiers.prefix_from_flags( + binding_data.ctrl_modifier, + binding_data.alt_modifier, + binding_data.shift_modifier + ) .. binding_data.key AshitaCore:GetChatManager():QueueCommand(1, '/unbind ' .. key_part) return true, '' else diff --git a/vk_codes.lua b/lib/vk_codes.lua similarity index 100% rename from vk_codes.lua rename to lib/vk_codes.lua