diff --git a/LICENSE b/LICENSE index 180ab2e..97a109d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ BSD 2-Clause License Copyright (c) 2019, dillydill123 +Copyright (c) 2025, AhDoozy All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/README.md b/README.md index ae30a12..2e93f31 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,127 @@ +# 🏆 Goal Tracker v2 + +> A complete reimagining of the Goal Tracker plugin — rebuilt with a more modern UI, powerful new features, and improved stability to help you plan, track, and achieve your Old School RuneScape goals with ease. + +--- + +
+

✨ New Features

+ +- Shift+Click removal of tasks for faster task management +- Automatic goal status checks for up-to-date progress +- New ActionBar and ActionBarButton UI components +- Hover states for better visual feedback +- New context menu organization for streamlined interaction +- Search toggle improvements for easier task searching +- New task right‑click menu with grouped **Move** submenu and cascading complete/incomplete toggle that applies to children (expanded beyond v1 functionality). +- Goal cards redesigned with lighter fills, full shadows, and hover/press effects only on the card face. +- Thicker header divider under “Goal Tracker” for clearer separation. +- Automatic refresh propagation so Home goals update instantly when tasks change. +- Completion chat messages delivered as proper Game messages with configurable colors. +- Export and Import functionality: save your goals to a JSON file and import them back with full UI refresh. +- Automatic warming of item icons so they load at startup, on login, and after import. +- GoalsChangedListener system to auto-refresh the Home panel when goals change. +- Preset Goal Lists: add goals from built-in presets (Quest Cape Core, Early Game Ironman) via a new “Add from Preset…” button. +- Presets automatically expand quest prerequisites using the existing right-click prereq logic. +- Goal titles and ManualTask descriptions now support click-to-edit with inline text fields. +- Long goal and task titles are ellipsized with … and show full text on hover. + +### ♻️ Redesigned Features + +- Redesigned quest prerequisites button for quick access to quest requirements +- Redesigned completion cascading to automatically complete related tasks +- Redesigned dropdown quest selector for faster quest task addition +- Redesigned right-click menus for prerequisites and child completion options +- Redesigned manual toggling for preset tasks to customize workflow +- Redesigned chatbox notification colors (now fully customizable) + +
+ +
+

🔧 Improvements

+ +- More compact prereq button for a cleaner interface +- Refreshed UI with updated design elements +- Font and ComboBox readability enhancements +- Consistent ActionBar UI throughout the plugin +- Unified goal view header for a cohesive look +- Improved context menus with better usability +- Enhanced cursor and hover detection accuracy +- Copy and paste support in the goal name input field +- Context menu entries rebuilt dynamically before opening to always reflect the latest state. +- Task and goal content now force an initial refresh so icons and text render correctly at login. +- Refined ActionBar spacing to ensure Export and Import buttons fit without overlap. +- Progress text (e.g., “1/10”) on goal cards now has a reserved width and never clips. +- + Add goal and Add from Preset buttons restyled and stacked vertically for cleaner layout. +- Task rows updated to match goal cards with ellipsized titles and consistent styling. + +
+ +
+

🐛 Fixes

+ +- Undo/Redo functionality cleanup for smoother editing +- ActionBarButton painting fixes to prevent visual glitches +- GoalTrackerPanel `home()` method refresh improvements +- Correct refresh behavior in ListPanel +- Improved mouse selection accuracy +- Keyboard shortcut fixes and enhancements +- Automatic removal of empty goals to keep lists tidy +- Visual refresh issue resolved on login +- Fixed child task refresh issues by recursively refreshing all descendants. +- Fixed blank panel issue when switching from Home to Goal view. +- Fixed completion chat message not appearing on task completion. +- Export/Import buttons were previously non-functional; now they work correctly. +- Item icons sometimes failed to appear until entering a goal; now fixed by preloading at startup/login and after import. +- Home panel not refreshing after completing a task; fixed with a new listener system. +- Overlapping issue around the Export button fixed by layout adjustments. + +
+ +
+

📥 Installation

+ +1. Open RuneLite. +2. Go to the Plugin Hub. +3. Search for "Goal Tracker v2". +4. Click **Install**. + +
+ +
+

🚀 Getting Started

+ +- Open the plugin panel in RuneLite once installed. +- Use **+ Add goal** to create a new goal. +- Add tasks (quests, skills, items, or manual) via the goal view. +- Use the new **ActionBar** buttons for navigation, undo/redo, and bulk actions. + +
+ +
+

🖼️ Screenshots

+ +[screenshot] Home panel with goal cards +[screenshot] Inside a goal with task list +[screenshot] Right‑click menu on a task +[screenshot] Config panel with customizable chat color + +
+ +
+

🙏 Acknowledgements

+ +- Original plugin created by **dillydill123**. +- Fully renovated and maintained by **AhDoozy**. + +
+## 📄 License +Licensed under the [BSD 2-Clause License](LICENSE). + +----- +
+

📜 Original Goal Tracker v1 Readme & Documentation (created by dillydill123)

+ # Runelite Goal Tracker Plugin Keep track of your OSRS goals and complete them automatically. @@ -60,3 +184,5 @@ Track quest progress and completion, just select a quest or miniquest from the d #### Item tasks Select an item using the search button and searching via the in-game chatbox, then set the desired quantity. The plugin will keep track of your items and tally up quantities stored in different inventories (bank, player, GIMP storage), and will be automatically completed once you get that amount of the item. + +
\ No newline at end of file diff --git a/build.gradle b/build.gradle index b48403e..cc76a13 100644 --- a/build.gradle +++ b/build.gradle @@ -19,6 +19,8 @@ dependencies { compileOnly 'org.projectlombok:lombok:1.18.20' annotationProcessor 'org.projectlombok:lombok:1.18.20' + implementation 'org.apache.commons:commons-lang3:3.12.0' + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.5.2' testImplementation 'org.junit.jupiter:junit-jupiter-params:5.5.2' testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.5.2' @@ -40,7 +42,7 @@ tasks.withType(JavaCompile) { tasks.test { useJUnitPlatform() - + finalizedBy jacocoTestReport // report is always generated after tests run } diff --git a/changelog.md b/changelog.md new file mode 100644 index 0000000..5f63782 --- /dev/null +++ b/changelog.md @@ -0,0 +1,106 @@ +# Changelog + +## [Unreleased] + +### Added +- Quest prerequisites button: Each quest task now has an **Add prereqs** button to insert its prerequisites + directly beneath it. +- Shift+Click removal: Shift+Click a task to remove it and all its indented children at once. +- Completion cascading: Marking a parent quest as complete/incomplete now automatically updates all its + child tasks. +- Dropdown quest selector: Replaced fuzzy search with a clean dropdown of all quests, displaying natural + names (e.g., "Tree Gnome Village"). +- Right-click menu option **Add pre-reqs** for quest tasks with prerequisites. Automatically inserts + missing prerequisites as subtasks with proper indentation and prevents duplicates. +- New `ActionBar` and `ActionBarButton` UI components introduced for consistent toolbar styling across + panels. +- Cursor hover state and hand cursor indicator for goal list items to improve usability. +- Added right-click menu to parent goals to mark all child tasks as completed or incomplete. +- Added manual completion toggling for tasks created from presets, allowing users to right-click and mark them complete/incomplete just like quick-added tasks. +- Added customizable color setting for task completion messages shown in the chatbox. +- Implemented automatic goal status checking upon login to mark goals as completed if requirements are already met. +- Context menu entries now rebuilt dynamically right before opening to ensure the latest state. +- Task list panel context menus reorganized with a **Move** submenu and cascading complete/incomplete toggle that applies to children. +- Initial refresh calls for Task and Goal content so icons/text render correctly on login. +- Automatic ancestor `ListPanel` refresh propagation to keep Home goal list in sync when tasks change. +- Export and Import functionality: Goals can now be exported to JSON and imported back with full UI refresh. +- JSON file filter in Import/Export dialogs for safer file selection. +- Automatic warming of ItemTask icons on plugin startup and login tick, and after JSON import, ensuring icons display immediately. +- GoalsChangedListener system in GoalManager to notify panels and auto-refresh home view when goals are saved or loaded. +- Preset Goal Lists: Added “Add from Preset…” button in the Goal Tracker panel, with initial presets (Quest Cape Core, Early Game Ironman). Presets now expand to include prerequisite quests automatically. +- Centralized presets into a new `GoalPresetRepository` class for easier management and expansion. +- Automatic prerequisite expansion for presets leverages the same logic as the quest right-click **Add prereqs** option. +- Ellipsized titles: Goal cards and Task rows now ellipsize long titles with `…` and show full text on hover via tooltip. +- Click-to-edit: Goal titles and ManualTask descriptions can now be edited by clicking their label; label swaps to an inline text field and saves on Enter or blur. +- Added per-lane Ladlor presets (Melee, Ranged, Magic, Utility, Void Set, God Capes, Crystal & Bowfa, Jewelry & Boots, Slayer & Undead, Imbued Rings, God Wars Armor, Elite Void, Prayer Scrolls, Raids Uniques, Milestones & Capes), alongside the full combined Ladlor Ironman preset. +- Added Early, Mid, and Late Ironman presets as consolidated single-goal lists rather than split by category. +- Added a new **Add pre-reqs** button to the Goal view header, next to Undo/Redo. This button triggers the same logic as the quest right-click **Add prerequisites** option, but applies to all quest tasks in the goal at once. +- Converted all Early, Mid, and Late Ironman preset skill milestones from ManualTask to SkillLevelTask for accurate level tracking. +- Expanded Early Ironman preset with Birdhouse run unlocks (Dig Site, Bone Voyage, Hunter 5, Crafting 8, Construction 16) and Seaweed run unlock (Farming 23). +- Reworked Early, Mid, and Late Ironman presets with more appropriate content and progression pacing (e.g., Early emphasizes graceful set and mobility, Mid includes Iban’s staff, Ava’s, Barrows gloves, Late includes Blowpipe, Bowfa, Bandos, endgame quests). +- Commented out Ladlor presets from default loading in GoalPresetRepository to simplify active preset list. +- Fixed incorrect ItemID constants for Salve amulet (ei) in Slayer & Undead presets (SALVE_AMULET_EI). +- Removed stray selection marker and corrected field declaration in GoalPresetRepository. + +### Changed +- Pre-req button made more compact (~25% smaller). +- Prerequisite insertion now places child tasks directly below their parent instead of at the end of the + list. +- UI now automatically refreshes after task mutations (add/remove/indent/status change). +- Right-click context menu reorganized: Move actions grouped under a **Move** submenu; Remove action now + labeled as **Remove (Shift+Left Click)**. +- Quest tasks now only display the **Add pre-reqs** option if they actually have missing prerequisites. +- Search button now toggles between **Search...** and **Close** to open/close the item search overlay. +- Removed in-panel close button; close control is now handled directly via the Search button toggle. +- Green **+Add** button hidden for item search inputs (still visible for other input types). +- Duplicate prerequisites can no longer be added multiple times to the same quest. +- Quest dropdown now uses **RuneScape UF** font at a normal crisp size for improved readability. +- ComboBox font scaling updated to use integer point sizes, preventing fuzzy text. + + - Back and Undo buttons added to a new top control bar in goal view; later removed and replaced with a + cleaner single bar design. + - Embedded red "< Back" button removed from GoalPanel header, leaving only the goal name input aligned + cleanly. + - Goal name input updated to support copy, paste, cut, and select-all actions via both context menu and + keyboard shortcuts. + - Remove menu option enhanced to also delete all indented child tasks when removing a parent. + - Remove menu label updated so the "(Shift+Left Click)" hint displays smaller and in gray. + - Home view updated: "Goal Tracker" title moved to its own header, with a new action bar beneath it + containing **+ Add goal**, **Move**, and **Bulk Edit** buttons (the latter two are placeholders marked + "Coming soon"). + - Home panel action bar refactored to use new `ActionBar` and `ActionBarButton` components for consistent + styling. + - Goal view header updated to use unified `ActionBar` with Back (left) and Undo/Redo (right) buttons. + - ActionBar now includes a center spacer to properly separate left and right button groups. + - Right-click context menus fixed to reliably open across platforms (Windows, macOS, Linux). + - Task right-click now defaults to showing full ListItemPanel menu (move, remove, etc.), with fallback to + toggle-only menu if no parent menu exists. + - Goal item context menus updated to forward right-clicks to parent ListItemPanel menus for consistent + options. + - Cursor/hover detection improved on home goal list: listeners now attach recursively to all child + components for accurate selection and highlighting. +- Goal cards now use a lighter fill with a full shadow around borders, and hover/press only affect the card face. +- Header divider under “Goal Tracker” made thicker (4px) for stronger separation. +- Right-click menus refactored so Tasks build their own menu and Goals build theirs, preventing duplicate/unusable items. +- ActionBar spacing refined between Redo and Export buttons to prevent overlap and fit Import button. +- Plugin startup now triggers item icon warm-up so icons are ready before login. +- “+ Add goal” and “Add from Preset…” buttons now stacked vertically in the Goal Tracker panel header for cleaner layout. +- Goal card progress text (e.g., “1/10”) reserved fixed width and no longer clips; typography is consistent and does not shrink. +- Task rows updated with consistent styling and ellipsis/edit behavior, matching Goal cards for a unified UI. + +### Fixed +- Home panel Undo/Redo buttons removed; these controls now exist only in Goal view. +- ActionBarButton painting fixed: clears background correctly, text always drawn on top, and hover state no longer causes overlapping artifacts. +- GoalTrackerPanel `home()` method fixed so returning from Goal view refreshes and displays the goal list instead of a blank panel. +- ListPanel `refresh()` updated to rebuild list before refreshing children, preventing stale or empty views. +- Improved accuracy of mouse selection in home goal list; click/hover listeners now consistently cover the entire item area. +- Goal name input now fully supports keyboard shortcuts (Ctrl/Cmd+C, V, X, A, Insert/Delete variants) and right-click context menu for copy/paste across all platforms. +- Empty goals (0/0 tasks) created via **+ Add goal** are now automatically removed when backing out without adding tasks, preventing clutter in saved goal lists. +- Fixed visual refresh issue where quest task statuses didn’t show correctly on login unless re-entering the goal. +- Fixed child task refresh issues after parent complete/incomplete cascades by recursively refreshing all descendants. +- Fixed blank panel issue when switching from Home to Goal view by only using inner card body for Goal rows. +- Fixed completion chat message not appearing; now delivered as a proper `GAMEMESSAGE` with customizable config color. +- Export/Import buttons previously non-functional; now wired to save/load JSON correctly. +- Item icons not appearing until entering a goal; fixed by warming icons at startup/login and after import. +- Home panel not refreshing after task completion; fixed with GoalsChangedListener refresh hook. +- Overlapping UI issue around Export button resolved by adjusting panel borders and layout. diff --git a/src/main/java/com/toofifty/goaltracker/GoalManager.java b/src/main/java/com/toofifty/goaltracker/GoalManager.java index 790bf2f..b8ee876 100644 --- a/src/main/java/com/toofifty/goaltracker/GoalManager.java +++ b/src/main/java/com/toofifty/goaltracker/GoalManager.java @@ -28,6 +28,8 @@ public class GoalManager @Getter private final ReorderableList goals = new ReorderableList<>(); + private final List goalsChangedListeners = new ArrayList<>(); + public Goal createGoal() { Goal goal = Goal.builder().build(); @@ -35,39 +37,96 @@ public Goal createGoal() return goal; } + /** + * Append a batch of goals and persist + notify listeners. + */ + public void addGoals(List newGoals) + { + if (newGoals == null || newGoals.isEmpty()) + { + return; + } + goals.addAll(newGoals); + save(); + } + @SuppressWarnings("unchecked") - public List getTasksByTypeAndAnyStatus(TaskType type, Status ...statuses) + public List getTasksByTypeAndAnyStatus(TaskType type, Status... statuses) { List tasks = new ArrayList<>(); - for (Goal goal : goals) { + for (Goal goal : goals) + { tasks.addAll((List) goal.getTasks().stream() - .filter((task) -> task.getType() == type && Arrays.stream(statuses).anyMatch((status) -> status == task.getStatus())) - .collect(Collectors.toList()) - ); + .filter(task -> task.getType() == type && Arrays.stream(statuses).anyMatch(status -> status == task.getStatus())) + .collect(Collectors.toList())); } return tasks; } - public List getIncompleteTasksByType(TaskType type) { + public List getIncompleteTasksByType(TaskType type) + { return this.getTasksByTypeAndAnyStatus(type, Status.NOT_STARTED, Status.IN_PROGRESS); } public void save() { config.goalTrackerData(goalSerializer.serialize(goals)); - log.info("Saved " + goals.size() + " goals"); + notifyGoalsChanged(); } public void load() { - try { + try + { this.goals.clear(); this.goals.addAll(goalSerializer.deserialize(config.goalTrackerData())); - + notifyGoalsChanged(); log.info("Loaded " + this.goals.size() + " goals"); - } catch (Exception e) { - log.error("Failed to load goals!"); + } + catch (Exception e) + { + log.error("Failed to load goals!", e); + } + } + /** + * Return the current goals as JSON for export. + * @param pretty pretty-print output + */ + public String exportJson(boolean pretty) + { + return goalSerializer.serialize(goals, pretty); + } + + /** + * Import goals from JSON and persist them. + */ + public void importJson(String json) + { + try + { + this.goals.clear(); + this.goals.addAll(goalSerializer.deserialize(json)); + save(); + } + catch (Exception e) + { + log.error("Failed to import goals!", e); + } + } + public void addGoalsChangedListener(Runnable listener) + { + if (listener != null && !goalsChangedListeners.contains(listener)) + { + goalsChangedListeners.add(listener); + } + } + + private void notifyGoalsChanged() + { + for (Runnable r : goalsChangedListeners) + { + try { r.run(); } catch (Exception ignored) {} } } -} +} \ No newline at end of file diff --git a/src/main/java/com/toofifty/goaltracker/GoalTrackerConfig.java b/src/main/java/com/toofifty/goaltracker/GoalTrackerConfig.java index ad07313..3409cd2 100644 --- a/src/main/java/com/toofifty/goaltracker/GoalTrackerConfig.java +++ b/src/main/java/com/toofifty/goaltracker/GoalTrackerConfig.java @@ -1,8 +1,11 @@ package com.toofifty.goaltracker; -import net.runelite.client.config.Config; -import net.runelite.client.config.ConfigGroup; import net.runelite.client.config.ConfigItem; +import net.runelite.client.config.ConfigGroup; +import net.runelite.client.config.ConfigSection; +import net.runelite.client.config.Config; +import net.runelite.client.config.Alpha; +import java.awt.Color; @ConfigGroup("goaltracker") public interface GoalTrackerConfig extends Config @@ -33,4 +36,15 @@ default String goalTrackerItemNoteMapCache() @ConfigItem(keyName = "goalTrackerItemNoteMapCache", name = "", description = "", hidden = true) void goalTrackerItemNoteMapCache(String str); + + @ConfigItem( + keyName = "completionMessageColor", + name = "Completion Message Color", + description = "Color of the chat message when a goal is completed" + ) + @Alpha + default Color completionMessageColor() + { + return new Color(0xF227A509, true); + } } diff --git a/src/main/java/com/toofifty/goaltracker/GoalTrackerPlugin.java b/src/main/java/com/toofifty/goaltracker/GoalTrackerPlugin.java index 21c110e..86478bf 100644 --- a/src/main/java/com/toofifty/goaltracker/GoalTrackerPlugin.java +++ b/src/main/java/com/toofifty/goaltracker/GoalTrackerPlugin.java @@ -21,12 +21,13 @@ import net.runelite.client.game.ItemManager; import net.runelite.client.game.SkillIconManager; import net.runelite.client.game.chatbox.ChatboxItemSearch; +import net.runelite.client.game.chatbox.ChatboxPanelManager; import net.runelite.client.plugins.Plugin; import net.runelite.client.plugins.PluginDescriptor; import net.runelite.client.ui.ClientToolbar; -import net.runelite.client.ui.ColorScheme; import net.runelite.client.ui.NavigationButton; import net.runelite.client.util.AsyncBufferedImage; +import net.runelite.client.util.ColorUtil; import javax.inject.Inject; import java.util.List; @@ -60,6 +61,10 @@ public class GoalTrackerPlugin extends Plugin @Inject private ChatboxItemSearch itemSearch; + @Getter + @Inject + private ChatboxPanelManager chatboxPanelManager; + @Inject private ClientToolbar clientToolbar; @@ -102,6 +107,8 @@ public class GoalTrackerPlugin extends Plugin @Setter private boolean validateAll = true; + private boolean warmedIcons = false; + @Override protected void startUp() { @@ -135,6 +142,10 @@ protected void startUp() goalManager.save(); }); goalTrackerPanel.onTaskUpdated((task) -> goalManager.save()); + + // Preload item icons at plugin startup so they are visible immediately + warmItemIcons(); + warmedIcons = true; // avoid re-warming on first login tick } @Override @@ -147,6 +158,7 @@ protected void shutDown() public void onSessionOpen(SessionOpen event) { goalManager.load(); + goalTrackerPanel.refresh(); } @Subscribe @@ -184,20 +196,35 @@ public void notifyTask(Task task) log.debug("Notify: [Goal Tracker] You have completed a task: " + task + "!"); String message = "[Goal Tracker] You have completed a task: " + task + "!"; - String formattedMessage = new ChatMessageBuilder().append(ColorScheme.PROGRESS_COMPLETE_COLOR, message).build(); - chatMessageManager.queue(QueuedMessage.builder() - .type(ChatMessageType.CONSOLE) - .name("Goal Tracker") - .runeLiteFormattedMessage(formattedMessage) - .build()); + String formattedMessage = ColorUtil.wrapWithColorTag(message, config.completionMessageColor()); + client.addChatMessage(ChatMessageType.GAMEMESSAGE, "", formattedMessage, null); task.setNotified(true); } + public void warmItemIcons() + { + // Iterate all tasks and ensure item icons are requested so they appear without opening search + goalManager.getGoals().forEach(goal -> { + if (goal.getTasks() == null) return; + goal.getTasks().forEach(task -> { + if (task instanceof ItemTask) + { + try { + taskIconService.get((ItemTask) task); + } catch (Exception ignored) { } + } + }); + }); + // Refresh UI after warming to repaint icons + javax.swing.SwingUtilities.invokeLater(goalTrackerPanel::refresh); + } + @Subscribe public void onGameStateChanged(GameStateChanged event) { if (client.getGameState() != GameState.LOGGED_IN) { + warmedIcons = false; return; } @@ -220,6 +247,38 @@ public void onGameTick(GameTick event) // onGameStateChanged reports incorrect quest statuses, // so this need to be done in this subscriber goalTrackerPanel.refresh(); + + if (!warmedIcons) + { + warmItemIcons(); + warmedIcons = true; + } + + goalManager.getGoals().stream() + .flatMap(goal -> goal.getTasks().stream()) + .filter(task -> !task.getStatus().isCompleted()) + .forEach(task -> { + if (taskUpdateService.update(task)) { + if (task.getStatus().isCompleted()) { + notifyTask(task); + } + uiStatusManager.refresh(task); + } + }); + + List questTasks = goalManager.getIncompleteTasksByType(TaskType.QUEST); + for (QuestTask task : questTasks) { + if (!taskUpdateService.update(task)) continue; + + if (task.getStatus().isCompleted()) { + notifyTask(task); + } + + uiStatusManager.refresh(task); + } + + goalManager.save(); + goalTrackerPanel.refresh(); } @Subscribe @@ -238,6 +297,7 @@ public void onChatMessage(ChatMessage event) uiStatusManager.refresh(task); this.goalManager.save(); } + goalTrackerPanel.refresh(); } @Subscribe diff --git a/src/main/java/com/toofifty/goaltracker/models/ActionHistory.java b/src/main/java/com/toofifty/goaltracker/models/ActionHistory.java new file mode 100644 index 0000000..d1ec2c8 --- /dev/null +++ b/src/main/java/com/toofifty/goaltracker/models/ActionHistory.java @@ -0,0 +1,69 @@ +package com.toofifty.goaltracker.models; + +import java.util.ArrayDeque; +import java.util.Deque; + +/** + * Generic history manager for undo/redo of user actions. + * + * Usage: + * - Define actions implementing {@link ActionHistory.Action} + * - Call {@link #push(Action)} whenever an action is performed + * - Call {@link #undo()} or {@link #redo()} from UI buttons + */ +public class ActionHistory +{ + public interface Action + { + void undo(); + void redo(); + } + + private final Deque undoStack = new ArrayDeque<>(); + private final Deque redoStack = new ArrayDeque<>(); + + /** Push a new action, clearing the redo history. */ + public void push(Action action) + { + undoStack.addLast(action); + redoStack.clear(); + } + + /** Undo the most recent action, if any. */ + public void undo() + { + Action a = undoStack.pollLast(); + if (a != null) + { + a.undo(); + redoStack.addLast(a); + } + } + + /** Redo the most recently undone action, if any. */ + public void redo() + { + Action a = redoStack.pollLast(); + if (a != null) + { + a.redo(); + undoStack.addLast(a); + } + } + + public boolean hasUndo() + { + return !undoStack.isEmpty(); + } + + public boolean hasRedo() + { + return !redoStack.isEmpty(); + } + + public void clear() + { + undoStack.clear(); + redoStack.clear(); + } +} diff --git a/src/main/java/com/toofifty/goaltracker/models/Goal.java b/src/main/java/com/toofifty/goaltracker/models/Goal.java index 72f6952..dcdf50d 100644 --- a/src/main/java/com/toofifty/goaltracker/models/Goal.java +++ b/src/main/java/com/toofifty/goaltracker/models/Goal.java @@ -25,6 +25,9 @@ public class Goal @Builder.Default private int displayOrder = -1; + @Builder.Default + private boolean pinned = false; + @SerializedName("items") @Builder.Default private ReorderableList tasks = new ReorderableList<>(); @@ -57,4 +60,10 @@ public Status getStatus() { return Status.NOT_STARTED; } + + public void setAllTasksCompleted(boolean completed) { + for (Task task : tasks) { + task.setStatus(completed ? Status.COMPLETED : Status.NOT_STARTED); + } + } } diff --git a/src/main/java/com/toofifty/goaltracker/models/IndentChangeAction.java b/src/main/java/com/toofifty/goaltracker/models/IndentChangeAction.java new file mode 100644 index 0000000..357414e --- /dev/null +++ b/src/main/java/com/toofifty/goaltracker/models/IndentChangeAction.java @@ -0,0 +1,35 @@ +package com.toofifty.goaltracker.models; + +import java.util.List; +import com.toofifty.goaltracker.models.task.Task; + +/** + * Action for indenting or outdenting a task in a list. + */ +public class IndentChangeAction implements ActionHistory.Action +{ + private final List tasks; + private final Task task; + private final int oldIndent; + private final int newIndent; + + public IndentChangeAction(List tasks, Task task, int oldIndent, int newIndent) + { + this.tasks = tasks; + this.task = task; + this.oldIndent = oldIndent; + this.newIndent = newIndent; + } + + @Override + public void undo() + { + task.setIndentLevel(oldIndent); + } + + @Override + public void redo() + { + task.setIndentLevel(newIndent); + } +} diff --git a/src/main/java/com/toofifty/goaltracker/models/RemoveTaskAction.java b/src/main/java/com/toofifty/goaltracker/models/RemoveTaskAction.java new file mode 100644 index 0000000..f1138bd --- /dev/null +++ b/src/main/java/com/toofifty/goaltracker/models/RemoveTaskAction.java @@ -0,0 +1,35 @@ +package com.toofifty.goaltracker.models; + +import com.toofifty.goaltracker.models.task.Task; + +import java.util.List; + +/** + * Action for removing a task from a list. + */ +public class RemoveTaskAction implements ActionHistory.Action +{ + private final List tasks; + private final Task task; + private final int index; + + public RemoveTaskAction(List tasks, Task task, int index) + { + this.tasks = tasks; + this.task = task; + this.index = index; + } + + @Override + public void undo() + { + int insertIndex = Math.max(0, Math.min(index, tasks.size())); + tasks.add(insertIndex, task); + } + + @Override + public void redo() + { + tasks.remove(task); + } +} diff --git a/src/main/java/com/toofifty/goaltracker/models/ReorderTaskAction.java b/src/main/java/com/toofifty/goaltracker/models/ReorderTaskAction.java new file mode 100644 index 0000000..6f7c50f --- /dev/null +++ b/src/main/java/com/toofifty/goaltracker/models/ReorderTaskAction.java @@ -0,0 +1,40 @@ +package com.toofifty.goaltracker.models; + +import com.toofifty.goaltracker.models.task.Task; + +import java.util.List; + +/** + * Action for reordering a task within a list (move up/down). + */ +public class ReorderTaskAction implements ActionHistory.Action +{ + private final List tasks; + private final Task task; + private final int oldIndex; + private final int newIndex; + + public ReorderTaskAction(List tasks, Task task, int oldIndex, int newIndex) + { + this.tasks = tasks; + this.task = task; + this.oldIndex = oldIndex; + this.newIndex = newIndex; + } + + @Override + public void undo() + { + tasks.remove(task); + int insertIndex = Math.max(0, Math.min(oldIndex, tasks.size())); + tasks.add(insertIndex, task); + } + + @Override + public void redo() + { + tasks.remove(task); + int insertIndex = Math.max(0, Math.min(newIndex, tasks.size())); + tasks.add(insertIndex, task); + } +} diff --git a/src/main/java/com/toofifty/goaltracker/models/ToggleCompleteAction.java b/src/main/java/com/toofifty/goaltracker/models/ToggleCompleteAction.java new file mode 100644 index 0000000..8416722 --- /dev/null +++ b/src/main/java/com/toofifty/goaltracker/models/ToggleCompleteAction.java @@ -0,0 +1,55 @@ +package com.toofifty.goaltracker.models; + +import com.toofifty.goaltracker.models.task.Task; +import java.lang.reflect.Method; + +/** + * Action for toggling a task's completion state. + */ +public class ToggleCompleteAction implements ActionHistory.Action +{ + private final Task task; + private final boolean oldValue; + private final boolean newValue; + + public ToggleCompleteAction(Task task, boolean oldValue, boolean newValue) + { + this.task = task; + this.oldValue = oldValue; + this.newValue = newValue; + } + + @Override + public void undo() + { + setStatusByName(oldValue ? "COMPLETED" : "NOT_STARTED"); + } + + @Override + public void redo() + { + setStatusByName(newValue ? "COMPLETED" : "NOT_STARTED"); + } + + /** + * Set task status by enum name without importing the enum type. + * This works whether Status is a nested enum or a top-level type. + */ + private void setStatusByName(String name) + { + try + { + Object current = task.getStatus(); + Class enumClass = current.getClass(); + @SuppressWarnings({"unchecked","rawtypes"}) + Enum newStatus = Enum.valueOf((Class) enumClass, name); + + Method m = task.getClass().getMethod("setStatus", enumClass); + m.invoke(task, newStatus); + } + catch (Exception ignored) + { + // If we can't reflectively set it, ignore rather than crash. + } + } +} diff --git a/src/main/java/com/toofifty/goaltracker/models/UndoStack.java b/src/main/java/com/toofifty/goaltracker/models/UndoStack.java new file mode 100644 index 0000000..7a561be --- /dev/null +++ b/src/main/java/com/toofifty/goaltracker/models/UndoStack.java @@ -0,0 +1,100 @@ +package com.toofifty.goaltracker.models; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.Objects; + +/** + * Generic undo/redo stack for removed items (e.g., Goals, Tasks). + * + * Flow: + * - When an item is removed from a list, call {@link #pushRemove(Object, int)}. + * This records the item and its original index, and clears the redo stack. + * - To undo the removal: call {@link #popForUndo()} and re-insert the item at the + * returned index. The entry is moved to the redo stack so it can be re-applied. + * - To redo the removal (only valid after an undo): call {@link #popForRedo()} and + * remove that item again. The entry moves back to the undo stack. + */ +public final class UndoStack +{ + public static final class RemovedEntry + { + private final T item; + private final int index; + + public RemovedEntry(T item, int index) + { + this.item = Objects.requireNonNull(item, "item"); + this.index = index; + } + + public T getItem() + { + return item; + } + + public int getIndex() + { + return index; + } + } + + private final Deque> undo = new ArrayDeque<>(); + private final Deque> redo = new ArrayDeque<>(); + + /** Record a removal and clear redo history. */ + public void pushRemove(T item, int index) + { + undo.addLast(new RemovedEntry<>(item, index)); + redo.clear(); + } + + /** + * Prepare to UNDO the most-recent removal. + * + * Moves the entry from the undo stack to the redo stack and returns it. + * Caller should re-insert the item at {@code getIndex()}. + */ + public RemovedEntry popForUndo() + { + RemovedEntry e = undo.pollLast(); + if (e != null) + { + redo.addLast(e); + } + return e; + } + + /** + * Prepare to REDO the most-recent undo (i.e., remove again). + * + * Moves the entry from the redo stack back to the undo stack and returns it. + * Caller should remove the item again. + */ + public RemovedEntry popForRedo() + { + RemovedEntry e = redo.pollLast(); + if (e != null) + { + undo.addLast(e); + } + return e; + } + + public boolean hasUndo() + { + return !undo.isEmpty(); + } + + public boolean hasRedo() + { + return !redo.isEmpty(); + } + + /** Clears all history. */ + public void clear() + { + undo.clear(); + redo.clear(); + } +} diff --git a/src/main/java/com/toofifty/goaltracker/models/task/ItemTask.java b/src/main/java/com/toofifty/goaltracker/models/task/ItemTask.java index e002232..448481f 100644 --- a/src/main/java/com/toofifty/goaltracker/models/task/ItemTask.java +++ b/src/main/java/com/toofifty/goaltracker/models/task/ItemTask.java @@ -11,7 +11,8 @@ @Setter @Getter @SuperBuilder -public class ItemTask extends Task +public class +ItemTask extends Task { private transient BufferedImage cachedIcon; private int quantity; diff --git a/src/main/java/com/toofifty/goaltracker/presets/GoalPresetRepository.java b/src/main/java/com/toofifty/goaltracker/presets/GoalPresetRepository.java new file mode 100644 index 0000000..b74658d --- /dev/null +++ b/src/main/java/com/toofifty/goaltracker/presets/GoalPresetRepository.java @@ -0,0 +1,581 @@ +package com.toofifty.goaltracker.presets; + +import com.toofifty.goaltracker.models.Goal; +import com.toofifty.goaltracker.models.task.ItemTask; +import com.toofifty.goaltracker.models.task.QuestTask; +import com.toofifty.goaltracker.models.task.SkillLevelTask; +import com.toofifty.goaltracker.utils.ReorderableList; +import lombok.Getter; +import net.runelite.api.Quest; +import net.runelite.api.Skill; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static net.runelite.api.ItemID.*; + +public class GoalPresetRepository { + + @Getter + public static final class Preset { + private final String name; + private final String description; + private final List goals; + public Preset(String name, String description, List goals) { + this.name = name; + this.description = description; + this.goals = goals; + } + + @Override public String toString() { return name; } + } + + public static List getAll() { + List list = new ArrayList<>(); + // list.add(buildLadlorIronman()); + // list.add(buildLadlorMelee()); + // list.add(buildLadlorRanged()); + // list.add(buildLadlorMagic()); + // list.add(buildLadlorUtility()); + // list.add(buildLadlorVoidSet()); + // list.add(buildLadlorGodCapes()); + // list.add(buildLadlorCrystalBowfa()); + // list.add(buildLadlorJewelryBoots()); + // list.add(buildLadlorSlayerUndead()); + // list.add(buildLadlorImbuedRings()); + // list.add(buildLadlorGwdArmor()); + // list.add(buildLadlorEliteVoid()); + // list.add(buildLadlorPrayerScrolls()); + // list.add(buildLadlorRaidsUniques()); + // list.add(buildLadlorMilestones()); + list.add(buildEarlyIronman()); + list.add(buildMidIronman()); + list.add(buildLateIronman()); + return list; + } + + /* + private static Preset buildLadlorIronman() { + // Melee lane + Goal melee = Goal.builder() + .description("Ladlor — Melee Progression") + .tasks(ReorderableList.from( + ItemTask.builder().itemId(DRAGON_SCIMITAR).itemName("Dragon scimitar").quantity(1).build(), + ItemTask.builder().itemId(DRAGON_DEFENDER).itemName("Dragon defender").quantity(1).build(), + ItemTask.builder().itemId(FIGHTER_TORSO).itemName("Fighter torso").quantity(1).build(), + ItemTask.builder().itemId(DRAGON_BOOTS).itemName("Dragon boots").quantity(1).build(), + ItemTask.builder().itemId(ABYSSAL_WHIP).itemName("Abyssal whip").quantity(1).build(), + ItemTask.builder().itemId(BERSERKER_RING).itemName("Berserker ring").quantity(1).build(), + ItemTask.builder().itemId(FIRE_CAPE).itemName("Fire cape").quantity(1).build(), + ItemTask.builder().itemId(FEROCIOUS_GLOVES).itemName("Ferocious gloves").quantity(1).build(), + ItemTask.builder().itemId(ABYSSAL_TENTACLE).itemName("Abyssal tentacle").quantity(1).build() + )) + .build(); + + // Ranged lane + Goal ranged = Goal.builder() + .description("Ladlor — Ranged Progression") + .tasks(ReorderableList.from( + ItemTask.builder().itemId(MAGIC_SHORTBOW).itemName("Magic shortbow").quantity(1).build(), + ItemTask.builder().itemId(RUNE_CROSSBOW).itemName("Rune crossbow").quantity(1).build(), + ItemTask.builder().itemId(BLACK_DHIDE_BODY).itemName("Black d'hide body").quantity(1).build(), + ItemTask.builder().itemId(AVAS_ACCUMULATOR).itemName("Ava's accumulator").quantity(1).build(), + ItemTask.builder().itemId(KARILS_LEATHERTOP).itemName("Karil's leathertop").quantity(1).build(), + ItemTask.builder().itemId(KARILS_LEATHERSKIRT).itemName("Karil's leatherskirt").quantity(1).build(), + ItemTask.builder().itemId(TOXIC_BLOWPIPE).itemName("Toxic blowpipe").quantity(1).build(), + ItemTask.builder().itemId(ARMADYL_CROSSBOW).itemName("Armadyl crossbow").quantity(1).build(), + ItemTask.builder().itemId(AVAS_ASSEMBLER).itemName("Ava's assembler").quantity(1).build() + )) + .build(); + + // Magic lane + Goal magic = Goal.builder() + .description("Ladlor — Magic Progression") + .tasks(ReorderableList.from( + ItemTask.builder().itemId(MYSTIC_ROBE_TOP).itemName("Mystic robe top").quantity(1).build(), + ItemTask.builder().itemId(MYSTIC_ROBE_BOTTOM).itemName("Mystic robe bottom").quantity(1).build(), + ItemTask.builder().itemId(OCCULT_NECKLACE).itemName("Occult necklace").quantity(1).build(), + ItemTask.builder().itemId(MAGES_BOOK).itemName("Mage's book").quantity(1).build(), + ItemTask.builder().itemId(TRIDENT_OF_THE_SEAS).itemName("Trident of the seas").quantity(1).build(), + ItemTask.builder().itemId(UNCHARGED_TOXIC_TRIDENT).itemName("Trident of the swamp").quantity(1).build(), + ItemTask.builder().itemId(ANCESTRAL_ROBE_TOP).itemName("Ancestral robe top").quantity(1).build(), + ItemTask.builder().itemId(ANCESTRAL_ROBE_BOTTOM).itemName("Ancestral robe bottom").quantity(1).build(), + ItemTask.builder().itemId(ANCESTRAL_HAT).itemName("Ancestral hat").quantity(1).build() + )) + .build(); + + // Utility / Unlocks lane + Goal utility = Goal.builder() + .description("Ladlor — Utility & Unlocks") + .tasks(ReorderableList.from( + ItemTask.builder().itemId(GRACEFUL_HOOD).itemName("Graceful hood").quantity(1).build(), + ItemTask.builder().itemId(GRACEFUL_TOP).itemName("Graceful top").quantity(1).build(), + ItemTask.builder().itemId(GRACEFUL_LEGS).itemName("Graceful legs").quantity(1).build(), + ItemTask.builder().itemId(GRACEFUL_GLOVES).itemName("Graceful gloves").quantity(1).build(), + ItemTask.builder().itemId(GRACEFUL_BOOTS).itemName("Graceful boots").quantity(1).build(), + ItemTask.builder().itemId(GRACEFUL_CAPE).itemName("Graceful cape").quantity(1).build(), + ItemTask.builder().itemId(BARROWS_GLOVES).itemName("Barrows gloves").quantity(1).build() + )) + .build(); + + // Void set lane + Goal voidSet = Goal.builder() + .description("Ladlor — Void Set") + .tasks(ReorderableList.from( + ItemTask.builder().itemId(VOID_KNIGHT_TOP).itemName("Void knight top").quantity(1).build(), + ItemTask.builder().itemId(VOID_KNIGHT_ROBE).itemName("Void knight robe").quantity(1).build(), + ItemTask.builder().itemId(VOID_KNIGHT_GLOVES).itemName("Void knight gloves").quantity(1).build(), + ItemTask.builder().itemId(VOID_MAGE_HELM).itemName("Void mage helm").quantity(1).build(), + ItemTask.builder().itemId(VOID_RANGER_HELM).itemName("Void ranger helm").quantity(1).build(), + ItemTask.builder().itemId(VOID_MELEE_HELM).itemName("Void melee helm").quantity(1).build() + )) + .build(); + + // God capes imbued lane + Goal godCapes = Goal.builder() + .description("Ladlor — Imbued God Capes") + .tasks(ReorderableList.from( + ItemTask.builder().itemId(IMBUED_SARADOMIN_CAPE).itemName("Imbued Saradomin cape").quantity(1).build(), + ItemTask.builder().itemId(IMBUED_GUTHIX_CAPE).itemName("Imbued Guthix cape").quantity(1).build(), + ItemTask.builder().itemId(IMBUED_ZAMORAK_CAPE).itemName("Imbued Zamorak cape").quantity(1).build() + )) + .build(); + + // Crystal / Bofa lane + Goal crystal = Goal.builder() + .description("Ladlor — Crystal & Bowfa") + .tasks(ReorderableList.from( + ItemTask.builder().itemId(CRYSTAL_HELM).itemName("Crystal helm").quantity(1).build(), + ItemTask.builder().itemId(CRYSTAL_BODY).itemName("Crystal body").quantity(1).build(), + ItemTask.builder().itemId(CRYSTAL_LEGS).itemName("Crystal legs").quantity(1).build(), + ItemTask.builder().itemId(BOW_OF_FAERDHINEN).itemName("Bow of Faerdhinen").quantity(1).build() + )) + .build(); + + // Jewelry / Boots upgrades lane + Goal jewelry = Goal.builder() + .description("Ladlor — Jewelry & Boots Upgrades") + .tasks(ReorderableList.from( + ItemTask.builder().itemId(AMULET_OF_TORTURE).itemName("Amulet of torture").quantity(1).build(), + ItemTask.builder().itemId(NECKLACE_OF_ANGUISH).itemName("Necklace of anguish").quantity(1).build(), + ItemTask.builder().itemId(PEGASIAN_BOOTS).itemName("Pegasian boots").quantity(1).build(), + ItemTask.builder().itemId(RANGER_BOOTS).itemName("Ranger boots").quantity(1).build(), + ItemTask.builder().itemId(PRIMORDIAL_BOOTS).itemName("Primordial boots").quantity(1).build(), + ItemTask.builder().itemId(ETERNAL_BOOTS).itemName("Eternal boots").quantity(1).build() + )) + .build(); + + // Slayer & Undead lane + Goal slayerUndead = Goal.builder() + .description("Ladlor — Slayer & Undead") + .tasks(ReorderableList.from( + ItemTask.builder().itemId(BLACK_MASK).itemName("Black mask").quantity(1).build(), + ItemTask.builder().itemId(SLAYER_HELMET).itemName("Slayer helmet").quantity(1).build(), + ItemTask.builder().itemId(SLAYER_HELMET_I).itemName("Slayer helmet (i)").quantity(1).build(), + ItemTask.builder().itemId(SALVE_AMULET_E).itemName("Salve amulet (e)").quantity(1).build(), + ItemTask.builder().itemId(SALVE_AMULET_EI).itemName("Salve amulet (ei)").quantity(1).build(), + ItemTask.builder().itemId(IMBUED_HEART).itemName("Imbued heart").quantity(1).build(), + ItemTask.builder().itemId(RUNE_POUCH).itemName("Rune pouch").quantity(1).build(), + ItemTask.builder().itemId(RING_OF_SUFFERING).itemName("Ring of suffering").quantity(1).build(), + ItemTask.builder().itemId(RING_OF_SUFFERING_I).itemName("Ring of suffering (i)").quantity(1).build() + )) + .build(); + + // Imbued Rings lane + Goal imbuedRings = Goal.builder() + .description("Ladlor — Imbued Rings") + .tasks(ReorderableList.from( + ItemTask.builder().itemId(BERSERKER_RING_I).itemName("Berserker ring (i)").quantity(1).build(), + ItemTask.builder().itemId(ARCHERS_RING_I).itemName("Archer's ring (i)").quantity(1).build(), + ItemTask.builder().itemId(SEERS_RING_I).itemName("Seers ring (i)").quantity(1).build(), + ItemTask.builder().itemId(WARRIOR_RING_I).itemName("Warrior ring (i)").quantity(1).build() + )) + .build(); + + // God Wars Dungeon Armor lane + Goal gwdArmor = Goal.builder() + .description("Ladlor — God Wars Armor") + .tasks(ReorderableList.from( + ItemTask.builder().itemId(BANDOS_CHESTPLATE).itemName("Bandos chestplate").quantity(1).build(), + ItemTask.builder().itemId(BANDOS_TASSETS).itemName("Bandos tassets").quantity(1).build(), + ItemTask.builder().itemId(ARMADYL_CHESTPLATE).itemName("Armadyl chestplate").quantity(1).build(), + ItemTask.builder().itemId(ARMADYL_CHAINSKIRT).itemName("Armadyl chainskirt").quantity(1).build() + )) + .build(); + + // Elite Void upgrades lane + Goal eliteVoid = Goal.builder() + .description("Ladlor — Elite Void") + .tasks(ReorderableList.from( + ItemTask.builder().itemId(ELITE_VOID_TOP).itemName("Elite void top").quantity(1).build(), + ItemTask.builder().itemId(ELITE_VOID_ROBE).itemName("Elite void robe").quantity(1).build() + )) + .build(); + + // Prayer scrolls lane (Rigour & Augury) + Goal prayerScrolls = Goal.builder() + .description("Ladlor — Prayer Scrolls") + .tasks(ReorderableList.from( + ItemTask.builder().itemId(DEXTEROUS_PRAYER_SCROLL).itemName("Dexterous prayer scroll (Rigour)").quantity(1).build(), + ItemTask.builder().itemId(ARCANE_PRAYER_SCROLL).itemName("Arcane prayer scroll (Augury)").quantity(1).build() + )) + .build(); + + // Raids uniques lane + Goal raidsUniques = Goal.builder() + .description("Ladlor — Raids Uniques") + .tasks(ReorderableList.from( + ItemTask.builder().itemId(DRAGON_HUNTER_CROSSBOW).itemName("Dragon hunter crossbow").quantity(1).build(), + ItemTask.builder().itemId(TWISTED_BOW).itemName("Twisted bow").quantity(1).build(), + ItemTask.builder().itemId(SCYTHE_OF_VITUR).itemName("Scythe of Vitur").quantity(1).build(), + ItemTask.builder().itemId(GHRAZI_RAPIER).itemName("Ghrazi rapier").quantity(1).build(), + ItemTask.builder().itemId(OSMUMTENS_FANG).itemName("Osmumten's fang").quantity(1).build(), + ItemTask.builder().itemId(MASORI_BODY).itemName("Masori body").quantity(1).build(), + ItemTask.builder().itemId(MASORI_CHAPS).itemName("Masori chaps").quantity(1).build(), + ItemTask.builder().itemId(TUMEKENS_SHADOW).itemName("Tumeken's shadow").quantity(1).build() + )) + .build(); + + // Boss / cape milestones (quests mixed in) + Goal milestones = Goal.builder() + .description("Ladlor — Milestones & Capes") + .tasks(ReorderableList.from( + ItemTask.builder().itemId(FIRE_CAPE).itemName("Fire cape").quantity(1).build(), + QuestTask.builder().quest(Quest.SONG_OF_THE_ELVES).build(), + QuestTask.builder().quest(Quest.SINS_OF_THE_FATHER).build() + )) + .build(); + + List goals = Arrays.asList( + melee, ranged, magic, utility, + voidSet, eliteVoid, godCapes, crystal, jewelry, + slayerUndead, imbuedRings, gwdArmor, prayerScrolls, raidsUniques, + milestones + ); + return new Preset( + "Ladlor Ironman Progression", + "A multi-lane item-based progression inspired by Ladlor's Ironman chart.", + goals + ); + } + private static Preset buildLadlorMelee() { + Goal melee = Goal.builder() + .description("Ladlor — Melee Progression") + .tasks(ReorderableList.from( + ItemTask.builder().itemId(DRAGON_SCIMITAR).itemName("Dragon scimitar").quantity(1).build(), + ItemTask.builder().itemId(DRAGON_DEFENDER).itemName("Dragon defender").quantity(1).build(), + ItemTask.builder().itemId(FIGHTER_TORSO).itemName("Fighter torso").quantity(1).build(), + ItemTask.builder().itemId(DRAGON_BOOTS).itemName("Dragon boots").quantity(1).build(), + ItemTask.builder().itemId(ABYSSAL_WHIP).itemName("Abyssal whip").quantity(1).build(), + ItemTask.builder().itemId(BERSERKER_RING).itemName("Berserker ring").quantity(1).build(), + ItemTask.builder().itemId(FIRE_CAPE).itemName("Fire cape").quantity(1).build(), + ItemTask.builder().itemId(FEROCIOUS_GLOVES).itemName("Ferocious gloves").quantity(1).build(), + ItemTask.builder().itemId(ABYSSAL_TENTACLE).itemName("Abyssal tentacle").quantity(1).build() + )) + .build(); + return new Preset("Ladlor — Melee", "Melee progression lane from Ladlor chart.", Arrays.asList(melee)); + } + + private static Preset buildLadlorRanged() { + Goal ranged = Goal.builder() + .description("Ladlor — Ranged Progression") + .tasks(ReorderableList.from( + ItemTask.builder().itemId(MAGIC_SHORTBOW).itemName("Magic shortbow").quantity(1).build(), + ItemTask.builder().itemId(RUNE_CROSSBOW).itemName("Rune crossbow").quantity(1).build(), + ItemTask.builder().itemId(BLACK_DHIDE_BODY).itemName("Black d'hide body").quantity(1).build(), + ItemTask.builder().itemId(AVAS_ACCUMULATOR).itemName("Ava's accumulator").quantity(1).build(), + ItemTask.builder().itemId(KARILS_LEATHERTOP).itemName("Karil's leathertop").quantity(1).build(), + ItemTask.builder().itemId(KARILS_LEATHERSKIRT).itemName("Karil's leatherskirt").quantity(1).build(), + ItemTask.builder().itemId(TOXIC_BLOWPIPE).itemName("Toxic blowpipe").quantity(1).build(), + ItemTask.builder().itemId(ARMADYL_CROSSBOW).itemName("Armadyl crossbow").quantity(1).build(), + ItemTask.builder().itemId(AVAS_ASSEMBLER).itemName("Ava's assembler").quantity(1).build() + )) + .build(); + return new Preset("Ladlor — Ranged", "Ranged progression lane from Ladlor chart.", Arrays.asList(ranged)); + } + + private static Preset buildLadlorMagic() { + Goal magic = Goal.builder() + .description("Ladlor — Magic Progression") + .tasks(ReorderableList.from( + ItemTask.builder().itemId(MYSTIC_ROBE_TOP).itemName("Mystic robe top").quantity(1).build(), + ItemTask.builder().itemId(MYSTIC_ROBE_BOTTOM).itemName("Mystic robe bottom").quantity(1).build(), + ItemTask.builder().itemId(OCCULT_NECKLACE).itemName("Occult necklace").quantity(1).build(), + ItemTask.builder().itemId(MAGES_BOOK).itemName("Mage's book").quantity(1).build(), + ItemTask.builder().itemId(TRIDENT_OF_THE_SEAS).itemName("Trident of the seas").quantity(1).build(), + ItemTask.builder().itemId(UNCHARGED_TOXIC_TRIDENT).itemName("Trident of the swamp").quantity(1).build(), + ItemTask.builder().itemId(ANCESTRAL_ROBE_TOP).itemName("Ancestral robe top").quantity(1).build(), + ItemTask.builder().itemId(ANCESTRAL_ROBE_BOTTOM).itemName("Ancestral robe bottom").quantity(1).build(), + ItemTask.builder().itemId(ANCESTRAL_HAT).itemName("Ancestral hat").quantity(1).build() + )) + .build(); + return new Preset("Ladlor — Magic", "Magic progression lane from Ladlor chart.", Arrays.asList(magic)); + } + + private static Preset buildLadlorUtility() { + Goal utility = Goal.builder() + .description("Ladlor — Utility & Unlocks") + .tasks(ReorderableList.from( + ItemTask.builder().itemId(GRACEFUL_HOOD).itemName("Graceful hood").quantity(1).build(), + ItemTask.builder().itemId(GRACEFUL_TOP).itemName("Graceful top").quantity(1).build(), + ItemTask.builder().itemId(GRACEFUL_LEGS).itemName("Graceful legs").quantity(1).build(), + ItemTask.builder().itemId(GRACEFUL_GLOVES).itemName("Graceful gloves").quantity(1).build(), + ItemTask.builder().itemId(GRACEFUL_BOOTS).itemName("Graceful boots").quantity(1).build(), + ItemTask.builder().itemId(GRACEFUL_CAPE).itemName("Graceful cape").quantity(1).build(), + ItemTask.builder().itemId(BARROWS_GLOVES).itemName("Barrows gloves").quantity(1).build() + )) + .build(); + return new Preset("Ladlor — Utility & Unlocks", "Utility, movement and QoL lane from Ladlor chart.", Arrays.asList(utility)); + } + + private static Preset buildLadlorVoidSet() { + Goal voidSet = Goal.builder() + .description("Ladlor — Void Set") + .tasks(ReorderableList.from( + ItemTask.builder().itemId(VOID_KNIGHT_TOP).itemName("Void knight top").quantity(1).build(), + ItemTask.builder().itemId(VOID_KNIGHT_ROBE).itemName("Void knight robe").quantity(1).build(), + ItemTask.builder().itemId(VOID_KNIGHT_GLOVES).itemName("Void knight gloves").quantity(1).build(), + ItemTask.builder().itemId(VOID_MAGE_HELM).itemName("Void mage helm").quantity(1).build(), + ItemTask.builder().itemId(VOID_RANGER_HELM).itemName("Void ranger helm").quantity(1).build(), + ItemTask.builder().itemId(VOID_MELEE_HELM).itemName("Void melee helm").quantity(1).build() + )) + .build(); + return new Preset("Ladlor — Void Set", "Standard Void set lane from Ladlor chart.", Arrays.asList(voidSet)); + } + + private static Preset buildLadlorGodCapes() { + Goal godCapes = Goal.builder() + .description("Ladlor — Imbued God Capes") + .tasks(ReorderableList.from( + ItemTask.builder().itemId(IMBUED_SARADOMIN_CAPE).itemName("Imbued Saradomin cape").quantity(1).build(), + ItemTask.builder().itemId(IMBUED_GUTHIX_CAPE).itemName("Imbued Guthix cape").quantity(1).build(), + ItemTask.builder().itemId(IMBUED_ZAMORAK_CAPE).itemName("Imbued Zamorak cape").quantity(1).build() + )) + .build(); + return new Preset("Ladlor — Imbued God Capes", "Mage Arena II imbued god capes.", Arrays.asList(godCapes)); + } + + private static Preset buildLadlorCrystalBowfa() { + Goal crystal = Goal.builder() + .description("Ladlor — Crystal & Bowfa") + .tasks(ReorderableList.from( + ItemTask.builder().itemId(CRYSTAL_HELM).itemName("Crystal helm").quantity(1).build(), + ItemTask.builder().itemId(CRYSTAL_BODY).itemName("Crystal body").quantity(1).build(), + ItemTask.builder().itemId(CRYSTAL_LEGS).itemName("Crystal legs").quantity(1).build(), + ItemTask.builder().itemId(BOW_OF_FAERDHINEN).itemName("Bow of Faerdhinen").quantity(1).build() + )) + .build(); + return new Preset("Ladlor — Crystal & Bowfa", "Crystal armour and Bow of Faerdhinen lane.", Arrays.asList(crystal)); + } + + private static Preset buildLadlorJewelryBoots() { + Goal jewelry = Goal.builder() + .description("Ladlor — Jewelry & Boots Upgrades") + .tasks(ReorderableList.from( + ItemTask.builder().itemId(AMULET_OF_TORTURE).itemName("Amulet of torture").quantity(1).build(), + ItemTask.builder().itemId(NECKLACE_OF_ANGUISH).itemName("Necklace of anguish").quantity(1).build(), + ItemTask.builder().itemId(PEGASIAN_BOOTS).itemName("Pegasian boots").quantity(1).build(), + ItemTask.builder().itemId(RANGER_BOOTS).itemName("Ranger boots").quantity(1).build(), + ItemTask.builder().itemId(PRIMORDIAL_BOOTS).itemName("Primordial boots").quantity(1).build(), + ItemTask.builder().itemId(ETERNAL_BOOTS).itemName("Eternal boots").quantity(1).build() + )) + .build(); + return new Preset("Ladlor — Jewelry & Boots", "Jewelry and footwear upgrades lane.", Arrays.asList(jewelry)); + } + + private static Preset buildLadlorSlayerUndead() { + Goal slayerUndead = Goal.builder() + .description("Ladlor — Slayer & Undead") + .tasks(ReorderableList.from( + ItemTask.builder().itemId(BLACK_MASK).itemName("Black mask").quantity(1).build(), + ItemTask.builder().itemId(SLAYER_HELMET).itemName("Slayer helmet").quantity(1).build(), + ItemTask.builder().itemId(SLAYER_HELMET_I).itemName("Slayer helmet (i)").quantity(1).build(), + ItemTask.builder().itemId(SALVE_AMULET_E).itemName("Salve amulet (e)").quantity(1).build(), + ItemTask.builder().itemId(SALVE_AMULET_EI).itemName("Salve amulet (ei)").quantity(1).build(), + ItemTask.builder().itemId(IMBUED_HEART).itemName("Imbued heart").quantity(1).build(), + ItemTask.builder().itemId(RUNE_POUCH).itemName("Rune pouch").quantity(1).build(), + ItemTask.builder().itemId(RING_OF_SUFFERING).itemName("Ring of suffering").quantity(1).build(), + ItemTask.builder().itemId(RING_OF_SUFFERING_I).itemName("Ring of suffering (i)").quantity(1).build() + )) + .build(); + return new Preset("Ladlor — Slayer & Undead", "Slayer and undead-focused unlocks.", Arrays.asList(slayerUndead)); + } + + private static Preset buildLadlorImbuedRings() { + Goal imbuedRings = Goal.builder() + .description("Ladlor — Imbued Rings") + .tasks(ReorderableList.from( + ItemTask.builder().itemId(BERSERKER_RING_I).itemName("Berserker ring (i)").quantity(1).build(), + ItemTask.builder().itemId(ARCHERS_RING_I).itemName("Archer's ring (i)").quantity(1).build(), + ItemTask.builder().itemId(SEERS_RING_I).itemName("Seers ring (i)").quantity(1).build(), + ItemTask.builder().itemId(WARRIOR_RING_I).itemName("Warrior ring (i)").quantity(1).build() + )) + .build(); + return new Preset("Ladlor — Imbued Rings", "Imbued Dagannoth Kings rings.", Arrays.asList(imbuedRings)); + } + + private static Preset buildLadlorGwdArmor() { + Goal gwdArmor = Goal.builder() + .description("Ladlor — God Wars Armor") + .tasks(ReorderableList.from( + ItemTask.builder().itemId(BANDOS_CHESTPLATE).itemName("Bandos chestplate").quantity(1).build(), + ItemTask.builder().itemId(BANDOS_TASSETS).itemName("Bandos tassets").quantity(1).build(), + ItemTask.builder().itemId(ARMADYL_CHESTPLATE).itemName("Armadyl chestplate").quantity(1).build(), + ItemTask.builder().itemId(ARMADYL_CHAINSKIRT).itemName("Armadyl chainskirt").quantity(1).build() + )) + .build(); + return new Preset("Ladlor — God Wars Armor", "Bandos and Armadyl armour lane.", Arrays.asList(gwdArmor)); + } + + private static Preset buildLadlorEliteVoid() { + Goal eliteVoid = Goal.builder() + .description("Ladlor — Elite Void") + .tasks(ReorderableList.from( + ItemTask.builder().itemId(ELITE_VOID_TOP).itemName("Elite void top").quantity(1).build(), + ItemTask.builder().itemId(ELITE_VOID_ROBE).itemName("Elite void robe").quantity(1).build() + )) + .build(); + return new Preset("Ladlor — Elite Void", "Elite void upgrade lane.", Arrays.asList(eliteVoid)); + } + + private static Preset buildLadlorPrayerScrolls() { + Goal prayerScrolls = Goal.builder() + .description("Ladlor — Prayer Scrolls") + .tasks(ReorderableList.from( + ItemTask.builder().itemId(DEXTEROUS_PRAYER_SCROLL).itemName("Dexterous prayer scroll (Rigour)").quantity(1).build(), + ItemTask.builder().itemId(ARCANE_PRAYER_SCROLL).itemName("Arcane prayer scroll (Augury)").quantity(1).build() + )) + .build(); + return new Preset("Ladlor — Prayer Scrolls", "Rigour and Augury unlock scrolls.", Arrays.asList(prayerScrolls)); + } + + private static Preset buildLadlorRaidsUniques() { + Goal raidsUniques = Goal.builder() + .description("Ladlor — Raids Uniques") + .tasks(ReorderableList.from( + ItemTask.builder().itemId(DRAGON_HUNTER_CROSSBOW).itemName("Dragon hunter crossbow").quantity(1).build(), + ItemTask.builder().itemId(TWISTED_BOW).itemName("Twisted bow").quantity(1).build(), + ItemTask.builder().itemId(SCYTHE_OF_VITUR).itemName("Scythe of Vitur").quantity(1).build(), + ItemTask.builder().itemId(GHRAZI_RAPIER).itemName("Ghrazi rapier").quantity(1).build(), + ItemTask.builder().itemId(OSMUMTENS_FANG).itemName("Osmumten's fang").quantity(1).build(), + ItemTask.builder().itemId(MASORI_BODY).itemName("Masori body").quantity(1).build(), + ItemTask.builder().itemId(MASORI_CHAPS).itemName("Masori chaps").quantity(1).build(), + ItemTask.builder().itemId(TUMEKENS_SHADOW).itemName("Tumeken's shadow").quantity(1).build() + )) + .build(); + return new Preset("Ladlor — Raids Uniques", "Raids 1/2/3 uniques lane.", Arrays.asList(raidsUniques)); + } + + private static Preset buildLadlorMilestones() { + Goal milestones = Goal.builder() + .description("Ladlor — Milestones & Capes") + .tasks(ReorderableList.from( + ItemTask.builder().itemId(FIRE_CAPE).itemName("Fire cape").quantity(1).build(), + QuestTask.builder().quest(Quest.SONG_OF_THE_ELVES).build(), + QuestTask.builder().quest(Quest.SINS_OF_THE_FATHER).build() + )) + .build(); + return new Preset("Ladlor — Milestones & Capes", "Key capes and late-game quest milestones.", Arrays.asList(milestones)); + } + */ + + private static Preset buildEarlyIronman() { + Goal early = Goal.builder() + .description("Early Ironman Progression") + .tasks(ReorderableList.from( + // Core Early Skills (unlock movement & teleports) + SkillLevelTask.builder().skill(Skill.AGILITY).level(30).build(), + SkillLevelTask.builder().skill(Skill.MAGIC).level(37).build(), // Falador/Camelot teles + SkillLevelTask.builder().skill(Skill.THIEVING).level(20).build(), + // Early set-up gear + ItemTask.builder().itemId(GRACEFUL_HOOD).itemName("Graceful hood").quantity(1).build(), + ItemTask.builder().itemId(GRACEFUL_TOP).itemName("Graceful top").quantity(1).build(), + ItemTask.builder().itemId(GRACEFUL_LEGS).itemName("Graceful legs").quantity(1).build(), + ItemTask.builder().itemId(GRACEFUL_GLOVES).itemName("Graceful gloves").quantity(1).build(), + ItemTask.builder().itemId(GRACEFUL_BOOTS).itemName("Graceful boots").quantity(1).build(), + ItemTask.builder().itemId(GRACEFUL_CAPE).itemName("Graceful cape").quantity(1).build(), + // Foundational quests for stats/unlocks + QuestTask.builder().quest(Quest.DRUIDIC_RITUAL).build(), + QuestTask.builder().quest(Quest.WATERFALL_QUEST).build(), + QuestTask.builder().quest(Quest.PRIEST_IN_PERIL).build(), + QuestTask.builder().quest(Quest.TREE_GNOME_VILLAGE).build(), + QuestTask.builder().quest(Quest.THE_GRAND_TREE).build(), + // Birdhouse runs (Fossil Island access) + QuestTask.builder().quest(Quest.THE_DIG_SITE).build(), + QuestTask.builder().quest(Quest.BONE_VOYAGE).build(), + SkillLevelTask.builder().skill(Skill.HUNTER).level(5).build(), + SkillLevelTask.builder().skill(Skill.CRAFTING).level(8).build(), // clockwork + SkillLevelTask.builder().skill(Skill.CONSTRUCTION).level(16).build(), // clockmaker's bench + + // Seaweed runs (Giant seaweed patches on Fossil Island) + SkillLevelTask.builder().skill(Skill.FARMING).level(23).build() + )) + .build(); + return new Preset("Early Ironman Progression", "Stats, gear, and quest goals for early game Ironman.", Arrays.asList(early)); + } + + private static Preset buildMidIronman() { + Goal mid = Goal.builder() + .description("Mid Ironman Progression") + .tasks(ReorderableList.from( + // Midgame Skill Targets + SkillLevelTask.builder().skill(Skill.ATTACK).level(60).build(), + SkillLevelTask.builder().skill(Skill.STRENGTH).level(60).build(), + SkillLevelTask.builder().skill(Skill.DEFENCE).level(60).build(), + SkillLevelTask.builder().skill(Skill.RANGED).level(60).build(), + SkillLevelTask.builder().skill(Skill.MAGIC).level(55).build(), // High Alch + SkillLevelTask.builder().skill(Skill.PRAYER).level(43).build(), // Protect prayers + SkillLevelTask.builder().skill(Skill.AGILITY).level(60).build(), + // Midgame Gear & Upgrades + ItemTask.builder().itemId(DRAGON_SCIMITAR).itemName("Dragon scimitar").quantity(1).build(), + ItemTask.builder().itemId(DRAGON_DEFENDER).itemName("Dragon defender").quantity(1).build(), + ItemTask.builder().itemId(FIGHTER_TORSO).itemName("Fighter torso").quantity(1).build(), + ItemTask.builder().itemId(RUNE_CROSSBOW).itemName("Rune crossbow").quantity(1).build(), + ItemTask.builder().itemId(IBANS_STAFF_U).itemName("Iban's staff (u)").quantity(1).build(), + ItemTask.builder().itemId(BARROWS_GLOVES).itemName("Barrows gloves").quantity(1).build(), + ItemTask.builder().itemId(AVAS_ACCUMULATOR).itemName("Ava's accumulator").quantity(1).build(), + // Midgame Quests / Unlocks + QuestTask.builder().quest(Quest.RECIPE_FOR_DISASTER).build(), + QuestTask.builder().quest(Quest.FAIRYTALE_II__CURE_A_QUEEN).build(), // Fairy rings access (partial) + QuestTask.builder().quest(Quest.LUNAR_DIPLOMACY).build(), + QuestTask.builder().quest(Quest.ANIMAL_MAGNETISM).build(), + QuestTask.builder().quest(Quest.UNDERGROUND_PASS).build() + )) + .build(); + return new Preset("Mid Ironman Progression", "Stats, gear, and quest goals for mid game Ironman.", Arrays.asList(mid)); + } + + private static Preset buildLateIronman() { + Goal late = Goal.builder() + .description("Late Ironman Progression") + .tasks(ReorderableList.from( + // Late Skill Targets + SkillLevelTask.builder().skill(Skill.ATTACK).level(85).build(), + SkillLevelTask.builder().skill(Skill.STRENGTH).level(85).build(), + SkillLevelTask.builder().skill(Skill.DEFENCE).level(85).build(), + SkillLevelTask.builder().skill(Skill.MAGIC).level(94).build(), // Vengeance/Ice Barrage + SkillLevelTask.builder().skill(Skill.PRAYER).level(77).build(), // Rigour/Augury + SkillLevelTask.builder().skill(Skill.RANGED).level(85).build(), + // Late Gear Goals + ItemTask.builder().itemId(ABYSSAL_TENTACLE).itemName("Abyssal tentacle").quantity(1).build(), + ItemTask.builder().itemId(TOXIC_BLOWPIPE).itemName("Toxic blowpipe").quantity(1).build(), + ItemTask.builder().itemId(ARMADYL_CROSSBOW).itemName("Armadyl crossbow").quantity(1).build(), + ItemTask.builder().itemId(BOW_OF_FAERDHINEN).itemName("Bow of Faerdhinen").quantity(1).build(), + ItemTask.builder().itemId(BANDOS_CHESTPLATE).itemName("Bandos chestplate").quantity(1).build(), + ItemTask.builder().itemId(BANDOS_TASSETS).itemName("Bandos tassets").quantity(1).build(), + ItemTask.builder().itemId(TRIDENT_OF_THE_SEAS).itemName("Trident of the seas").quantity(1).build(), + ItemTask.builder().itemId(RING_OF_SUFFERING).itemName("Ring of suffering").quantity(1).build(), + ItemTask.builder().itemId(RING_OF_SUFFERING_I).itemName("Ring of suffering (i)").quantity(1).build(), + ItemTask.builder().itemId(SALVE_AMULETEI).itemName("Salve amulet (ei)").quantity(1).build(), + ItemTask.builder().itemId(INFERNAL_CAPE).itemName("Infernal cape").quantity(1).build(), + // Late Quests / Diaries + QuestTask.builder().quest(Quest.SONG_OF_THE_ELVES).build(), + QuestTask.builder().quest(Quest.SINS_OF_THE_FATHER).build(), + QuestTask.builder().quest(Quest.MONKEY_MADNESS_II).build(), + QuestTask.builder().quest(Quest.DESERT_TREASURE_I).build() + )) + .build(); + return new Preset("Late Ironman Progression", "Stats, gear, and quest goals for late game Ironman.", Arrays.asList(late)); + } +} diff --git a/src/main/java/com/toofifty/goaltracker/ui/GoalItemContent.java b/src/main/java/com/toofifty/goaltracker/ui/GoalItemContent.java index 00dc54f..c2229ed 100644 --- a/src/main/java/com/toofifty/goaltracker/ui/GoalItemContent.java +++ b/src/main/java/com/toofifty/goaltracker/ui/GoalItemContent.java @@ -1,29 +1,196 @@ package com.toofifty.goaltracker.ui; +import net.runelite.client.ui.ColorScheme; import com.toofifty.goaltracker.GoalTrackerPlugin; import com.toofifty.goaltracker.models.Goal; +import com.toofifty.goaltracker.ui.Refreshable; + import javax.swing.*; +import javax.swing.JMenuItem; +import javax.swing.event.PopupMenuListener; +import javax.swing.event.PopupMenuEvent; import java.awt.*; +import com.toofifty.goaltracker.ui.components.ListItemPanel; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import javax.swing.SwingUtilities; + +import java.awt.event.ComponentAdapter; +import java.awt.event.ComponentEvent; + import static com.toofifty.goaltracker.utils.Constants.STATUS_TO_COLOR; public class GoalItemContent extends JPanel implements Refreshable { - private final JLabel title = new JLabel(); + private final JLabel titleLabel = new JLabel(); + private final JTextField titleEdit = new JTextField(); private final JLabel progress = new JLabel(); + private final SlimBar progressBar = new SlimBar(); + private final JPanel titleStack = new JPanel(new CardLayout()); private final Goal goal; + private JPanel topRow; + + //private static final int PIN_STRIPE_W = 3; // px + private static final Color PINNED_BG_COLOR = new Color(45, 45, 45); // darker gray background + GoalItemContent(GoalTrackerPlugin plugin, Goal goal) { super(new BorderLayout()); this.goal = goal; - add(title, BorderLayout.WEST); - add(progress, BorderLayout.EAST); + setBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8)); // padding for centered text + // Let the parent card body paint the background; avoid double fills + setOpaque(false); + setBackground(null); + + topRow = new JPanel(new BorderLayout()); + topRow.setOpaque(false); + + // Title label (display mode) + titleLabel.setBorder(null); + titleLabel.setOpaque(false); + titleLabel.setFocusable(false); + titleLabel.setToolTipText(null); + + // Title edit (edit mode) + titleEdit.setBorder(null); + titleEdit.setOpaque(false); + titleEdit.setDragEnabled(true); + + titleStack.setOpaque(false); + titleStack.add(titleLabel, "label"); + titleStack.add(titleEdit, "edit"); + topRow.add(titleStack, BorderLayout.CENTER); + + // Swap to edit on label click + titleLabel.addMouseListener(new MouseAdapter() { + @Override public void mouseClicked(MouseEvent e) { enterEdit(); } + }); + // Commit edits on Enter and when focus is lost + titleEdit.addActionListener(e -> exitEdit(true)); + titleEdit.addFocusListener(new java.awt.event.FocusAdapter() { + @Override public void focusLost(java.awt.event.FocusEvent e) { exitEdit(true); } + }); + + topRow.add(progress, BorderLayout.EAST); + + // Reserve fixed width for progress like 999/999 so it never clips + int progW = getFontMetrics(progress.getFont()).stringWidth("999/999"); + Dimension progSize = new Dimension(progW, progress.getPreferredSize().height); + progress.setPreferredSize(progSize); + progress.setMinimumSize(progSize); + + add(topRow, BorderLayout.CENTER); + + // Slim custom progress bar under the title row + progressBar.setBorder(BorderFactory.createEmptyBorder(4, 0, 0, 0)); // gap above the bar + progressBar.setPreferredSize(new Dimension(0, 6)); // 6px tall bar + add(progressBar, BorderLayout.SOUTH); + + // Initialize visible text and colors immediately (before first refresh) + { + Color color = STATUS_TO_COLOR.get(goal.getStatus()); + updateTitleLabel(); + titleEdit.setCaretColor(color); + progress.setText(goal.getComplete().size() + "/" + goal.getTasks().size()); + progress.setForeground(color); + } + + topRow.addComponentListener(new ComponentAdapter() { + @Override public void componentResized(ComponentEvent e) { updateTitleLabel(); } + }); plugin.getUiStatusManager().addRefresher(goal, this::refresh); + + // Ensure right-click on any child shows the parent ListItemPanel context menu + MouseAdapter forwardPopup = new MouseAdapter() + { + private void maybeShow(MouseEvent e) + { + if (!(e.isPopupTrigger() || SwingUtilities.isRightMouseButton(e))) + { + return; + } + java.awt.Component src = (java.awt.Component) e.getSource(); + javax.swing.JComponent listItem = (javax.swing.JComponent) SwingUtilities.getAncestorOfClass(ListItemPanel.class, src); + if (listItem == null) + { + listItem = (javax.swing.JComponent) SwingUtilities.getAncestorOfClass(ListItemPanel.class, GoalItemContent.this); + } + if (listItem != null && listItem.getComponentPopupMenu() != null) + { + java.awt.Point p = SwingUtilities.convertPoint(src, e.getPoint(), listItem); + JPopupMenu menu = listItem.getComponentPopupMenu(); + + // --- Pin / Unpin (temporary additions) --- + JSeparator sep = new JSeparator(); + ((JComponent) sep).putClientProperty("pinToggle", Boolean.TRUE); + menu.add(sep); + + JMenuItem pinToggle = new JMenuItem(goal.isPinned() ? "Unpin" : "Pin"); + ((JComponent) pinToggle).putClientProperty("pinToggle", Boolean.TRUE); + pinToggle.addActionListener(ev -> { + goal.setPinned(!goal.isPinned()); + try { + plugin.getGoalManager().save(); + } catch (Throwable t) { + plugin.getUiStatusManager().refresh(goal); + } + GoalItemContent.this.revalidate(); + GoalItemContent.this.repaint(); + }); + menu.add(pinToggle); + + PopupMenuListener cleanup = new PopupMenuListener() { + @Override public void popupMenuWillBecomeVisible(PopupMenuEvent e) { } + @Override public void popupMenuCanceled(PopupMenuEvent e) { cleanup(menu, this); } + @Override public void popupMenuWillBecomeInvisible(PopupMenuEvent e) { cleanup(menu, this); } + private void cleanup(JPopupMenu m, PopupMenuListener self) { + // Remove only the components we added this time + java.util.List toRemove = new java.util.ArrayList<>(); + for (java.awt.Component c : m.getComponents()) { + if (c instanceof JComponent) { + Object flag = ((JComponent) c).getClientProperty("pinToggle"); + if (Boolean.TRUE.equals(flag)) { + toRemove.add(c); + } + } + } + for (java.awt.Component c : toRemove) { + m.remove(c); + } + m.removePopupMenuListener(self); + } + }; + menu.addPopupMenuListener(cleanup); + + menu.show(listItem, p.x, p.y); + } + } + + @Override public void mousePressed(MouseEvent e) { maybeShow(e); } + @Override public void mouseReleased(MouseEvent e) { maybeShow(e); } + }; + + this.addMouseListener(forwardPopup); + titleLabel.addMouseListener(forwardPopup); + titleEdit.addMouseListener(forwardPopup); + progress.addMouseListener(forwardPopup); + progressBar.addMouseListener(forwardPopup); + // Ensure item icon/text initialize on first render (e.g., on login) + javax.swing.SwingUtilities.invokeLater(this::refresh); + } + + @Override + public void addNotify() + { + super.addNotify(); + // In case construction happened before UI was realized, refresh when shown + javax.swing.SwingUtilities.invokeLater(this::refresh); } @Override @@ -31,11 +198,114 @@ public void refresh() { Color color = STATUS_TO_COLOR.get(goal.getStatus()); - title.setText(goal.getDescription()); - title.setForeground(color); + updateTitleLabel(); + titleEdit.setCaretColor(color); progress.setText( goal.getComplete().size() + "/" + goal.getTasks().size()); progress.setForeground(color); + + int total = goal.getTasks().size(); + int done = goal.getComplete().size(); + progressBar.setVisible(total > 0); + progressBar.setProgress(done, total, color); + javax.swing.SwingUtilities.invokeLater(this::updateTitleLabel); + } + private static class SlimBar extends JComponent { + private int done = 0; + private int total = 1; + private Color fill = Color.GREEN; + + void setProgress(int done, int total, Color fill) { + this.done = done; + this.total = Math.max(1, total); + this.fill = fill; + repaint(); + } + + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); + Graphics2D g2 = (Graphics2D) g.create(); + int w = getWidth(); + int h = getHeight(); + // track + g2.setColor(ColorScheme.DARKER_GRAY_COLOR); + g2.fillRect(0, 0, w, h); + // fill + int barW = (int) ((done / (double) total) * w); + g2.setColor(fill != null ? fill : Color.GREEN); + g2.fillRect(0, 0, barW, h); + g2.dispose(); + } + } + + private void updateTitleLabel() + { + if (topRow == null) return; + String full = goal.getDescription() != null ? goal.getDescription() : ""; + titleLabel.setToolTipText(full.isEmpty() ? null : full); + + // Compute available width for title (row width minus progress preferred width and a small gap) + int rowW = topRow.getWidth(); + if (rowW <= 0) { titleLabel.setText(full); return; } + int gap = 8; + int avail = Math.max(16, rowW - progress.getPreferredSize().width - gap); + + FontMetrics fm = titleLabel.getFontMetrics(titleLabel.getFont()); + if (fm.stringWidth(full) <= avail) { + titleLabel.setText(full); + return; + } + String ellipsis = "…"; + String text = full; + // Binary-like trim from the end until it fits + int lo = 0, hi = full.length(); + int cut = hi; + while (lo <= hi) { + int mid = (lo + hi) >>> 1; + String candidate = full.substring(0, Math.max(0, mid)) + ellipsis; + if (fm.stringWidth(candidate) <= avail) { + cut = mid; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + text = full.substring(0, Math.max(0, cut)) + ellipsis; + titleLabel.setText(text); + } + + private void enterEdit() + { + titleEdit.setText(goal.getDescription() != null ? goal.getDescription() : ""); + ((CardLayout) titleStack.getLayout()).show(titleStack, "edit"); + titleEdit.requestFocusInWindow(); + titleEdit.selectAll(); + } + + private void exitEdit(boolean save) + { + if (save) { + String newText = titleEdit.getText(); + if (newText != null) { + goal.setDescription(newText); + } + } + ((CardLayout) titleStack.getLayout()).show(titleStack, "label"); + updateTitleLabel(); + } + + @Override + protected void paintComponent(Graphics g) + { + if (goal != null && goal.isPinned()) + { + Graphics2D g2 = (Graphics2D) g.create(); + g2.setColor(PINNED_BG_COLOR); + g2.fillRect(0, 0, getWidth(), getHeight()); + g2.dispose(); + } + super.paintComponent(g); } } diff --git a/src/main/java/com/toofifty/goaltracker/ui/GoalPanel.java b/src/main/java/com/toofifty/goaltracker/ui/GoalPanel.java index e076aad..f31ed7d 100644 --- a/src/main/java/com/toofifty/goaltracker/ui/GoalPanel.java +++ b/src/main/java/com/toofifty/goaltracker/ui/GoalPanel.java @@ -2,18 +2,27 @@ import com.toofifty.goaltracker.GoalTrackerPlugin; import com.toofifty.goaltracker.models.Goal; +import com.toofifty.goaltracker.models.ActionHistory; +import com.toofifty.goaltracker.models.RemoveTaskAction; import com.toofifty.goaltracker.models.enums.TaskType; +import com.toofifty.goaltracker.models.enums.Status; import com.toofifty.goaltracker.models.task.ManualTask; import com.toofifty.goaltracker.models.task.Task; import com.toofifty.goaltracker.ui.components.EditableInput; import com.toofifty.goaltracker.ui.components.ListPanel; import com.toofifty.goaltracker.ui.components.ListTaskPanel; -import com.toofifty.goaltracker.ui.components.TextButton; +import com.toofifty.goaltracker.ui.components.ActionBar; +import com.toofifty.goaltracker.ui.components.ActionBarButton; +import javax.swing.*; +import javax.swing.text.DefaultEditorKit; +import javax.swing.text.JTextComponent; +import java.awt.*; +import java.awt.event.KeyEvent; import java.awt.BorderLayout; import java.util.Objects; import java.util.function.Consumer; -import javax.swing.JPanel; import javax.swing.border.EmptyBorder; +import javax.swing.BorderFactory; import net.runelite.client.ui.ColorScheme; public class GoalPanel extends JPanel implements Refreshable @@ -27,6 +36,11 @@ public class GoalPanel extends JPanel implements Refreshable private Consumer taskAddedListener; private Consumer taskUpdatedListener; + private final ActionHistory actionHistory = new ActionHistory(); + private ActionBarButton undoButton; + private ActionBarButton redoButton; + private ActionBarButton prereqsButton; + GoalPanel(GoalTrackerPlugin plugin, Goal goal, Runnable closeListener) { super(); @@ -35,39 +49,69 @@ public class GoalPanel extends JPanel implements Refreshable setLayout(new BorderLayout()); - TextButton backButton = new TextButton("< Back", ColorScheme.PROGRESS_ERROR_COLOR); - backButton.onClick((e) -> closeListener.run()); - JPanel headerPanel = new JPanel(new BorderLayout()); - headerPanel.add(backButton, BorderLayout.WEST); headerPanel.setBorder(new EmptyBorder(0, 0, 8, 0)); add(headerPanel, BorderLayout.NORTH); + // Back row above the action bar + JPanel backRow = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 0)); + backRow.setBackground(ColorScheme.DARK_GRAY_COLOR); + ActionBarButton backButton = new ActionBarButton("Back", closeListener::run); + backRow.add(backButton); + + // Action bar with Add pre-reqs on the left of Undo/Redo + ActionBar actionBar = new ActionBar(); + prereqsButton = new ActionBarButton("Add pre-reqs", this::addPrereqs); + prereqsButton.setToolTipText("Add prerequisite quests/tasks for this goal"); + prereqsButton.setEnabled(true); + + undoButton = new ActionBarButton("Undo", this::doUndo); + redoButton = new ActionBarButton("Redo", this::doRedo); + + actionBar.left().add(prereqsButton); + actionBar.left().add(undoButton); + actionBar.left().add(redoButton); + + // Stack back row above the action bar in the header's NORTH + JPanel headerTop = new JPanel(); + headerTop.setBackground(ColorScheme.DARK_GRAY_COLOR); + headerTop.setLayout(new BoxLayout(headerTop, BoxLayout.Y_AXIS)); + headerTop.add(backRow); + headerTop.add(actionBar); + + headerPanel.add(headerTop, BorderLayout.NORTH); + + updateUndoRedoButtons(); + descriptionInput = new EditableInput((value) -> { goal.setDescription(value); this.goalUpdatedListener.accept(goal); }); headerPanel.add(descriptionInput, BorderLayout.CENTER); + SwingUtilities.invokeLater(() -> installClipboardSupport(descriptionInput)); + descriptionInput.addContainerListener(new java.awt.event.ContainerAdapter() + { + @Override public void componentAdded(java.awt.event.ContainerEvent e) + { + SwingUtilities.invokeLater(() -> installClipboardSupport(descriptionInput)); + } + }); taskListPanel = new ListPanel<>(goal.getTasks(), (task) -> { ListTaskPanel taskPanel = new ListTaskPanel(goal.getTasks(), task); - taskPanel.add(new TaskItemContent(plugin, task)); - taskPanel.setBorder(new EmptyBorder(2, 4, 2, 4)); - - if (TaskType.MANUAL.equals(task.getType())) { - ManualTask manualTask = (ManualTask) task; - - taskPanel.onClick(e -> { - manualTask.toggle(); - - if (task.getStatus().isCompleted()) { - plugin.notifyTask(task); - } + taskPanel.setActionHistory(actionHistory); + TaskItemContent taskContent = new TaskItemContent(plugin, goal, task); + taskContent.setActionHistory(actionHistory); + taskPanel.add(taskContent); + taskPanel.setTaskContent(taskContent); + taskContent.refresh(); + taskPanel.setOpaque(true); + taskPanel.setBackground(ColorScheme.DARK_GRAY_COLOR); + taskPanel.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createMatteBorder(4, 6, 0, 6, ColorScheme.DARKER_GRAY_COLOR), // darker line for contrast + new EmptyBorder(2, 4, 2, 4) + )); - plugin.getUiStatusManager().refresh(task); - this.taskUpdatedListener.accept(manualTask); - }); - } taskPanel.onIndented(e -> { this.goalUpdatedListener.accept(goal); @@ -81,6 +125,11 @@ public class GoalPanel extends JPanel implements Refreshable this.refresh(); }); + taskPanel.onRemovedWithIndex((removedTask, index) -> { + actionHistory.push(new RemoveTaskAction(goal.getTasks(), removedTask, index)); + updateUndoRedoButtons(); + }); + return taskPanel; }); taskListPanel.setGap(0); @@ -97,11 +146,23 @@ public void updateFromNewTask(Task task) taskListPanel.tryBuildList(); taskListPanel.refresh(); plugin.setValidateAll(true); + plugin.getUiStatusManager().refresh(goal); + revalidate(); + repaint(); if (Objects.nonNull(this.taskAddedListener)) this.taskAddedListener.accept(task); if (Objects.nonNull(this.taskUpdatedListener)) this.taskUpdatedListener.accept(task); } + public void refreshTaskList() + { + taskListPanel.tryBuildList(); + taskListPanel.refresh(); + plugin.getUiStatusManager().refresh(goal); + revalidate(); + repaint(); + } + @Override public void refresh() { @@ -127,4 +188,155 @@ public void onTaskUpdated(Consumer listener) taskListPanel.onUpdated(this.taskUpdatedListener); } + private void installClipboardSupport(Component root) + { + JTextComponent tc = findTextComponent(root); + if (tc == null) + { + return; + } + + // Ensure transfer handler to enable clipboard operations on text property + tc.setTransferHandler(new TransferHandler("text")); + tc.setDragEnabled(true); + + // Context menu with Cut/Copy/Paste/Select All + JPopupMenu menu = new JPopupMenu(); + JMenuItem cut = new JMenuItem(new DefaultEditorKit.CutAction()); + cut.setText("Cut"); + JMenuItem copy = new JMenuItem(new DefaultEditorKit.CopyAction()); + copy.setText("Copy"); + JMenuItem paste = new JMenuItem(new DefaultEditorKit.PasteAction()); + paste.setText("Paste"); + JMenuItem selectAll = new JMenuItem("Select All"); + selectAll.addActionListener(e -> tc.selectAll()); + menu.add(cut); + menu.add(copy); + menu.add(paste); + menu.addSeparator(); + menu.add(selectAll); + tc.setComponentPopupMenu(menu); + + // Keyboard shortcuts (Ctrl/Cmd + C/V/X/A) + int mask = Toolkit.getDefaultToolkit().getMenuShortcutKeyMaskEx(); + InputMap im = tc.getInputMap(JComponent.WHEN_FOCUSED); + ActionMap am = tc.getActionMap(); + im.put(KeyStroke.getKeyStroke(KeyEvent.VK_C, mask), DefaultEditorKit.copyAction); + im.put(KeyStroke.getKeyStroke(KeyEvent.VK_V, mask), DefaultEditorKit.pasteAction); + im.put(KeyStroke.getKeyStroke(KeyEvent.VK_X, mask), DefaultEditorKit.cutAction); + im.put(KeyStroke.getKeyStroke(KeyEvent.VK_A, mask), DefaultEditorKit.selectAllAction); + am.put(DefaultEditorKit.copyAction, new DefaultEditorKit.CopyAction()); + am.put(DefaultEditorKit.pasteAction, new DefaultEditorKit.PasteAction()); + am.put(DefaultEditorKit.cutAction, new DefaultEditorKit.CutAction()); + am.put(DefaultEditorKit.selectAllAction, new AbstractAction() + { + @Override public void actionPerformed(java.awt.event.ActionEvent e) { tc.selectAll(); } + }); + } + + private JTextComponent findTextComponent(Component c) + { + if (c instanceof JTextComponent) + { + return (JTextComponent) c; + } + if (c instanceof Container) + { + for (Component child : ((Container) c).getComponents()) + { + JTextComponent tc = findTextComponent(child); + if (tc != null) + { + return tc; + } + } + } + return null; + } + + private void updateUndoRedoButtons() + { + if (undoButton != null) + { + undoButton.setEnabled(actionHistory.hasUndo()); + } + if (redoButton != null) + { + redoButton.setEnabled(actionHistory.hasRedo()); + } + } + + private void doUndo() + { + actionHistory.undo(); + refreshTaskList(); + updateUndoRedoButtons(); + } + + private void doRedo() + { + actionHistory.redo(); + refreshTaskList(); + updateUndoRedoButtons(); + } + + private void addPrereqs() + { + int processed = 0; + int invoked = 0; + + java.util.List contents = new java.util.ArrayList<>(); + collectTaskItemContents(taskListPanel, contents); + + for (TaskItemContent tic : contents) + { + Task t = tic.getTask(); + if (t != null && t.getType() == TaskType.QUEST) + { + processed++; + if (tic.addPrereqsFromContext()) + { + invoked++; + } + } + } + + // Refresh UI/state after batch + plugin.setValidateAll(true); + plugin.getUiStatusManager().refresh(goal); + refreshTaskList(); + updateUndoRedoButtons(); + + if (processed == 0) + { + JOptionPane.showMessageDialog(this, + "No quest tasks found in this goal.", + "Add pre-reqs", + JOptionPane.INFORMATION_MESSAGE); + return; + } + + if (invoked == 0) + { + JOptionPane.showMessageDialog(this, + "All pre-reqs have already been added.\n", + "Add pre-reqs", + JOptionPane.WARNING_MESSAGE); + } + } + + private static void collectTaskItemContents(Component root, java.util.List out) + { + if (root instanceof TaskItemContent) + { + out.add((TaskItemContent) root); + } + if (root instanceof Container) + { + for (Component child : ((Container) root).getComponents()) + { + collectTaskItemContents(child, out); + } + } + } } diff --git a/src/main/java/com/toofifty/goaltracker/ui/GoalTrackerPanel.java b/src/main/java/com/toofifty/goaltracker/ui/GoalTrackerPanel.java index 18715a1..b2b71d7 100644 --- a/src/main/java/com/toofifty/goaltracker/ui/GoalTrackerPanel.java +++ b/src/main/java/com/toofifty/goaltracker/ui/GoalTrackerPanel.java @@ -3,134 +3,202 @@ import com.toofifty.goaltracker.GoalManager; import com.toofifty.goaltracker.GoalTrackerPlugin; import com.toofifty.goaltracker.models.Goal; +import com.toofifty.goaltracker.models.UndoStack; +import com.toofifty.goaltracker.presets.GoalPresetRepository; +import com.toofifty.goaltracker.presets.GoalPresetRepository.Preset; import com.toofifty.goaltracker.models.task.Task; +import com.toofifty.goaltracker.utils.ReorderableList; +import com.toofifty.goaltracker.ui.components.ActionBar; +import com.toofifty.goaltracker.ui.components.ActionBarButton; import com.toofifty.goaltracker.ui.components.ListItemPanel; import com.toofifty.goaltracker.ui.components.ListPanel; -import com.toofifty.goaltracker.ui.components.TextButton; import net.runelite.client.ui.ColorScheme; import net.runelite.client.ui.FontManager; import net.runelite.client.ui.PluginPanel; import javax.inject.Inject; -import javax.inject.Singleton; import javax.swing.*; import javax.swing.border.EmptyBorder; +import javax.swing.filechooser.FileNameExtensionFilter; import java.awt.*; -import java.util.Objects; import java.util.function.Consumer; +import java.util.Collections; +import java.util.Comparator; -@Singleton public class GoalTrackerPanel extends PluginPanel implements Refreshable { private final JPanel mainPanel = new JPanel(new BorderLayout()); private final ListPanel goalListPanel; private final GoalTrackerPlugin plugin; + private final GoalManager goalManager; + private final UndoStack undoStack = new UndoStack<>(); + private ActionBarButton undoButtonRef; + private ActionBarButton redoButtonRef; private GoalPanel goalPanel; private Consumer goalAddedListener; private Consumer goalUpdatedListener; private Consumer taskAddedListener; private Consumer taskUpdatedListener; + private Goal pendingNewGoal; @Inject public GoalTrackerPanel(GoalTrackerPlugin plugin, GoalManager goalManager) { super(false); this.plugin = plugin; + this.goalManager = goalManager; + this.goalManager.addGoalsChangedListener(() -> javax.swing.SwingUtilities.invokeLater(this::refreshHomeListIfVisible)); setBackground(ColorScheme.DARK_GRAY_COLOR); setLayout(new BorderLayout()); setBorder(new EmptyBorder(8, 8, 8, 8)); + // Header with title and + Add goal on right JPanel titlePanel = new JPanel(); titlePanel.setBorder(new EmptyBorder(10, 10, 10, 10)); titlePanel.setLayout(new BorderLayout()); titlePanel.setBackground(ColorScheme.DARK_GRAY_COLOR); - titlePanel.add( - new TextButton("+ Add goal", - e -> { - Goal goal = goalManager.createGoal(); - view(goal); - - if (Objects.nonNull(this.goalAddedListener)) this.goalAddedListener.accept(goal); - if (Objects.nonNull(this.goalUpdatedListener)) this.goalUpdatedListener.accept(goal); - } - ).narrow(), BorderLayout.EAST); - - JLabel title = new JLabel(); - title.setText("Goal Tracker"); + JLabel title = new JLabel("Goal Tracker v2"); title.setForeground(Color.WHITE); title.setFont(FontManager.getRunescapeBoldFont()); - titlePanel.add(title, BorderLayout.WEST); - goalListPanel = new ListPanel<>(goalManager.getGoals(), - (goal) -> { - var panel = new ListItemPanel<>(goalManager.getGoals(), goal); + JLabel author = new JLabel("By: AhDoozy"); + author.setForeground(Color.LIGHT_GRAY); + author.setFont(title.getFont().deriveFont(title.getFont().getSize2D() - 3f)); - panel.onClick(e -> this.view(goal)); - panel.add(new GoalItemContent(plugin, goal)); + JPanel titleTextPanel = new JPanel(new GridLayout(2, 1)); + titleTextPanel.setBackground(ColorScheme.DARK_GRAY_COLOR); + titleTextPanel.add(title); + titleTextPanel.add(author); + titlePanel.add(titleTextPanel, BorderLayout.WEST); - return panel; - } - ); + JPanel addGoalPanel = new JPanel(); + addGoalPanel.setLayout(new BoxLayout(addGoalPanel, BoxLayout.Y_AXIS)); + addGoalPanel.setBackground(ColorScheme.DARK_GRAY_COLOR); + JPanel addGoalRow = new JPanel(new FlowLayout(FlowLayout.RIGHT, 5, 0)); + addGoalRow.setBackground(ColorScheme.DARK_GRAY_COLOR); + ActionBarButton addGoalBtn = new ActionBarButton("+ Add goal", this::addNewGoal); + addGoalRow.add(addGoalBtn); + addGoalPanel.add(addGoalRow); + addGoalPanel.add(Box.createVerticalStrut(4)); + ActionBarButton addFromPresetBtn = new ActionBarButton("Add from Preset…", this::addFromPreset); + addGoalPanel.add(addFromPresetBtn); + titlePanel.add(addGoalPanel, BorderLayout.EAST); + + // Action bar + ActionBar actionBar = new ActionBar(); + actionBar.right().setBorder(new EmptyBorder(0, 4, 0, 0)); + + undoButtonRef = new ActionBarButton("Undo", this::doUndo); + redoButtonRef = new ActionBarButton("Redo", this::doRedo); + actionBar.left().add(undoButtonRef); + actionBar.left().add(redoButtonRef); + + ActionBarButton exportButton = new ActionBarButton("Export", this::exportGoalsToFile); + ActionBarButton importButton = new ActionBarButton("Import", this::importGoalsFromFile); + actionBar.right().add(exportButton); + actionBar.right().add(importButton); + + updateUndoRedoButtons(); + + JPanel titleWrapper = new JPanel(new BorderLayout()); + titleWrapper.setBackground(ColorScheme.DARK_GRAY_COLOR); + titleWrapper.add(titlePanel, BorderLayout.CENTER); + + JPanel headerSeparator = new JPanel(); + headerSeparator.setBackground(ColorScheme.DARKER_GRAY_COLOR); + headerSeparator.setPreferredSize(new Dimension(1, 4)); + titleWrapper.add(headerSeparator, BorderLayout.SOUTH); + + JPanel headerContainer = new JPanel(new BorderLayout()); + headerContainer.setBackground(ColorScheme.DARK_GRAY_COLOR); + headerContainer.add(titleWrapper, BorderLayout.NORTH); + headerContainer.add(actionBar, BorderLayout.SOUTH); + + goalListPanel = new ListPanel<>(goalManager.getGoals(), (goal) -> { + var panel = new ListItemPanel<>(goalManager.getGoals(), goal); + panel.onClick(e -> this.view(goal)); + panel.add(new GoalItemContent(plugin, goal)); + panel.onRemovedWithIndex(this::recordGoalRemoval); + return panel; + }); goalListPanel.setGap(0); - goalListPanel.setPlaceholder("Add a new goal using the button above"); + goalListPanel.setPlaceholder("
No goals yet.
Click + Add goal above to create your first one.
"); - mainPanel.add(titlePanel, BorderLayout.NORTH); + mainPanel.add(headerContainer, BorderLayout.NORTH); mainPanel.add(goalListPanel, BorderLayout.CENTER); home(); } + private void refreshHomeListIfVisible() + { + if (goalPanel == null) + { + sortGoalsForHome(); + goalListPanel.tryBuildList(); + goalListPanel.refresh(); + revalidate(); + repaint(); + } + } + public void view(Goal goal) { removeAll(); - this.goalPanel = new GoalPanel(plugin, goal, this::home); - this.goalPanel.onGoalUpdated(this.goalUpdatedListener); this.goalPanel.onTaskAdded(this.taskAddedListener); this.goalPanel.onTaskUpdated(this.taskUpdatedListener); - add(this.goalPanel, BorderLayout.CENTER); this.goalPanel.refresh(); - revalidate(); repaint(); } public void home() { + if (pendingNewGoal != null) + { + try { + if (pendingNewGoal.getTasks() == null || pendingNewGoal.getTasks().isEmpty()) { + goalManager.getGoals().remove(pendingNewGoal); + } + } finally { + pendingNewGoal = null; + } + } removeAll(); - add(mainPanel, BorderLayout.CENTER); + sortGoalsForHome(); goalListPanel.tryBuildList(); goalListPanel.refresh(); - + mainPanel.remove(goalListPanel); + mainPanel.add(goalListPanel, BorderLayout.CENTER); + add(mainPanel, BorderLayout.CENTER); + mainPanel.revalidate(); + mainPanel.repaint(); revalidate(); repaint(); - this.goalPanel = null; } + @Override public void refresh() { - // refresh single-view goal for (Component component : getComponents()) { if (component instanceof Refreshable) { ((Refreshable) component).refresh(); } } - goalListPanel.refresh(); } public void onGoalUpdated(Consumer listener) { this.goalUpdatedListener = listener; - this.goalListPanel.onUpdated(this.goalUpdatedListener); - if (this.goalPanel != null) { this.goalPanel.onGoalUpdated(this.goalUpdatedListener); } @@ -139,7 +207,6 @@ public void onGoalUpdated(Consumer listener) public void onTaskUpdated(Consumer listener) { this.taskUpdatedListener = listener; - if (this.goalPanel != null) { this.goalPanel.onTaskUpdated(this.taskUpdatedListener); } @@ -148,9 +215,186 @@ public void onTaskUpdated(Consumer listener) public void onTaskAdded(Consumer listener) { this.taskAddedListener = listener; - if (this.goalPanel != null) { this.goalPanel.onTaskAdded(this.taskAddedListener); } } -} + + private void updateUndoRedoButtons() + { + if (undoButtonRef != null) + { + undoButtonRef.setEnabled(undoStack.hasUndo()); + undoButtonRef.setToolTipText(undoStack.hasUndo() ? null : "Nothing to undo"); + } + if (redoButtonRef != null) + { + redoButtonRef.setEnabled(undoStack.hasRedo()); + redoButtonRef.setToolTipText(undoStack.hasRedo() ? null : "Nothing to redo"); + } + } + + private void sortGoalsForHome() + { + java.util.List goals = goalManager.getGoals(); + Collections.sort(goals, Comparator + .comparing(Goal::isPinned).reversed() + .thenComparing(g -> { + String d = g.getDescription(); + return d == null ? "" : d.toLowerCase(); + })); + } + + private void doUndo() + { + var entry = undoStack.popForUndo(); + if (entry == null) { updateUndoRedoButtons(); return; } + java.util.List goals = goalManager.getGoals(); + int idx = Math.max(0, Math.min(entry.getIndex(), goals.size())); + goals.add(idx, entry.getItem()); + if (goalPanel == null) + { + goalListPanel.tryBuildList(); + goalListPanel.refresh(); + revalidate(); + repaint(); + } + updateUndoRedoButtons(); + } + + private void doRedo() + { + var entry = undoStack.popForRedo(); + if (entry == null) { updateUndoRedoButtons(); return; } + java.util.List goals = goalManager.getGoals(); + int idx = goals.indexOf(entry.getItem()); + if (idx >= 0) + { + goals.remove(idx); + } + if (goalPanel == null) + { + goalListPanel.tryBuildList(); + goalListPanel.refresh(); + revalidate(); + repaint(); + } + updateUndoRedoButtons(); + } + + public void recordGoalRemoval(Goal goal, int index) + { + undoStack.pushRemove(goal, index); + updateUndoRedoButtons(); + } + + private void addNewGoal() + { + Goal goal = Goal.builder().tasks(ReorderableList.from()).build(); + goalManager.getGoals().add(0, goal); + pendingNewGoal = goal; + view(goal); + } + + private void exportGoalsToFile() + { + JFileChooser chooser = new JFileChooser(); + chooser.setAcceptAllFileFilterUsed(false); + chooser.setMultiSelectionEnabled(false); + chooser.setFileFilter(new FileNameExtensionFilter("JSON files (*.json)", "json")); + chooser.setDialogTitle("Export goals to JSON"); + chooser.setSelectedFile(new java.io.File("goals.json")); + int res = chooser.showSaveDialog(this); + if (res != JFileChooser.APPROVE_OPTION) { return; } + java.io.File file = chooser.getSelectedFile(); + if (!file.getName().toLowerCase().endsWith(".json")) + { + file = new java.io.File(file.getParentFile(), file.getName() + ".json"); + } + try (java.io.FileWriter fw = new java.io.FileWriter(file)) + { + String json = goalManager.exportJson(true); + fw.write(json); + fw.flush(); + JOptionPane.showMessageDialog(this, "Exported " + goalManager.getGoals().size() + " goal(s) to\n" + file.getAbsolutePath(), "Export Complete", JOptionPane.INFORMATION_MESSAGE); + } + catch (Exception ex) + { + JOptionPane.showMessageDialog(this, "Failed to export goals: " + ex.getMessage(), "Export Error", JOptionPane.ERROR_MESSAGE); + } + } + + private void importGoalsFromFile() + { + JFileChooser chooser = new JFileChooser(); + chooser.setAcceptAllFileFilterUsed(false); + chooser.setMultiSelectionEnabled(false); + chooser.setFileFilter(new FileNameExtensionFilter("JSON files (*.json)", "json")); + chooser.setDialogTitle("Import goals from JSON"); + int res = chooser.showOpenDialog(this); + if (res != JFileChooser.APPROVE_OPTION) { return; } + java.io.File file = chooser.getSelectedFile(); + try + { + String json = new String(java.nio.file.Files.readAllBytes(file.toPath())); + goalManager.importJson(json); + plugin.warmItemIcons(); + if (goalPanel != null) { + home(); + } else { + goalListPanel.tryBuildList(); + goalListPanel.refresh(); + revalidate(); + repaint(); + } + JOptionPane.showMessageDialog(this, "Imported goals from\n" + file.getAbsolutePath(), "Import Complete", JOptionPane.INFORMATION_MESSAGE); + } + catch (Exception ex) + { + JOptionPane.showMessageDialog(this, "Failed to import goals: " + ex.getMessage(), "Import Error", JOptionPane.ERROR_MESSAGE); + } + } + + private void addFromPreset() + { + java.util.List options = GoalPresetRepository.getAll(); + + JComboBox combo = new JComboBox<>(options.toArray(new Preset[0])); + JTextArea details = new JTextArea(6, 36); + details.setWrapStyleWord(true); + details.setLineWrap(true); + details.setEditable(false); + details.setBackground(new JPanel().getBackground()); + + java.util.function.Consumer update = (opt) -> { + if (opt == null) { details.setText(""); return; } + int goals = opt.getGoals().size(); + int tasks = opt.getGoals().stream().mapToInt(g -> g.getTasks().size()).sum(); + details.setText(opt.getDescription() + "\n\nThis will add " + goals + " goal(s) with " + tasks + " task(s)."); + }; + combo.addActionListener(e -> update.accept((Preset) combo.getSelectedItem())); + update.accept((Preset) combo.getSelectedItem()); + + JPanel panel = new JPanel(new BorderLayout(6,6)); + panel.add(new JLabel("Choose a preset:"), BorderLayout.NORTH); + panel.add(combo, BorderLayout.CENTER); + panel.add(new JScrollPane(details), BorderLayout.SOUTH); + + int res = JOptionPane.showConfirmDialog(this, panel, "Add from Preset…", JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE); + if (res != JOptionPane.OK_OPTION) { return; } + + Preset selected = (Preset) combo.getSelectedItem(); + if (selected == null) { return; } + + goalManager.addGoals(selected.getGoals()); + + if (goalPanel != null) { + home(); + } else { + goalListPanel.tryBuildList(); + goalListPanel.refresh(); + revalidate(); + repaint(); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/toofifty/goaltracker/ui/TaskItemContent.java b/src/main/java/com/toofifty/goaltracker/ui/TaskItemContent.java index 9f0adbf..5bf0f96 100644 --- a/src/main/java/com/toofifty/goaltracker/ui/TaskItemContent.java +++ b/src/main/java/com/toofifty/goaltracker/ui/TaskItemContent.java @@ -1,8 +1,17 @@ package com.toofifty.goaltracker.ui; import com.toofifty.goaltracker.GoalTrackerPlugin; +import com.toofifty.goaltracker.models.Goal; import com.toofifty.goaltracker.models.task.Task; import com.toofifty.goaltracker.services.TaskIconService; +import com.toofifty.goaltracker.models.task.QuestTask; +import com.toofifty.goaltracker.utils.QuestRequirements; +import java.util.List; +import java.util.Locale; + +import com.toofifty.goaltracker.ui.components.ListPanel; +import com.toofifty.goaltracker.ui.components.ListItemPanel; +import javax.swing.SwingUtilities; import javax.swing.*; import javax.swing.border.EmptyBorder; @@ -10,35 +19,136 @@ import static com.toofifty.goaltracker.utils.Constants.STATUS_TO_COLOR; +import com.toofifty.goaltracker.models.ActionHistory; +import com.toofifty.goaltracker.models.ToggleCompleteAction; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import com.toofifty.goaltracker.models.enums.Status; + +import java.awt.event.ComponentAdapter; +import java.awt.event.ComponentEvent; +import java.awt.CardLayout; + +import com.toofifty.goaltracker.models.task.ManualTask; + public class TaskItemContent extends JPanel implements Refreshable { private final Task task; + private final Goal goal; private final TaskIconService iconService; private final JLabel titleLabel = new JLabel(); + private final JTextField titleEdit = new JTextField(); + private final JPanel titleStack = new JPanel(new CardLayout()); private final JLabel iconLabel = new JLabel(); + private JPanel iconWrapper; + private boolean titleEditable; + + private final GoalTrackerPlugin plugin; + private ActionHistory actionHistory; - TaskItemContent(GoalTrackerPlugin plugin, Task task) + TaskItemContent(GoalTrackerPlugin plugin, Goal goal, Task task) { super(new BorderLayout()); + this.plugin = plugin; this.task = task; + this.goal = goal; iconService = plugin.getTaskIconService(); titleLabel.setPreferredSize(new Dimension(0, 24)); - add(titleLabel, BorderLayout.CENTER); + titleLabel.setBorder(null); + titleLabel.setOpaque(false); + titleEdit.setBorder(null); + titleEdit.setOpaque(false); + titleEdit.setDragEnabled(true); + + titleStack.setOpaque(false); + titleStack.add(titleLabel, "label"); + titleStack.add(titleEdit, "edit"); + add(titleStack, BorderLayout.CENTER); - JPanel iconWrapper = new JPanel(new BorderLayout()); + iconWrapper = new JPanel(new BorderLayout()); iconWrapper.setBorder(new EmptyBorder(4, 0, 0, 4)); iconWrapper.add(iconLabel, BorderLayout.NORTH); add(iconWrapper, BorderLayout.WEST); plugin.getUiStatusManager().addRefresher(task, this::refresh); + + this.addComponentListener(new ComponentAdapter() { + @Override public void componentResized(ComponentEvent e) { updateTitleLabel(); } + }); + + titleEditable = (task instanceof ManualTask); + if (titleEditable) { + titleLabel.addMouseListener(new MouseAdapter() { + @Override public void mouseClicked(MouseEvent e) { enterEdit(); } + }); + titleEdit.addActionListener(e -> exitEdit(true)); + titleEdit.addFocusListener(new java.awt.event.FocusAdapter() { + @Override public void focusLost(java.awt.event.FocusEvent e) { exitEdit(true); } + }); + } + + // Right-click to toggle completion with ActionHistory + MouseAdapter contextMenuListener = new MouseAdapter() + { + private void showMenuIfNeeded(MouseEvent e) + { + if (!(e.isPopupTrigger() || javax.swing.SwingUtilities.isRightMouseButton(e))) + { + return; + } + // Prefer the parent ListItemPanel context menu (move up/down/remove, etc.) + Component src = (Component) e.getSource(); + JComponent listItem = (JComponent) SwingUtilities.getAncestorOfClass(ListItemPanel.class, src); + if (listItem != null && listItem.getComponentPopupMenu() != null) + { + Point p = SwingUtilities.convertPoint(src, e.getPoint(), listItem); + listItem.getComponentPopupMenu().show(listItem, p.x, p.y); + return; + } + + // Fallback: show simple toggle menu if no parent popup menu is available + boolean currentlyComplete = task.getStatus() == Status.COMPLETED; + String label = currentlyComplete ? "Mark as Incomplete" : "Mark as Completed"; + + JPopupMenu menu = new JPopupMenu(); + JMenuItem toggle = new JMenuItem(label); + toggle.addActionListener(a -> { + ToggleCompleteAction act = new ToggleCompleteAction(task, currentlyComplete, !currentlyComplete); + act.redo(); + if (actionHistory != null) + { + actionHistory.push(act); + } + plugin.getUiStatusManager().refresh(goal); + }); + menu.add(toggle); + Component invoker = (Component) e.getSource(); + menu.show(invoker, e.getX(), e.getY()); + } + + @Override public void mousePressed(MouseEvent e) { showMenuIfNeeded(e); } + @Override public void mouseReleased(MouseEvent e) { showMenuIfNeeded(e); } + }; + + // Attach listener to multiple components to make right-click reliable across platforms + this.addMouseListener(contextMenuListener); + titleStack.addMouseListener(contextMenuListener); + titleLabel.addMouseListener(contextMenuListener); + titleEdit.addMouseListener(contextMenuListener); + iconLabel.addMouseListener(contextMenuListener); + } + + public void setActionHistory(ActionHistory history) + { + this.actionHistory = history; } @Override public void refresh() { - titleLabel.setText(task.toString()); titleLabel.setForeground(STATUS_TO_COLOR.get(task.getStatus())); + updateTitleLabel(); int indent = 16 * task.getIndentLevel(); iconLabel.setIcon(iconService.get(task)); @@ -55,4 +165,116 @@ public void setBackground(Color bg) component.setBackground(bg); } } + + private void updateTitleLabel() + { + String full = task.toString(); + titleLabel.setToolTipText((full == null || full.isEmpty()) ? null : full); + if (getWidth() <= 0) { titleLabel.setText(full); return; } + + int insets = 0; + if (getBorder() != null) { + Insets ins = getBorder().getBorderInsets(this); + insets = (ins.left + ins.right); + } + int iconW = iconWrapper != null ? iconWrapper.getPreferredSize().width : 0; + int gap = 8; + int avail = Math.max(16, getWidth() - insets - iconW - gap); + + FontMetrics fm = titleLabel.getFontMetrics(titleLabel.getFont()); + if (full == null) full = ""; + if (fm.stringWidth(full) <= avail) { titleLabel.setText(full); return; } + + String ellipsis = "…"; + int lo = 0, hi = full.length(); + int cut = hi; + while (lo <= hi) { + int mid = (lo + hi) >>> 1; + String candidate = full.substring(0, Math.max(0, mid)) + ellipsis; + if (fm.stringWidth(candidate) <= avail) { cut = mid; lo = mid + 1; } + else { hi = mid - 1; } + } + titleLabel.setText(full.substring(0, Math.max(0, cut)) + ellipsis); + } + + private void enterEdit() + { + if (!titleEditable) return; + titleEdit.setText(task.toString()); + ((CardLayout) titleStack.getLayout()).show(titleStack, "edit"); + titleEdit.requestFocusInWindow(); + titleEdit.selectAll(); + } + + private void exitEdit(boolean save) + { + if (!titleEditable) { ((CardLayout) titleStack.getLayout()).show(titleStack, "label"); return; } + if (save) { + String newText = titleEdit.getText(); + if (newText != null && task instanceof ManualTask) { + ((ManualTask) task).setDescription(newText); + } + } + ((CardLayout) titleStack.getLayout()).show(titleStack, "label"); + updateTitleLabel(); + plugin.getUiStatusManager().refresh(goal); + } + + public Task getTask() + { + return task; + } + + /** + * Try to invoke the same context menu action as right-click -> "Add prerequisites". + * @return true if the action was found and invoked, false otherwise + */ + public boolean addPrereqsFromContext() + { + JComponent listItem = (JComponent) SwingUtilities.getAncestorOfClass(ListItemPanel.class, this); + if (listItem == null || listItem.getComponentPopupMenu() == null) + { + return false; + } + + JPopupMenu menu = listItem.getComponentPopupMenu(); + // Accept several label variants (case-insensitive) + String[] targets = new String[] { + "add prerequisites", "add pre-reqs", "add prereqs", "prerequisites" + }; + + return clickMenuItemByLabels(menu.getSubElements(), targets); + } + + // Recursively search menu/submenus for a matching label and click it. + private static boolean clickMenuItemByLabels(MenuElement[] items, String[] needles) + { + for (MenuElement me : items) + { + if (me instanceof JMenuItem) + { + JMenuItem it = (JMenuItem) me; + String txt = it.getText(); + if (txt != null) + { + String lower = txt.toLowerCase(Locale.ROOT).trim(); + for (String needle : needles) + { + if (lower.contains(needle)) + { + it.doClick(); + return true; + } + } + } + } + // Recurse into submenus and containers + MenuElement[] children = me.getSubElements(); + if (children != null && children.length > 0 && clickMenuItemByLabels(children, needles)) + { + return true; + } + } + return false; + } } diff --git a/src/main/java/com/toofifty/goaltracker/ui/components/ActionBar.java b/src/main/java/com/toofifty/goaltracker/ui/components/ActionBar.java new file mode 100644 index 0000000..c919ff0 --- /dev/null +++ b/src/main/java/com/toofifty/goaltracker/ui/components/ActionBar.java @@ -0,0 +1,35 @@ +package com.toofifty.goaltracker.ui.components; + +import java.awt.FlowLayout; +import javax.swing.JPanel; +import javax.swing.border.EmptyBorder; +import javax.swing.BoxLayout; +import net.runelite.client.ui.ColorScheme; + +public class ActionBar extends JPanel +{ + private final JPanel left = new JPanel(new FlowLayout(FlowLayout.LEFT, 3, 0)); + private final JPanel right = new JPanel(new FlowLayout(FlowLayout.RIGHT, 3, 0)); + + public ActionBar() + { + super(); + setLayout(new BoxLayout(this, BoxLayout.X_AXIS)); + setBackground(ColorScheme.DARK_GRAY_COLOR); + setBorder(new EmptyBorder(6, 6, 6, 6)); + + left.setOpaque(true); + left.setBackground(ColorScheme.DARK_GRAY_COLOR); + + right.setOpaque(true); + right.setBackground(ColorScheme.DARK_GRAY_COLOR); + + add(left); + // Removed strut to eliminate extra spacing + // add(javax.swing.Box.createHorizontalStrut(2)); + add(right); + } + + public JPanel left() { return left; } + public JPanel right() { return right; } +} \ No newline at end of file diff --git a/src/main/java/com/toofifty/goaltracker/ui/components/ActionBarButton.java b/src/main/java/com/toofifty/goaltracker/ui/components/ActionBarButton.java new file mode 100644 index 0000000..70b6219 --- /dev/null +++ b/src/main/java/com/toofifty/goaltracker/ui/components/ActionBarButton.java @@ -0,0 +1,87 @@ +package com.toofifty.goaltracker.ui.components; + +import java.awt.Cursor; +import java.awt.Graphics; +import java.awt.Insets; +import java.awt.RenderingHints; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import javax.swing.JButton; +import javax.swing.border.EmptyBorder; +import net.runelite.client.ui.ColorScheme; +import net.runelite.client.ui.FontManager; +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.Dimension; + +public class ActionBarButton extends JButton +{ + private boolean hover; + + public ActionBarButton(String text, Runnable onClick) + { + super(text); + setFont(FontManager.getRunescapeSmallFont()); + setForeground(Color.WHITE); + setFocusable(false); + setOpaque(false); + setBorder(new EmptyBorder(4, 10, 4, 10)); + setMargin(new Insets(0,0,0,0)); + setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + setContentAreaFilled(false); + + addActionListener(e -> { if (onClick != null) onClick.run(); }); + + addMouseListener(new MouseAdapter() + { + @Override public void mouseEntered(MouseEvent e) { hover = true; repaint(); } + @Override public void mouseExited(MouseEvent e) { hover = false; repaint(); } + }); + } + + @Override + protected void paintComponent(Graphics g) + { + Graphics2D g2 = (Graphics2D) g.create(); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + + Color bg = hover ? ColorScheme.DARKER_GRAY_HOVER_COLOR : ColorScheme.DARK_GRAY_COLOR.brighter(); + Color outline = ColorScheme.DARKER_GRAY_HOVER_COLOR.darker(); + + int arc = 8; + int w = getWidth(); + int h = getHeight(); + + int x = 0; + int y = 0; + int drawW = w; + int drawH = h; + + g2.setColor(bg); + g2.fillRoundRect(x, y, drawW, drawH, arc, arc); + + g2.setColor(outline); + g2.drawRoundRect(x, y, drawW - 1, drawH - 1, arc, arc); + + g2.dispose(); + super.paintComponent(g); + } + + @Override + public boolean isContentAreaFilled() + { + return false; + } + + @Override + public boolean isOpaque() + { + return false; + } + + @Override + public Dimension getMinimumSize() + { + return getPreferredSize(); + } +} \ No newline at end of file diff --git a/src/main/java/com/toofifty/goaltracker/ui/components/ComboBox.java b/src/main/java/com/toofifty/goaltracker/ui/components/ComboBox.java index 70fd11d..3591d57 100644 --- a/src/main/java/com/toofifty/goaltracker/ui/components/ComboBox.java +++ b/src/main/java/com/toofifty/goaltracker/ui/components/ComboBox.java @@ -1,12 +1,12 @@ package com.toofifty.goaltracker.ui.components; -import com.toofifty.goaltracker.GoalTrackerPlugin; import java.awt.BorderLayout; import java.awt.Color; import java.awt.Component; -import java.awt.image.BufferedImage; +import java.awt.Font; import java.util.List; import java.util.function.Function; +import javax.swing.DefaultComboBoxModel; import javax.swing.ImageIcon; import javax.swing.JButton; import javax.swing.JComboBox; @@ -14,100 +14,225 @@ import javax.swing.JList; import javax.swing.JPanel; import javax.swing.ListCellRenderer; +import javax.swing.SwingUtilities; import javax.swing.border.EmptyBorder; +import javax.swing.event.PopupMenuEvent; +import javax.swing.event.PopupMenuListener; import javax.swing.plaf.basic.BasicComboBoxUI; + import net.runelite.client.ui.ColorScheme; -import net.runelite.client.util.ImageUtil; import net.runelite.client.util.Text; +/** + * Simple generic ComboBox with optional formatter and a renderer + * that matches RuneLite dark styling. Includes a helper to keep the + * popup open after a selection (for rapid multi-add flows). + * + * This version also supports: + * - Custom up/down arrow icons loaded from resources (combo_arrow_down.png / combo_arrow_up.png) + * - Compact mode (~10% smaller font), or arbitrary font scaling via setFontScale + */ public class ComboBox extends JComboBox { private Function formatter = null; - @SuppressWarnings("unchecked") - public ComboBox(List items) + private double fontScale = 1.0; // 1.0 = default; 0.9 = compact + private ImageIcon arrowDownIcon; + private ImageIcon arrowUpIcon; + private JButton arrowButtonRef; // created by UI#createArrowButton + + public ComboBox() { - this((T[]) items.toArray()); + super(); + setRenderer(new ComboBoxListRenderer()); + // Keep consistent look with RuneLite + setBackground(ColorScheme.DARKER_GRAY_COLOR); + setForeground(Color.WHITE); + + // Load arrow icons from resources if available + // (If null, we just fall back to no icon customization) + arrowDownIcon = loadIcon("/combo_arrow_down.png"); + arrowUpIcon = loadIcon("/combo_arrow_up.png"); + + // Install a BasicComboBoxUI that uses our custom arrow button (if icons are available) + setUI(new BasicComboBoxUI() + { + @Override + protected JButton createArrowButton() + { + JButton b = (arrowDownIcon != null) ? new JButton(arrowDownIcon) : new JButton(); + b.setBorder(new EmptyBorder(0, 6, 0, 6)); + b.setContentAreaFilled(false); + b.setFocusPainted(false); + b.setOpaque(false); + arrowButtonRef = b; + return b; + } + }); + + // Swap arrow icon on popup visibility changes (if we have icons) + addPopupMenuListener(new PopupMenuListener() + { + @Override public void popupMenuWillBecomeVisible(PopupMenuEvent e) + { + if (arrowButtonRef != null && arrowUpIcon != null) + { + arrowButtonRef.setIcon(arrowUpIcon); + } + } + @Override public void popupMenuWillBecomeInvisible(PopupMenuEvent e) + { + if (arrowButtonRef != null && arrowDownIcon != null) + { + arrowButtonRef.setIcon(arrowDownIcon); + } + } + @Override public void popupMenuCanceled(PopupMenuEvent e) + { + if (arrowButtonRef != null && arrowDownIcon != null) + { + arrowButtonRef.setIcon(arrowDownIcon); + } + } + }); + + // Apply initial font scaling + applyFontScale(); } public ComboBox(T[] items) { - super(items); - - setForeground(Color.WHITE); - setBackground(ColorScheme.DARKER_GRAY_COLOR); - setFocusable(false); - setRenderer(new ComboBoxListRenderer<>(formatter)); - setUI(new ComboBoxUI()); - setBorder(new EmptyBorder(0, 0, 0, 0)); + this(); + setItems(java.util.Arrays.asList(items)); } - public void setFormatter(Function formatter) + public ComboBox(List items) { - this.formatter = formatter; - setRenderer(new ComboBoxListRenderer<>(formatter)); + this(); + setItems(items); } -} -class ComboBoxUI extends BasicComboBoxUI -{ - private static final ImageIcon ARROW_UP; - private static final ImageIcon ARROW_DOWN; + private ImageIcon loadIcon(String path) + { + java.net.URL url = getClass().getResource(path); + return (url != null) ? new ImageIcon(url) : null; + } - static { - final BufferedImage arrowUp = ImageUtil.loadImageResource( - GoalTrackerPlugin.class, "/combo_arrow_up.png"); - ARROW_UP = new ImageIcon(arrowUp); + /** + * Compact preset (~10% smaller font). + */ + public void setCompact(boolean compact) + { + setFontScale(compact ? 0.9 : 1.0); + } - final BufferedImage arrowDown = ImageUtil.loadImageResource( - GoalTrackerPlugin.class, "/combo_arrow_down.png"); - ARROW_DOWN = new ImageIcon(arrowDown); + /** + * Arbitrary font scaling (1.0 = normal). + */ + public void setFontScale(double scale) + { + if (scale <= 0) scale = 1.0; + this.fontScale = scale; + applyFontScale(); + repaint(); } - @Override - protected JButton createArrowButton() + private void applyFontScale() { - JButton button = new JButton(); - button.setBackground(ColorScheme.DARKER_GRAY_COLOR); - button.setBorder(new EmptyBorder(4, 4, 4, 4)); - button.setBorderPainted(false); - button.add(new JLabel(ARROW_DOWN)); - return button; + Font f = getFont(); + if (f != null) + { + int newSize = Math.max(10, Math.round(f.getSize() * (float) fontScale)); + setFont(new Font(f.getName(), f.getStyle(), newSize)); + } } -} -class ComboBoxListRenderer implements ListCellRenderer -{ - private Function formatter; + public void setItems(List items) + { + DefaultComboBoxModel model = new DefaultComboBoxModel<>(); + if (items != null) + { + for (T it : items) + { + model.addElement(it); + } + } + setModel(model); + } - ComboBoxListRenderer(Function formatter) + public void setFormatter(Function formatter) { this.formatter = formatter; + repaint(); } - @Override - public Component getListCellRendererComponent( - JList list, T o, int index, boolean isSelected, - boolean cellHasFocus) + /** + * Re-opens the popup after a selection is made, enabling fast repeated adds. + * Call this once after constructing the combo if you want the behavior. + */ + public void setStayOpenOnSelection(boolean stayOpen) { - JPanel container = new JPanel(new BorderLayout()); - - container.setBorder(new EmptyBorder(0, 4, 0, 4)); - - JLabel label = new JLabel(); - if (formatter != null) { - label.setText(formatter.apply(o)); - } else { - label.setText( - o instanceof Enum ? Text.titleCase((Enum) o) : o.toString()); + if (stayOpen) + { + this.addActionListener(e -> + SwingUtilities.invokeLater(() -> this.setPopupVisible(true)) + ); } - container.add(label, BorderLayout.WEST); + } - if (isSelected) { - container.setBackground(ColorScheme.DARK_GRAY_COLOR); - label.setForeground(Color.WHITE); + private class ComboBoxListRenderer implements ListCellRenderer + { + @Override + public Component getListCellRendererComponent( + JList list, + T value, + int index, + boolean isSelected, + boolean cellHasFocus) + { + JPanel container = new JPanel(new BorderLayout()); + container.setBorder(new EmptyBorder(2, 6, 2, 6)); + + JLabel label = new JLabel(); + label.setOpaque(false); + + if (value != null) + { + if (formatter != null) + { + label.setText(formatter.apply(value)); + } + else if (value instanceof Enum) + { + label.setText(Text.titleCase((Enum) value)); + } + else + { + label.setText(value.toString()); + } + } + else + { + label.setText(""); + } + + // Use the exact same font as the combo for crisp text + label.setFont(ComboBox.this.getFont()); + + container.add(label, BorderLayout.WEST); + + if (isSelected) + { + container.setBackground(ColorScheme.DARK_GRAY_COLOR); + label.setForeground(Color.WHITE); + } + else + { + container.setBackground(ColorScheme.DARKER_GRAY_COLOR); + label.setForeground(Color.WHITE); + } + + return container; } - - return container; } } diff --git a/src/main/java/com/toofifty/goaltracker/ui/components/EditableInput.java b/src/main/java/com/toofifty/goaltracker/ui/components/EditableInput.java index c86da01..771fc63 100644 --- a/src/main/java/com/toofifty/goaltracker/ui/components/EditableInput.java +++ b/src/main/java/com/toofifty/goaltracker/ui/components/EditableInput.java @@ -12,6 +12,13 @@ import javax.swing.border.Border; import javax.swing.border.CompoundBorder; import javax.swing.border.EmptyBorder; +import javax.swing.JPopupMenu; +import javax.swing.JMenuItem; +import javax.swing.KeyStroke; +import javax.swing.ActionMap; +import javax.swing.InputMap; +import javax.swing.text.DefaultEditorKit; +import java.awt.Toolkit; import lombok.Setter; import net.runelite.client.ui.ColorScheme; import net.runelite.client.ui.components.FlatTextField; @@ -54,7 +61,7 @@ public EditableInput(Consumer saveAction) cancel.onClick(e -> this.cancel()); edit.onClick(e -> { - inputField.setEditable(true); + inputField.getTextField().setEditable(true); updateActions(true); }); @@ -64,12 +71,43 @@ public EditableInput(Consumer saveAction) inputField.setText(localValue); inputField.setBorder(null); - inputField.setEditable(false); + inputField.getTextField().setEditable(false); + inputField.getTextField().setFocusable(true); inputField.setBackground(ColorScheme.DARKER_GRAY_COLOR); inputField.setPreferredSize(new Dimension(50, 24)); inputField.getTextField().setForeground(Color.WHITE); inputField.getTextField().setBorder(new EmptyBorder(0, 8, 0, 0)); - inputField.addKeyListener(new KeyAdapter() + // Ensure standard copy/paste/cut/select-all shortcuts work on all platforms + final var tf = inputField.getTextField(); + InputMap im = tf.getInputMap(); + ActionMap am = tf.getActionMap(); + int menuMask = Toolkit.getDefaultToolkit().getMenuShortcutKeyMaskEx(); + im.put(KeyStroke.getKeyStroke(KeyEvent.VK_C, menuMask), DefaultEditorKit.copyAction); + im.put(KeyStroke.getKeyStroke(KeyEvent.VK_V, menuMask), DefaultEditorKit.pasteAction); + im.put(KeyStroke.getKeyStroke(KeyEvent.VK_X, menuMask), DefaultEditorKit.cutAction); + im.put(KeyStroke.getKeyStroke(KeyEvent.VK_A, menuMask), DefaultEditorKit.selectAllAction); + // Also support Insert/Delete variants + im.put(KeyStroke.getKeyStroke(KeyEvent.VK_INSERT, KeyEvent.SHIFT_DOWN_MASK), DefaultEditorKit.pasteAction); + im.put(KeyStroke.getKeyStroke(KeyEvent.VK_INSERT, KeyEvent.CTRL_DOWN_MASK), DefaultEditorKit.copyAction); + im.put(KeyStroke.getKeyStroke(KeyEvent.VK_DELETE, KeyEvent.SHIFT_DOWN_MASK), DefaultEditorKit.cutAction); + + // Context menu for mouse-based copy/paste + JPopupMenu popup = new JPopupMenu(); + JMenuItem cut = new JMenuItem("Cut"); + cut.addActionListener(e -> tf.cut()); + popup.add(cut); + JMenuItem copy = new JMenuItem("Copy"); + copy.addActionListener(e -> tf.copy()); + popup.add(copy); + JMenuItem paste = new JMenuItem("Paste"); + paste.addActionListener(e -> tf.paste()); + popup.add(paste); + JMenuItem selectAll = new JMenuItem("Select All"); + selectAll.addActionListener(e -> tf.selectAll()); + popup.add(selectAll); + tf.setComponentPopupMenu(popup); + tf.setDragEnabled(true); + inputField.getTextField().addKeyListener(new KeyAdapter() { @Override public void keyPressed(KeyEvent e) @@ -91,14 +129,14 @@ private void save() localValue = inputField.getText(); saveAction.accept(localValue); - inputField.setEditable(false); + inputField.getTextField().setEditable(false); updateActions(false); requestFocusInWindow(); } private void cancel() { - inputField.setEditable(false); + inputField.getTextField().setEditable(false); inputField.setText(localValue); updateActions(false); diff --git a/src/main/java/com/toofifty/goaltracker/ui/components/ListItemPanel.java b/src/main/java/com/toofifty/goaltracker/ui/components/ListItemPanel.java index fcc9882..e2bdc16 100644 --- a/src/main/java/com/toofifty/goaltracker/ui/components/ListItemPanel.java +++ b/src/main/java/com/toofifty/goaltracker/ui/components/ListItemPanel.java @@ -2,16 +2,20 @@ import com.toofifty.goaltracker.utils.ReorderableList; import com.toofifty.goaltracker.ui.Refreshable; +import com.toofifty.goaltracker.models.Goal; import java.awt.BorderLayout; import java.awt.Color; import java.awt.Component; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.util.function.Consumer; +import java.util.function.BiConsumer; import javax.swing.JMenuItem; import javax.swing.JPanel; import javax.swing.JPopupMenu; +import javax.swing.JComponent; import javax.swing.border.EmptyBorder; +import javax.swing.border.MatteBorder; import net.runelite.client.ui.ColorScheme; public class ListItemPanel extends JPanel implements Refreshable @@ -28,6 +32,52 @@ public class ListItemPanel extends JPanel implements Refreshable protected Consumer reorderedListener; protected Consumer removedListener; + protected BiConsumer removedWithIndexListener; + + private MouseAdapter clickListenerAdapter; + + // Inner face of the goal card; only this area changes color on hover/press + private JPanel cardBody; + + private void addClickListenerRecursive(Component c) + { + if (clickListenerAdapter == null) return; + c.addMouseListener(clickListenerAdapter); + if (c instanceof java.awt.Container) + { + for (Component child : ((java.awt.Container) c).getComponents()) + { + addClickListenerRecursive(child); + } + } + } + + private void addContextMenuListenerRecursive(Component c) + { + c.addMouseListener(contextMenuListener); + if (c instanceof java.awt.Container) + { + for (Component child : ((java.awt.Container) c).getComponents()) + { + addContextMenuListenerRecursive(child); + } + } + } + + private final MouseAdapter contextMenuListener = new MouseAdapter() + { + private void maybeShow(MouseEvent e) + { + if (!(e.isPopupTrigger() || javax.swing.SwingUtilities.isRightMouseButton(e))) + { + return; + } + popupMenu.show((Component) e.getSource(), e.getX(), e.getY()); + } + + @Override public void mousePressed(MouseEvent e) { maybeShow(e); } + @Override public void mouseReleased(MouseEvent e) { maybeShow(e); } + }; public ListItemPanel(ReorderableList list, T item) { @@ -35,92 +85,116 @@ public ListItemPanel(ReorderableList list, T item) this.list = list; this.item = item; - setBorder(new EmptyBorder(8, 8, 8, 8)); - setBackground(ColorScheme.DARK_GRAY_COLOR); + // Create inner card surface to isolate hover/press background changes + cardBody = new JPanel(new BorderLayout()); + cardBody.setOpaque(true); + // Add the cardBody as the main content area + super.add(cardBody, BorderLayout.CENTER); + + if (item instanceof Goal) { + applyGoalCardDefaultStyle(); + } else { + setBorder(new EmptyBorder(2, 4, 2, 4)); // add horizontal and vertical spacing for tasks + setBackground(ColorScheme.DARK_GRAY_COLOR); + } moveUp.addActionListener(e -> { list.moveUp(item); - this.reorderedListener.accept(item); + if (this.reorderedListener != null) this.reorderedListener.accept(item); }); moveDown.addActionListener(e -> { list.moveDown(item); - this.reorderedListener.accept(item); + if (this.reorderedListener != null) this.reorderedListener.accept(item); }); moveToTop.addActionListener(e -> { list.moveToTop(item); - this.reorderedListener.accept(item); + if (this.reorderedListener != null) this.reorderedListener.accept(item); }); moveToBottom.addActionListener(e -> { list.moveToBottom(item); - this.reorderedListener.accept(item); + if (this.reorderedListener != null) this.reorderedListener.accept(item); }); removeItem.addActionListener(e -> { + int index = list.indexOf(item); list.remove(item); - this.removedListener.accept(item); + if (this.removedWithIndexListener != null) this.removedWithIndexListener.accept(item, index); + if (this.removedListener != null) this.removedListener.accept(item); }); popupMenu.setBorder(new EmptyBorder(5, 5, 5, 5)); setComponentPopupMenu(popupMenu); + // Also show context menu on press/release to handle platform differences + this.addMouseListener(contextMenuListener); + // Ensure popup and click listeners cover all descendants initially + addContextMenuListenerRecursive(this); + setOpaque(true); } @Override - public void setBackground(Color bg) + public void add(Component comp, Object constraints) { - super.setBackground(bg); - for (Component component : getComponents()) { - component.setBackground(bg); + if (item instanceof Goal) + { + cardBody.add(comp, BorderLayout.CENTER); + // For goal cards, strip inner borders from the content to avoid double outlines + if (comp instanceof JComponent) + { + ((JComponent) comp).setBorder(new EmptyBorder(0, 0, 0, 0)); + } + addContextMenuListenerRecursive(comp); + if (clickListenerAdapter != null) addClickListenerRecursive(comp); + return; } + super.add(comp, constraints); } @Override - public void refresh() + public Component add(String name, Component comp) { - refreshMenu(); - - for (Component component : getComponents()) { - if (component instanceof Refreshable) { - ((Refreshable) component).refresh(); + if (item instanceof Goal) + { + cardBody.add(comp, BorderLayout.CENTER); + if (comp instanceof JComponent) + { + ((JComponent) comp).setBorder(new EmptyBorder(0, 0, 0, 0)); } + addContextMenuListenerRecursive(comp); + if (clickListenerAdapter != null) addClickListenerRecursive(comp); + return comp; } - } - - public void refreshMenu() - { - popupMenu.removeAll(); - if (!list.isFirst(item)) { - popupMenu.add(moveUp); - } - if (!list.isLast(item)) { - popupMenu.add(moveDown); - } - if (!list.isFirst(item)) { - popupMenu.add(moveToTop); - } - if (!list.isLast(item)) { - popupMenu.add(moveToBottom); - } - popupMenu.add(removeItem); + return super.add(name, comp); } public ListItemPanel add(Component comp) { - super.add(comp, BorderLayout.CENTER); + cardBody.add(comp, BorderLayout.CENTER); + // For goal cards, strip inner borders from the content to avoid double outlines + if (item instanceof Goal && comp instanceof JComponent) + { + ((JComponent) comp).setBorder(new EmptyBorder(0, 0, 0, 0)); + } + addContextMenuListenerRecursive(comp); + if (clickListenerAdapter != null) addClickListenerRecursive(comp); return this; } public void onClick(Consumer clickListener) { - addMouseListener(new MouseAdapter() + clickListenerAdapter = new MouseAdapter() { @Override public void mousePressed(MouseEvent e) { - if (e.getButton() == MouseEvent.BUTTON1) { + if (e.getButton() == MouseEvent.BUTTON1) + { + if (item instanceof Goal) { + applyGoalCardPressedStyle(); + } clickListener.accept(e); } } @@ -128,15 +202,38 @@ public void mousePressed(MouseEvent e) @Override public void mouseEntered(MouseEvent e) { - setBackground(ColorScheme.DARK_GRAY_HOVER_COLOR); + if (item instanceof Goal) { + applyGoalCardHoverStyle(); + } } @Override public void mouseExited(MouseEvent e) { - setBackground(ColorScheme.DARK_GRAY_COLOR); + if (item instanceof Goal) { + applyGoalCardDefaultStyle(); + } } - }); + + @Override + public void mouseReleased(MouseEvent e) + { + if (item instanceof Goal) { + if (contains(e.getPoint())) { + applyGoalCardHoverStyle(); + } else { + applyGoalCardDefaultStyle(); + } + } + } + }; + + // Attach to this panel and all current children + addMouseListener(clickListenerAdapter); + addClickListenerRecursive(this); + + // Optional: use a hand cursor to indicate clickability + setCursor(new java.awt.Cursor(java.awt.Cursor.HAND_CURSOR)); } public void onRemoved(Consumer removeListener) { @@ -146,4 +243,115 @@ public void onRemoved(Consumer removeListener) { public void onReordered(Consumer reorderListener) { this.reorderedListener = reorderListener; } + + public void onRemovedWithIndex(BiConsumer removeListener) { + this.removedWithIndexListener = removeListener; + } + + private void applyGoalCardDefaultStyle() + { + // Outer spacing + shadow on the container panel + setBorder(javax.swing.BorderFactory.createCompoundBorder( + new EmptyBorder(8, 6, 8, 6), + new MatteBorder(2, 2, 4, 4, new Color(0, 0, 0, 60)) // shadow on all sides + )); + setBackground(ColorScheme.DARK_GRAY_COLOR); // keep outer area stable + + // Inner card face: rounded outline + inner padding + if (cardBody != null) + { + cardBody.setBorder(javax.swing.BorderFactory.createCompoundBorder( + javax.swing.BorderFactory.createLineBorder(ColorScheme.DARK_GRAY_COLOR, 1, true), + new EmptyBorder(6, 8, 6, 8) + )); + cardBody.setBackground(ColorScheme.DARK_GRAY_COLOR); + } + } + + private void applyGoalCardHoverStyle() + { + if (cardBody != null) + { + cardBody.setBackground(ColorScheme.DARK_GRAY_HOVER_COLOR); + } + } + + private void applyGoalCardPressedStyle() + { + if (cardBody != null) + { + cardBody.setBackground(ColorScheme.DARK_GRAY_COLOR); + } + } + + @Override + public void refresh() + { + // Refresh the context menu + popupMenu.removeAll(); + if (!list.isFirst(item)) { + popupMenu.add(moveUp); + } + if (!list.isLast(item)) { + popupMenu.add(moveDown); + } + if (!list.isFirst(item)) { + popupMenu.add(moveToTop); + } + if (!list.isLast(item)) { + popupMenu.add(moveToBottom); + } + popupMenu.add(removeItem); + + buildAdditionalMenu(); + + // Refresh all descendants that implement Refreshable + for (Component component : getComponents()) { + refreshDescendants(component); + } + revalidate(); + repaint(); + } + + /** + * Hook for subclasses to append extra context‑menu items. + * Base implementation adds Goal‑specific actions only. + */ + protected void buildAdditionalMenu() + { + if (item instanceof Goal) + { + JMenuItem markAllComplete = new JMenuItem("Mark all as completed"); + JMenuItem markAllIncomplete = new JMenuItem("Mark all as incomplete"); + + markAllComplete.addActionListener(e -> { + Goal goal = (Goal) item; + goal.setAllTasksCompleted(true); + refresh(); + }); + + markAllIncomplete.addActionListener(e -> { + Goal goal = (Goal) item; + goal.setAllTasksCompleted(false); + refresh(); + }); + + popupMenu.addSeparator(); + popupMenu.add(markAllComplete); + popupMenu.add(markAllIncomplete); + } + // Non‑Goal rows (e.g., Tasks) should add their own items in subclasses like ListTaskPanel + } + + private void refreshDescendants(Component c) + { + if (c instanceof Refreshable) { + ((Refreshable) c).refresh(); + } + if (c instanceof java.awt.Container) { + for (Component child : ((java.awt.Container) c).getComponents()) { + refreshDescendants(child); + } + } + } } diff --git a/src/main/java/com/toofifty/goaltracker/ui/components/ListPanel.java b/src/main/java/com/toofifty/goaltracker/ui/components/ListPanel.java index d514ffc..8fecf26 100644 --- a/src/main/java/com/toofifty/goaltracker/ui/components/ListPanel.java +++ b/src/main/java/com/toofifty/goaltracker/ui/components/ListPanel.java @@ -78,7 +78,10 @@ private List> buildItemPanels() @Override public void refresh() { - // refresh all children + // Rebuild the list first in case the underlying data changed + tryBuildList(); + + // Then refresh all children for (Component component : listPanel.getComponents()) { if (component instanceof Refreshable) { ((Refreshable) component).refresh(); @@ -96,14 +99,12 @@ private ListItemPanel buildItemPanel(T item) itemPanel.onReordered((updatedItem) -> { tryBuildList(); - - this.updatedListener.accept(updatedItem); + if (this.updatedListener != null) this.updatedListener.accept(updatedItem); }); itemPanel.onRemoved((updatedItem) -> { tryBuildList(); - - this.updatedListener.accept(updatedItem); + if (this.updatedListener != null) this.updatedListener.accept(updatedItem); }); itemPanelMap.put(item, itemPanel); @@ -131,7 +132,7 @@ private void refreshChildMenus() { for (Component component : listPanel.getComponents()) { if (component instanceof ListItemPanel) { - ((ListItemPanel) component).refreshMenu(); + ((Refreshable) component).refresh(); } } } diff --git a/src/main/java/com/toofifty/goaltracker/ui/components/ListTaskPanel.java b/src/main/java/com/toofifty/goaltracker/ui/components/ListTaskPanel.java index 46d4e81..ac397f3 100644 --- a/src/main/java/com/toofifty/goaltracker/ui/components/ListTaskPanel.java +++ b/src/main/java/com/toofifty/goaltracker/ui/components/ListTaskPanel.java @@ -1,76 +1,224 @@ package com.toofifty.goaltracker.ui.components; +import com.toofifty.goaltracker.ui.TaskItemContent; +import com.toofifty.goaltracker.utils.QuestRequirements; + import com.toofifty.goaltracker.utils.ReorderableList; import com.toofifty.goaltracker.models.task.Task; +import com.toofifty.goaltracker.models.enums.Status; import java.util.function.Consumer; import javax.swing.JMenuItem; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.Component; +import javax.swing.SwingUtilities; +import java.awt.Container; +import com.toofifty.goaltracker.models.ActionHistory; +import com.toofifty.goaltracker.models.ReorderTaskAction; public class ListTaskPanel extends ListItemPanel { + private TaskItemContent taskContent; + private final JMenuItem indentItem = new JMenuItem("Indent"); private final JMenuItem unindentItem = new JMenuItem("Unindent"); private Consumer indentedListener; private Consumer unindentedListener; + private ActionHistory history; + + private final MouseAdapter shiftClickRemoveListener = new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (e.isShiftDown() && SwingUtilities.isLeftMouseButton(e)) { + removeItem.doClick(); + } + } + }; + + private void addShiftRemoveListenerRecursive(Component c) + { + c.addMouseListener(shiftClickRemoveListener); + if (c instanceof Container) + { + for (Component child : ((Container) c).getComponents()) + { + addShiftRemoveListenerRecursive(child); + } + } + } + public ListTaskPanel(ReorderableList list, Task item) { super(list, item); indentItem.addActionListener(e -> { - // Indent all of the items children - var index = list.indexOf(item); + int index = list.indexOf(item); + int baseIndent = item.getIndentLevel(); + + // Collect affected tasks (item + descendants until sibling/parent) + java.util.List affected = new java.util.ArrayList<>(); + java.util.List oldIndents = new java.util.ArrayList<>(); + + affected.add(item); + oldIndents.add(baseIndent); for (int i = index + 1; i < list.size(); i++) { var child = list.get(i); + if (child.getIndentLevel() <= baseIndent) break; + affected.add(child); + oldIndents.add(child.getIndentLevel()); + } - System.out.println(String.format("%s >= %s", item.getIndentLevel(), child.getIndentLevel())); - // If a child is less indented then this item assume its a parent node and break - if (item.getIndentLevel() >= child.getIndentLevel()) break; + ActionHistory.Action act = new ActionHistory.Action() { + @Override public void undo() { + for (int i = 0; i < affected.size(); i++) { + affected.get(i).setIndentLevel(oldIndents.get(i)); + } + } + @Override public void redo() { + for (int i = 0; i < affected.size(); i++) { + affected.get(i).setIndentLevel(oldIndents.get(i) + 1); + } + } + }; - child.indent(); + if (history != null) { + act.redo(); + history.push(act); + } else { + // Fallback: apply directly + for (int i = 0; i < affected.size(); i++) { + affected.get(i).setIndentLevel(oldIndents.get(i) + 1); + } } - item.indent(); - this.indentedListener.accept(item); + if (this.indentedListener != null) this.indentedListener.accept(item); + refreshParentList(); }); unindentItem.addActionListener(e -> { - // Unindent all of the items children - var index = list.indexOf(item); + int index = list.indexOf(item); + int baseIndent = item.getIndentLevel(); + + // Collect affected tasks (item + descendants until sibling/parent) + java.util.List affected = new java.util.ArrayList<>(); + java.util.List oldIndents = new java.util.ArrayList<>(); + + affected.add(item); + oldIndents.add(baseIndent); for (int i = index + 1; i < list.size(); i++) { var child = list.get(i); + if (child.getIndentLevel() <= baseIndent) break; + affected.add(child); + oldIndents.add(child.getIndentLevel()); + } - System.out.println(String.format("%s >= %s", item.getIndentLevel(), child.getIndentLevel())); - // If a child is less indented then this item assume its a parent node and break - if (item.getIndentLevel() >= child.getIndentLevel()) break; + ActionHistory.Action act = new ActionHistory.Action() { + @Override public void undo() { + for (int i = 0; i < affected.size(); i++) { + affected.get(i).setIndentLevel(oldIndents.get(i)); + } + } + @Override public void redo() { + for (int i = 0; i < affected.size(); i++) { + affected.get(i).setIndentLevel(Math.max(0, oldIndents.get(i) - 1)); + } + } + }; - child.unindent();; + if (history != null) { + act.redo(); + history.push(act); + } else { + for (int i = 0; i < affected.size(); i++) { + affected.get(i).setIndentLevel(Math.max(0, oldIndents.get(i) - 1)); + } } - item.unindent(); - this.unindentedListener.accept(item); + if (this.unindentedListener != null) this.unindentedListener.accept(item); + refreshParentList(); }); + // Allow shift-click to remove this item and all its indented children + addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (e.isShiftDown() && e.getButton() == MouseEvent.BUTTON1) { + // Delegate to the Remove action (which cascades children and notifies listeners) + removeItem.doClick(); + } + } + }); + // Apply the same shift-click removal listener to all nested components + addShiftRemoveListenerRecursive(this); } - @Override public void refreshMenu() { + super.refresh(); popupMenu.removeAll(); + javax.swing.JMenu moveMenu = new javax.swing.JMenu("Move"); + boolean hasMove = false; if (!list.isFirst(item)) { - popupMenu.add(moveUp); + for (var l : moveUp.getActionListeners()) moveUp.removeActionListener(l); + moveUp.addActionListener(e -> { + int oldIndex = list.indexOf(item); + int newIndex = Math.max(0, oldIndex - 1); + list.moveUp(item); + if (history != null) { + history.push(new ReorderTaskAction(list, item, oldIndex, newIndex)); + } + refreshParentList(); + }); + moveMenu.add(moveUp); + hasMove = true; } if (!list.isLast(item)) { - popupMenu.add(moveDown); + for (var l : moveDown.getActionListeners()) moveDown.removeActionListener(l); + moveDown.addActionListener(e -> { + int oldIndex = list.indexOf(item); + int newIndex = Math.min(list.size() - 1, oldIndex + 1); + list.moveDown(item); + if (history != null) { + history.push(new ReorderTaskAction(list, item, oldIndex, newIndex)); + } + refreshParentList(); + }); + moveMenu.add(moveDown); + hasMove = true; } if (!list.isFirst(item)) { - popupMenu.add(moveToTop); + for (var l : moveToTop.getActionListeners()) moveToTop.removeActionListener(l); + moveToTop.addActionListener(e -> { + int oldIndex = list.indexOf(item); + list.moveToTop(item); + if (history != null) { + history.push(new ReorderTaskAction(list, item, oldIndex, 0)); + } + refreshParentList(); + }); + moveMenu.add(moveToTop); + hasMove = true; } if (!list.isLast(item)) { - popupMenu.add(moveToBottom); + for (var l : moveToBottom.getActionListeners()) moveToBottom.removeActionListener(l); + moveToBottom.addActionListener(e -> { + int oldIndex = list.indexOf(item); + list.moveToBottom(item); + if (history != null) { + history.push(new ReorderTaskAction(list, item, oldIndex, list.size() - 1)); + } + refreshParentList(); + }); + moveMenu.add(moveToBottom); + hasMove = true; + } + if (hasMove) { + popupMenu.add(moveMenu); } var previousItem = list.getPreviousItem(item); - + if (item.isNotFullyIndented() && previousItem != null && previousItem.getIndentLevel() >= item.getIndentLevel()) { popupMenu.add(indentItem); } @@ -79,6 +227,137 @@ public void refreshMenu() popupMenu.add(unindentItem); } + String toggleLabel = "Mark as " + (item.getStatus() == Status.COMPLETED ? "Incomplete" : "Completed"); + JMenuItem toggleStatusItem = new JMenuItem(toggleLabel); + toggleStatusItem.addActionListener(e -> { + // Determine new status for the root item + Status newStatus = (item.getStatus() == Status.COMPLETED ? Status.NOT_STARTED : Status.COMPLETED); + + // Collect affected tasks (item + descendants until sibling/parent) and their old statuses + java.util.List affected = new java.util.ArrayList<>(); + java.util.List oldStatuses = new java.util.ArrayList<>(); + + int index = list.indexOf(item); + int baseIndent = item.getIndentLevel(); + affected.add(item); + oldStatuses.add(item.getStatus()); + for (int i = index + 1; i < list.size(); i++) { + Task child = list.get(i); + if (child.getIndentLevel() <= baseIndent) break; + affected.add(child); + oldStatuses.add(child.getStatus()); + } + + ActionHistory.Action act = new ActionHistory.Action() { + @Override public void undo() { + for (int i = 0; i < affected.size(); i++) { + affected.get(i).setStatus(oldStatuses.get(i)); + } + } + @Override public void redo() { + for (Task t : affected) { + t.setStatus(newStatus); + } + } + }; + + if (history != null) { + act.redo(); // apply change now + history.push(act); // record for undo/redo + } else { + // Fallback if no history injected + for (Task t : affected) { + t.setStatus(newStatus); + } + } + + if (taskContent != null) { + taskContent.refresh(); + } + refreshParentList(); + }); + popupMenu.add(toggleStatusItem); + + // Add quest pre-reqs menu item only if the quest actually has prereqs + if (item instanceof com.toofifty.goaltracker.models.task.QuestTask) { + com.toofifty.goaltracker.models.task.QuestTask questTask = (com.toofifty.goaltracker.models.task.QuestTask) item; + int baseIndent = item.getIndentLevel(); + // Gather existing direct/descendant children under this quest to avoid duplicates + java.util.Set existingKeys = new java.util.HashSet<>(); + int parentIndex = list.indexOf(item); + for (int i = parentIndex + 1; i < list.size(); i++) { + var child = list.get(i); + if (child.getIndentLevel() <= baseIndent) { + break; // stop at siblings/parents + } + existingKeys.add(child.getClass().getName() + "|" + child.toString()); + } + var rawPrereqs = QuestRequirements.getRequirements(questTask.getQuest(), baseIndent + 1); + java.util.List missingPrereqs = new java.util.ArrayList<>(); + if (rawPrereqs != null) { + for (com.toofifty.goaltracker.models.task.Task p : rawPrereqs) { + String key = p.getClass().getName() + "|" + p.toString(); + if (!existingKeys.contains(key)) { + missingPrereqs.add(p); + } + } + } + if (!missingPrereqs.isEmpty()) { + JMenuItem prereqItem = new JMenuItem("Add pre-reqs"); + prereqItem.addActionListener(e -> { + // Recompute and filter again at click time + var raw = QuestRequirements.getRequirements(questTask.getQuest(), baseIndent + 1); + if (raw != null) { + // Refresh existing keys in case the list changed since menu was built + java.util.Set currentKeys = new java.util.HashSet<>(); + int pIndex = list.indexOf(item); + for (int i = pIndex + 1; i < list.size(); i++) { + var child = list.get(i); + if (child.getIndentLevel() <= baseIndent) break; + currentKeys.add(child.getClass().getName() + "|" + child.toString()); + } + java.util.List filtered = new java.util.ArrayList<>(); + for (com.toofifty.goaltracker.models.task.Task t : raw) { + String key = t.getClass().getName() + "|" + t.toString(); + if (!currentKeys.contains(key)) { + filtered.add(t); + } + } + if (!filtered.isEmpty()) { + int index = list.indexOf(item); + for (com.toofifty.goaltracker.models.task.Task prereq : filtered) { + list.add(index + 1, prereq); + index++; + } + refreshParentList(); + } + } + }); + popupMenu.add(prereqItem); + } + } + + // Make the Remove action also delete all indented children of this item + for (var l : removeItem.getActionListeners()) { + removeItem.removeActionListener(l); + } + removeItem.addActionListener(e -> { + int removedIndex = list.indexOf(item); + int baseIndent = item.getIndentLevel(); + // Remove all children more indented than this item + while (removedIndex + 1 < list.size() && list.get(removedIndex + 1).getIndentLevel() > baseIndent) { + list.remove(list.get(removedIndex + 1)); + } + // Remove the item itself + list.remove(item); + // Notify listeners for Undo/Redo support + if (this.removedWithIndexListener != null) this.removedWithIndexListener.accept(item, removedIndex); + if (this.removedListener != null) this.removedListener.accept(item); + // Refresh UI + refreshParentList(); + }); + + removeItem.setText("Remove (Shift+Left Click)"); popupMenu.add(removeItem); } @@ -89,4 +368,247 @@ public void onIndented(Consumer indentedListener) { public void onUnindented(Consumer unindentedListener) { this.unindentedListener = unindentedListener; } + + public void setTaskContent(TaskItemContent taskContent) { + this.taskContent = taskContent; + if (taskContent != null) { + addShiftRemoveListenerRecursive(taskContent); + } + } + + public void setActionHistory(ActionHistory history) { + this.history = history; + } + + /** + * Appends Task-specific context menu items. + */ + @Override + protected void buildAdditionalMenu() + { + javax.swing.JMenu moveMenu = new javax.swing.JMenu("Move"); + boolean hasMove = false; + // Rewire generic move actions to include ActionHistory entries + if (!list.isFirst(item)) { + for (var l : moveUp.getActionListeners()) moveUp.removeActionListener(l); + moveUp.addActionListener(e -> { + int oldIndex = list.indexOf(item); + int newIndex = Math.max(0, oldIndex - 1); + list.moveUp(item); + if (history != null) { + history.push(new ReorderTaskAction(list, item, oldIndex, newIndex)); + } + refreshParentList(); + }); + moveMenu.add(moveUp); + hasMove = true; + } + if (!list.isLast(item)) { + for (var l : moveDown.getActionListeners()) moveDown.removeActionListener(l); + moveDown.addActionListener(e -> { + int oldIndex = list.indexOf(item); + int newIndex = Math.min(list.size() - 1, oldIndex + 1); + list.moveDown(item); + if (history != null) { + history.push(new ReorderTaskAction(list, item, oldIndex, newIndex)); + } + refreshParentList(); + }); + moveMenu.add(moveDown); + hasMove = true; + } + if (!list.isFirst(item)) { + for (var l : moveToTop.getActionListeners()) moveToTop.removeActionListener(l); + moveToTop.addActionListener(e -> { + int oldIndex = list.indexOf(item); + list.moveToTop(item); + if (history != null) { + history.push(new ReorderTaskAction(list, item, oldIndex, 0)); + } + refreshParentList(); + }); + moveMenu.add(moveToTop); + hasMove = true; + } + if (!list.isLast(item)) { + for (var l : moveToBottom.getActionListeners()) moveToBottom.removeActionListener(l); + moveToBottom.addActionListener(e -> { + int oldIndex = list.indexOf(item); + list.moveToBottom(item); + if (history != null) { + history.push(new ReorderTaskAction(list, item, oldIndex, list.size() - 1)); + } + refreshParentList(); + }); + moveMenu.add(moveToBottom); + hasMove = true; + } + if (hasMove) { + popupMenu.add(moveMenu); + } + + // Indent / Unindent + var previousItem = list.getPreviousItem(item); + if (item.isNotFullyIndented() && previousItem != null && previousItem.getIndentLevel() >= item.getIndentLevel()) { + popupMenu.add(indentItem); + } + if (item.isIndented()) { + popupMenu.add(unindentItem); + } + + // Toggle Completed/Incomplete for this task and its descendants + String toggleLabel = "Mark as " + (item.getStatus() == Status.COMPLETED ? "Incomplete" : "Completed"); + JMenuItem toggleStatusItem = new JMenuItem(toggleLabel); + toggleStatusItem.addActionListener(e -> { + Status newStatus = (item.getStatus() == Status.COMPLETED ? Status.NOT_STARTED : Status.COMPLETED); + java.util.List affected = new java.util.ArrayList<>(); + java.util.List oldStatuses = new java.util.ArrayList<>(); + int index = list.indexOf(item); + int baseIndent = item.getIndentLevel(); + affected.add(item); + oldStatuses.add(item.getStatus()); + for (int i = index + 1; i < list.size(); i++) { + Task child = list.get(i); + if (child.getIndentLevel() <= baseIndent) break; + affected.add(child); + oldStatuses.add(child.getStatus()); + } + ActionHistory.Action act = new ActionHistory.Action() { + @Override public void undo() { for (int i = 0; i < affected.size(); i++) affected.get(i).setStatus(oldStatuses.get(i)); } + @Override public void redo() { for (Task t : affected) t.setStatus(newStatus); } + }; + if (history != null) { act.redo(); history.push(act); } + else { for (Task t : affected) t.setStatus(newStatus); } + if (taskContent != null) taskContent.refresh(); + refreshParentList(); + }); + popupMenu.add(toggleStatusItem); + + // Quest pre-reqs (if available) + if (item instanceof com.toofifty.goaltracker.models.task.QuestTask) { + com.toofifty.goaltracker.models.task.QuestTask questTask = (com.toofifty.goaltracker.models.task.QuestTask) item; + int baseIndent = item.getIndentLevel(); + java.util.Set existingKeys = new java.util.HashSet<>(); + int parentIndex = list.indexOf(item); + for (int i = parentIndex + 1; i < list.size(); i++) { + var child = list.get(i); + if (child.getIndentLevel() <= baseIndent) break; + existingKeys.add(child.getClass().getName() + "|" + child.toString()); + } + var rawPrereqs = QuestRequirements.getRequirements(questTask.getQuest(), baseIndent + 1); + java.util.List missingPrereqs = new java.util.ArrayList<>(); + if (rawPrereqs != null) { + for (com.toofifty.goaltracker.models.task.Task p : rawPrereqs) { + String key = p.getClass().getName() + "|" + p.toString(); + if (!existingKeys.contains(key)) missingPrereqs.add(p); + } + } + if (!missingPrereqs.isEmpty()) { + JMenuItem prereqItem = new JMenuItem("Add pre-reqs"); + prereqItem.addActionListener(e -> { + var raw = QuestRequirements.getRequirements(questTask.getQuest(), baseIndent + 1); + if (raw != null) { + java.util.Set currentKeys = new java.util.HashSet<>(); + int pIndex = list.indexOf(item); + for (int i = pIndex + 1; i < list.size(); i++) { + var child = list.get(i); + if (child.getIndentLevel() <= baseIndent) break; + currentKeys.add(child.getClass().getName() + "|" + child.toString()); + } + java.util.List filtered = new java.util.ArrayList<>(); + for (com.toofifty.goaltracker.models.task.Task t : raw) { + String key = t.getClass().getName() + "|" + t.toString(); + if (!currentKeys.contains(key)) filtered.add(t); + } + if (!filtered.isEmpty()) { + int insertIndex = list.indexOf(item); + for (com.toofifty.goaltracker.models.task.Task prereq : filtered) { + list.add(insertIndex + 1, prereq); + insertIndex++; + } + refreshParentList(); + } + } + }); + popupMenu.add(prereqItem); + } + } + + // Rewire Remove to cascade children and update label + for (var l : removeItem.getActionListeners()) removeItem.removeActionListener(l); + removeItem.addActionListener(e -> { + int removedIndex = list.indexOf(item); + int baseIndent = item.getIndentLevel(); + while (removedIndex + 1 < list.size() && list.get(removedIndex + 1).getIndentLevel() > baseIndent) { + list.remove(list.get(removedIndex + 1)); + } + list.remove(item); + if (this.removedWithIndexListener != null) this.removedWithIndexListener.accept(item, removedIndex); + if (this.removedListener != null) this.removedListener.accept(item); + refreshParentList(); + }); + removeItem.setText("Remove (Shift+Left Click)"); + // Ensure remove is at the bottom: remove and re-add it + popupMenu.remove(removeItem); + popupMenu.add(removeItem); + } + + private void refreshParentList() + { + Container parent = SwingUtilities.getAncestorOfClass(ListPanel.class, this); + if (parent instanceof ListPanel) { + ((ListPanel) parent).tryBuildList(); + ((ListPanel) parent).refresh(); + } else { + // Fallback + revalidate(); + repaint(); + } + } + /** + * Programmatically add quest prerequisites using the same logic as the context menu action. + * This lets callers (e.g., presets) trigger prereq insertion without simulating a right-click. + */ + public static void addPrereqsForTask(ReorderableList list, Task item) + { + if (!(item instanceof com.toofifty.goaltracker.models.task.QuestTask)) { + return; + } + com.toofifty.goaltracker.models.task.QuestTask questTask = (com.toofifty.goaltracker.models.task.QuestTask) item; + int baseIndent = item.getIndentLevel(); + + // Gather existing children under this task to avoid duplicates + java.util.Set existingKeys = new java.util.HashSet<>(); + int parentIndex = list.indexOf(item); + for (int i = parentIndex + 1; i < list.size(); i++) { + Task child = list.get(i); + if (child.getIndentLevel() <= baseIndent) break; + existingKeys.add(child.getClass().getName() + "|" + child.toString()); + } + + java.util.List raw = QuestRequirements.getRequirements(questTask.getQuest(), baseIndent + 1); + if (raw == null || raw.isEmpty()) { + return; + } + + // Filter out any already-present entries + java.util.List filtered = new java.util.ArrayList<>(); + for (com.toofifty.goaltracker.models.task.Task t : raw) { + String key = t.getClass().getName() + "|" + t.toString(); + if (!existingKeys.contains(key)) { + filtered.add(t); + } + } + + if (filtered.isEmpty()) { + return; + } + + // Insert directly after the parent item, preserving order from QuestRequirements + int insertIndex = list.indexOf(item); + for (com.toofifty.goaltracker.models.task.Task prereq : filtered) { + list.add(insertIndex + 1, prereq); + insertIndex++; + } + } } diff --git a/src/main/java/com/toofifty/goaltracker/ui/components/TextButton.java b/src/main/java/com/toofifty/goaltracker/ui/components/TextButton.java index be242c2..3adb92a 100644 --- a/src/main/java/com/toofifty/goaltracker/ui/components/TextButton.java +++ b/src/main/java/com/toofifty/goaltracker/ui/components/TextButton.java @@ -75,4 +75,7 @@ public TextButton narrow() setBorder(new EmptyBorder(0, 2, 0, 2)); return this; } + + public void setOnClick(Object o) { + } } diff --git a/src/main/java/com/toofifty/goaltracker/ui/inputs/ItemTaskInput.java b/src/main/java/com/toofifty/goaltracker/ui/inputs/ItemTaskInput.java index 431c780..cefe64a 100644 --- a/src/main/java/com/toofifty/goaltracker/ui/inputs/ItemTaskInput.java +++ b/src/main/java/com/toofifty/goaltracker/ui/inputs/ItemTaskInput.java @@ -13,17 +13,32 @@ import net.runelite.client.ui.components.FlatTextField; import javax.swing.*; +import javax.swing.SwingUtilities; import javax.swing.border.EmptyBorder; import java.awt.*; +import java.awt.Point; +import java.awt.KeyboardFocusManager; +import java.awt.Window; import java.util.regex.Pattern; +import java.awt.Color; +import javax.swing.JDialog; +import javax.swing.JButton; +import java.awt.event.ActionListener; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import javax.swing.text.JTextComponent; -public class ItemTaskInput extends TaskInput + + + +public class ItemTaskInput extends TaskInput { private final ItemManager itemManager; private final ClientThread clientThread; private final FlatTextField quantityField = new FlatTextField(); private final TextButton searchItemButton = new TextButton("Search..."); + private boolean searchOpen = false; private final JLabel selectedItemLabel = new JLabel(); private final JPanel selectedItemPanel = new JPanel(new BorderLayout()); @@ -33,6 +48,7 @@ public class ItemTaskInput extends TaskInput private String quantityFieldValue = "1"; private ItemComposition selectedItem; + private String lastSearchText = ""; public ItemTaskInput(GoalTrackerPlugin plugin, Goal goal) { @@ -41,18 +57,29 @@ public ItemTaskInput(GoalTrackerPlugin plugin, Goal goal) this.clientThread = plugin.getClientThread(); searchItemButton.onClick(e -> { - if (plugin.getClient().getGameState() != GameState.LOGGED_IN) { - JOptionPane.showMessageDialog(this, - "You must be logged in to choose items", - "UwU", - JOptionPane.ERROR_MESSAGE); - return; - } + if (!searchOpen) { + if (plugin.getClient().getGameState() != GameState.LOGGED_IN) { + JOptionPane.showMessageDialog(this, + "You must be logged in to choose items", + "UwU", + JOptionPane.ERROR_MESSAGE); + return; + } - plugin.getItemSearch() - .tooltipText("Choose an item") - .onItemSelected(this::setSelectedItem) - .build(); + plugin.getItemSearch() + .tooltipText("Choose an item") + .onItemSelected(this::setSelectedItem) + .build(); + searchItemButton.setText("Close"); + searchOpen = true; + } + else { + try { + plugin.getChatboxPanelManager().close(); + } catch (Exception ignored) {} + searchItemButton.setText("Search..."); + searchOpen = false; + } }); getInputRow().add(searchItemButton, BorderLayout.WEST); @@ -108,6 +135,18 @@ private void setSelectedItem(Integer rawId) revalidate(); repaint(); + + // Immediately add the item task + submit(); + + // Reopen search so user can add multiple items + plugin.getItemSearch() + .tooltipText("Choose an item") + .onItemSelected(this::setSelectedItem) + .build(); + + searchItemButton.setText("Close"); + searchOpen = true; }); } @@ -122,7 +161,7 @@ protected void submit() .itemId(selectedItem.getId()) .itemName(selectedItem.getName()) .quantity(Integer.parseInt(quantityField.getText())) - .build()); + .build()); } @Override @@ -142,5 +181,181 @@ private void clearSelectedItem() revalidate(); repaint(); + + searchItemButton.setText("Search..."); + searchOpen = false; + } + + private boolean containsTextField(Container c) + { + if (c == null) return false; + for (Component comp : c.getComponents()) + { + if (comp instanceof JTextComponent) return true; + if (comp instanceof Container && containsTextField((Container) comp)) return true; + } + return false; + } + + private JTextComponent findTextField(Container c) + { + if (c == null) return null; + for (Component comp : c.getComponents()) + { + if (comp instanceof JTextComponent) return (JTextComponent) comp; + if (comp instanceof Container) + { + JTextComponent tf = findTextField((Container) comp); + if (tf != null) return tf; + } + } + return null; + } + + // Small floating red X button to close the item search popup + private void showCloseOverlay() + { + final int[] attempts = {0}; + final int maxAttempts = 20; + final int delayMs = 150; + + ActionListener tryShow = new ActionListener() { + @Override + public void actionPerformed(java.awt.event.ActionEvent ev) { + Component focusOwner = KeyboardFocusManager.getCurrentKeyboardFocusManager().getFocusOwner(); + final JTextComponent searchField = (focusOwner instanceof JTextComponent) ? (JTextComponent) focusOwner : null; + Window target = (focusOwner != null) ? SwingUtilities.getWindowAncestor(focusOwner) : null; + + if (target == null || searchField == null) + { + if (++attempts[0] < maxAttempts) + { + new javax.swing.Timer(delayMs, this) {{ setRepeats(false); }}.start(); + } + return; + } + + // Create overlay dialog owned by the search window so it closes with it + final JDialog overlay = new JDialog(target); + overlay.setUndecorated(true); + overlay.setAlwaysOnTop(true); + overlay.setFocusableWindowState(false); + overlay.setType(Window.Type.UTILITY); + + JPanel header = new JPanel(new BorderLayout()); + header.setBackground(ColorScheme.DARKER_GRAY_COLOR); + JButton close = new JButton("X"); + close.setForeground(Color.WHITE); + close.setBackground(ColorScheme.PROGRESS_ERROR_COLOR); + close.setBorder(new EmptyBorder(2, 6, 2, 6)); + close.setFocusable(false); + close.addActionListener(e2 -> { + overlay.dispose(); + lastSearchText = ""; + if (target instanceof JDialog) { + ((JDialog) target).dispose(); + } else if (target instanceof javax.swing.JFrame) { + ((javax.swing.JFrame) target).dispose(); + } else { + target.dispose(); + } + }); + header.add(close, BorderLayout.EAST); + overlay.getContentPane().add(header); + overlay.pack(); + + // Position at top-right of the search text field (anchor to the actual box) + Point tfLoc = searchField.getLocationOnScreen(); + int tfRight = tfLoc.x + searchField.getWidth(); + int x = tfRight - overlay.getWidth() - 4; // tuck inside right edge + int y = tfLoc.y + 2; // inside the box near the top + overlay.setLocation(x, y); + overlay.setVisible(true); + overlay.toFront(); + + // Restore and persist the user's query + if (lastSearchText != null && !lastSearchText.isEmpty()) + { + searchField.setText(lastSearchText); + searchField.setCaretPosition(searchField.getText().length()); + searchField.requestFocusInWindow(); + } + searchField.getDocument().addDocumentListener(new DocumentListener() + { + @Override public void insertUpdate(DocumentEvent e) { lastSearchText = searchField.getText(); } + @Override public void removeUpdate(DocumentEvent e) { lastSearchText = searchField.getText(); } + @Override public void changedUpdate(DocumentEvent e) { lastSearchText = searchField.getText(); } + }); + + if (target instanceof Window) { + ((Window) target).addWindowListener(new java.awt.event.WindowAdapter() { + @Override + public void windowClosed(java.awt.event.WindowEvent e) { overlay.dispose(); } + @Override + public void windowClosing(java.awt.event.WindowEvent e) { overlay.dispose(); } + }); + + target.addComponentListener(new java.awt.event.ComponentAdapter() { + @Override + public void componentMoved(java.awt.event.ComponentEvent e) { + try { + Point tfLoc2 = searchField.getLocationOnScreen(); + int tfRight2 = tfLoc2.x + searchField.getWidth(); + int x2 = tfRight2 - overlay.getWidth() - 4; + int y2 = tfLoc2.y + 2; + overlay.setLocation(x2, y2); + } catch (IllegalComponentStateException ignored) { } + } + @Override + public void componentResized(java.awt.event.ComponentEvent e) { + try { + Point tfLoc2 = searchField.getLocationOnScreen(); + int tfRight2 = tfLoc2.x + searchField.getWidth(); + int x2 = tfRight2 - overlay.getWidth() - 4; + int y2 = tfLoc2.y + 2; + overlay.setLocation(x2, y2); + } catch (IllegalComponentStateException ignored) { } + } + }); + } + } + }; + + // start first attempt slightly delayed to give the popup time to appear + new javax.swing.Timer(delayMs, tryShow) {{ setRepeats(false); }}.start(); + } + + // Try to locate the RuneLite Item Search popup window reliably + private Window findItemSearchWindow() + { + // Prefer visible JDialogs containing a text field (likely the chatbox item search) + for (Window w : Window.getWindows()) + { + if (w != null && w.isShowing() && w instanceof JDialog) + { + if (w instanceof Container && containsTextField((Container) w)) + { + return w; + } + } + } + Window active = KeyboardFocusManager.getCurrentKeyboardFocusManager().getActiveWindow(); + if (active != null && active.isShowing() && active instanceof Container && containsTextField((Container) active)) + { + return active; + } + for (Window w : Window.getWindows()) + { + if (w != null && w.isShowing() && w instanceof Container && containsTextField((Container) w)) + { + return w; + } + } + return null; + } + @Override + protected boolean showAddButton() + { + return false; } -} +} \ No newline at end of file diff --git a/src/main/java/com/toofifty/goaltracker/ui/inputs/ManualTaskInput.java b/src/main/java/com/toofifty/goaltracker/ui/inputs/ManualTaskInput.java index 4a7f1ce..39fe519 100644 --- a/src/main/java/com/toofifty/goaltracker/ui/inputs/ManualTaskInput.java +++ b/src/main/java/com/toofifty/goaltracker/ui/inputs/ManualTaskInput.java @@ -10,7 +10,7 @@ import java.awt.event.KeyAdapter; import java.awt.event.KeyEvent; -public class ManualTaskInput extends TaskInput +public class ManualTaskInput extends TaskInput { private final FlatTextField titleField; @@ -50,4 +50,4 @@ protected void reset() titleField.setText(""); titleField.requestFocusInWindow(); } -} +} \ No newline at end of file diff --git a/src/main/java/com/toofifty/goaltracker/ui/inputs/QuestTaskInput.java b/src/main/java/com/toofifty/goaltracker/ui/inputs/QuestTaskInput.java index 9aeff72..2225e6f 100644 --- a/src/main/java/com/toofifty/goaltracker/ui/inputs/QuestTaskInput.java +++ b/src/main/java/com/toofifty/goaltracker/ui/inputs/QuestTaskInput.java @@ -3,41 +3,87 @@ import com.toofifty.goaltracker.GoalTrackerPlugin; import com.toofifty.goaltracker.models.Goal; import com.toofifty.goaltracker.models.task.QuestTask; +import com.toofifty.goaltracker.models.task.Task; +import com.toofifty.goaltracker.utils.QuestRequirements; import com.toofifty.goaltracker.ui.components.ComboBox; import net.runelite.api.Quest; +import net.runelite.client.ui.ColorScheme; +import javax.swing.*; +import javax.swing.border.EmptyBorder; import java.awt.*; import java.util.Arrays; import java.util.Comparator; import java.util.List; +import java.util.ArrayList; +import java.awt.event.ActionEvent; +import java.awt.event.KeyEvent; -public class QuestTaskInput extends TaskInput +public class QuestTaskInput extends TaskInput { - private final ComboBox questField; + private final List allQuests; + private Quest bestMatch; + private final ComboBox questDropdown; public QuestTaskInput(GoalTrackerPlugin plugin, Goal goal) { super(plugin, goal, "Quest"); - List quests = Arrays.asList(Quest.values()); - quests.sort(Comparator.comparing( - (quest) -> quest.getName().replaceFirst("^(A|The) ", ""))); - questField = new ComboBox<>(quests); - questField.setFormatter(Quest::getName); - getInputRow().add(questField, BorderLayout.CENTER); + // Initialize all quests and sort them + allQuests = Arrays.asList(Quest.values()); + allQuests.sort(Comparator.comparing(Quest::getName)); + + // Initialize dropdown via shared ComboBox for consistent arrows and crisp fonts + questDropdown = new ComboBox<>(allQuests); + // questDropdown.setCompact(true); // ~10% smaller font + questDropdown.setFormatter(q -> q != null ? q.getName() : ""); + + // Force RuneScape UF font at normal size + Font rsFont = new Font("RuneScape UF", Font.PLAIN, 10); + questDropdown.setFont(rsFont); + + + + questDropdown.setSelectedIndex(-1); // no selection initially + questDropdown.addActionListener(e -> { + Quest selected = (Quest) questDropdown.getSelectedItem(); + if (selected != null) { + bestMatch = selected; + } + }); + + questDropdown.getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT) + .put(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), "submitQuest"); + questDropdown.getActionMap().put("submitQuest", new AbstractAction() { + @Override + public void actionPerformed(ActionEvent e) { + submit(); + } + }); + + // Add components to layout + JPanel container = new JPanel(new BorderLayout(5, 5)); + container.add(questDropdown, BorderLayout.CENTER); + getInputRow().add(container, BorderLayout.CENTER); } @Override protected void submit() { - addTask(QuestTask.builder() - .quest((Quest) questField.getSelectedItem()) - .build()); + if (bestMatch != null) { + QuestTask mainQuestTask = QuestTask.builder() + .quest(bestMatch) + .indentLevel(0) + .build(); + + addTask(mainQuestTask); + } } @Override protected void reset() { - questField.setSelectedIndex(0); + questDropdown.setSelectedIndex(-1); + bestMatch = null; } -} +} \ No newline at end of file diff --git a/src/main/java/com/toofifty/goaltracker/ui/inputs/SkillLevelTaskInput.java b/src/main/java/com/toofifty/goaltracker/ui/inputs/SkillLevelTaskInput.java index 71d07f1..fc29ba8 100644 --- a/src/main/java/com/toofifty/goaltracker/ui/inputs/SkillLevelTaskInput.java +++ b/src/main/java/com/toofifty/goaltracker/ui/inputs/SkillLevelTaskInput.java @@ -14,9 +14,8 @@ import java.awt.*; import java.util.regex.Pattern; -public class SkillLevelTaskInput extends TaskInput +public class SkillLevelTaskInput extends TaskInput { - private FlatTextField levelField; private String levelFieldValue = "99"; @@ -61,7 +60,7 @@ protected void submit() addTask(SkillLevelTask.builder() .skill((Skill) skillField.getSelectedItem()) .level(Integer.parseInt(levelField.getText())) - .build()); + .build()); } @Override @@ -72,4 +71,4 @@ protected void reset() skillField.setSelectedIndex(0); } -} +} \ No newline at end of file diff --git a/src/main/java/com/toofifty/goaltracker/ui/inputs/SkillXpTaskInput.java b/src/main/java/com/toofifty/goaltracker/ui/inputs/SkillXpTaskInput.java index 408ba23..f02a9a4 100644 --- a/src/main/java/com/toofifty/goaltracker/ui/inputs/SkillXpTaskInput.java +++ b/src/main/java/com/toofifty/goaltracker/ui/inputs/SkillXpTaskInput.java @@ -14,7 +14,7 @@ import java.awt.*; import java.util.regex.Pattern; -public class SkillXpTaskInput extends TaskInput +public class SkillXpTaskInput extends TaskInput { private final FlatTextField xpField; private final ComboBox skillField; @@ -79,7 +79,7 @@ protected void submit() addTask(SkillXpTask.builder() .skill((Skill) skillField.getSelectedItem()) .xp(Integer.parseInt(xpField.getText())) - .build()); + .build()); } @Override @@ -90,4 +90,4 @@ protected void reset() skillField.setSelectedIndex(0); } -} +} \ No newline at end of file diff --git a/src/main/java/com/toofifty/goaltracker/ui/inputs/TaskInput.java b/src/main/java/com/toofifty/goaltracker/ui/inputs/TaskInput.java index f651f6f..804231a 100644 --- a/src/main/java/com/toofifty/goaltracker/ui/inputs/TaskInput.java +++ b/src/main/java/com/toofifty/goaltracker/ui/inputs/TaskInput.java @@ -7,23 +7,24 @@ import java.awt.BorderLayout; import java.awt.GridBagConstraints; import java.awt.GridBagLayout; -import java.util.function.Consumer; import javax.swing.JLabel; import javax.swing.JPanel; import javax.swing.border.EmptyBorder; import lombok.Getter; import net.runelite.client.ui.ColorScheme; import net.runelite.client.ui.FontManager; +import java.util.function.Consumer; +import java.util.Collection; -public abstract class TaskInput extends JPanel +public abstract class TaskInput extends JPanel { protected final int PREFERRED_INPUT_HEIGHT = 16; - protected GoalTrackerPlugin plugin; + protected final GoalTrackerPlugin plugin; private final Goal goal; @Getter private final JPanel inputRow; @Getter - private Consumer listener; + private Consumer listener; TaskInput(GoalTrackerPlugin plugin, Goal goal, String title) { @@ -51,30 +52,46 @@ public abstract class TaskInput extends JPanel inputRow = new JPanel(new BorderLayout()); inputRow.setBackground(ColorScheme.DARKER_GRAY_COLOR); - - TextButton addButton = new TextButton("Add"); - addButton.onClick(e -> submit()); - - inputRow.add(addButton, BorderLayout.EAST); - + if (showAddButton()) { + TextButton addButton = new TextButton("Add"); + addButton.onClick(e -> submit()); + inputRow.add(addButton, BorderLayout.EAST); + } add(inputRow, constraints); constraints.gridy++; } abstract protected void submit(); - public void addTask(T task) + public void addTask(Task task) { goal.getTasks().add(task); - this.listener.accept(task); + if (listener != null) { + listener.accept(task); + } + this.reset(); + } + + public void addTasks(Collection tasks) + { + goal.getTasks().addAll(tasks); + if (listener != null) { + for (Task t : tasks) { + listener.accept(t); + } + } this.reset(); } abstract protected void reset(); - public TaskInput onSubmit(Consumer listener) + public TaskInput onSubmit(Consumer listener) { this.listener = listener; return this; } -} + protected boolean showAddButton() + { + return true; + } +} \ No newline at end of file diff --git a/src/main/java/com/toofifty/goaltracker/utils/QuestRequirements.java b/src/main/java/com/toofifty/goaltracker/utils/QuestRequirements.java new file mode 100644 index 0000000..b468856 --- /dev/null +++ b/src/main/java/com/toofifty/goaltracker/utils/QuestRequirements.java @@ -0,0 +1,1240 @@ +package com.toofifty.goaltracker.utils; + +import com.toofifty.goaltracker.models.task.QuestTask; +import com.toofifty.goaltracker.models.task.SkillLevelTask; +import com.toofifty.goaltracker.models.task.Task; +import net.runelite.api.Quest; +import net.runelite.api.Skill; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class QuestRequirements +{ + // Map of quests to their requirements (quests and skills) + private static final Map> REQUIREMENT_MAP; + + // Static initializer with minimal dependencies + static { + REQUIREMENT_MAP = new HashMap<>(); + REQUIREMENT_MAP.put( + Quest.FAIRYTALE_I__GROWING_PAINS, + Arrays.asList( + QuestTask.builder().quest(Quest.LOST_CITY).build(), + QuestTask.builder().quest(Quest.NATURE_SPIRIT).build(), + SkillLevelTask.builder().skill(Skill.FARMING).level(18).build() + ) + ); + REQUIREMENT_MAP.put( + Quest.NATURE_SPIRIT, + Arrays.asList( + QuestTask.builder().quest(Quest.PRIEST_IN_PERIL).build(), + QuestTask.builder().quest(Quest.THE_RESTLESS_GHOST).build(), + SkillLevelTask.builder().skill(Skill.PRAYER).level(18).build() + ) + ); + REQUIREMENT_MAP.put( + Quest.DRAGON_SLAYER_I, + Arrays.asList( + SkillLevelTask.builder().skill(Skill.ATTACK).level(32).build(), + QuestTask.builder().quest(Quest.PRIEST_IN_PERIL).build() + ) + ); + // Jungle Potion requires Druidic Ritual and Herblore 3【532018769049742†L45-L49】. + REQUIREMENT_MAP.put( + Quest.JUNGLE_POTION, + Arrays.asList( + QuestTask.builder().quest(Quest.DRUIDIC_RITUAL).build(), + SkillLevelTask.builder().skill(Skill.HERBLORE).level(3).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.BIOHAZARD, + Arrays.asList( + QuestTask.builder().quest(Quest.PLAGUE_CITY).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.EAGLES_PEAK, + Arrays.asList( + SkillLevelTask.builder().skill(Skill.HUNTER).level(27).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.ELEMENTAL_WORKSHOP_I, + Arrays.asList( + SkillLevelTask.builder().skill(Skill.MINING).level(20).build(), + SkillLevelTask.builder().skill(Skill.SMITHING).level(20).build(), + SkillLevelTask.builder().skill(Skill.CRAFTING).level(20).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.FISHING_CONTEST, + Arrays.asList( + SkillLevelTask.builder().skill(Skill.FISHING).level(10).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.OBSERVATORY_QUEST, + Arrays.asList( + SkillLevelTask.builder().skill(Skill.CRAFTING).level(10).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.RECRUITMENT_DRIVE, + Collections.singletonList( + QuestTask.builder().quest(Quest.BLACK_KNIGHTS_FORTRESS).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.TOWER_OF_LIFE, + Arrays.asList( + SkillLevelTask.builder().skill(Skill.CONSTRUCTION).level(10).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.ANIMAL_MAGNETISM, + Arrays.asList( + QuestTask.builder().quest(Quest.ERNEST_THE_CHICKEN).build(), + QuestTask.builder().quest(Quest.PRIEST_IN_PERIL).build(), + QuestTask.builder().quest(Quest.THE_RESTLESS_GHOST).build(), + SkillLevelTask.builder().skill(Skill.SLAYER).level(18).build(), + SkillLevelTask.builder().skill(Skill.RANGED).level(30).build(), + SkillLevelTask.builder().skill(Skill.CRAFTING).level(19).build(), + SkillLevelTask.builder().skill(Skill.WOODCUTTING).level(35).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.ANOTHER_SLICE_OF_HAM, + Arrays.asList( + QuestTask.builder().quest(Quest.DEATH_TO_THE_DORGESHUUN).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.BIG_CHOMPY_BIRD_HUNTING, + Arrays.asList( + SkillLevelTask.builder().skill(Skill.COOKING).level(30).build(), + SkillLevelTask.builder().skill(Skill.FLETCHING).level(5).build(), + SkillLevelTask.builder().skill(Skill.RANGED).level(30).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.COLD_WAR, + Arrays.asList( + SkillLevelTask.builder().skill(Skill.AGILITY).level(30).build(), + SkillLevelTask.builder().skill(Skill.CONSTRUCTION).level(34).build(), + SkillLevelTask.builder().skill(Skill.CRAFTING).level(30).build(), + SkillLevelTask.builder().skill(Skill.HUNTER).level(10).build(), + SkillLevelTask.builder().skill(Skill.THIEVING).level(15).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.CREATURE_OF_FENKENSTRAIN, + Arrays.asList( + SkillLevelTask.builder().skill(Skill.CRAFTING).level(20).build(), + SkillLevelTask.builder().skill(Skill.THIEVING).level(25).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.DARKNESS_OF_HALLOWVALE, + Arrays.asList( + QuestTask.builder().quest(Quest.IN_AID_OF_THE_MYREQUE).build(), + SkillLevelTask.builder().skill(Skill.AGILITY).level(26).build(), + SkillLevelTask.builder().skill(Skill.CONSTRUCTION).level(5).build(), + SkillLevelTask.builder().skill(Skill.CRAFTING).level(32).build(), + SkillLevelTask.builder().skill(Skill.MAGIC).level(33).build(), + SkillLevelTask.builder().skill(Skill.MINING).level(20).build(), + SkillLevelTask.builder().skill(Skill.STRENGTH).level(40).build(), + SkillLevelTask.builder().skill(Skill.THIEVING).level(22).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.DEATH_TO_THE_DORGESHUUN, + Arrays.asList( + QuestTask.builder().quest(Quest.THE_LOST_TRIBE).build(), + SkillLevelTask.builder().skill(Skill.AGILITY).level(23).build(), + SkillLevelTask.builder().skill(Skill.THIEVING).level(17).build(), + SkillLevelTask.builder().skill(Skill.MINING).level(23).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.THE_DIG_SITE, + Arrays.asList( + SkillLevelTask.builder().skill(Skill.AGILITY).level(10).build(), + SkillLevelTask.builder().skill(Skill.HERBLORE).level(10).build(), + SkillLevelTask.builder().skill(Skill.THIEVING).level(25).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.ELEMENTAL_WORKSHOP_II, + Arrays.asList( + QuestTask.builder().quest(Quest.ELEMENTAL_WORKSHOP_I).build(), + SkillLevelTask.builder().skill(Skill.DEFENCE).level(20).build(), + SkillLevelTask.builder().skill(Skill.SMITHING).level(20).build(), + SkillLevelTask.builder().skill(Skill.CRAFTING).level(20).build(), + SkillLevelTask.builder().skill(Skill.MINING).level(30).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.ENLIGHTENED_JOURNEY, + Arrays.asList( + SkillLevelTask.builder().skill(Skill.CRAFTING).level(36).build(), + SkillLevelTask.builder().skill(Skill.FIREMAKING).level(20).build(), + SkillLevelTask.builder().skill(Skill.FARMING).level(30).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.THE_EYES_OF_GLOUPHRIE, + Arrays.asList( + QuestTask.builder().quest(Quest.THE_GRAND_TREE).build(), + SkillLevelTask.builder().skill(Skill.MAGIC).level(46).build(), + SkillLevelTask.builder().skill(Skill.FARMING).level(5).build(), + SkillLevelTask.builder().skill(Skill.CONSTRUCTION).level(5).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.THE_FEUD, + Arrays.asList( + SkillLevelTask.builder().skill(Skill.THIEVING).level(30).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.FORGETTABLE_TALE, + Arrays.asList( + QuestTask.builder().quest(Quest.THE_GIANT_DWARF).build(), + SkillLevelTask.builder().skill(Skill.COOKING).level(22).build(), + SkillLevelTask.builder().skill(Skill.FARMING).level(12).build(), + SkillLevelTask.builder().skill(Skill.HERBLORE).level(17).build(), + SkillLevelTask.builder().skill(Skill.THIEVING).level(16).build(), + SkillLevelTask.builder().skill(Skill.FISHING).level(10).build(), + SkillLevelTask.builder().skill(Skill.MINING).level(33).build(), + SkillLevelTask.builder().skill(Skill.STRENGTH).level(14).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.GARDEN_OF_TRANQUILLITY, + Arrays.asList( + QuestTask.builder().quest(Quest.CREATURE_OF_FENKENSTRAIN).build(), + SkillLevelTask.builder().skill(Skill.FARMING).level(25).build(), + SkillLevelTask.builder().skill(Skill.HERBLORE).level(20).build(), + SkillLevelTask.builder().skill(Skill.THIEVING).level(25).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.GHOSTS_AHOY, + Arrays.asList( + QuestTask.builder().quest(Quest.PRIEST_IN_PERIL).build(), + SkillLevelTask.builder().skill(Skill.AGILITY).level(25).build(), + SkillLevelTask.builder().skill(Skill.COOKING).level(20).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.THE_GIANT_DWARF, + Arrays.asList( + SkillLevelTask.builder().skill(Skill.CRAFTING).level(12).build(), + SkillLevelTask.builder().skill(Skill.FIREMAKING).level(16).build(), + SkillLevelTask.builder().skill(Skill.MAGIC).level(33).build(), + SkillLevelTask.builder().skill(Skill.THIEVING).level(14).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.THE_GOLEM, + Arrays.asList( + SkillLevelTask.builder().skill(Skill.THIEVING).level(20).build(), + SkillLevelTask.builder().skill(Skill.CRAFTING).level(25).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.THE_HAND_IN_THE_SAND, + Arrays.asList( + SkillLevelTask.builder().skill(Skill.CRAFTING).level(49).build(), + SkillLevelTask.builder().skill(Skill.THIEVING).level(17).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.HOLY_GRAIL, + Arrays.asList( + QuestTask.builder().quest(Quest.MERLINS_CRYSTAL).build(), + SkillLevelTask.builder().skill(Skill.ATTACK).level(20).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.ICTHLARINS_LITTLE_HELPER, + Arrays.asList( + QuestTask.builder().quest(Quest.PRINCE_ALI_RESCUE).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.IN_AID_OF_THE_MYREQUE, + Arrays.asList( + QuestTask.builder().quest(Quest.IN_SEARCH_OF_THE_MYREQUE).build(), + SkillLevelTask.builder().skill(Skill.CRAFTING).level(25).build(), + SkillLevelTask.builder().skill(Skill.MINING).level(25).build(), + SkillLevelTask.builder().skill(Skill.MAGIC).level(7).build(), + SkillLevelTask.builder().skill(Skill.FISHING).level(15).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.IN_SEARCH_OF_THE_MYREQUE, + Arrays.asList( + QuestTask.builder().quest(Quest.NATURE_SPIRIT).build(), + SkillLevelTask.builder().skill(Skill.AGILITY).level(25).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.THE_KNIGHTS_SWORD, + Arrays.asList( + SkillLevelTask.builder().skill(Skill.MINING).level(10).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.THE_LOST_TRIBE, + Arrays.asList( + QuestTask.builder().quest(Quest.GOBLIN_DIPLOMACY).build(), + SkillLevelTask.builder().skill(Skill.AGILITY).level(13).build(), + SkillLevelTask.builder().skill(Skill.MINING).level(17).build(), + SkillLevelTask.builder().skill(Skill.THIEVING).level(13).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.MAKING_HISTORY, + Arrays.asList( + QuestTask.builder().quest(Quest.PRIEST_IN_PERIL).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.MOUNTAIN_DAUGHTER, + Arrays.asList( + SkillLevelTask.builder().skill(Skill.AGILITY).level(20).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.MY_ARMS_BIG_ADVENTURE, + Arrays.asList( + QuestTask.builder().quest(Quest.JUNGLE_POTION).build(), + SkillLevelTask.builder().skill(Skill.AGILITY).level(15).build(), + SkillLevelTask.builder().skill(Skill.FARMING).level(29).build(), + SkillLevelTask.builder().skill(Skill.HERBLORE).level(31).build(), + SkillLevelTask.builder().skill(Skill.WOODCUTTING).level(30).build(), + SkillLevelTask.builder().skill(Skill.COOKING).level(10).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.OLAFS_QUEST, + Arrays.asList( + SkillLevelTask.builder().skill(Skill.AGILITY).level(40).build(), + SkillLevelTask.builder().skill(Skill.FIREMAKING).level(50).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.RATCATCHERS, + Arrays.asList( + QuestTask.builder().quest(Quest.ICTHLARINS_LITTLE_HELPER).build(), + SkillLevelTask.builder().skill(Skill.AGILITY).level(12).build(), + SkillLevelTask.builder().skill(Skill.SLAYER).level(16).build(), + SkillLevelTask.builder().skill(Skill.THIEVING).level(33).build(), + SkillLevelTask.builder().skill(Skill.HUNTER).level(14).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.SCORPION_CATCHER, + Arrays.asList( + QuestTask.builder().quest(Quest.ALFRED_GRIMHANDS_BARCRAWL).build(), + SkillLevelTask.builder().skill(Skill.PRAYER).level(31).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.SEA_SLUG, + Arrays.asList( + SkillLevelTask.builder().skill(Skill.FIREMAKING).level(30).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.SHADES_OF_MORTTON, + Arrays.asList( + SkillLevelTask.builder().skill(Skill.CRAFTING).level(20).build(), + SkillLevelTask.builder().skill(Skill.HERBLORE).level(5).build(), + SkillLevelTask.builder().skill(Skill.MINING).level(15).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.SHADOW_OF_THE_STORM, + Arrays.asList( + QuestTask.builder().quest(Quest.DEMON_SLAYER).build(), + QuestTask.builder().quest(Quest.THE_GOLEM).build(), + SkillLevelTask.builder().skill(Skill.CRAFTING).level(30).build(), + SkillLevelTask.builder().skill(Skill.THIEVING).level(25).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.THE_SLUG_MENACE, + Arrays.asList( + QuestTask.builder().quest(Quest.WANTED).build(), + SkillLevelTask.builder().skill(Skill.RUNECRAFT).level(13).build(), + SkillLevelTask.builder().skill(Skill.CRAFTING).level(30).build(), + SkillLevelTask.builder().skill(Skill.SLAYER).level(30).build(), + SkillLevelTask.builder().skill(Skill.THIEVING).level(17).build(), + SkillLevelTask.builder().skill(Skill.SMITHING).level(30).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.SPIRITS_OF_THE_ELID, + Arrays.asList( + SkillLevelTask.builder().skill(Skill.MAGIC).level(33).build(), + SkillLevelTask.builder().skill(Skill.MINING).level(37).build(), + SkillLevelTask.builder().skill(Skill.RANGED).level(37).build(), + SkillLevelTask.builder().skill(Skill.THIEVING).level(37).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.TAI_BWO_WANNAI_TRIO, + Arrays.asList( + SkillLevelTask.builder().skill(Skill.AGILITY).level(15).build(), + SkillLevelTask.builder().skill(Skill.COOKING).level(30).build(), + SkillLevelTask.builder().skill(Skill.HERBLORE).level(5).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.A_TAIL_OF_TWO_CATS, + Arrays.asList( + QuestTask.builder().quest(Quest.ICTHLARINS_LITTLE_HELPER).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.TEARS_OF_GUTHIX, + Arrays.asList( + SkillLevelTask.builder().skill(Skill.CRAFTING).level(20).build(), + SkillLevelTask.builder().skill(Skill.FIREMAKING).level(49).build(), + SkillLevelTask.builder().skill(Skill.MINING).level(20).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.THE_TOURIST_TRAP, + Arrays.asList( + SkillLevelTask.builder().skill(Skill.FLETCHING).level(10).build(), + SkillLevelTask.builder().skill(Skill.SMITHING).level(20).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.TRIBAL_TOTEM, + Arrays.asList( + SkillLevelTask.builder().skill(Skill.THIEVING).level(21).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.WANTED, + Arrays.asList( + QuestTask.builder().quest(Quest.RECRUITMENT_DRIVE).build(), + SkillLevelTask.builder().skill(Skill.AGILITY).level(13).build(), + SkillLevelTask.builder().skill(Skill.MINING).level(17).build(), + SkillLevelTask.builder().skill(Skill.THIEVING).level(13).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.WATCHTOWER, + Arrays.asList( + SkillLevelTask.builder().skill(Skill.AGILITY).level(25).build(), + SkillLevelTask.builder().skill(Skill.HERBLORE).level(14).build(), + SkillLevelTask.builder().skill(Skill.MAGIC).level(15).build(), + SkillLevelTask.builder().skill(Skill.MINING).level(40).build(), + SkillLevelTask.builder().skill(Skill.THIEVING).level(15).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.WHAT_LIES_BELOW, + Arrays.asList( + QuestTask.builder().quest(Quest.RUNE_MYSTERIES).build(), + SkillLevelTask.builder().skill(Skill.RUNECRAFT).level(35).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.ZOGRE_FLESH_EATERS, + Arrays.asList( + QuestTask.builder().quest(Quest.JUNGLE_POTION).build(), + QuestTask.builder().quest(Quest.BIG_CHOMPY_BIRD_HUNTING).build(), + SkillLevelTask.builder().skill(Skill.FLETCHING).level(30).build(), + SkillLevelTask.builder().skill(Skill.HERBLORE).level(8).build(), + SkillLevelTask.builder().skill(Skill.SMITHING).level(4).build(), + SkillLevelTask.builder().skill(Skill.RANGED).level(30).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.BETWEEN_A_ROCK, + Arrays.asList( + QuestTask.builder().quest(Quest.DWARF_CANNON).build(), + SkillLevelTask.builder().skill(Skill.DEFENCE).level(30).build(), + SkillLevelTask.builder().skill(Skill.MINING).level(10).build(), + SkillLevelTask.builder().skill(Skill.CRAFTING).level(40).build(), + SkillLevelTask.builder().skill(Skill.SMITHING).level(50).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.CABIN_FEVER, + Arrays.asList( + QuestTask.builder().quest(Quest.RUM_DEAL).build(), + SkillLevelTask.builder().skill(Skill.AGILITY).level(42).build(), + SkillLevelTask.builder().skill(Skill.CRAFTING).level(30).build(), + SkillLevelTask.builder().skill(Skill.SMITHING).level(45).build(), + SkillLevelTask.builder().skill(Skill.RANGED).level(40).build(), + SkillLevelTask.builder().skill(Skill.COOKING).level(50).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.DEVIOUS_MINDS, + Arrays.asList( + QuestTask.builder().quest(Quest.WANTED).build(), + SkillLevelTask.builder().skill(Skill.THIEVING).level(50).build(), + SkillLevelTask.builder().skill(Skill.RUNECRAFT).level(50).build(), + SkillLevelTask.builder().skill(Skill.SMITHING).level(65).build(), + SkillLevelTask.builder().skill(Skill.FLETCHING).level(50).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.EADGARS_RUSE, + Arrays.asList( + QuestTask.builder().quest(Quest.DRUIDIC_RITUAL).build(), + SkillLevelTask.builder().skill(Skill.HERBLORE).level(31).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.ENAKHRAS_LAMENT, + Arrays.asList( + SkillLevelTask.builder().skill(Skill.CRAFTING).level(50).build(), + SkillLevelTask.builder().skill(Skill.FIREMAKING).level(45).build(), + SkillLevelTask.builder().skill(Skill.MAGIC).level(39).build(), + SkillLevelTask.builder().skill(Skill.PRAYER).level(43).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.FAIRYTALE_II__CURE_A_QUEEN, + Arrays.asList( + QuestTask.builder().quest(Quest.FAIRYTALE_I__GROWING_PAINS).build(), + SkillLevelTask.builder().skill(Skill.THIEVING).level(40).build(), + SkillLevelTask.builder().skill(Skill.MAGIC).level(49).build(), + SkillLevelTask.builder().skill(Skill.HERBLORE).level(57).build(), + SkillLevelTask.builder().skill(Skill.FARMING).level(37).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.FAMILY_CREST, + Arrays.asList( + SkillLevelTask.builder().skill(Skill.MINING).level(40).build(), + SkillLevelTask.builder().skill(Skill.MAGIC).level(59).build(), + SkillLevelTask.builder().skill(Skill.SMITHING).level(40).build(), + SkillLevelTask.builder().skill(Skill.CRAFTING).level(40).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.THE_FREMENNIK_ISLES, + Arrays.asList( + QuestTask.builder().quest(Quest.THE_FREMENNIK_TRIALS).build(), + SkillLevelTask.builder().skill(Skill.AGILITY).level(40).build(), + SkillLevelTask.builder().skill(Skill.CONSTRUCTION).level(20).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.THE_GRAND_TREE, + Arrays.asList( + SkillLevelTask.builder().skill(Skill.AGILITY).level(25).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.THE_GREAT_BRAIN_ROBBERY, + Arrays.asList( + QuestTask.builder().quest(Quest.CABIN_FEVER).build(), + SkillLevelTask.builder().skill(Skill.PRAYER).level(50).build(), + SkillLevelTask.builder().skill(Skill.CONSTRUCTION).level(30).build(), + SkillLevelTask.builder().skill(Skill.COOKING).level(31).build(), + SkillLevelTask.builder().skill(Skill.CRAFTING).level(45).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.HAUNTED_MINE, + Arrays.asList( + SkillLevelTask.builder().skill(Skill.AGILITY).level(15).build(), + SkillLevelTask.builder().skill(Skill.CRAFTING).level(35).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.HEROES_QUEST, + Arrays.asList( + QuestTask.builder().quest(Quest.SHIELD_OF_ARRAV).build(), + QuestTask.builder().quest(Quest.DRAGON_SLAYER_I).build(), + QuestTask.builder().quest(Quest.MERLINS_CRYSTAL).build(), + QuestTask.builder().quest(Quest.LOST_CITY).build(), + SkillLevelTask.builder().skill(Skill.COOKING).level(53).build(), + SkillLevelTask.builder().skill(Skill.HERBLORE).level(25).build(), + SkillLevelTask.builder().skill(Skill.FISHING).level(53).build(), + SkillLevelTask.builder().skill(Skill.AGILITY).level(25).build(), + SkillLevelTask.builder().skill(Skill.MINING).level(50).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.HORROR_FROM_THE_DEEP, + Arrays.asList( + SkillLevelTask.builder().skill(Skill.AGILITY).level(35).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.KINGS_RANSOM, + Arrays.asList( + QuestTask.builder().quest(Quest.BLACK_KNIGHTS_FORTRESS).build(), + QuestTask.builder().quest(Quest.HOLY_GRAIL).build(), + QuestTask.builder().quest(Quest.MURDER_MYSTERY).build(), + SkillLevelTask.builder().skill(Skill.MAGIC).level(65).build(), + SkillLevelTask.builder().skill(Skill.DEFENCE).level(45).build(), + SkillLevelTask.builder().skill(Skill.AGILITY).level(30).build(), + SkillLevelTask.builder().skill(Skill.HERBLORE).level(18).build(), + SkillLevelTask.builder().skill(Skill.SMITHING).level(45).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.LOST_CITY, + Arrays.asList( + SkillLevelTask.builder().skill(Skill.CRAFTING).level(31).build(), + SkillLevelTask.builder().skill(Skill.WOODCUTTING).level(36).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.LUNAR_DIPLOMACY, + Arrays.asList( + QuestTask.builder().quest(Quest.THE_FREMENNIK_TRIALS).build(), + SkillLevelTask.builder().skill(Skill.WOODCUTTING).level(55).build(), + SkillLevelTask.builder().skill(Skill.MINING).level(60).build(), + SkillLevelTask.builder().skill(Skill.HERBLORE).level(5).build(), + SkillLevelTask.builder().skill(Skill.FIREMAKING).level(49).build(), + SkillLevelTask.builder().skill(Skill.DEFENCE).level(40).build(), + SkillLevelTask.builder().skill(Skill.CRAFTING).level(61).build(), + SkillLevelTask.builder().skill(Skill.AGILITY).level(32).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.ONE_SMALL_FAVOUR, + Arrays.asList( + SkillLevelTask.builder().skill(Skill.AGILITY).level(36).build(), + SkillLevelTask.builder().skill(Skill.CRAFTING).level(25).build(), + SkillLevelTask.builder().skill(Skill.HERBLORE).level(18).build(), + SkillLevelTask.builder().skill(Skill.SMITHING).level(30).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.ROYAL_TROUBLE, + Arrays.asList( + QuestTask.builder().quest(Quest.THRONE_OF_MISCELLANIA).build(), + SkillLevelTask.builder().skill(Skill.AGILITY).level(40).build(), + SkillLevelTask.builder().skill(Skill.SLAYER).level(40).build(), + SkillLevelTask.builder().skill(Skill.FARMING).level(53).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.RUM_DEAL, + Arrays.asList( + QuestTask.builder().quest(Quest.ZOGRE_FLESH_EATERS).build(), + SkillLevelTask.builder().skill(Skill.CRAFTING).level(30).build(), + SkillLevelTask.builder().skill(Skill.PRAYER).level(47).build(), + SkillLevelTask.builder().skill(Skill.FISHING).level(50).build(), + SkillLevelTask.builder().skill(Skill.FARMING).level(40).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.SHILO_VILLAGE, + Arrays.asList( + QuestTask.builder().quest(Quest.JUNGLE_POTION).build(), + SkillLevelTask.builder().skill(Skill.AGILITY).level(32).build(), + SkillLevelTask.builder().skill(Skill.CRAFTING).level(20).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.TEMPLE_OF_IKOV, + Arrays.asList( + SkillLevelTask.builder().skill(Skill.RANGED).level(40).build(), + SkillLevelTask.builder().skill(Skill.THIEVING).level(42).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.THRONE_OF_MISCELLANIA, + Arrays.asList( + QuestTask.builder().quest(Quest.HEROES_QUEST).build(), + SkillLevelTask.builder().skill(Skill.COOKING).level(45).build(), + SkillLevelTask.builder().skill(Skill.AGILITY).level(40).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.TROLL_ROMANCE, + Arrays.asList( + QuestTask.builder().quest(Quest.TROLL_STRONGHOLD).build(), + SkillLevelTask.builder().skill(Skill.AGILITY).level(28).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.TROLL_STRONGHOLD, + Arrays.asList( + QuestTask.builder().quest(Quest.DEATH_PLATEAU).build(), + SkillLevelTask.builder().skill(Skill.AGILITY).level(15).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.UNDERGROUND_PASS, + Arrays.asList( + QuestTask.builder().quest(Quest.BIOHAZARD).build(), + SkillLevelTask.builder().skill(Skill.RANGED).level(25).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.CONTACT, + Arrays.asList( + QuestTask.builder().quest(Quest.PRINCE_ALI_RESCUE).build(), + QuestTask.builder().quest(Quest.ICTHLARINS_LITTLE_HELPER).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.DESERT_TREASURE_I, + Arrays.asList( + QuestTask.builder().quest(Quest.TEMPLE_OF_IKOV).build(), + QuestTask.builder().quest(Quest.THE_DIG_SITE).build(), + QuestTask.builder().quest(Quest.TROLL_STRONGHOLD).build(), + SkillLevelTask.builder().skill(Skill.THIEVING).level(53).build(), + SkillLevelTask.builder().skill(Skill.MAGIC).level(50).build(), + SkillLevelTask.builder().skill(Skill.SLAYER).level(10).build(), + SkillLevelTask.builder().skill(Skill.HERBLORE).level(10).build(), + SkillLevelTask.builder().skill(Skill.FIREMAKING).level(50).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.DREAM_MENTOR, + Arrays.asList( + QuestTask.builder().quest(Quest.LUNAR_DIPLOMACY).build(), + SkillLevelTask.builder().skill(Skill.DEFENCE).level(85).build(), + SkillLevelTask.builder().skill(Skill.HITPOINTS).level(65).build(), + SkillLevelTask.builder().skill(Skill.WOODCUTTING).level(55).build(), + SkillLevelTask.builder().skill(Skill.MINING).level(60).build(), + SkillLevelTask.builder().skill(Skill.MAGIC).level(45).build(), + SkillLevelTask.builder().skill(Skill.CRAFTING).level(40).build(), + SkillLevelTask.builder().skill(Skill.SMITHING).level(49).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.GRIM_TALES, + Arrays.asList( + SkillLevelTask.builder().skill(Skill.AGILITY).level(59).build(), + SkillLevelTask.builder().skill(Skill.HERBLORE).level(52).build(), + SkillLevelTask.builder().skill(Skill.THIEVING).level(45).build(), + SkillLevelTask.builder().skill(Skill.FISHING).level(58).build(), + SkillLevelTask.builder().skill(Skill.WOODCUTTING).level(71).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.LEGENDS_QUEST, + Arrays.asList( + QuestTask.builder().quest(Quest.FAMILY_CREST).build(), + QuestTask.builder().quest(Quest.SHILO_VILLAGE).build(), + QuestTask.builder().quest(Quest.HEROES_QUEST).build(), + QuestTask.builder().quest(Quest.UNDERGROUND_PASS).build(), + SkillLevelTask.builder().skill(Skill.AGILITY).level(50).build(), + SkillLevelTask.builder().skill(Skill.THIEVING).level(50).build(), + SkillLevelTask.builder().skill(Skill.CRAFTING).level(50).build(), + SkillLevelTask.builder().skill(Skill.FISHING).level(50).build(), + SkillLevelTask.builder().skill(Skill.MINING).level(52).build(), + SkillLevelTask.builder().skill(Skill.MAGIC).level(50).build(), + SkillLevelTask.builder().skill(Skill.STRENGTH).level(50).build(), + SkillLevelTask.builder().skill(Skill.PRAYER).level(42).build(), + SkillLevelTask.builder().skill(Skill.WOODCUTTING).level(50).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.MONKEY_MADNESS_I, + Arrays.asList( + QuestTask.builder().quest(Quest.THE_GRAND_TREE).build(), + QuestTask.builder().quest(Quest.TREE_GNOME_VILLAGE).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.MOURNINGS_END_PART_I, + Arrays.asList( + QuestTask.builder().quest(Quest.ROVING_ELVES).build(), + SkillLevelTask.builder().skill(Skill.AGILITY).level(60).build(), + SkillLevelTask.builder().skill(Skill.THIEVING).level(50).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.MOURNINGS_END_PART_II, + Arrays.asList( + QuestTask.builder().quest(Quest.MOURNINGS_END_PART_I).build(), + SkillLevelTask.builder().skill(Skill.AGILITY).level(60).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.RECIPE_FOR_DISASTER, + Arrays.asList( + QuestTask.builder().quest(Quest.COOKS_ASSISTANT).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.REGICIDE, + Arrays.asList( + QuestTask.builder().quest(Quest.UNDERGROUND_PASS).build(), + SkillLevelTask.builder().skill(Skill.AGILITY).level(56).build(), + SkillLevelTask.builder().skill(Skill.CRAFTING).level(10).build(), + SkillLevelTask.builder().skill(Skill.FLETCHING).level(25).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.ROVING_ELVES, + Arrays.asList( + QuestTask.builder().quest(Quest.REGICIDE).build(), + SkillLevelTask.builder().skill(Skill.AGILITY).level(56).build(), + SkillLevelTask.builder().skill(Skill.HERBLORE).level(10).build(), + SkillLevelTask.builder().skill(Skill.RANGED).level(25).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.SWAN_SONG, + Arrays.asList( + QuestTask.builder().quest(Quest.ONE_SMALL_FAVOUR).build(), + SkillLevelTask.builder().skill(Skill.WOODCUTTING).level(62).build(), + SkillLevelTask.builder().skill(Skill.COOKING).level(66).build(), + SkillLevelTask.builder().skill(Skill.CRAFTING).level(42).build(), + SkillLevelTask.builder().skill(Skill.SMITHING).level(45).build(), + SkillLevelTask.builder().skill(Skill.FISHING).level(62).build(), + SkillLevelTask.builder().skill(Skill.SLAYER).level(18).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.DRAGON_SLAYER_II, + Arrays.asList( + QuestTask.builder().quest(Quest.LEGENDS_QUEST).build(), + QuestTask.builder().quest(Quest.A_TAIL_OF_TWO_CATS).build(), + QuestTask.builder().quest(Quest.DREAM_MENTOR).build(), + QuestTask.builder().quest(Quest.BONE_VOYAGE).build(), + SkillLevelTask.builder().skill(Skill.AGILITY).level(60).build(), + SkillLevelTask.builder().skill(Skill.CRAFTING).level(50).build(), + SkillLevelTask.builder().skill(Skill.MINING).level(60).build(), + SkillLevelTask.builder().skill(Skill.SMITHING).level(68).build(), + SkillLevelTask.builder().skill(Skill.HUNTER).level(60).build(), + SkillLevelTask.builder().skill(Skill.THIEVING).level(60).build(), + SkillLevelTask.builder().skill(Skill.MAGIC).level(75).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.MONKEY_MADNESS_II, + Arrays.asList( + QuestTask.builder().quest(Quest.ENLIGHTENED_JOURNEY).build(), + QuestTask.builder().quest(Quest.THE_EYES_OF_GLOUPHRIE).build(), + QuestTask.builder().quest(Quest.THE_GRAND_TREE).build(), + SkillLevelTask.builder().skill(Skill.SLAYER).level(69).build(), + SkillLevelTask.builder().skill(Skill.HUNTER).level(60).build(), + SkillLevelTask.builder().skill(Skill.THIEVING).level(55).build(), + SkillLevelTask.builder().skill(Skill.AGILITY).level(70).build(), + SkillLevelTask.builder().skill(Skill.CRAFTING).level(70).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.SONG_OF_THE_ELVES, + Arrays.asList( + QuestTask.builder().quest(Quest.MOURNINGS_END_PART_II).build(), + QuestTask.builder().quest(Quest.MAKING_HISTORY).build(), + SkillLevelTask.builder().skill(Skill.AGILITY).level(70).build(), + SkillLevelTask.builder().skill(Skill.CONSTRUCTION).level(70).build(), + SkillLevelTask.builder().skill(Skill.FARMING).level(70).build(), + SkillLevelTask.builder().skill(Skill.HERBLORE).level(70).build(), + SkillLevelTask.builder().skill(Skill.HUNTER).level(70).build(), + SkillLevelTask.builder().skill(Skill.MINING).level(70).build(), + SkillLevelTask.builder().skill(Skill.SMITHING).level(70).build(), + SkillLevelTask.builder().skill(Skill.WOODCUTTING).level(70).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.SINS_OF_THE_FATHER, + Arrays.asList( + QuestTask.builder().quest(Quest.A_TASTE_OF_HOPE).build(), + SkillLevelTask.builder().skill(Skill.WOODCUTTING).level(62).build(), + SkillLevelTask.builder().skill(Skill.FLETCHING).level(60).build(), + SkillLevelTask.builder().skill(Skill.CRAFTING).level(56).build(), + SkillLevelTask.builder().skill(Skill.AGILITY).level(52).build(), + SkillLevelTask.builder().skill(Skill.ATTACK).level(50).build(), + SkillLevelTask.builder().skill(Skill.SLAYER).level(50).build(), + SkillLevelTask.builder().skill(Skill.MAGIC).level(49).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.A_TASTE_OF_HOPE, + Arrays.asList( + QuestTask.builder().quest(Quest.DARKNESS_OF_HALLOWVALE).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.A_KINGDOM_DIVIDED, + Arrays.asList( + QuestTask.builder().quest(Quest.CLIENT_OF_KOUREND).build(), + QuestTask.builder().quest(Quest.X_MARKS_THE_SPOT).build(), + QuestTask.builder().quest(Quest.THE_DEPTHS_OF_DESPAIR).build(), + QuestTask.builder().quest(Quest.THE_QUEEN_OF_THIEVES).build(), + QuestTask.builder().quest(Quest.THE_ASCENT_OF_ARCEUUS).build(), + QuestTask.builder().quest(Quest.THE_FORSAKEN_TOWER).build(), + QuestTask.builder().quest(Quest.TALE_OF_THE_RIGHTEOUS).build(), + SkillLevelTask.builder().skill(Skill.AGILITY).level(54).build(), + SkillLevelTask.builder().skill(Skill.THIEVING).level(52).build(), + SkillLevelTask.builder().skill(Skill.WOODCUTTING).level(52).build(), + SkillLevelTask.builder().skill(Skill.HERBLORE).level(50).build(), + SkillLevelTask.builder().skill(Skill.MINING).level(42).build(), + SkillLevelTask.builder().skill(Skill.CRAFTING).level(38).build(), + SkillLevelTask.builder().skill(Skill.MAGIC).level(35).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.A_NIGHT_AT_THE_THEATRE, + Arrays.asList( + QuestTask.builder().quest(Quest.A_TASTE_OF_HOPE).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.THE_FREMENNIK_EXILES, + Arrays.asList( + QuestTask.builder().quest(Quest.THE_FREMENNIK_ISLES).build(), + SkillLevelTask.builder().skill(Skill.CRAFTING).level(65).build(), + SkillLevelTask.builder().skill(Skill.SMITHING).level(60).build(), + SkillLevelTask.builder().skill(Skill.SLAYER).level(60).build(), + SkillLevelTask.builder().skill(Skill.FISHING).level(60).build(), + SkillLevelTask.builder().skill(Skill.RUNECRAFT).level(55).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.BONE_VOYAGE, + Arrays.asList( + QuestTask.builder().quest(Quest.THE_DIG_SITE).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.SLEEPING_GIANTS, + Arrays.asList( + SkillLevelTask.builder().skill(Skill.SMITHING).level(15).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.TEMPLE_OF_THE_EYE, + Arrays.asList( + QuestTask.builder().quest(Quest.RUNE_MYSTERIES).build(), + SkillLevelTask.builder().skill(Skill.RUNECRAFT).level(10).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.LAND_OF_THE_GOBLINS, + Arrays.asList( + QuestTask.builder().quest(Quest.ANOTHER_SLICE_OF_HAM).build(), + SkillLevelTask.builder().skill(Skill.PRAYER).level(30).build(), + SkillLevelTask.builder().skill(Skill.AGILITY).level(36).build(), + SkillLevelTask.builder().skill(Skill.THIEVING).level(36).build(), + SkillLevelTask.builder().skill(Skill.HERBLORE).level(37).build(), + SkillLevelTask.builder().skill(Skill.FISHING).level(36).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.BENEATH_CURSED_SANDS, + Arrays.asList( + QuestTask.builder().quest(Quest.CONTACT).build(), + SkillLevelTask.builder().skill(Skill.AGILITY).level(62).build(), + SkillLevelTask.builder().skill(Skill.CRAFTING).level(55).build(), + SkillLevelTask.builder().skill(Skill.FIREMAKING).level(55).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.SECRETS_OF_THE_NORTH, + Arrays.asList( + QuestTask.builder().quest(Quest.MAKING_HISTORY).build(), + QuestTask.builder().quest(Quest.HAZEEL_CULT).build(), + SkillLevelTask.builder().skill(Skill.AGILITY).level(69).build(), + SkillLevelTask.builder().skill(Skill.THIEVING).level(64).build(), + SkillLevelTask.builder().skill(Skill.HUNTER).level(56).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.DESERT_TREASURE_II__THE_FALLEN_EMPIRE, + Arrays.asList( + QuestTask.builder().quest(Quest.DESERT_TREASURE_I).build(), + QuestTask.builder().quest(Quest.SECRETS_OF_THE_NORTH).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.TWILIGHTS_PROMISE, + Arrays.asList( + QuestTask.builder().quest(Quest.CHILDREN_OF_THE_SUN).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.AT_FIRST_LIGHT, + Arrays.asList( + QuestTask.builder().quest(Quest.TWILIGHTS_PROMISE).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.PERILOUS_MOONS, + Arrays.asList( + QuestTask.builder().quest(Quest.TWILIGHTS_PROMISE).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.THE_RIBBITING_TALE_OF_A_LILY_PAD_LABOUR_DISPUTE, + Arrays.asList( + QuestTask.builder().quest(Quest.TWILIGHTS_PROMISE).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.THE_HEART_OF_DARKNESS, + Arrays.asList( + QuestTask.builder().quest(Quest.TWILIGHTS_PROMISE).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.DEATH_ON_THE_ISLE, + Arrays.asList( + QuestTask.builder().quest(Quest.TWILIGHTS_PROMISE).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.MEAT_AND_GREET, + Arrays.asList( + QuestTask.builder().quest(Quest.TWILIGHTS_PROMISE).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.ETHICALLY_ACQUIRED_ANTIQUITIES, + Arrays.asList( + QuestTask.builder().quest(Quest.TWILIGHTS_PROMISE).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.DEFENDER_OF_VARROCK, + Arrays.asList( + QuestTask.builder().quest(Quest.SHIELD_OF_ARRAV).build(), + QuestTask.builder().quest(Quest.ROMEO__JULIET).build(), + QuestTask.builder().quest(Quest.THE_KNIGHTS_SWORD).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.THE_CURSE_OF_ARRAV, + Arrays.asList( + QuestTask.builder().quest(Quest.DEFENDER_OF_VARROCK).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.WHILE_GUTHIX_SLEEPS, + Arrays.asList( + QuestTask.builder().quest(Quest.THE_CURSE_OF_ARRAV).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.THE_FINAL_DAWN, + Arrays.asList( + QuestTask.builder().quest(Quest.THE_HEART_OF_DARKNESS).build(), + QuestTask.builder().quest(Quest.PERILOUS_MOONS).build(), + SkillLevelTask.builder().skill(Skill.THIEVING).level(66).build(), + SkillLevelTask.builder().skill(Skill.FLETCHING).level(52).build(), + SkillLevelTask.builder().skill(Skill.RUNECRAFT).level(52).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.SHADOWS_OF_CUSTODIA, + Arrays.asList( + QuestTask.builder().quest(Quest.THE_FINAL_DAWN).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.SCRAMBLED, + Arrays.asList( + QuestTask.builder().quest(Quest.THE_FINAL_DAWN).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.AN_EXISTENTIAL_CRISIS, + Arrays.asList( + QuestTask.builder().quest(Quest.THE_FINAL_DAWN).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.IMPENDING_CHAOS, + Arrays.asList( + QuestTask.builder().quest(Quest.THE_FINAL_DAWN).build() + ) + ); + + REQUIREMENT_MAP.put( + Quest.VALE_TOTEMS, + Arrays.asList( + QuestTask.builder().quest(Quest.THE_FINAL_DAWN).build() + ) + ); + } + + /** + * Get all requirements for a quest, including recursive sub-quest requirements. + * @param quest The quest to get requirements for. + * @param indentLevel The indentation level for the tasks (0 for main quest). + * @return List of tasks (quest and skill requirements) with appropriate indentation. + */ + public static List getRequirements(Quest quest, int indentLevel) + { + List tasks = new ArrayList<>(); + if (indentLevel >= 3) { + // Respect Task.java's max indent level of 3 + return tasks; + } + + List directRequirements = REQUIREMENT_MAP.getOrDefault(quest, Collections.emptyList()); + for (Task task : directRequirements) { + Task withIndent = copyWithIndent(task, indentLevel + 1); + tasks.add(withIndent); + + if (withIndent instanceof QuestTask) { + Quest subQuest = ((QuestTask) withIndent).getQuest(); + List subRequirements = getRequirements(subQuest, indentLevel + 1); + tasks.addAll(subRequirements); + } + } + + return tasks; + } + + private static Task copyWithIndent(Task task, int indentLevel) + { + if (task instanceof QuestTask) { + QuestTask qt = (QuestTask) task; + return QuestTask.builder() + .quest(qt.getQuest()) + .indentLevel(indentLevel) + .build(); + } + if (task instanceof SkillLevelTask) { + SkillLevelTask st = (SkillLevelTask) task; + return SkillLevelTask.builder() + .skill(st.getSkill()) + .level(st.getLevel()) + .indentLevel(indentLevel) + .build(); + } + // Fallback: set indent on the same instance (least preferable, but safe for unknown subclasses) + task.setIndentLevel(indentLevel); + return task; + } +} \ No newline at end of file diff --git a/src/test/java/com/toofifty/goaltracker/utils/QuestRequirementsTest.java b/src/test/java/com/toofifty/goaltracker/utils/QuestRequirementsTest.java new file mode 100644 index 0000000..8d5fec6 --- /dev/null +++ b/src/test/java/com/toofifty/goaltracker/utils/QuestRequirementsTest.java @@ -0,0 +1,56 @@ +package com.toofifty.goaltracker.utils; + +import com.toofifty.goaltracker.models.task.QuestTask; +import com.toofifty.goaltracker.models.task.SkillLevelTask; +import com.toofifty.goaltracker.models.task.Task; +import net.runelite.api.Quest; +import net.runelite.api.Skill; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +public class QuestRequirementsTest +{ + @Test + void testGetRequirements() + { + List requirements = QuestRequirements.getRequirements(Quest.FAIRYTALE_I__GROWING_PAINS, 0); + assertNotNull(requirements, "Requirements list should not be null"); + assertEquals(3, requirements.size(), "Fairytale I should have 3 direct requirements"); + + // All items should be indented one level when called with indentLevel=0 + requirements.forEach(t -> assertEquals(1, t.getIndentLevel(), "Each requirement should have indent 1")); + + // Expect two quest tasks and one skill requirement + long questCount = requirements.stream().filter(t -> t instanceof QuestTask).count(); + long skillCount = requirements.stream().filter(t -> t instanceof SkillLevelTask).count(); + assertEquals(2, questCount, "Should contain two QuestTask requirements"); + assertEquals(1, skillCount, "Should contain one SkillLevelTask requirement"); + + // Verify specific quests are present + boolean hasLostCity = requirements.stream().anyMatch(t -> t instanceof QuestTask && ((QuestTask) t).getQuest() == Quest.LOST_CITY); + boolean hasNatureSpirit = requirements.stream().anyMatch(t -> t instanceof QuestTask && ((QuestTask) t).getQuest() == Quest.NATURE_SPIRIT); + assertTrue(hasLostCity, "Lost City should be a requirement"); + assertTrue(hasNatureSpirit, "Nature Spirit should be a requirement"); + + // Verify specific skill requirement (Farming 18) + boolean hasFarming18 = requirements.stream().anyMatch(t -> t instanceof SkillLevelTask && ((SkillLevelTask) t).getSkill() == Skill.FARMING && ((SkillLevelTask) t).getLevel() == 18); + assertTrue(hasFarming18, "Farming 18 should be a requirement"); + } + + @Test + void testRequirementsAreFreshInstances() + { + List first = QuestRequirements.getRequirements(Quest.FAIRYTALE_I__GROWING_PAINS, 0); + List second = QuestRequirements.getRequirements(Quest.FAIRYTALE_I__GROWING_PAINS, 0); + + assertEquals(first.size(), second.size(), "Both calls should return the same number of items"); + for (int i = 0; i < first.size(); i++) { + assertNotSame(first.get(i), second.get(i), "Each requirement should be a fresh instance on each call"); + assertEquals(first.get(i).getClass(), second.get(i).getClass(), "Classes should match between calls"); + assertEquals(first.get(i).getIndentLevel(), second.get(i).getIndentLevel(), "Indent levels should match between calls"); + } + } +} \ No newline at end of file