diff --git a/CharacterSelectPlugin/Assets/Backgrounds/1.jpg b/CharacterSelectPlugin/Assets/Backgrounds/1.jpg new file mode 100644 index 0000000..37bb3bc Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/1.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/10.jpg b/CharacterSelectPlugin/Assets/Backgrounds/10.jpg new file mode 100644 index 0000000..32b89ea Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/10.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/11.jpg b/CharacterSelectPlugin/Assets/Backgrounds/11.jpg new file mode 100644 index 0000000..f5b5908 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/11.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/12.jpg b/CharacterSelectPlugin/Assets/Backgrounds/12.jpg new file mode 100644 index 0000000..dcf8b4b Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/12.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/13.jpg b/CharacterSelectPlugin/Assets/Backgrounds/13.jpg new file mode 100644 index 0000000..e7b496b Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/13.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/14.jpg b/CharacterSelectPlugin/Assets/Backgrounds/14.jpg new file mode 100644 index 0000000..6baaad6 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/14.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/15.jpg b/CharacterSelectPlugin/Assets/Backgrounds/15.jpg new file mode 100644 index 0000000..4d2f2f4 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/15.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/16.jpg b/CharacterSelectPlugin/Assets/Backgrounds/16.jpg new file mode 100644 index 0000000..7e445a8 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/16.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/17.jpg b/CharacterSelectPlugin/Assets/Backgrounds/17.jpg new file mode 100644 index 0000000..4df296e Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/17.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/18.jpg b/CharacterSelectPlugin/Assets/Backgrounds/18.jpg new file mode 100644 index 0000000..956968f Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/18.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/19.jpg b/CharacterSelectPlugin/Assets/Backgrounds/19.jpg new file mode 100644 index 0000000..7fbc6d8 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/19.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/2.jpg b/CharacterSelectPlugin/Assets/Backgrounds/2.jpg new file mode 100644 index 0000000..1ce7790 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/2.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/20.jpg b/CharacterSelectPlugin/Assets/Backgrounds/20.jpg new file mode 100644 index 0000000..f157312 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/20.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/21.jpg b/CharacterSelectPlugin/Assets/Backgrounds/21.jpg new file mode 100644 index 0000000..b6237c7 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/21.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/22.jpg b/CharacterSelectPlugin/Assets/Backgrounds/22.jpg new file mode 100644 index 0000000..a196c3b Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/22.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/23.jpg b/CharacterSelectPlugin/Assets/Backgrounds/23.jpg new file mode 100644 index 0000000..67784a0 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/23.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/24.jpg b/CharacterSelectPlugin/Assets/Backgrounds/24.jpg new file mode 100644 index 0000000..c0fcff1 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/24.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/25.jpg b/CharacterSelectPlugin/Assets/Backgrounds/25.jpg new file mode 100644 index 0000000..ec79422 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/25.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/26.jpg b/CharacterSelectPlugin/Assets/Backgrounds/26.jpg new file mode 100644 index 0000000..742fd39 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/26.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/27.jpg b/CharacterSelectPlugin/Assets/Backgrounds/27.jpg new file mode 100644 index 0000000..99faa98 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/27.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/28.jpg b/CharacterSelectPlugin/Assets/Backgrounds/28.jpg new file mode 100644 index 0000000..0a6b636 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/28.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/29.jpg b/CharacterSelectPlugin/Assets/Backgrounds/29.jpg new file mode 100644 index 0000000..68bfd9e Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/29.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/3.jpg b/CharacterSelectPlugin/Assets/Backgrounds/3.jpg new file mode 100644 index 0000000..4e35ab3 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/3.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/30.jpg b/CharacterSelectPlugin/Assets/Backgrounds/30.jpg new file mode 100644 index 0000000..4375682 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/30.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/31.jpg b/CharacterSelectPlugin/Assets/Backgrounds/31.jpg new file mode 100644 index 0000000..853fe40 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/31.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/32.jpg b/CharacterSelectPlugin/Assets/Backgrounds/32.jpg new file mode 100644 index 0000000..2de8e19 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/32.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/33.jpg b/CharacterSelectPlugin/Assets/Backgrounds/33.jpg new file mode 100644 index 0000000..1bac593 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/33.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/34.jpg b/CharacterSelectPlugin/Assets/Backgrounds/34.jpg new file mode 100644 index 0000000..4e8fefb Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/34.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/35.jpg b/CharacterSelectPlugin/Assets/Backgrounds/35.jpg new file mode 100644 index 0000000..7a37770 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/35.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/36.jpg b/CharacterSelectPlugin/Assets/Backgrounds/36.jpg new file mode 100644 index 0000000..23af243 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/36.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/37.jpg b/CharacterSelectPlugin/Assets/Backgrounds/37.jpg new file mode 100644 index 0000000..f85242b Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/37.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/38.jpg b/CharacterSelectPlugin/Assets/Backgrounds/38.jpg new file mode 100644 index 0000000..47cd9c2 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/38.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/39.jpg b/CharacterSelectPlugin/Assets/Backgrounds/39.jpg new file mode 100644 index 0000000..526b658 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/39.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/4.jpg b/CharacterSelectPlugin/Assets/Backgrounds/4.jpg new file mode 100644 index 0000000..c2193de Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/4.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/40.jpg b/CharacterSelectPlugin/Assets/Backgrounds/40.jpg new file mode 100644 index 0000000..04ae415 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/40.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/41.jpg b/CharacterSelectPlugin/Assets/Backgrounds/41.jpg new file mode 100644 index 0000000..0fbfbd4 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/41.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/42.jpg b/CharacterSelectPlugin/Assets/Backgrounds/42.jpg new file mode 100644 index 0000000..74a51a8 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/42.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/43.jpg b/CharacterSelectPlugin/Assets/Backgrounds/43.jpg new file mode 100644 index 0000000..f0d6529 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/43.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/44.jpg b/CharacterSelectPlugin/Assets/Backgrounds/44.jpg new file mode 100644 index 0000000..2c8e57c Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/44.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/45.jpg b/CharacterSelectPlugin/Assets/Backgrounds/45.jpg new file mode 100644 index 0000000..87f103f Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/45.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/46.jpg b/CharacterSelectPlugin/Assets/Backgrounds/46.jpg new file mode 100644 index 0000000..e1849d7 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/46.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/47.jpg b/CharacterSelectPlugin/Assets/Backgrounds/47.jpg new file mode 100644 index 0000000..a2d2b6a Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/47.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/48.jpg b/CharacterSelectPlugin/Assets/Backgrounds/48.jpg new file mode 100644 index 0000000..b29cf6f Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/48.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/49.jpg b/CharacterSelectPlugin/Assets/Backgrounds/49.jpg new file mode 100644 index 0000000..633946a Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/49.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/5.jpg b/CharacterSelectPlugin/Assets/Backgrounds/5.jpg new file mode 100644 index 0000000..1d66f63 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/5.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/50.jpg b/CharacterSelectPlugin/Assets/Backgrounds/50.jpg new file mode 100644 index 0000000..96a199f Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/50.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/51.jpg b/CharacterSelectPlugin/Assets/Backgrounds/51.jpg new file mode 100644 index 0000000..f403140 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/51.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/52.jpg b/CharacterSelectPlugin/Assets/Backgrounds/52.jpg new file mode 100644 index 0000000..4fd2b17 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/52.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/53.jpg b/CharacterSelectPlugin/Assets/Backgrounds/53.jpg new file mode 100644 index 0000000..3305388 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/53.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/54.jpg b/CharacterSelectPlugin/Assets/Backgrounds/54.jpg new file mode 100644 index 0000000..d46f85d Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/54.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/55.jpg b/CharacterSelectPlugin/Assets/Backgrounds/55.jpg new file mode 100644 index 0000000..4927d21 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/55.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/56.jpg b/CharacterSelectPlugin/Assets/Backgrounds/56.jpg new file mode 100644 index 0000000..42bebb6 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/56.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/57.jpg b/CharacterSelectPlugin/Assets/Backgrounds/57.jpg new file mode 100644 index 0000000..9b97575 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/57.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/58.jpg b/CharacterSelectPlugin/Assets/Backgrounds/58.jpg new file mode 100644 index 0000000..4bc58e9 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/58.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/59.jpg b/CharacterSelectPlugin/Assets/Backgrounds/59.jpg new file mode 100644 index 0000000..5050909 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/59.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/6.jpg b/CharacterSelectPlugin/Assets/Backgrounds/6.jpg new file mode 100644 index 0000000..0ad73f2 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/6.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/60.jpg b/CharacterSelectPlugin/Assets/Backgrounds/60.jpg new file mode 100644 index 0000000..5b1c4ad Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/60.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/61.jpg b/CharacterSelectPlugin/Assets/Backgrounds/61.jpg new file mode 100644 index 0000000..4e72208 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/61.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/62.jpg b/CharacterSelectPlugin/Assets/Backgrounds/62.jpg new file mode 100644 index 0000000..25bff63 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/62.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/63.jpg b/CharacterSelectPlugin/Assets/Backgrounds/63.jpg new file mode 100644 index 0000000..d46f7be Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/63.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/64.jpg b/CharacterSelectPlugin/Assets/Backgrounds/64.jpg new file mode 100644 index 0000000..1cdf5e9 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/64.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/65.jpg b/CharacterSelectPlugin/Assets/Backgrounds/65.jpg new file mode 100644 index 0000000..79ee32c Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/65.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/66.jpg b/CharacterSelectPlugin/Assets/Backgrounds/66.jpg new file mode 100644 index 0000000..0b10a30 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/66.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/67.jpg b/CharacterSelectPlugin/Assets/Backgrounds/67.jpg new file mode 100644 index 0000000..a7134c9 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/67.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/68.jpg b/CharacterSelectPlugin/Assets/Backgrounds/68.jpg new file mode 100644 index 0000000..131a43b Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/68.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/69.jpg b/CharacterSelectPlugin/Assets/Backgrounds/69.jpg new file mode 100644 index 0000000..c433bdb Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/69.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/7.jpg b/CharacterSelectPlugin/Assets/Backgrounds/7.jpg new file mode 100644 index 0000000..c6cfa58 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/7.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/70.jpg b/CharacterSelectPlugin/Assets/Backgrounds/70.jpg new file mode 100644 index 0000000..da61226 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/70.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/71.jpg b/CharacterSelectPlugin/Assets/Backgrounds/71.jpg new file mode 100644 index 0000000..f1bd2e9 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/71.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/72.jpg b/CharacterSelectPlugin/Assets/Backgrounds/72.jpg new file mode 100644 index 0000000..b605a7c Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/72.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/73.jpg b/CharacterSelectPlugin/Assets/Backgrounds/73.jpg new file mode 100644 index 0000000..7615048 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/73.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/74.jpg b/CharacterSelectPlugin/Assets/Backgrounds/74.jpg new file mode 100644 index 0000000..5b027f7 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/74.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/75.jpg b/CharacterSelectPlugin/Assets/Backgrounds/75.jpg new file mode 100644 index 0000000..da8d1b4 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/75.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/76.jpg b/CharacterSelectPlugin/Assets/Backgrounds/76.jpg new file mode 100644 index 0000000..1638a0e Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/76.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/77.jpg b/CharacterSelectPlugin/Assets/Backgrounds/77.jpg new file mode 100644 index 0000000..18936af Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/77.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/78.jpg b/CharacterSelectPlugin/Assets/Backgrounds/78.jpg new file mode 100644 index 0000000..480f337 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/78.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/79.jpg b/CharacterSelectPlugin/Assets/Backgrounds/79.jpg new file mode 100644 index 0000000..7aefe0b Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/79.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/8.jpg b/CharacterSelectPlugin/Assets/Backgrounds/8.jpg new file mode 100644 index 0000000..ab24bc1 Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/8.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/80.jpg b/CharacterSelectPlugin/Assets/Backgrounds/80.jpg new file mode 100644 index 0000000..494270d Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/80.jpg differ diff --git a/CharacterSelectPlugin/Assets/Backgrounds/9.jpg b/CharacterSelectPlugin/Assets/Backgrounds/9.jpg new file mode 100644 index 0000000..5681b7a Binary files /dev/null and b/CharacterSelectPlugin/Assets/Backgrounds/9.jpg differ diff --git a/CharacterSelectPlugin/Assets/banner.png b/CharacterSelectPlugin/Assets/banner.png new file mode 100644 index 0000000..9d9083b Binary files /dev/null and b/CharacterSelectPlugin/Assets/banner.png differ diff --git a/CharacterSelectPlugin/Assets/butterfly_1.png b/CharacterSelectPlugin/Assets/butterfly_1.png new file mode 100644 index 0000000..4613859 Binary files /dev/null and b/CharacterSelectPlugin/Assets/butterfly_1.png differ diff --git a/CharacterSelectPlugin/Assets/butterfly_10.png b/CharacterSelectPlugin/Assets/butterfly_10.png new file mode 100644 index 0000000..d378b88 Binary files /dev/null and b/CharacterSelectPlugin/Assets/butterfly_10.png differ diff --git a/CharacterSelectPlugin/Assets/butterfly_11.png b/CharacterSelectPlugin/Assets/butterfly_11.png new file mode 100644 index 0000000..8584304 Binary files /dev/null and b/CharacterSelectPlugin/Assets/butterfly_11.png differ diff --git a/CharacterSelectPlugin/Assets/butterfly_12.png b/CharacterSelectPlugin/Assets/butterfly_12.png new file mode 100644 index 0000000..8f9cc31 Binary files /dev/null and b/CharacterSelectPlugin/Assets/butterfly_12.png differ diff --git a/CharacterSelectPlugin/Assets/butterfly_13.png b/CharacterSelectPlugin/Assets/butterfly_13.png new file mode 100644 index 0000000..ad3a01e Binary files /dev/null and b/CharacterSelectPlugin/Assets/butterfly_13.png differ diff --git a/CharacterSelectPlugin/Assets/butterfly_14.png b/CharacterSelectPlugin/Assets/butterfly_14.png new file mode 100644 index 0000000..e7a9138 Binary files /dev/null and b/CharacterSelectPlugin/Assets/butterfly_14.png differ diff --git a/CharacterSelectPlugin/Assets/butterfly_2.png b/CharacterSelectPlugin/Assets/butterfly_2.png new file mode 100644 index 0000000..dc4235b Binary files /dev/null and b/CharacterSelectPlugin/Assets/butterfly_2.png differ diff --git a/CharacterSelectPlugin/Assets/butterfly_3.png b/CharacterSelectPlugin/Assets/butterfly_3.png new file mode 100644 index 0000000..1c417b8 Binary files /dev/null and b/CharacterSelectPlugin/Assets/butterfly_3.png differ diff --git a/CharacterSelectPlugin/Assets/butterfly_4.png b/CharacterSelectPlugin/Assets/butterfly_4.png new file mode 100644 index 0000000..050c3f8 Binary files /dev/null and b/CharacterSelectPlugin/Assets/butterfly_4.png differ diff --git a/CharacterSelectPlugin/Assets/butterfly_5.png b/CharacterSelectPlugin/Assets/butterfly_5.png new file mode 100644 index 0000000..08243c1 Binary files /dev/null and b/CharacterSelectPlugin/Assets/butterfly_5.png differ diff --git a/CharacterSelectPlugin/Assets/butterfly_6.png b/CharacterSelectPlugin/Assets/butterfly_6.png new file mode 100644 index 0000000..44ac486 Binary files /dev/null and b/CharacterSelectPlugin/Assets/butterfly_6.png differ diff --git a/CharacterSelectPlugin/Assets/butterfly_7.png b/CharacterSelectPlugin/Assets/butterfly_7.png new file mode 100644 index 0000000..450a1e6 Binary files /dev/null and b/CharacterSelectPlugin/Assets/butterfly_7.png differ diff --git a/CharacterSelectPlugin/Assets/butterfly_8.png b/CharacterSelectPlugin/Assets/butterfly_8.png new file mode 100644 index 0000000..478af56 Binary files /dev/null and b/CharacterSelectPlugin/Assets/butterfly_8.png differ diff --git a/CharacterSelectPlugin/Assets/butterfly_9.png b/CharacterSelectPlugin/Assets/butterfly_9.png new file mode 100644 index 0000000..2a41532 Binary files /dev/null and b/CharacterSelectPlugin/Assets/butterfly_9.png differ diff --git a/CharacterSelectPlugin/Assets/fire_1.png b/CharacterSelectPlugin/Assets/fire_1.png new file mode 100644 index 0000000..0055702 Binary files /dev/null and b/CharacterSelectPlugin/Assets/fire_1.png differ diff --git a/CharacterSelectPlugin/Assets/fire_2.png b/CharacterSelectPlugin/Assets/fire_2.png new file mode 100644 index 0000000..f69daf9 Binary files /dev/null and b/CharacterSelectPlugin/Assets/fire_2.png differ diff --git a/CharacterSelectPlugin/Assets/fire_3.png b/CharacterSelectPlugin/Assets/fire_3.png new file mode 100644 index 0000000..2f3c381 Binary files /dev/null and b/CharacterSelectPlugin/Assets/fire_3.png differ diff --git a/CharacterSelectPlugin/Assets/fire_4.png b/CharacterSelectPlugin/Assets/fire_4.png new file mode 100644 index 0000000..e5b7e42 Binary files /dev/null and b/CharacterSelectPlugin/Assets/fire_4.png differ diff --git a/CharacterSelectPlugin/Assets/fire_5.png b/CharacterSelectPlugin/Assets/fire_5.png new file mode 100644 index 0000000..aafec46 Binary files /dev/null and b/CharacterSelectPlugin/Assets/fire_5.png differ diff --git a/CharacterSelectPlugin/Assets/fire_6.png b/CharacterSelectPlugin/Assets/fire_6.png new file mode 100644 index 0000000..946fc76 Binary files /dev/null and b/CharacterSelectPlugin/Assets/fire_6.png differ diff --git a/CharacterSelectPlugin/Assets/fire_7.png b/CharacterSelectPlugin/Assets/fire_7.png new file mode 100644 index 0000000..d331b89 Binary files /dev/null and b/CharacterSelectPlugin/Assets/fire_7.png differ diff --git a/CharacterSelectPlugin/Assets/pixel_bat_normal.png b/CharacterSelectPlugin/Assets/pixel_bat_normal.png new file mode 100644 index 0000000..a4b7527 Binary files /dev/null and b/CharacterSelectPlugin/Assets/pixel_bat_normal.png differ diff --git a/CharacterSelectPlugin/Assets/pixel_bat_wings_up.png b/CharacterSelectPlugin/Assets/pixel_bat_wings_up.png new file mode 100644 index 0000000..a24d985 Binary files /dev/null and b/CharacterSelectPlugin/Assets/pixel_bat_wings_up.png differ diff --git a/CharacterSelectPlugin/Assets/pixel_leaf.png b/CharacterSelectPlugin/Assets/pixel_leaf.png new file mode 100644 index 0000000..1434674 Binary files /dev/null and b/CharacterSelectPlugin/Assets/pixel_leaf.png differ diff --git a/CharacterSelectPlugin/Assets/pixel_leaf_blue.png b/CharacterSelectPlugin/Assets/pixel_leaf_blue.png new file mode 100644 index 0000000..eb8e384 Binary files /dev/null and b/CharacterSelectPlugin/Assets/pixel_leaf_blue.png differ diff --git a/CharacterSelectPlugin/Assets/pixel_leaf_dark.png b/CharacterSelectPlugin/Assets/pixel_leaf_dark.png new file mode 100644 index 0000000..8774eb0 Binary files /dev/null and b/CharacterSelectPlugin/Assets/pixel_leaf_dark.png differ diff --git a/CharacterSelectPlugin/Assets/pixel_leaf_green.png b/CharacterSelectPlugin/Assets/pixel_leaf_green.png new file mode 100644 index 0000000..1897592 Binary files /dev/null and b/CharacterSelectPlugin/Assets/pixel_leaf_green.png differ diff --git a/CharacterSelectPlugin/Assets/pixel_leaf_teal.png b/CharacterSelectPlugin/Assets/pixel_leaf_teal.png new file mode 100644 index 0000000..cea68e4 Binary files /dev/null and b/CharacterSelectPlugin/Assets/pixel_leaf_teal.png differ diff --git a/CharacterSelectPlugin/BackupManager.cs b/CharacterSelectPlugin/BackupManager.cs new file mode 100644 index 0000000..11dfecc --- /dev/null +++ b/CharacterSelectPlugin/BackupManager.cs @@ -0,0 +1,238 @@ +using System; +using System.IO; +using Newtonsoft.Json; +using System.Linq; + +namespace CharacterSelectPlugin.Managers +{ + public static class BackupManager + { + private static string BackupDirectory => Path.Combine(Plugin.PluginInterface.GetPluginConfigDirectory(), "Backups"); + private static string ConfigBackupPath => Path.Combine(BackupDirectory, "characterselectplugin_backup.json"); + private static string VersionFilePath => Path.Combine(BackupDirectory, "last_backup_version.txt"); + + // Create a backup of the current configuration before updates + public static void CreateBackupIfNeeded(Configuration config, string currentVersion) + { + try + { + // Ensure backup directory exists + Directory.CreateDirectory(BackupDirectory); + + // Check if we need to create a backup + bool shouldBackup = ShouldCreateBackup(currentVersion); + + if (shouldBackup) + { + Plugin.Log.Info($"[Backup] Creating configuration backup for version {currentVersion}"); + + // Backup main config file + BackupMainConfig(config); + + // Clean old backups (keep last 5) + CleanOldBackups(); + + // Update version file + File.WriteAllText(VersionFilePath, currentVersion); + + Plugin.Log.Info($"[Backup] Backup completed successfully"); + } + } + catch (Exception ex) + { + Plugin.Log.Error($"[Backup] Failed to create backup: {ex.Message}"); + } + } + + // Determine if a backup should be created based on version changes + private static bool ShouldCreateBackup(string currentVersion) + { + try + { + if (!File.Exists(VersionFilePath)) + { + // First time running this version of backup system + return true; + } + + string lastBackupVersion = File.ReadAllText(VersionFilePath).Trim(); + + // Create backup if version changed + if (lastBackupVersion != currentVersion) + { + Plugin.Log.Debug($"[Backup] Version changed from {lastBackupVersion} to {currentVersion}"); + return true; + } + + // Also create backup if it's been more than 7 days since last backup + var backupInfo = new FileInfo(ConfigBackupPath); + if (backupInfo.Exists && DateTime.Now - backupInfo.LastWriteTime > TimeSpan.FromDays(7)) + { + Plugin.Log.Debug("[Backup] Creating periodic backup (7+ days since last)"); + return true; + } + + return false; + } + catch (Exception ex) + { + Plugin.Log.Warning($"[Backup] Error checking backup necessity: {ex.Message}"); + return true; // Errr on the side of caution + } + } + + // Back up the main configuration file + private static void BackupMainConfig(Configuration config) + { + try + { + // Create timestamped backup + string timestamp = DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss"); + string timestampedBackup = Path.Combine(BackupDirectory, $"config_backup_{timestamp}.json"); + + // Serialize current config + string configJson = JsonConvert.SerializeObject(config, Formatting.Indented); + + // Save timestamped backup + File.WriteAllText(timestampedBackup, configJson); + + // Also save as the "current" backup + File.WriteAllText(ConfigBackupPath, configJson); + + Plugin.Log.Debug($"[Backup] Config backed up to: {timestampedBackup}"); + } + catch (Exception ex) + { + Plugin.Log.Error($"[Backup] Failed to backup main config: {ex.Message}"); + } + } + + // Remove old backup files, keeping only the most recent 5 + private static void CleanOldBackups() + { + try + { + if (!Directory.Exists(BackupDirectory)) + return; + + var backupFiles = Directory.GetFiles(BackupDirectory, "config_backup_*.json") + .Select(f => new FileInfo(f)) + .OrderByDescending(f => f.CreationTime) + .ToArray(); + + // Keep the 5 most recent backups + var filesToDelete = backupFiles.Skip(5); + + foreach (var file in filesToDelete) + { + try + { + file.Delete(); + Plugin.Log.Debug($"[Backup] Cleaned old backup: {file.Name}"); + } + catch (Exception ex) + { + Plugin.Log.Warning($"[Backup] Failed to delete old backup {file.Name}: {ex.Message}"); + } + } + } + catch (Exception ex) + { + Plugin.Log.Warning($"[Backup] Failed to clean old backups: {ex.Message}"); + } + } + + // Restore configuration from backup + public static Configuration? RestoreFromBackup() + { + try + { + if (!File.Exists(ConfigBackupPath)) + { + Plugin.Log.Warning("[Backup] No backup file found for restoration"); + return null; + } + + string backupJson = File.ReadAllText(ConfigBackupPath); + var restoredConfig = JsonConvert.DeserializeObject(backupJson); + + if (restoredConfig != null) + { + Plugin.Log.Info("[Backup] Configuration restored from backup successfully"); + return restoredConfig; + } + else + { + Plugin.Log.Error("[Backup] Failed to deserialize backup configuration"); + return null; + } + } + catch (Exception ex) + { + Plugin.Log.Error($"[Backup] Failed to restore from backup: {ex.Message}"); + return null; + } + } + + // Get information about available backups + public static BackupInfo GetBackupInfo() + { + var info = new BackupInfo(); + + try + { + info.BackupExists = File.Exists(ConfigBackupPath); + + if (info.BackupExists) + { + var backupFile = new FileInfo(ConfigBackupPath); + info.LastBackupDate = backupFile.LastWriteTime; + } + + if (File.Exists(VersionFilePath)) + { + info.LastBackupVersion = File.ReadAllText(VersionFilePath).Trim(); + } + + // Count timestamped backups + if (Directory.Exists(BackupDirectory)) + { + info.BackupCount = Directory.GetFiles(BackupDirectory, "config_backup_*.json").Length; + } + } + catch (Exception ex) + { + Plugin.Log.Error($"[Backup] Error getting backup info: {ex.Message}"); + } + + return info; + } + + // Create an emergency backup + public static void CreateEmergencyBackup(Configuration config) + { + try + { + Directory.CreateDirectory(BackupDirectory); + + string emergencyBackup = Path.Combine(BackupDirectory, $"emergency_backup_{DateTime.Now:yyyy-MM-dd_HH-mm-ss}.json"); + string configJson = JsonConvert.SerializeObject(config, Formatting.Indented); + File.WriteAllText(emergencyBackup, configJson); + + Plugin.Log.Info($"[Backup] Emergency backup created: {emergencyBackup}"); + } + catch (Exception ex) + { + Plugin.Log.Error($"[Backup] Failed to create emergency backup: {ex.Message}"); + } + } + } + + public class BackupInfo + { + public bool BackupExists { get; set; } + public DateTime? LastBackupDate { get; set; } + public string? LastBackupVersion { get; set; } + public int BackupCount { get; set; } + } +} diff --git a/CharacterSelectPlugin/Character.cs b/CharacterSelectPlugin/Character.cs index aef5727..53055f2 100644 --- a/CharacterSelectPlugin/Character.cs +++ b/CharacterSelectPlugin/Character.cs @@ -10,14 +10,14 @@ namespace CharacterSelectPlugin public class Character { public string Name { get; set; } - public string Macros { get; set; } = ""; // Default to empty instead of null + public string Macros { get; set; } = ""; public string? ImagePath { get; set; } public List Designs { get; set; } public Vector3 NameplateColor { get; set; } = new Vector3(1.0f, 1.0f, 1.0f); // Default to white public string PenumbraCollection { get; set; } = ""; public string GlamourerDesign { get; set; } = ""; public string CustomizeProfile { get; set; } = ""; - public bool IsFavorite { get; set; } = false; // Allows favouriting + public bool IsFavorite { get; set; } = false; public DateTime DateAdded { get; set; } = DateTime.Now; // Tracks when the character was added public int SortOrder { get; set; } = 0; // Tracks manual drag-drop order public string HonorificTitle { get; set; } = ""; @@ -26,7 +26,7 @@ namespace CharacterSelectPlugin public Vector3 HonorificColor { get; set; } = new Vector3(1.0f, 1.0f, 1.0f); // Default white public Vector3 HonorificGlow { get; set; } = new Vector3(1.0f, 1.0f, 1.0f); // Default white public string MoodlePreset { get; set; } = ""; // MOODLES - public byte IdlePoseIndex { get; set; } = 0; // Idles! + public byte IdlePoseIndex { get; set; } = 7; // Idles! public byte SitPoseIndex { get; set; } = 255; public byte GroundSitPoseIndex { get; set; } = 255; public byte DozePoseIndex { get; set; } = 255; @@ -40,7 +40,7 @@ namespace CharacterSelectPlugin public string? Abilities { get; set; } public string? Bio { get; set; } public string? RpTags { get; set; } - public string? RpImagePath { get; set; } // Optional override image + public string? RpImagePath { get; set; } public RPProfile RPProfile { get; set; } = new(); public string? LastInGameName { get; set; } public List Tags { get; set; } = new(); @@ -59,9 +59,11 @@ namespace CharacterSelectPlugin public List DesignTags { get; set; } = new List(); public string CharacterAutomation { get; set; } = ""; public List DesignFolders { get; set; } = new(); - public Vector3? OverrideAccentColor { get; set; } // if you're ever persisting the selection outside RPProfile - - + public Vector3? OverrideAccentColor { get; set; } + public string? BackgroundImage { get; set; } + public ProfileEffects? Effects { get; set; } + public string GalleryStatus { get; set; } = ""; + public bool IsAdvancedMode { get; set; } = false; @@ -82,10 +84,11 @@ namespace CharacterSelectPlugin Vector3 honorificColor, Vector3 honorificGlow, string moodlePreset, - string characterautomation) + string characterautomation, + string galleryStatus = "") { Name = name; - Macros = macros ?? ""; // Prevents null macros + Macros = macros ?? ""; ImagePath = imagePath; Designs = designs ?? new List(); NameplateColor = nameplateColor; @@ -99,19 +102,21 @@ namespace CharacterSelectPlugin HonorificGlow = honorificGlow; MoodlePreset = moodlePreset; CharacterAutomation = characterautomation; + BackgroundImage = null; + Effects = new ProfileEffects(); + GalleryStatus = galleryStatus; } } public class DesignFolder { public string Name { get; set; } public Guid Id { get; set; } + public Vector3? CustomColor { get; set; } = null; - // Optional parent (for future nesting) public Guid? ParentFolderId { get; set; } = null; - // Manual ordering index within its parent public int SortOrder { get; set; } = 0; - // <<< Add this! >>> + public DesignFolder() { Name = ""; diff --git a/CharacterSelectPlugin/CharacterDesign.cs b/CharacterSelectPlugin/CharacterDesign.cs index 2549b0a..9d13e90 100644 --- a/CharacterSelectPlugin/CharacterDesign.cs +++ b/CharacterSelectPlugin/CharacterDesign.cs @@ -11,20 +11,21 @@ namespace CharacterSelectPlugin public string AdvancedMacro { get; set; } // Stores Advanced Mode macro separately public string GlamourerDesign { get; set; } // Store Glamourer Design separately public DateTime DateAdded { get; set; } = DateTime.UtcNow; // Automatically set when created - public bool IsFavorite { get; set; } // Used for sorting by Favorites + public bool IsFavorite { get; set; } // Sorting by Favourites public string Automation { get; set; } = ""; public string CustomizePlusProfile { get; set; } = ""; + public string? PreviewImagePath { get; set; } = null; public string Tag { get; set; } = "Unsorted"; public List KnownTags { get; set; } = new(); public List DesignTags { get; set; } = new List(); - public Guid? FolderId { get; set; } = null; // null = Unsorted + public Guid? FolderId { get; set; } = null; public Guid Id { get; set; } = Guid.NewGuid(); public int SortOrder { get; set; } = 0; - public CharacterDesign(string name, string macro, bool isAdvancedMode = false, string advancedMacro = "", string glamourerDesign = "", string automation = "", string customizePlusProfile = "") + public CharacterDesign(string name, string macro, bool isAdvancedMode = false, string advancedMacro = "", string glamourerDesign = "", string automation = "", string customizePlusProfile = "", string? previewImagePath = null) { Name = name; Macro = macro; @@ -33,6 +34,7 @@ namespace CharacterSelectPlugin GlamourerDesign = glamourerDesign; // Store Glamourer Design Automation = automation; // Store Glamourer Automation CustomizePlusProfile = customizePlusProfile; // Store Design C+ Profiles + PreviewImagePath = previewImagePath; DateAdded = DateTime.UtcNow; IsFavorite = false; // Default to not favourited } diff --git a/CharacterSelectPlugin/CharacterSelectPlugin.csproj b/CharacterSelectPlugin/CharacterSelectPlugin.csproj index afd7a74..a0d81cc 100644 --- a/CharacterSelectPlugin/CharacterSelectPlugin.csproj +++ b/CharacterSelectPlugin/CharacterSelectPlugin.csproj @@ -1,19 +1,16 @@ - net9.0-windows7.0 + net9.0-windows - 1.1.1.3 + 2.0.0.3 Character Select+. https://github.com/USBTURTLECUP/Character-Select- AGPL-3.0-or-later false - true true - - - + PreserveNewest diff --git a/CharacterSelectPlugin/Configuration.cs b/CharacterSelectPlugin/Configuration.cs index f3e9c3d..295b0be 100644 --- a/CharacterSelectPlugin/Configuration.cs +++ b/CharacterSelectPlugin/Configuration.cs @@ -1,3 +1,4 @@ +using CharacterSelectPlugin.Windows; using Dalamud.Configuration; using Dalamud.Plugin; using Newtonsoft.Json; @@ -5,6 +6,7 @@ using System; using System.Collections.Generic; using System.ComponentModel; using System.Numerics; +using System.Text.Json.Serialization; namespace CharacterSelectPlugin { @@ -14,8 +16,6 @@ namespace CharacterSelectPlugin public int Version { get; set; } = 1; public List Characters { get; set; } = new List(); public Vector3 NewCharacterColor { get; set; } = new Vector3(1.0f, 1.0f, 1.0f); - - // Existing Settings public bool IsConfigWindowMovable { get; set; } = true; public bool SomePropertyToBeSavedAndWithADefault { get; set; } = false; @@ -55,7 +55,59 @@ namespace CharacterSelectPlugin public bool EnablePoseAutoSave { get; set; } = true; public bool EnableSafeMode { get; set; } = false; public bool QuickSwitchCompact { get; set; } = false; + public bool EnableCharacterHoverEffects { get; set; } = false; + public HashSet FavoriteGalleryProfiles { get; set; } = new(); + public HashSet LikedGalleryProfiles { get; set; } = new(); + public List FavoriteSnapshots { get; set; } = new(); + public bool ShowRecentlyActiveStatus { get; set; } = true; + public bool HasSeenTutorial { get; set; } = false; + public bool TutorialActive { get; set; } = false; + public int CurrentTutorialStep { get; set; } = 0; + public bool ShowTutorialOnStartup { get; set; } = true; + public Dictionary GearsetJobMapping { get; set; } = new(); + public uint? LastUsedGearset { get; set; } = null; + public string? GalleryMainCharacter { get; set; } = null; // Format: "CharacterName@Server" + public bool EnableGalleryAutoRefresh { get; set; } = true; + public int GalleryAutoRefreshSeconds { get; set; } = 30; + [DefaultValue(false)] + public bool RandomSelectionFavoritesOnly { get; set; } = false; + public string? MainCharacterName { get; set; } = null; + public bool EnableMainCharacterOnly { get; set; } = false; + public bool ShowMainCharacterCrown { get; set; } = true; + public HashSet BlockedGalleryProfiles { get; set; } = new(); + public float DesignPanelWidth { get; set; } = 300f; + public HashSet FollowedPlayers { get; set; } = new(); + [JsonPropertyName("enableDialogueIntegration")] + public bool EnableDialogueIntegration { get; set; } = false; + [JsonPropertyName("replaceNameInDialogue")] + public bool ReplaceNameInDialogue { get; set; } = true; + + [JsonPropertyName("replacePronounsInDialogue")] + public bool ReplacePronounsInDialogue { get; set; } = true; + + [JsonPropertyName("enableSmartGrammarInDialogue")] + public bool EnableSmartGrammarInDialogue { get; set; } = true; + + [JsonPropertyName("showDialogueReplacementPreview")] + public bool ShowDialogueReplacementPreview { get; set; } = false; + // New enhanced dialogue settings + [JsonPropertyName("enableLuaHookDialogue")] + public bool EnableLuaHookDialogue { get; set; } = true; + + [JsonPropertyName("replaceGenderedTerms")] + public bool ReplaceGenderedTerms { get; set; } = true; + + [JsonPropertyName("enableAdvancedTitleReplacement")] + public bool EnableAdvancedTitleReplacement { get; set; } = true; + + [JsonPropertyName("theyThemStyle")] + public GenderNeutralStyle TheyThemStyle { get; set; } = GenderNeutralStyle.Friend; + + [JsonPropertyName("customGenderNeutralTitle")] + public string CustomGenderNeutralTitle { get; set; } = "friend"; + public bool EnableRaceReplacement { get; set; } = false; + public DateTime LastSeenAnnouncements { get; set; } = DateTime.MinValue; public Configuration(IDalamudPluginInterface pluginInterface) @@ -82,8 +134,40 @@ namespace CharacterSelectPlugin public byte GroundSit { get; set; } = 255; public byte Doze { get; set; } = 255; } + public enum GenderNeutralStyle + { + Friend, + HonoredOne, + Traveler, + Adventurer, + Custom + } + public string GetGenderNeutralTitle() + { + return TheyThemStyle switch + { + GenderNeutralStyle.Friend => "friend", + GenderNeutralStyle.HonoredOne => "Mx.", + GenderNeutralStyle.Traveler => "traveler", + GenderNeutralStyle.Adventurer => "adventurer", + GenderNeutralStyle.Custom => CustomGenderNeutralTitle, + _ => "friend" + }; + } + public string GetGenderNeutralFormalTitle() + { + return TheyThemStyle switch + { + GenderNeutralStyle.Friend => "friend", + GenderNeutralStyle.HonoredOne => "Mx.", + GenderNeutralStyle.Traveler => "traveler", + GenderNeutralStyle.Adventurer => "adventurer", + GenderNeutralStyle.Custom => CustomGenderNeutralTitle, + _ => "friend" + }; + } public void Save() { diff --git a/CharacterSelectPlugin/ContextMenuManager.cs b/CharacterSelectPlugin/ContextMenuManager.cs index 84d59d1..676f675 100644 --- a/CharacterSelectPlugin/ContextMenuManager.cs +++ b/CharacterSelectPlugin/ContextMenuManager.cs @@ -4,6 +4,10 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Dalamud.Game.ClientState.Objects; +using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Game.ClientState.Objects.Enums; +using Dalamud.Game.ClientState.Objects.SubKinds; namespace CharacterSelectPlugin.Managers { @@ -27,6 +31,11 @@ namespace CharacterSelectPlugin.Managers "SocialList", "ContactList", "CharacterInspect", + "_Target", + "NamePlate", + "_NaviMap", + "SelectString", + "SelectIconString" ]; private static readonly Dictionary WorldIdToName = new() @@ -51,10 +60,21 @@ namespace CharacterSelectPlugin.Managers private void OnMenuOpened(IMenuOpenedArgs args) { - if (args.Target is not MenuTargetDefault def || !ValidAddons.Contains(args.AddonName)) + if (args.Target is MenuTargetDefault def && ValidAddons.Contains(args.AddonName)) + { + HandleUIContextMenu(args, def); return; + } - // Skip if the clicked thing has no valid home world (NPCs, FC actions, etc) + if (args.Target is MenuTargetDefault objTarget && args.AddonName == null) + { + HandleGameObjectContextMenu(args, objTarget); + return; + } + } + + private void HandleUIContextMenu(IMenuOpenedArgs args, MenuTargetDefault def) + { if (def.TargetHomeWorld.RowId == 0) return; @@ -76,5 +96,34 @@ namespace CharacterSelectPlugin.Managers } } + private void HandleGameObjectContextMenu(IMenuOpenedArgs args, MenuTargetDefault target) + { + try + { + var currentTarget = Plugin.TargetManager.Target; + + if (currentTarget != null && + currentTarget.ObjectKind == ObjectKind.Player && + currentTarget is IPlayerCharacter player) + { + string characterName = player.Name.TextValue; + string worldName = player.HomeWorld.Value.Name.ToString(); + + if (!string.IsNullOrWhiteSpace(characterName) && !string.IsNullOrWhiteSpace(worldName)) + { + args.AddMenuItem(new MenuItem + { + Name = "View RP Profile", + OnClicked = _ => Task.Run(() => plugin.TryRequestRPProfile($"{characterName}@{worldName}")), + IsEnabled = true + }); + } + } + } + catch (Exception ex) + { + Plugin.Log.Error($"Error handling game object context menu: {ex.Message}"); + } + } } } diff --git a/CharacterSelectPlugin/DesignItem.cs b/CharacterSelectPlugin/DesignItem.cs index bf00d1c..0035d68 100644 --- a/CharacterSelectPlugin/DesignItem.cs +++ b/CharacterSelectPlugin/DesignItem.cs @@ -1,4 +1,3 @@ -// CharacterSelectPlugin/DesignItem.cs using System; namespace CharacterSelectPlugin @@ -12,17 +11,17 @@ namespace CharacterSelectPlugin public CharacterDesign? Design { get; } public int SortOrder { get; set; } - // Folder ctor + // Folder public DesignItem(DesignFolder f) { Id = f.Id; IsFolder = true; Folder = f; - ParentFolderId = f.ParentFolderId; // adapt if your folder tracks this differently - SortOrder = f.SortOrder; // assume you’ve persisted this + ParentFolderId = f.ParentFolderId; + SortOrder = f.SortOrder; } - // Design ctor + // Design public DesignItem(CharacterDesign d) { Id = d.Id; diff --git a/CharacterSelectPlugin/GalleryWindow.cs b/CharacterSelectPlugin/GalleryWindow.cs new file mode 100644 index 0000000..4e55e3f --- /dev/null +++ b/CharacterSelectPlugin/GalleryWindow.cs @@ -0,0 +1,4746 @@ +using Dalamud.Interface.Windowing; +using ImGuiNET; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using System.Threading.Tasks; +using System.Net.Http; +using Newtonsoft.Json; +using System.IO; +using Dalamud.Interface; +using Dalamud.Interface.Textures.TextureWraps; +using System.Threading; +using CharacterSelectPlugin.Effects; +using FFXIVClientStructs.FFXIV.Client.Game.UI; +using FFXIVClientStructs.FFXIV.Client.UI.Info; +using Dalamud.Plugin.Services; +using System.Text; +using Dalamud.Game.Gui; + +namespace CharacterSelectPlugin.Windows +{ + // Data classes + public class GalleryProfile + { + public string CharacterId { get; set; } = ""; + public string CharacterName { get; set; } = ""; + public string Server { get; set; } = ""; + public string? ProfileImageUrl { get; set; } + public string Tags { get; set; } = ""; + public string Bio { get; set; } = ""; + public string Race { get; set; } = ""; + public string Pronouns { get; set; } = ""; + public int LikeCount { get; set; } + public string LastUpdated { get; set; } = ""; + public float ImageZoom { get; set; } = 1.0f; + public Vector2 ImageOffset { get; set; } = Vector2.Zero; + public string GalleryStatus { get; set; } = ""; + } + + public class FavoriteSnapshot + { + public string CharacterId { get; set; } = ""; + public string CharacterName { get; set; } = ""; + public string Server { get; set; } = ""; + public string? ProfileImageUrl { get; set; } + public string Tags { get; set; } = ""; + public string Bio { get; set; } = ""; + public string Race { get; set; } = ""; + public string Pronouns { get; set; } = ""; + public DateTime FavoritedAt { get; set; } = DateTime.Now; + public float ImageZoom { get; set; } + public Vector2 ImageOffset { get; set; } + public string OwnerCharacterKey { get; set; } = ""; + public string LocalImagePath { get; set; } = ""; + } + + public class LikeResponse + { + public int LikeCount { get; set; } + } + + public enum GallerySortType + { + Popular, + Recent, + Alphabetical + } + + public class Announcement + { + public string Id { get; set; } = ""; + public string Title { get; set; } = ""; + public string Message { get; set; } = ""; + public string Type { get; set; } = "info"; // info, warning, update, maintenance + public DateTime CreatedAt { get; set; } + public bool Active { get; set; } = true; + } + + public class ReportRequest + { + public string ReportedCharacterId { get; set; } = ""; + public string ReportedCharacterName { get; set; } = ""; + public string ReporterCharacter { get; set; } = ""; + public string Reason { get; set; } = ""; + public string Details { get; set; } = ""; + } + public enum ReportReason + { + InappropriateContent, + Spam, + MaliciousLinks, + Other + } + public class GalleryWindow : Window + { + private readonly Plugin plugin; + private enum GalleryTab { Gallery, Friends, Favourites, Blocked, Announcements, Settings } + private GalleryTab currentTab = GalleryTab.Gallery; + + // Gallery data + private List allProfiles = new(); + private List filteredProfiles = new(); + private bool isLoading = false; + private string searchFilter = ""; + private List popularTags = new(); + private GallerySortType sortType = GallerySortType.Popular; + private List favoriteSnapshots = new(); + private Dictionary cachedCharacterColors = new(); + private HashSet profileLoadingStarted = new(); + private DateTime lastCacheCleanup = DateTime.MinValue; + private readonly TimeSpan cacheCleanupInterval = TimeSpan.FromHours(1); + private readonly TimeSpan maxImageAge = TimeSpan.FromDays(7); + private readonly long maxCacheSizeBytes = 500 * 1024 * 1024; + private static readonly Dictionary LastRequestTimes = new(); + private static readonly TimeSpan MinimumRequestInterval = TimeSpan.FromSeconds(1); + private Dictionary galleryLikeEffects = new(); + private Dictionary galleryFavoriteEffects = new(); + private List friendsInGallery = new(); + private DateTime lastFriendsUpdate = DateTime.MinValue; + private readonly TimeSpan friendsUpdateInterval = TimeSpan.FromMinutes(1); + private bool showAddedFriends = false; + private GalleryTab lastActiveTab = GalleryTab.Gallery; + private readonly Dictionary textureCache = new(); + private DateTime lastTextureCacheCleanup = DateTime.MinValue; + private readonly Dictionary imagePathCache = new(); + private bool shouldResetScroll = false; + private List announcements = new(); + private DateTime lastAnnouncementUpdate = DateTime.MinValue; + private readonly TimeSpan announcementUpdateInterval = TimeSpan.FromMinutes(5); + private DateTime lastSeenAnnouncements = DateTime.MinValue; + private string lastErrorMessage = ""; + private DateTime lastErrorTime = DateTime.MinValue; + + // Report dialog state + private bool showReportDialog = false; + private string reportTargetCharacterId = ""; + private string reportTargetCharacterName = ""; + private ReportReason selectedReportReason = ReportReason.InappropriateContent; + private string customReportReason = ""; + private string reportDetails = ""; + private bool showReportConfirmation = false; + private string reportConfirmationMessage = ""; + private void ClearPerformanceCaches() + { + cachedCharacterColors.Clear(); + profileLoadingStarted.Clear(); + imageLoadStarted.Clear(); + loadingProfiles.Clear(); + } + + private const int PROFILES_PER_PAGE = 20; + private int currentPage = 0; + private readonly Dictionary imageLoadStarted = new(); + private readonly HashSet loadingProfiles = new(); + private DateTime lastAutoRefresh = DateTime.MinValue; + private readonly TimeSpan autoRefreshInterval = TimeSpan.FromMinutes(5); + private bool wasWindowFocused = false; + private HashSet blockedProfiles = new(); + private string addFriendName = ""; + private string addFriendServer = ""; + private List cachedMutualFriends = new(); + private DateTime lastMutualFriendsUpdate = DateTime.MinValue; + private readonly TimeSpan mutualFriendsUpdateInterval = TimeSpan.FromMinutes(2); + + // UI state + private float scrollPosition = 0f; + private Dictionary likedProfiles = new(); + private Dictionary downloadedProfiles = new(); + private Character? lastActiveCharacter = null; + private HashSet currentFavoritedProfiles = new(); + private bool pendingStateUpdate = false; + private Dictionary frozenLikedProfiles = new(); + private HashSet frozenFavoritedProfiles = new(); + private bool useStateFreeze = false; + private DateTime lastSuccessfulRefresh = DateTime.MinValue; + private bool isAutoRefreshing = false; + private readonly Dictionary galleryTextureCache = new(); + private void EnsureFavoritesFilteredByCSCharacter() + { + var ownerKey = GetActiveCharacter()?.Name; + if (string.IsNullOrEmpty(ownerKey)) + { + currentFavoritedProfiles.Clear(); + return; + } + + currentFavoritedProfiles.Clear(); + + foreach (var favorite in favoriteSnapshots) + { + if (favorite.OwnerCharacterKey == ownerKey) + { + var profileKey = GetProfileKey(favorite); + currentFavoritedProfiles.Add(profileKey); + Plugin.Log.Debug($"[Gallery] Restored favourites"); + } + } + + Plugin.Log.Debug($"[Gallery] Restored {currentFavoritedProfiles.Count} favourites for {ownerKey}"); + } + + // Image preview + private string? imagePreviewUrl = null; + private bool showImagePreview = false; + private const float RpProfileFrameSize = 140f; + + private string GetProfileKey(string? id, string name, string server) + => !string.IsNullOrWhiteSpace(id) ? id! : $"{name}@{server}"; + + private string GetProfileKey(GalleryProfile p) + => GetProfileKey(p.CharacterId, p.CharacterName, p.Server); + + private string GetProfileKey(FavoriteSnapshot f) + => GetProfileKey(f.CharacterId, f.CharacterName, f.Server); + + private IDalamudTextureWrap? TryGetDownloadedTexture(string? imageUrl) + { + if (string.IsNullOrEmpty(imageUrl)) + return null; + + var local = GetDownloadedImagePath(imageUrl); + if (!string.IsNullOrEmpty(local) && File.Exists(local)) + return Plugin.TextureProvider.GetFromFile(local).GetWrapOrDefault(); + + return null; + } + + public GalleryWindow(Plugin plugin) : base("Character Select+ Gallery", ImGuiWindowFlags.None) + { + this.plugin = plugin; + IsOpen = false; + + var dpiScale = ImGui.GetIO().DisplayFramebufferScale.X; + var uiScale = plugin.Configuration.UIScaleMultiplier; + var totalScale = GetSafeScale(dpiScale * uiScale); + + SizeConstraints = new WindowSizeConstraints + { + MinimumSize = new Vector2(735 * totalScale, 500 * totalScale), + MaximumSize = new Vector2(1200 * totalScale, 900 * totalScale) + }; + + LoadFavorites(); + LoadBlockedProfiles(); + + likedProfiles = new Dictionary(); + var savedLikes = plugin.Configuration.LikedGalleryProfiles ?? new HashSet(); + foreach (var likeKey in savedLikes) + { + likedProfiles[likeKey] = true; + } + } + + public override void Draw() + { + var dpiScale = ImGui.GetIO().DisplayFramebufferScale.X; + var uiScale = plugin.Configuration.UIScaleMultiplier; + var totalScale = GetSafeScale(dpiScale * uiScale); + + // Main window dark styling + ImGui.PushStyleColor(ImGuiCol.WindowBg, new Vector4(0.06f, 0.06f, 0.06f, 0.98f)); + ImGui.PushStyleColor(ImGuiCol.Separator, new Vector4(0.25f, 0.25f, 0.25f, 0.6f)); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.92f, 0.92f, 0.92f, 1.0f)); + ImGui.PushStyleColor(ImGuiCol.TextDisabled, new Vector4(0.5f, 0.5f, 0.5f, 0.8f)); + + ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 10.0f * totalScale); + ImGui.PushStyleVar(ImGuiStyleVar.TabRounding, 6.0f * totalScale); + + try + { + bool isWindowFocused = ImGui.IsWindowFocused(); + + // Deadlock detection and recovery + var timeSinceRefreshStarted = DateTime.Now - lastAutoRefresh; + if (isAutoRefreshing && timeSinceRefreshStarted.TotalSeconds > 30) + { + Plugin.Log.Warning("[Gallery] Auto-refresh appears stuck, resetting flag"); + isAutoRefreshing = false; + } + + // Auto-refresh logic + if (IsOpen && !isAutoRefreshing && + DateTime.Now - lastAutoRefresh > autoRefreshInterval && + lastSuccessfulRefresh != DateTime.MinValue) + { + if (DateTime.Now - lastSuccessfulRefresh > TimeSpan.FromSeconds(10)) + { + Plugin.Log.Debug($"[Gallery] Starting auto-refresh (last attempt: {(DateTime.Now - lastAutoRefresh).TotalMinutes:F1}m ago)"); + + isAutoRefreshing = true; + lastAutoRefresh = DateTime.Now; + + _ = Task.Run(async () => + { + try + { + await RefreshLikeCountsOnlyFixed(); + } + catch (Exception ex) + { + Plugin.Log.Error($"[Gallery] Auto-refresh task failed: {ex.Message}"); + } + finally + { + isAutoRefreshing = false; + Plugin.Log.Debug("[Gallery] Auto-refresh task completed, flag reset"); + } + }); + } + } + + // Focus change handling + if (isWindowFocused && !wasWindowFocused) + { + var timeSinceLastRefresh = DateTime.Now - lastAutoRefresh; + if (timeSinceLastRefresh > TimeSpan.FromMinutes(1)) + { + if (!isAutoRefreshing) + { + Plugin.Log.Debug("[Gallery] Focus-triggered refresh starting"); + isAutoRefreshing = true; + lastAutoRefresh = DateTime.Now; + + _ = Task.Run(async () => + { + try + { + await RefreshLikeCountsOnlyFixed(); + } + catch (Exception ex) + { + Plugin.Log.Error($"[Gallery] Focus refresh failed: {ex.Message}"); + } + finally + { + isAutoRefreshing = false; + Plugin.Log.Debug("[Gallery] Focus refresh completed, flag reset"); + } + }); + } + Plugin.Log.Debug("[Gallery] Auto-refreshed on window focus"); + } + } + + wasWindowFocused = isWindowFocused; + + // Cache cleanup + if (DateTime.Now - lastCacheCleanup > cacheCleanupInterval) + { + Task.Run(() => CleanupImageCache()); + lastCacheCleanup = DateTime.Now; + } + + // Character change detection + if (HasCharacterChanged()) + { + Plugin.Log.Info("[Gallery] Character changed, freezing current button states until refresh"); + + frozenLikedProfiles = new Dictionary(likedProfiles); + frozenFavoritedProfiles = new HashSet(currentFavoritedProfiles); + useStateFreeze = true; + + _ = LoadGalleryData(); + } + + if (ImGui.BeginTabBar("GalleryTabs")) + { + GalleryTab newActiveTab = currentTab; + + var tabTextColors = new Vector4[] +{ + new Vector4(0.4f, 0.8f, 0.8f, 1.0f), // Cyan - Gallery + new Vector4(0.4f, 0.8f, 0.4f, 1.0f), // Green - Friends + new Vector4(1.0f, 0.8f, 0.2f, 1.0f), // Yellow - Favourites + new Vector4(1.0f, 0.4f, 0.4f, 1.0f), // Red - Blocked + new Vector4(0.8f, 0.4f, 1.0f, 1.0f), // Purple - Announcements + new Vector4(1.0f, 1.0f, 1.0f, 1.0f) // White - Settings +}; + + // Dark tab backgrounds + ImGui.PushStyleColor(ImGuiCol.Tab, new Vector4(0.12f, 0.12f, 0.12f, 0.8f)); + ImGui.PushStyleColor(ImGuiCol.TabHovered, new Vector4(0.18f, 0.18f, 0.18f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.TabActive, new Vector4(0.22f, 0.22f, 0.22f, 1.0f)); + ImGui.PushStyleColor(ImGuiCol.TabUnfocused, new Vector4(0.08f, 0.08f, 0.08f, 0.8f)); + ImGui.PushStyleColor(ImGuiCol.TabUnfocusedActive, new Vector4(0.16f, 0.16f, 0.16f, 0.9f)); + + // Gallery Tab + ImGui.PushStyleColor(ImGuiCol.Text, currentTab == GalleryTab.Gallery ? tabTextColors[0] : new Vector4(0.92f, 0.92f, 0.92f, 1.0f)); + if (ImGui.BeginTabItem("Gallery")) + { + newActiveTab = GalleryTab.Gallery; + ImGui.PopStyleColor(1); + DrawGalleryTab(totalScale); + ImGui.EndTabItem(); + } + else ImGui.PopStyleColor(1); + + // Friends Tab + ImGui.PushStyleColor(ImGuiCol.Text, currentTab == GalleryTab.Friends ? tabTextColors[1] : new Vector4(0.92f, 0.92f, 0.92f, 1.0f)); + if (ImGui.BeginTabItem("Friends")) + { + newActiveTab = GalleryTab.Friends; + ImGui.PopStyleColor(1); + if (lastActiveTab != GalleryTab.Friends) + { + Plugin.Log.Debug("[Gallery] Switched to Friends tab, auto-refreshing mutual friends"); + _ = RefreshMutualFriends(); + lastMutualFriendsUpdate = DateTime.Now; + } + DrawFriendsTab(totalScale); + ImGui.EndTabItem(); + } + else ImGui.PopStyleColor(1); + + // Favourites Tab + ImGui.PushStyleColor(ImGuiCol.Text, currentTab == GalleryTab.Favourites ? tabTextColors[2] : new Vector4(0.92f, 0.92f, 0.92f, 1.0f)); + if (ImGui.BeginTabItem("Favourites")) + { + newActiveTab = GalleryTab.Favourites; + ImGui.PopStyleColor(1); + DrawFavouritesTab(totalScale); + ImGui.EndTabItem(); + } + else ImGui.PopStyleColor(1); + + // Blocked Tab + ImGui.PushStyleColor(ImGuiCol.Text, currentTab == GalleryTab.Blocked ? tabTextColors[3] : new Vector4(0.92f, 0.92f, 0.92f, 1.0f)); + if (ImGui.BeginTabItem("Blocked")) + { + newActiveTab = GalleryTab.Blocked; + ImGui.PopStyleColor(1); + DrawBlockedTab(totalScale); + ImGui.EndTabItem(); + } + else ImGui.PopStyleColor(1); + + // Announcements Tab with notification badge + bool hasNewAnnouncements = announcements.Any() && announcements.Any(a => a.CreatedAt > lastSeenAnnouncements.ToUniversalTime()); + + ImGui.PushStyleColor(ImGuiCol.Text, currentTab == GalleryTab.Announcements ? tabTextColors[4] : new Vector4(0.92f, 0.92f, 0.92f, 1.0f)); + + if (ImGui.BeginTabItem("Announcements")) + { + newActiveTab = GalleryTab.Announcements; + ImGui.PopStyleColor(1); + + // Draw notification dot AFTER the tab is created + if (hasNewAnnouncements && currentTab != GalleryTab.Announcements) + { + var drawList = ImGui.GetWindowDrawList(); + var tabMin = ImGui.GetItemRectMin(); + var tabMax = ImGui.GetItemRectMax(); + var dotCenter = new Vector2(tabMax.X - (6 * totalScale), tabMin.Y + (6 * totalScale)); + drawList.AddCircleFilled(dotCenter, 3 * totalScale, ImGui.GetColorU32(new Vector4(1.0f, 0.3f, 0.3f, 1.0f))); + } + + // Mark as seen when entering the tab + if (hasNewAnnouncements) + { + lastSeenAnnouncements = DateTime.UtcNow; + plugin.Configuration.LastSeenAnnouncements = DateTime.UtcNow; + plugin.Configuration.Save(); + } + + DrawAnnouncementsTab(totalScale); + ImGui.EndTabItem(); + } + else + { + ImGui.PopStyleColor(1); + + // Draw notification dot when NOT on the tab + if (hasNewAnnouncements) + { + var drawList = ImGui.GetWindowDrawList(); + var tabMin = ImGui.GetItemRectMin(); + var tabMax = ImGui.GetItemRectMax(); + var dotCenter = new Vector2(tabMax.X - (6 * totalScale), tabMin.Y + (6 * totalScale)); + drawList.AddCircleFilled(dotCenter, 3 * totalScale, ImGui.GetColorU32(new Vector4(1.0f, 0.3f, 0.3f, 1.0f))); + } + } + + // Settings Tab + ImGui.PushStyleColor(ImGuiCol.Text, currentTab == GalleryTab.Settings ? tabTextColors[5] : new Vector4(0.92f, 0.92f, 0.92f, 1.0f)); + if (ImGui.BeginTabItem("Settings")) + { + newActiveTab = GalleryTab.Settings; + ImGui.PopStyleColor(1); + DrawSettingsTab(totalScale); + ImGui.EndTabItem(); + } + else ImGui.PopStyleColor(1); + + ImGui.PopStyleColor(5); // Pop the 5 tab background colors + + currentTab = newActiveTab; + lastActiveTab = newActiveTab; + ImGui.EndTabBar(); + } + + DrawImagePreview(totalScale); + DrawReportDialog(totalScale); + DrawReportConfirmation(totalScale); + DrawErrorMessage(); + + // Draw gallery effects + float deltaTime = ImGui.GetIO().DeltaTime; + + foreach (var effect in galleryLikeEffects.Values) + { + effect.Update(deltaTime); + } + + foreach (var effect in galleryFavoriteEffects.Values) + { + effect.Update(deltaTime); + } + + foreach (var kvp in galleryLikeEffects.ToList()) + { + kvp.Value.Draw(); + if (!kvp.Value.IsActive) + galleryLikeEffects.Remove(kvp.Key); + } + + foreach (var kvp in galleryFavoriteEffects.ToList()) + { + kvp.Value.Draw(); + if (!kvp.Value.IsActive) + galleryFavoriteEffects.Remove(kvp.Key); + } + } + finally + { + ImGui.PopStyleVar(2); + ImGui.PopStyleColor(4); + } + } + + private void DrawImagePreview(float scale) + { + if (!showImagePreview || string.IsNullOrEmpty(imagePreviewUrl)) + return; + + var viewport = ImGui.GetMainViewport(); + ImGui.SetNextWindowPos(viewport.Pos); + ImGui.SetNextWindowSize(viewport.Size); + + ImGui.PushStyleColor(ImGuiCol.WindowBg, new Vector4(0, 0, 0, 0.9f)); + ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, Vector2.Zero); + ImGui.PushStyleVar(ImGuiStyleVar.WindowBorderSize, 0); + + if (ImGui.Begin("ImagePreview", ref showImagePreview, + ImGuiWindowFlags.NoDecoration | ImGuiWindowFlags.NoMove | ImGuiWindowFlags.NoResize)) + { + IDalamudTextureWrap? texture; + if (File.Exists(imagePreviewUrl!)) + texture = Plugin.TextureProvider.GetFromFile(imagePreviewUrl!).GetWrapOrDefault(); + else + texture = GetProfileTexture(imagePreviewUrl); + + if (texture != null) + { + var windowSize = ImGui.GetWindowSize(); + var imageSize = new Vector2(texture.Width, texture.Height); + + float scaleX = windowSize.X * 0.9f / imageSize.X; + float scaleY = windowSize.Y * 0.9f / imageSize.Y; + float imageScale = Math.Min(scaleX, scaleY); + + var displaySize = imageSize * imageScale; + var startPos = (windowSize - displaySize) * 0.5f; + + ImGui.SetCursorPos(startPos); + ImGui.Image(texture.ImGuiHandle, displaySize); + } + + if (ImGui.IsWindowHovered() && ImGui.IsMouseClicked(ImGuiMouseButton.Left)) + showImagePreview = false; + } + ImGui.End(); + + ImGui.PopStyleVar(2); + ImGui.PopStyleColor(); + } + + private void DrawGalleryTab(float scale) + { + ImGui.PushID("SearchSection"); + float searchAvailableWidth = ImGui.GetContentRegionAvail().X; + float clearButtonWidth = 0f; + + if (!string.IsNullOrEmpty(searchFilter)) + { + if (ImGui.Button("X##ClearGallerySearch")) + { + searchFilter = ""; + FilterProfiles(); + } + clearButtonWidth = ImGui.GetItemRectSize().X + ImGui.GetStyle().ItemSpacing.X; + ImGui.SameLine(); + } + + ImGui.SetNextItemWidth(searchAvailableWidth - clearButtonWidth); + if (ImGui.InputTextWithHint("##SearchGallery", "Search tags, names, bios...", ref searchFilter, 100)) + { + FilterProfiles(); + } + ImGui.PopID(); + + // Sort and Refresh controls + ImGui.Text("Sort:"); + ImGui.SameLine(); + ImGui.SetNextItemWidth(120 * scale); + if (ImGui.BeginCombo("##SortType", GetSortDisplayName(sortType))) + { + foreach (GallerySortType sort in Enum.GetValues()) + { + bool isSelected = sortType == sort; + if (ImGui.Selectable(GetSortDisplayName(sort), isSelected)) + { + sortType = sort; + SortProfiles(); + } + if (isSelected) ImGui.SetItemDefaultFocus(); + } + ImGui.EndCombo(); + } + + ImGui.SameLine(); + ImGui.SetCursorPosX(ImGui.GetCursorPosX() + (10f * scale)); + if (ImGui.Button("Refresh")) + { + _ = LoadGalleryData(); + } + + // Show refresh status + if (lastSuccessfulRefresh != DateTime.MinValue) + { + var timeSinceSuccess = DateTime.Now - lastSuccessfulRefresh; + var timeSinceAttempt = DateTime.Now - lastAutoRefresh; + + string timeText; + Vector4 statusColor; + string statusPrefix = ""; + + if (timeSinceSuccess.TotalSeconds < 30) + { + timeText = "Just now"; + statusColor = new Vector4(0.4f, 0.8f, 0.4f, 1.0f); + } + else if (timeSinceSuccess.TotalMinutes < 1) + { + timeText = $"{(int)timeSinceSuccess.TotalSeconds}s ago"; + statusColor = new Vector4(0.4f, 0.8f, 0.4f, 1.0f); + } + else if (timeSinceSuccess.TotalMinutes < 60) + { + timeText = $"{(int)timeSinceSuccess.TotalMinutes}m ago"; + + if (timeSinceSuccess.TotalMinutes > 10) + { + statusColor = new Vector4(1.0f, 0.8f, 0.4f, 1.0f); + statusPrefix = "⚠ "; + } + else + { + statusColor = new Vector4(0.6f, 0.6f, 0.6f, 1.0f); + } + } + else + { + timeText = $"{(int)timeSinceSuccess.TotalHours}h ago"; + statusColor = new Vector4(1.0f, 0.6f, 0.4f, 1.0f); + statusPrefix = "⚠ "; + } + + ImGui.SameLine(); + + if (isAutoRefreshing) + { + ImGui.TextColored(new Vector4(0.8f, 0.8f, 0.4f, 1.0f), "(Updating...)"); + } + else + { + if (timeSinceSuccess.TotalMinutes > 10 && timeSinceAttempt.TotalMinutes < 1) + { + ImGui.TextColored(statusColor, $"{statusPrefix}Updated {timeText} (connection issues)"); + } + else + { + ImGui.TextColored(statusColor, $"{statusPrefix}Updated {timeText}"); + } + } + + if (ImGui.IsItemHovered()) + { + ImGui.BeginTooltip(); + if (timeSinceSuccess.TotalMinutes > 5) + { + ImGui.Text($"Last successful update: {lastSuccessfulRefresh:HH:mm:ss}"); + ImGui.Text($"Last attempt: {lastAutoRefresh:HH:mm:ss}"); + ImGui.Text("Auto-refresh may be experiencing network issues"); + } + else + { + ImGui.Text($"Last update: {lastSuccessfulRefresh:HH:mm:ss}"); + ImGui.Text("Auto-refresh every 5 minutes"); + } + ImGui.EndTooltip(); + } + } + else if (isAutoRefreshing) + { + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.8f, 0.8f, 0.4f, 1.0f), "(Updating...)"); + } + + if (isLoading) + { + ImGui.Text("Loading character showcase..."); + return; + } + + if (filteredProfiles.Count == 0) + { + if (allProfiles.Count == 0) + { + ImGui.Text("No characters are currently showcased."); + ImGui.Text("Be the first to share your character!"); + } + else + { + ImGui.Text("No characters match your search."); + } + return; + } + + // Popular tags with button styling + if (popularTags.Count > 0) + { + var reallyPopularTags = popularTags.Take(6).ToList(); + if (reallyPopularTags.Count > 0) + { + ImGui.Text("Popular Tags:"); + ImGui.SameLine(); + + // Button styling for tags only + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.2f, 0.2f, 0.2f, 0.8f)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.3f, 0.3f, 0.3f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.4f, 0.4f, 0.4f, 1.0f)); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.8f, 0.6f, 1.0f, 1.0f)); // Purple text + + for (int i = 0; i < reallyPopularTags.Count; i++) + { + var tag = reallyPopularTags[i]; + if (ImGui.SmallButton(tag)) + { + searchFilter = tag; + FilterProfiles(); + currentPage = 0; + } + if (i < reallyPopularTags.Count - 1) ImGui.SameLine(); + } + + ImGui.PopStyleColor(4); // Pop tag button styles + } + } + + ImGui.Separator(); + + // Pagination + int totalPages = (int)Math.Ceiling((double)filteredProfiles.Count / PROFILES_PER_PAGE); + int startIndex = currentPage * PROFILES_PER_PAGE; + int endIndex = Math.Min(startIndex + PROFILES_PER_PAGE, filteredProfiles.Count); + + DrawPaginationControls(totalPages, scale, "top"); + + ImGui.Separator(); + + ImGui.BeginChild("GalleryContent", new Vector2(0, -20 * scale), false, ImGuiWindowFlags.AlwaysVerticalScrollbar); + if (shouldResetScroll) + { + ImGui.SetScrollY(0); + shouldResetScroll = false; + } + float availableWidth = ImGui.GetContentRegionAvail().X; + var style = ImGui.GetStyle(); + float scrollbarWidth = style.ScrollbarSize; + const float extraNudge = 10f; + float usableWidth = availableWidth - scrollbarWidth - (extraNudge * scale); + + float minCardWidth = 320f * scale; + float maxCardWidth = 400f * scale; + float cardPadding = 15f * scale; + + int cardsPerRow = Math.Max(1, (int)((usableWidth + cardPadding) / (minCardWidth + cardPadding))); + float cardWidth = Math.Min(maxCardWidth, (usableWidth - (cardsPerRow - 1) * cardPadding) / cardsPerRow); + float cardHeight = 120f * scale; + + float totalCardsWidth = cardWidth * cardsPerRow + cardPadding * (cardsPerRow - 1); + float leftoverSpace = usableWidth - totalCardsWidth; + float marginX = leftoverSpace > 0 ? leftoverSpace * 0.5f : style.WindowPadding.X; + + var currentPageProfiles = filteredProfiles.Skip(startIndex).Take(PROFILES_PER_PAGE).ToList(); + + int idx = 0; + foreach (var profile in currentPageProfiles) + { + if (idx % cardsPerRow == 0) + { + if (idx > 0) ImGui.Spacing(); + ImGui.SetCursorPosX(marginX); + } + else + { + ImGui.SameLine(); + ImGui.SetCursorPosX(ImGui.GetCursorPosX() + cardPadding); + } + + DrawProfileCard(profile, new Vector2(cardWidth, cardHeight), scale); + idx++; + } + + ImGui.EndChild(); + DrawPaginationControls(totalPages, scale, "bottom"); + } + + public void RefreshLikeStatesAfterProfileUpdate(string characterName) + { + Plugin.Log.Info($"[Gallery] Refreshing like states after profile update for {characterName}"); + + _ = LoadGalleryData(); + + var activeCharacter = GetActiveCharacter(); + if (activeCharacter != null) + { + string csCharacterKey = activeCharacter.Name; + var keysToRemove = likedProfiles.Keys + .Where(k => k.StartsWith($"{csCharacterKey}|{characterName}")) + .ToList(); + + foreach (var key in keysToRemove) + { + likedProfiles.Remove(key); + } + } + } + + private async Task RefreshLikeCountsOnlyFixed() + { + try + { + Plugin.Log.Debug($"[Gallery] Starting auto-refresh (last successful: {(DateTime.Now - lastSuccessfulRefresh).TotalMinutes:F1} minutes ago)"); + + using var http = Plugin.CreateAuthenticatedHttpClient(); + http.Timeout = TimeSpan.FromSeconds(10); + + var response = await http.GetAsync("https://character-select-profile-server-production.up.railway.app/gallery"); + + if (response.IsSuccessStatusCode) + { + var json = await response.Content.ReadAsStringAsync(); + var serverProfiles = JsonConvert.DeserializeObject>(json) ?? new(); + + int updatedCount = 0; + + foreach (var serverProfile in serverProfiles) + { + var serverKey = GetProfileKey(serverProfile); + + var localProfile = allProfiles.FirstOrDefault(p => GetProfileKey(p) == serverKey); + if (localProfile != null && localProfile.LikeCount != serverProfile.LikeCount) + { + Plugin.Log.Debug($"[Gallery] Updated like count for {localProfile.CharacterName}: {localProfile.LikeCount} → {serverProfile.LikeCount}"); + localProfile.LikeCount = serverProfile.LikeCount; + updatedCount++; + } + + var filteredProfile = filteredProfiles.FirstOrDefault(p => GetProfileKey(p) == serverKey); + if (filteredProfile != null && filteredProfile.LikeCount != serverProfile.LikeCount) + { + filteredProfile.LikeCount = serverProfile.LikeCount; + } + } + + ClearImpossibleZeroLikes(); + + lastAutoRefresh = DateTime.Now; + lastSuccessfulRefresh = DateTime.Now; + + if (updatedCount > 0) + { + Plugin.Log.Debug($"[Gallery] Auto-refresh updated {updatedCount} like counts"); + } + else + { + Plugin.Log.Debug("[Gallery] Auto-refresh completed, no changes"); + } + } + else + { + Plugin.Log.Warning($"[Gallery] Auto-refresh failed with status: {response.StatusCode}"); + + lastAutoRefresh = DateTime.Now; + + var timeSinceSuccess = DateTime.Now - lastSuccessfulRefresh; + if (timeSinceSuccess.TotalMinutes > 15) + { + Plugin.Log.Warning($"[Gallery] Auto-refresh has been failing for {timeSinceSuccess.TotalMinutes:F0} minutes"); + } + } + } + catch (HttpRequestException ex) + { + Plugin.Log.Warning($"[Gallery] Auto-refresh network error: {ex.Message}"); + lastAutoRefresh = DateTime.Now; + } + catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) + { + Plugin.Log.Warning("[Gallery] Auto-refresh timed out"); + lastAutoRefresh = DateTime.Now; + } + catch (Exception ex) + { + Plugin.Log.Error($"[Gallery] Auto-refresh failed: {ex.Message}"); + lastAutoRefresh = DateTime.Now; + } + } + + private async Task LoadProfileWithImageAsync(string characterId) + { + try + { + var profile = await Plugin.DownloadProfileAsync(characterId); + if (profile != null) + { + downloadedProfiles[characterId] = profile; + Plugin.Log.Debug($"[Gallery] Profile loaded for {SanitizeForLogging(characterId)}"); + } + } + catch (Exception ex) + { + Plugin.Log.Error($"[Gallery] Failed to load profile for {SanitizeForLogging(characterId)}: {ex.Message}"); + } + } + + private void DrawFavouritesTab(float scale) + { + if (favoriteSnapshots.Count == 0) + { + ImGui.Text("You haven't favourited any characters yet."); + ImGui.Text("Star characters in the Gallery tab to add them here!"); + return; + } + + ImGui.Text($"Your Favourited Characters ({favoriteSnapshots.Count})"); + ImGui.Separator(); + + FavoriteSnapshot? toRemove = null; // Track which item to remove + + ImGui.BeginChild("FavouritesContent", new Vector2(0, 0), false, ImGuiWindowFlags.AlwaysVerticalScrollbar); + + float availableWidth = ImGui.GetContentRegionAvail().X; + var style = ImGui.GetStyle(); + float scrollbarWidth = style.ScrollbarSize; + float usableWidth = availableWidth - scrollbarWidth - (10f * scale); + + float cardWidth = 300f * scale; + float cardHeight = 120f * scale; + float cardPadding = 15f * scale; + + int cardsPerRow = Math.Max(1, (int)((usableWidth + cardPadding) / (cardWidth + cardPadding))); + cardWidth = Math.Min(cardWidth, (usableWidth - (cardsPerRow - 1) * cardPadding) / cardsPerRow); + + float totalCardsWidth = cardWidth * cardsPerRow + cardPadding * (cardsPerRow - 1); + float leftoverSpace = usableWidth - totalCardsWidth; + float marginX = leftoverSpace > 0 ? leftoverSpace * 0.5f : style.WindowPadding.X; + + int idx = 0; + foreach (var favorite in favoriteSnapshots) + { + if (idx % cardsPerRow == 0) + { + if (idx > 0) ImGui.Spacing(); + ImGui.SetCursorPosX(marginX); + } + else + { + ImGui.SameLine(); + ImGui.SetCursorPosX(ImGui.GetCursorPosX() + cardPadding); + } + + DrawCompactFavoriteCard(favorite, new Vector2(cardWidth, cardHeight), scale, ref toRemove); + idx++; + } + + ImGui.EndChild(); + + // Remove item AFTER the loop, when it's safe + if (toRemove != null) + { + favoriteSnapshots.Remove(toRemove); + plugin.Configuration.FavoriteSnapshots = favoriteSnapshots; + plugin.Configuration.Save(); + EnsureFavoritesFilteredByCSCharacter(); + } + } + private void DrawCompactFavoriteCard(FavoriteSnapshot favorite, Vector2 cardSize, float scale, ref FavoriteSnapshot? toRemove) + { + var drawList = ImGui.GetWindowDrawList(); + var cardMin = ImGui.GetCursorScreenPos(); + var cardMax = cardMin + cardSize; + var snapKey = GetProfileKey(favorite); + + bool isCardHovered = ImGui.IsMouseHoveringRect(cardMin, cardMax); + + var bgColor = isCardHovered + ? new Vector4(0.12f, 0.12f, 0.18f, 0.95f) + : new Vector4(0.08f, 0.08f, 0.12f, 0.95f); + + var borderColor = isCardHovered + ? new Vector4(0.35f, 0.35f, 0.45f, 0.8f) + : new Vector4(0.25f, 0.25f, 0.35f, 0.6f); + + drawList.AddRectFilled(cardMin, cardMax, ImGui.GetColorU32(bgColor), 8f * scale); + drawList.AddRect(cardMin, cardMax, ImGui.GetColorU32(borderColor), 8f * scale, ImDrawFlags.None, 1f * scale); + + ImGui.BeginChild($"fav_compact_{favorite.CharacterId}_{favorite.FavoritedAt.Ticks}", cardSize, false); + + var imageSize = new Vector2(60 * scale, 60 * scale); + ImGui.SetCursorPos(new Vector2(8 * scale, 8 * scale)); + + // Load image + IDalamudTextureWrap? tex = null; + if (!string.IsNullOrEmpty(favorite.LocalImagePath) && File.Exists(favorite.LocalImagePath)) + { + tex = Plugin.TextureProvider.GetFromFile(favorite.LocalImagePath).GetWrapOrDefault(); + } + + if (tex != null) + { + const float RpProfileFrame = 140f; + float imageScale = imageSize.X / RpProfileFrame; + float zoom = favorite.ImageZoom; + Vector2 offset = favorite.ImageOffset * imageScale; + var cursor = ImGui.GetCursorScreenPos(); + + ImGui.BeginChild($"FavImageClip_{favorite.CharacterId}_{favorite.FavoritedAt.Ticks}", + imageSize, + false, + ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse); + ImGui.SetCursorScreenPos(cursor + offset); + var texW = tex.Width; + var texH = tex.Height; + float aspect = (float)texW / texH; + + float drawW = aspect >= 1f + ? imageSize.Y * aspect * zoom + : imageSize.X * zoom; + float drawH = aspect >= 1f + ? imageSize.Y * zoom + : imageSize.X / aspect * zoom; + + ImGui.Image(tex.ImGuiHandle, new Vector2(drawW, drawH)); + + if (ImGui.IsItemClicked(ImGuiMouseButton.Left) || ImGui.IsItemClicked(ImGuiMouseButton.Right)) + { + imagePreviewUrl = favorite.LocalImagePath; + showImagePreview = true; + } + + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Click to preview full image"); + } + + ImGui.EndChild(); + } + else + { + ImGui.TextColored(new Vector4(0.7f, 0.7f, 0.7f, 1f), "Loading…"); + } + + // Character info + ImGui.SameLine(); + ImGui.SetCursorPos(new Vector2(imageSize.X + (12 * scale), 8 * scale)); + ImGui.BeginGroup(); + + // Name and pronouns + ImGui.PushFont(UiBuilder.DefaultFont); + ImGui.Text(favorite.CharacterName); + ImGui.PopFont(); + + if (!string.IsNullOrEmpty(favorite.Pronouns)) + { + ImGui.SameLine(); + ImGui.SetCursorPosX(ImGui.GetCursorPosX() - (4f * scale)); + ImGui.TextColored(new Vector4(0.6f, 0.8f, 1.0f, 1.0f), $"({favorite.Pronouns})"); + } + + ImGui.TextColored(new Vector4(0.7f, 0.7f, 0.7f, 1.0f), $"{favorite.Race} • {favorite.Server}"); + + // Compact bio + if (!string.IsNullOrEmpty(favorite.Bio)) + { + var bioPreview = favorite.Bio.Length > 60 ? favorite.Bio.Substring(0, 60) + "..." : favorite.Bio; + ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + (180 * scale)); + ImGui.TextColored(new Vector4(0.9f, 0.9f, 0.85f, 1.0f), $"\"{bioPreview}\""); + ImGui.PopTextWrapPos(); + } + + // Favourited date + ImGui.TextColored(new Vector4(0.6f, 0.6f, 0.6f, 1.0f), $"★ {favorite.FavoritedAt:MMM dd}"); + + ImGui.EndGroup(); + + ImGui.SetCursorPos(new Vector2(cardSize.X - (25 * scale), 5 * scale)); + + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.0f, 0.0f, 0.0f, 0.0f)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.9f, 0.7f, 0.2f, 0.2f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.9f, 0.7f, 0.2f, 0.3f)); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.9f, 0.7f, 0.2f, 1.0f)); + + bool removeClicked = ImGui.Button($"★##{snapKey}_remove_{favorite.FavoritedAt.Ticks}", new Vector2(20 * scale, 20 * scale)); + + ImGui.PopStyleColor(4); // Always pop styles, even if clicked! + + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Remove from favourites"); + } + + // Mark for removal, but DO NOT remove inside the loop! + if (removeClicked) + { + toRemove = favorite; + } + + ImGui.EndChild(); + } + + private void DrawFavoriteCard(FavoriteSnapshot favorite, Vector2 cardSize, float scale, ref FavoriteSnapshot? toRemove) + { + var drawList = ImGui.GetWindowDrawList(); + var cardMin = ImGui.GetCursorScreenPos(); + var cardMax = cardMin + cardSize; + var snapKey = GetProfileKey(favorite); + + bool isCardHovered = ImGui.IsMouseHoveringRect(cardMin, cardMax); + + var bgColor = isCardHovered + ? new Vector4(0.12f, 0.12f, 0.18f, 0.95f) + : new Vector4(0.08f, 0.08f, 0.12f, 0.95f); + + var borderColor = isCardHovered + ? new Vector4(0.35f, 0.35f, 0.45f, 0.8f) + : new Vector4(0.25f, 0.25f, 0.35f, 0.6f); + + drawList.AddRectFilled(cardMin, cardMax, ImGui.GetColorU32(bgColor), 8f * scale); + drawList.AddRect(cardMin, cardMax, ImGui.GetColorU32(borderColor), 8f * scale, ImDrawFlags.None, 1f * scale); + + ImGui.BeginChild($"fav_card_{favorite.CharacterId}_{favorite.FavoritedAt.Ticks}", cardSize, false); + + var imageSize = new Vector2(100 * scale, 100 * scale); + ImGui.SetCursorPos(new Vector2(10 * scale, 20 * scale)); + + IDalamudTextureWrap? tex = null; + if (!string.IsNullOrEmpty(favorite.LocalImagePath) && File.Exists(favorite.LocalImagePath)) + { + tex = Plugin.TextureProvider.GetFromFile(favorite.LocalImagePath).GetWrapOrDefault(); + } + + if (tex != null) + { + const float RpProfileFrame = 140f; + float imageScale = imageSize.X / RpProfileFrame; + float zoom = favorite.ImageZoom; + Vector2 offset = favorite.ImageOffset * imageScale; + var cursor = ImGui.GetCursorScreenPos(); + + ImGui.BeginChild($"FavImageClip_{favorite.CharacterId}_{favorite.FavoritedAt.Ticks}", + imageSize, + false, + ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse); + ImGui.SetCursorScreenPos(cursor + offset); + var texW = tex.Width; + var texH = tex.Height; + float aspect = (float)texW / texH; + + float drawW = aspect >= 1f + ? imageSize.Y * aspect * zoom + : imageSize.X * zoom; + float drawH = aspect >= 1f + ? imageSize.Y * zoom + : imageSize.X / aspect * zoom; + + ImGui.Image(tex.ImGuiHandle, new Vector2(drawW, drawH)); + + if (ImGui.IsItemClicked(ImGuiMouseButton.Left) || ImGui.IsItemClicked(ImGuiMouseButton.Right)) + { + imagePreviewUrl = favorite.LocalImagePath; + showImagePreview = true; + } + + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Click to preview full image"); + } + + ImGui.EndChild(); + } + else + { + ImGui.TextColored(new Vector4(0.7f, 0.7f, 0.7f, 1f), "Downloading…"); + } + + // Right side - Character info + ImGui.SameLine(); + ImGui.BeginGroup(); + + // Character name from snapshot + ImGui.PushFont(UiBuilder.DefaultFont); + ImGui.Text(favorite.CharacterName); + ImGui.PopFont(); + + if (!string.IsNullOrEmpty(favorite.Pronouns)) + { + ImGui.SameLine(); + ImGui.SetCursorPosX(ImGui.GetCursorPosX() - (4f * scale)); + ImGui.TextColored(new Vector4(0.6f, 0.8f, 1.0f, 1.0f), $"({favorite.Pronouns})"); + } + + ImGui.TextColored(new Vector4(0.7f, 0.7f, 0.7f, 1.0f), $"{favorite.Race} • {favorite.Server}"); + + if (!string.IsNullOrEmpty(favorite.Bio)) + { + ImGui.Spacing(); + var bioPreview = favorite.Bio.Length > 120 ? favorite.Bio.Substring(0, 120) + "..." : favorite.Bio; + ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + (240 * scale)); + ImGui.TextColored(new Vector4(0.9f, 0.9f, 0.85f, 1.0f), $"\"{bioPreview}\""); + ImGui.PopTextWrapPos(); + } + + // Show when favourited + ImGui.Spacing(); + ImGui.TextColored(new Vector4(0.6f, 0.6f, 0.6f, 1.0f), $"Favourited {favorite.FavoritedAt:MMM dd}"); + + ImGui.EndGroup(); + + ImGui.SetCursorPos(new Vector2(cardSize.X - (35 * scale), cardSize.Y - (35 * scale))); + + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.0f, 0.0f, 0.0f, 0.0f)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.9f, 0.7f, 0.2f, 0.2f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.9f, 0.7f, 0.2f, 0.3f)); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.9f, 0.7f, 0.2f, 1.0f)); + + bool removeClicked = ImGui.Button($"★##{snapKey}_remove_{favorite.FavoritedAt.Ticks}", new Vector2(25 * scale, 25 * scale)); + + ImGui.PopStyleColor(4); // Always pop styles, even if clicked! + + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Remove from favourites"); + } + + // Mark for removal, but DO NOT remove inside the loop! + if (removeClicked) + { + toRemove = favorite; + } + + ImGui.EndChild(); + } + + private IDalamudTextureWrap? GetFavoriteTexture(string? imageUrl) + { + if (string.IsNullOrEmpty(imageUrl)) + return null; + + try + { + var hash = Convert.ToBase64String( + System.Security.Cryptography.MD5.HashData( + System.Text.Encoding.UTF8.GetBytes($"FAV_{imageUrl}") + ) + ).Replace("/", "_").Replace("+", "-"); + + string fileName = $"RPImage_FAV_{hash}.png"; + string localPath = Path.Combine( + Plugin.PluginInterface.GetPluginConfigDirectory(), + fileName + ); + + if (File.Exists(localPath)) + { + return Plugin.TextureProvider.GetFromFile(localPath).GetWrapOrDefault(); + } + + Task.Run(async () => + { + try + { + using var client = new HttpClient(); + client.Timeout = TimeSpan.FromSeconds(10); + var data = await client.GetByteArrayAsync(imageUrl); + + Directory.CreateDirectory(Path.GetDirectoryName(localPath)!); + File.WriteAllBytes(localPath, data); + } + catch (Exception ex) + { + Plugin.Log.Error($"[Gallery] Failed to download favourite image {imageUrl}: {ex.Message}"); + } + }); + + return null; + } + catch (Exception ex) + { + Plugin.Log.Error($"[Gallery] Error in GetFavoriteTexture: {ex.Message}"); + return null; + } + } + + private void DrawSettingsTab(float scale) + { + ImGui.Text("Gallery Settings"); + ImGui.Separator(); + + // Apply form styling to settings controls + ImGui.PushStyleColor(ImGuiCol.FrameBg, new Vector4(0.16f, 0.16f, 0.16f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.FrameBgHovered, new Vector4(0.22f, 0.22f, 0.22f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.FrameBgActive, new Vector4(0.28f, 0.28f, 0.28f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.2f, 0.2f, 0.2f, 0.8f)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.3f, 0.3f, 0.3f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.4f, 0.4f, 0.4f, 1.0f)); + + // Gallery Main Character + DrawSettingsSection("Gallery Main Character", scale, () => { + ImGui.TextWrapped("Choose which physical character represents you in the public gallery. Only this character will appear in the gallery, preventing duplicates."); + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("This determines which physical character name appears in the gallery.\nYour CS+ character profiles can still be applied to any physical character,\nbut only your chosen main will be visible to others in the gallery."); + } + + var currentMain = plugin.Configuration.GalleryMainCharacter; + string displayText = string.IsNullOrEmpty(currentMain) ? "None Selected" : currentMain; + + ImGui.SetNextItemWidth(300 * scale); + if (ImGui.BeginCombo("##GalleryMain", displayText)) + { + bool isNoneSelected = string.IsNullOrEmpty(currentMain); + if (ImGui.Selectable("None (Don't show in gallery)", isNoneSelected)) + { + plugin.Configuration.GalleryMainCharacter = null; + plugin.Configuration.Save(); + Plugin.Log.Info("[Gallery] Gallery main character cleared - will not appear in gallery"); + } + if (isNoneSelected) ImGui.SetItemDefaultFocus(); + + ImGui.Separator(); + + var physicalCharacterOptions = GetPhysicalCharacterOptions(); + foreach (var option in physicalCharacterOptions) + { + bool isSelected = currentMain == option; + if (ImGui.Selectable(option, isSelected)) + { + plugin.Configuration.GalleryMainCharacter = option; + plugin.Configuration.Save(); + Plugin.Log.Info($"[Gallery] Set gallery main character to: {option}"); + + var parts = option.Split('@'); + if (parts.Length == 2) + { + var characterName = parts[0]; + var character = plugin.Characters.FirstOrDefault(c => c.Name == characterName); + if (character?.RPProfile != null) + { + _ = Plugin.UploadProfileAsync(character.RPProfile, option); + } + } + } + if (isSelected) ImGui.SetItemDefaultFocus(); + } + ImGui.EndCombo(); + } + + if (!string.IsNullOrEmpty(currentMain)) + { + ImGui.TextColored(new Vector4(0.7f, 0.9f, 0.7f, 1.0f), $"✓ Gallery shows you as: {currentMain}"); + } + else + { + ImGui.TextColored(new Vector4(0.9f, 0.7f, 0.7f, 1.0f), "⚠ You will not appear in the public gallery"); + } + }); + + // Recently Active Status + DrawSettingsSection("Privacy Settings", scale, () => { + bool showMyStatus = plugin.Configuration.ShowRecentlyActiveStatus; + if (ImGui.Checkbox("Show my recently active status to others", ref showMyStatus)) + { + plugin.Configuration.ShowRecentlyActiveStatus = showMyStatus; + plugin.Configuration.Save(); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("When enabled, other players can see when you've used any of your characters recently (green globe).\nWhen disabled, all of your characters always appear offline to others.\nThis setting applies to all your Character Select+ profiles."); + } + }); + + // Current CS+ Character Settings + var activeCharacter = GetActiveCharacter(); + if (activeCharacter != null) + { + DrawSettingsSection($"Settings for CS+ Character: {activeCharacter.Name}", scale, () => { + var currentSharing = activeCharacter.RPProfile?.Sharing ?? ProfileSharing.AlwaysShare; + + if (ImGui.RadioButton("Don't share this CS+ character", currentSharing == ProfileSharing.NeverShare)) + { + SetCharacterSharing(activeCharacter, ProfileSharing.NeverShare); + } + + if (ImGui.RadioButton("Share when requested (/viewrp)", currentSharing == ProfileSharing.AlwaysShare)) + { + SetCharacterSharing(activeCharacter, ProfileSharing.AlwaysShare); + } + + if (ImGui.RadioButton("Show in public gallery", currentSharing == ProfileSharing.ShowcasePublic)) + { + SetCharacterSharing(activeCharacter, ProfileSharing.ShowcasePublic); + } + + if (currentSharing == ProfileSharing.ShowcasePublic && string.IsNullOrEmpty(plugin.Configuration.GalleryMainCharacter)) + { + ImGui.TextColored(new Vector4(1.0f, 0.7f, 0.4f, 1.0f), "⚠ This CS+ character won't appear in gallery without a main character selected above"); + } + }); + + // Gallery Status Message + if (activeCharacter.RPProfile?.Sharing == ProfileSharing.ShowcasePublic) + { + DrawSettingsSection($"Gallery Status Message for '{activeCharacter.Name}'", scale, () => { + ImGui.TextWrapped("Set a custom message for this CS+ character to display in the gallery instead of their bio. Think of it like a quote, lyric, or current mood."); + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("This is specific to the CS+ character you currently have selected.\nThis is NOT your online/offline status - it's a custom message that shows in gallery cards.\nLeave empty to show your bio instead."); + } + + string currentStatus = activeCharacter.GalleryStatus ?? ""; + if (ImGui.InputTextMultiline("##GalleryStatus", ref currentStatus, 200, new Vector2(-1, 50 * scale))) + { + activeCharacter.GalleryStatus = currentStatus; + plugin.SaveConfiguration(); + + if (activeCharacter.RPProfile != null) + { + _ = Plugin.UploadProfileAsync(activeCharacter.RPProfile, activeCharacter.LastInGameName ?? activeCharacter.Name); + } + } + + var statusLines = currentStatus.Split('\n'); + int lineCount = statusLines.Length; + ImGui.TextColored(new Vector4(0.7f, 0.7f, 0.7f, 1.0f), $"Characters: {currentStatus.Length}/200, Lines: {lineCount}/2"); + + if (lineCount > 2) + { + ImGui.TextColored(new Vector4(1f, 0.6f, 0.4f, 1f), "⚠ Status will be truncated to 2 lines in gallery"); + } + }); + } + } + + // Block Management + DrawSettingsSection("Block Management", scale, () => { + ImGui.Text($"You have blocked {blockedProfiles.Count} character(s)"); + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Blocked characters won't appear in your gallery view"); + } + + if (blockedProfiles.Count > 0) + { + ImGui.SameLine(); + if (ImGui.Button("View Blocked")) + { + currentTab = GalleryTab.Blocked; + } + + if (ImGui.Button("Clear All Blocks")) + { + ImGui.OpenPopup("ConfirmClearBlocks"); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip($"Unblock all {blockedProfiles.Count} blocked characters"); + } + + if (ImGui.BeginPopupModal("ConfirmClearBlocks")) + { + ImGui.Text($"Are you sure you want to unblock all {blockedProfiles.Count} blocked characters?"); + ImGui.Spacing(); + + if (ImGui.Button("Yes, Clear All", new Vector2(120 * scale, 0))) + { + blockedProfiles.Clear(); + plugin.Configuration.BlockedGalleryProfiles.Clear(); + plugin.Configuration.Save(); + _ = LoadGalleryData(); + ImGui.CloseCurrentPopup(); + } + + ImGui.SameLine(); + if (ImGui.Button("Cancel", new Vector2(120 * scale, 0))) + { + ImGui.CloseCurrentPopup(); + } + + ImGui.EndPopup(); + } + } + }); + + // Favourites Management + DrawSettingsSection("Favourites Management", scale, () => { + ImGui.Text($"You have {favoriteSnapshots.Count} favourited character(s)"); + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("These are snapshots of characters you've starred in the gallery"); + } + + if (favoriteSnapshots.Count > 0) + { + if (ImGui.Button("Clear All Favourites")) + { + ImGui.OpenPopup("ConfirmClearFavourites"); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip($"Remove all {favoriteSnapshots.Count} favourited characters"); + } + + if (ImGui.BeginPopupModal("ConfirmClearFavourites")) + { + ImGui.Text($"Are you sure you want to remove all {favoriteSnapshots.Count} favourited characters?"); + ImGui.Spacing(); + + if (ImGui.Button("Yes, Clear All", new Vector2(120 * scale, 0))) + { + favoriteSnapshots.Clear(); + plugin.Configuration.FavoriteSnapshots = favoriteSnapshots; + plugin.Configuration.Save(); + ImGui.CloseCurrentPopup(); + } + + ImGui.SameLine(); + if (ImGui.Button("Cancel", new Vector2(120 * scale, 0))) + { + ImGui.CloseCurrentPopup(); + } + + ImGui.EndPopup(); + } + } + }); + + ImGui.PopStyleColor(6); + } + + private void DrawSettingsSection(string title, float scale, Action content) + { + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.9f, 0.9f, 0.9f, 1.0f)); + ImGui.Text(title); + ImGui.PopStyleColor(); + + ImGui.Spacing(); + + ImGui.Indent(8 * scale); + content(); + ImGui.Unindent(8 * scale); + + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + } + + private List GetPhysicalCharacterOptions() + { + var options = new List(); + + foreach (var kvp in plugin.Configuration.LastUsedCharacterByPlayer) + { + var physicalCharacterKey = kvp.Key; + var csCharacterKey = kvp.Value; + + var csCharacterName = csCharacterKey.Contains('@') ? csCharacterKey.Split('@')[0] : csCharacterKey; + + var csCharacter = plugin.Characters.FirstOrDefault(c => c.Name == csCharacterName); + if (csCharacter?.RPProfile?.Sharing == ProfileSharing.ShowcasePublic) + { + if (!options.Contains(physicalCharacterKey)) + { + options.Add(physicalCharacterKey); + Plugin.Log.Debug($"[Gallery] Added physical character from login history: {physicalCharacterKey}"); + } + } + } + + if (Plugin.ClientState.LocalPlayer?.HomeWorld.IsValid == true) + { + var currentPhysicalName = Plugin.ClientState.LocalPlayer.Name.TextValue; + var currentServer = Plugin.ClientState.LocalPlayer.HomeWorld.Value.Name.ToString(); + var currentPhysicalKey = $"{currentPhysicalName}@{currentServer}"; + + var hasPublicCharacters = plugin.Characters.Any(c => c.RPProfile?.Sharing == ProfileSharing.ShowcasePublic); + + if (hasPublicCharacters && !options.Contains(currentPhysicalKey)) + { + options.Add(currentPhysicalKey); + Plugin.Log.Debug($"[Gallery] Added current physical character: {currentPhysicalKey}"); + } + } + + if (options.Count == 0) + { + Plugin.Log.Warning("[Gallery] No physical character options found. You may need to apply a CS+ profile first to populate the list."); + } + + return options.Distinct().OrderBy(x => x).ToList(); + } + private void DrawPaginationControls(int totalPages, float scale, string idSuffix = "") + { + ImGui.Text($"Page {currentPage + 1} of {totalPages} ({filteredProfiles.Count} profiles)"); + ImGui.SameLine(); + + SafeStyleScope(3, 0, () => { + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.2f, 0.2f, 0.2f, 0.8f)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.3f, 0.3f, 0.3f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.4f, 0.4f, 0.4f, 1.0f)); + + bool pageChanged = false; + + // First page button + if (currentPage > 0) + { + if (ImGui.Button($"First##{idSuffix}")) + { + currentPage = 0; + shouldResetScroll = true; + pageChanged = true; + } + ImGui.SameLine(); + } + else + { + ImGui.BeginDisabled(); + ImGui.Button($"First##{idSuffix}"); + ImGui.EndDisabled(); + ImGui.SameLine(); + } + + // Previous page button + if (currentPage > 0) + { + if (ImGui.Button($"< Previous##{idSuffix}")) + { + currentPage--; + shouldResetScroll = true; + pageChanged = true; + } + ImGui.SameLine(); + } + else + { + ImGui.BeginDisabled(); + ImGui.Button($"< Previous##{idSuffix}"); + ImGui.EndDisabled(); + ImGui.SameLine(); + } + + // Next page button + if (currentPage < totalPages - 1) + { + if (ImGui.Button($"Next >##{idSuffix}")) + { + currentPage++; + shouldResetScroll = true; + pageChanged = true; + } + ImGui.SameLine(); + } + else + { + ImGui.BeginDisabled(); + ImGui.Button($"Next >##{idSuffix}"); + ImGui.EndDisabled(); + ImGui.SameLine(); + } + + // Last page button + if (currentPage < totalPages - 1) + { + if (ImGui.Button($"Last##{idSuffix}")) + { + currentPage = totalPages - 1; + shouldResetScroll = true; + pageChanged = true; + } + } + else + { + ImGui.BeginDisabled(); + ImGui.Button($"Last##{idSuffix}"); + ImGui.EndDisabled(); + } + + // Force close any open context menus when page changes + if (pageChanged) + { + ImGui.CloseCurrentPopup(); + } + }); + } + + private void DrawProfileCard(GalleryProfile profile, Vector2 cardSize, float scale) + { + var characterKey = GetProfileKey(profile); + + var activeCharacter = GetActiveCharacter(); + string csCharacterKey = activeCharacter?.Name ?? "NoCharacter"; + + string likeKey = GetLikeKey(csCharacterKey, characterKey); + + var isLiked = useStateFreeze ? + frozenLikedProfiles.ContainsKey(likeKey) : + IsProfileLikedByAnyOfMyCharacters(characterKey); + + var isFavorited = useStateFreeze ? + frozenFavoritedProfiles.Contains(characterKey) : + currentFavoritedProfiles.Contains(characterKey); + + // Get nameplate colour + Vector3 nameplateColor; + + RPProfile? serverProfile = downloadedProfiles.ContainsKey(characterKey) ? downloadedProfiles[characterKey] : null; + if (serverProfile?.NameplateColor != null) + { + var serverColor = serverProfile.NameplateColor; + nameplateColor = new Vector3(serverColor.X, serverColor.Y, serverColor.Z); + cachedCharacterColors[characterKey] = nameplateColor; + } + else if (!cachedCharacterColors.TryGetValue(characterKey, out nameplateColor)) + { + nameplateColor = new Vector3(0.4f, 0.7f, 1.0f); + var config = Plugin.PluginInterface.GetPluginConfig() as Configuration; + var match = config?.Characters.FirstOrDefault(c => c.LastInGameName == characterKey || c.Name == profile.CharacterName); + if (match != null) + { + nameplateColor = match.NameplateColor; + } + cachedCharacterColors[characterKey] = nameplateColor; + } + + var accentColor = new Vector4(nameplateColor.X, nameplateColor.Y, nameplateColor.Z, 1.0f); + + // Card background with hover effect + var drawList = ImGui.GetWindowDrawList(); + var cardMin = ImGui.GetCursorScreenPos(); + var cardMax = cardMin + cardSize; + + bool isCardHovered = ImGui.IsMouseHoveringRect(cardMin, cardMax); + + var bgColor = isCardHovered + ? new Vector4(0.12f, 0.12f, 0.18f, 0.95f) + : new Vector4(0.08f, 0.08f, 0.12f, 0.95f); + + var borderColor = isCardHovered + ? new Vector4(0.35f, 0.35f, 0.45f, 0.8f) + : new Vector4(0.25f, 0.25f, 0.35f, 0.6f); + + float cornerRadius = 6f * scale; + + // Drop shadow + var shadowOffset = new Vector2(2f * scale, 2f * scale); + var shadowColor = new Vector4(0f, 0f, 0f, 0.2f); + drawList.AddRectFilled(cardMin + shadowOffset, cardMax + shadowOffset, ImGui.GetColorU32(shadowColor), cornerRadius); + + // Main card background + drawList.AddRectFilled(cardMin, cardMax, ImGui.GetColorU32(bgColor), cornerRadius); + + // Accent border + drawList.AddRectFilled(cardMin, cardMin + new Vector2(4f * scale, cardSize.Y), ImGui.GetColorU32(accentColor), cornerRadius, ImDrawFlags.RoundCornersLeft); + + // Card border + drawList.AddRect(cardMin, cardMax, ImGui.GetColorU32(borderColor), cornerRadius, ImDrawFlags.None, 1f * scale); + + // Hover glow + if (isCardHovered) + { + var glowColor = new Vector4(accentColor.X, accentColor.Y, accentColor.Z, 0.15f); + drawList.AddRect(cardMin, cardMax, ImGui.GetColorU32(glowColor), cornerRadius, ImDrawFlags.None, 2f * scale); + } + + string uniqueId = $"card_{characterKey}"; + ImGui.BeginChild(uniqueId, cardSize, false); + + // Profile loading optimization + if (!imageLoadStarted.ContainsKey(characterKey) && + !loadingProfiles.Contains(characterKey) && + lastSuccessfulRefresh != DateTime.MinValue) + { + imageLoadStarted[characterKey] = true; + loadingProfiles.Add(characterKey); + + _ = Task.Run(async () => + { + try + { + await Task.Delay(100); + await LoadProfileWithImageAsync(characterKey); + } + catch (Exception ex) + { + Plugin.Log.Error($"[Gallery] Failed to load profile for {characterKey}: {ex.Message}"); + } + finally + { + loadingProfiles.Remove(characterKey); + } + }); + } + + var imageSize = new Vector2(80 * scale, 80 * scale); + ImGui.SetCursorPos(new Vector2(12 * scale, 16 * scale)); + + RPProfile? fullProfile = downloadedProfiles.ContainsKey(characterKey) ? downloadedProfiles[characterKey] : null; + + var texture = GetCorrectProfileTexture(fullProfile, profile); + + if (texture != null) + { + DrawPortraitImageGalleryOnly(texture, profile, fullProfile, imageSize.X); + + if (ImGui.IsItemClicked(ImGuiMouseButton.Left)) + ViewProfile(characterKey); + else if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + { + var imagePreviewPath = GetCorrectImagePath(fullProfile, profile); + if (!string.IsNullOrEmpty(imagePreviewPath)) + { + imagePreviewUrl = imagePreviewPath; + showImagePreview = true; + } + else + { + Plugin.ChatGui.Print("[Gallery] No custom image to preview - profile is using default image"); + } + } + + bool isUsingDefaultImage = string.IsNullOrEmpty(GetCorrectImagePath(fullProfile, profile)); + if (ImGui.IsItemHovered()) + { + if (isUsingDefaultImage) + ImGui.SetTooltip("Left click: View Profile\nRight click: Default image (no preview available)"); + else + ImGui.SetTooltip("Left click: View Profile\nRight click: Preview Image"); + } + } + else + { + // Loading placeholder + var cursor = ImGui.GetCursorScreenPos(); + ImGui.Dummy(imageSize); + + var dl = ImGui.GetWindowDrawList(); + var min = cursor; + var max = cursor + imageSize; + + var loadingBg = new Vector4(0.15f, 0.15f, 0.2f, 0.9f); + var loadingBorder = new Vector4(0.3f, 0.3f, 0.35f, 0.8f); + + dl.AddRectFilled(min, max, ImGui.ColorConvertFloat4ToU32(loadingBg), 4f * scale); + dl.AddRect(min, max, ImGui.ColorConvertFloat4ToU32(loadingBorder), 4f * scale, ImDrawFlags.None, 1f * scale); + + var loadingText = "Loading..."; + var textSize = ImGui.CalcTextSize(loadingText); + var textPos = min + (imageSize - textSize) * 0.5f; + dl.AddText(textPos, ImGui.ColorConvertFloat4ToU32(new Vector4(0.7f, 0.7f, 0.7f, 1f)), loadingText); + + ImGui.SetCursorScreenPos(cursor); + if (ImGui.InvisibleButton($"##LoadingCard{characterKey}", imageSize)) + ViewProfile(characterKey); + } + + // Right side - Character info + ImGui.SameLine(); + ImGui.SetCursorPos(new Vector2(imageSize.X + (20 * scale), 16 * scale)); + ImGui.BeginGroup(); + + var nameStartPos = ImGui.GetCursorScreenPos(); + + // Name and pronouns + var nameText = profile.CharacterName; + var pronounsText = !string.IsNullOrEmpty(profile.Pronouns) ? $" ({profile.Pronouns})" : ""; + + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(accentColor.X * 1.2f, accentColor.Y * 1.2f, accentColor.Z * 1.2f, 1.0f)); + ImGui.PushFont(UiBuilder.DefaultFont); + ImGui.Text(nameText); + ImGui.PopFont(); + ImGui.PopStyleColor(); + + if (!string.IsNullOrEmpty(profile.Pronouns)) + { + var nameSize = ImGui.CalcTextSize(nameText); + var pronounsPos = nameStartPos + new Vector2(nameSize.X + (2f * scale), 1f * scale); + + var pronounsColor = ImGui.GetColorU32(new Vector4(0.7f, 0.7f, 0.8f, 0.9f)); + var smallFont = ImGui.GetFont(); + var smallFontSize = ImGui.GetFontSize() * 0.85f; + + drawList.AddText(smallFont, smallFontSize, pronounsPos, pronounsColor, pronounsText); + } + + // Check for name hover + var nameEndPos = ImGui.GetCursorScreenPos(); + var nameRect = new Vector2(nameEndPos.X - nameStartPos.X, ImGui.GetTextLineHeight()); + bool nameHovered = ImGui.IsMouseHoveringRect(nameStartPos, nameStartPos + nameRect); + + if (nameHovered) + { + ImGui.SetMouseCursor(ImGuiMouseCursor.Hand); + if (ImGui.IsMouseClicked(ImGuiMouseButton.Left)) + { + ViewProfile(characterKey); + } + } + + // Age and Race info + string? age = fullProfile?.Age ?? null; + string? race = fullProfile?.Race ?? profile.Race; + + if (!string.IsNullOrEmpty(age) && !string.IsNullOrEmpty(race)) + { + ImGui.TextColored(new Vector4(0.8f, 0.8f, 0.85f, 1.0f), $"{age} • {race}"); + } + else if (!string.IsNullOrEmpty(race)) + { + ImGui.TextColored(new Vector4(0.8f, 0.8f, 0.85f, 1.0f), race); + } + + // Status/Bio preview + if (!string.IsNullOrEmpty(profile.GalleryStatus)) + { + ImGui.Spacing(); + + float statusMaxWidth = cardSize.X - (imageSize.X + (20 * scale)) - (15f * scale); + + float reservedBottomSpace = 45f * scale; + float currentY = ImGui.GetCursorPosY(); + float availableHeight = cardSize.Y - currentY - reservedBottomSpace; + float lineHeight = ImGui.GetTextLineHeight(); + int maxAllowedLines = Math.Max(1, Math.Min(2, (int)(availableHeight / lineHeight))); + + var lines = new List(); + var statusLines = profile.GalleryStatus.Split('\n'); + + for (int i = 0; i < Math.Min(Math.Min(statusLines.Length, 2), maxAllowedLines); i++) + { + var line = statusLines[i].Trim(); + if (!string.IsNullOrEmpty(line)) + { + // Truncate line if too long...how many people are going to have a status longer than 50 characters anyway? + if (ImGui.CalcTextSize($"\"{line}\"").X > statusMaxWidth) + { + while (ImGui.CalcTextSize($"\"{line}...\"").X > statusMaxWidth && line.Length > 10) + { + line = line.Substring(0, line.Length - 1); + } + line += "..."; + } + lines.Add(line); + } + } + + ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + statusMaxWidth); + // Add quotes to status like bio has, cause they're fun and feel fancy + for (int i = 0; i < lines.Count; i++) + { + var line = lines[i]; + if (i == 0) line = $"\"{line}"; + if (i == lines.Count - 1) line = $"{line}\""; + ImGui.TextColored(new Vector4(0.9f, 0.85f, 0.9f, 1.0f), line); + } + ImGui.PopTextWrapPos(); + } + else if (!string.IsNullOrEmpty(profile.Bio)) + { + ImGui.Spacing(); + + float bioMaxWidth = cardSize.X - (imageSize.X + (20 * scale)) - (15f * scale); + + float reservedBottomSpace = 45f * scale; + float currentY = ImGui.GetCursorPosY(); + float availableHeight = cardSize.Y - currentY - reservedBottomSpace; + float lineHeight = ImGui.GetTextLineHeight(); + int maxAllowedLines = Math.Max(1, Math.Min(2, (int)(availableHeight / lineHeight))); + + // Replace line breaks with spaces for consistent wrapping + var cleanedBio = profile.Bio.Replace("\n", " ").Replace("\r", " "); + + // Remove extra spaces + while (cleanedBio.Contains(" ")) + { + cleanedBio = cleanedBio.Replace(" ", " "); + } + + var lines = new List(); + var words = cleanedBio.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var currentLine = ""; + int wordIndex = 0; + + foreach (var word in words) + { + var testLine = string.IsNullOrEmpty(currentLine) ? word : $"{currentLine} {word}"; + // Include quotes in width calculations + if (ImGui.CalcTextSize($"\"{testLine}\"").X <= bioMaxWidth) + { + currentLine = testLine; + wordIndex++; + } + else + { + if (!string.IsNullOrEmpty(currentLine)) + { + lines.Add(currentLine); + currentLine = word; + wordIndex++; + } + else + { + currentLine = word.Length > 30 ? word.Substring(0, 27) + "..." : word; + wordIndex++; + } + if (lines.Count >= Math.Min(2, maxAllowedLines)) break; + } + } + + if (!string.IsNullOrEmpty(currentLine) && lines.Count < Math.Min(2, maxAllowedLines)) + { + lines.Add(currentLine); + } + + if (wordIndex < words.Length && lines.Count > 0) + { + var lastLineIndex = lines.Count - 1; + var lastLine = lines[lastLineIndex]; + while (ImGui.CalcTextSize($"\"{lastLine}...\"").X > bioMaxWidth && lastLine.Contains(' ')) + { + var lastSpaceIndex = lastLine.LastIndexOf(' '); + if (lastSpaceIndex > 0) + { + lastLine = lastLine.Substring(0, lastSpaceIndex); + } + else + { + break; + } + } + lines[lastLineIndex] = lastLine + "..."; + } + + // Render lines with proper spacing + for (int i = 0; i < lines.Count; i++) + { + var line = lines[i]; + if (i == 0) + { + line = $"\"{line}"; + } + if (i == lines.Count - 1) + { + line = $"{line}\""; + } + + ImGui.TextColored(new Vector4(0.9f, 0.9f, 0.85f, 1.0f), line); + + // Only add spacing between lines, not after the last one + if (i < lines.Count - 1) + { + ImGui.SetCursorPosY(ImGui.GetCursorPosY() + (lineHeight * 0.1f)); // Small gap between lines + } + } + } + + // Globe icon in top right with "online recently" indicator + var globePos = new Vector2(cardSize.X - (25 * scale), 12 * scale); + ImGui.SetCursorPos(globePos); + + bool isRecentlyActive = IsCharacterRecentlyActive(profile); + var globeColor = isRecentlyActive ? + new Vector4(0.4f, 1.0f, 0.4f, 1.0f) : + new Vector4(0.6f, 0.6f, 0.7f, 1.0f); + + ImGui.PushFont(UiBuilder.IconFont); + ImGui.TextColored(globeColor, "\uf0ac"); + ImGui.PopFont(); + + if (ImGui.IsItemHovered()) + { + if (isRecentlyActive) + ImGui.SetTooltip($"{profile.Server}\n(Recently Active)"); + else + ImGui.SetTooltip(profile.Server); + } + + ImGui.SetCursorPos(new Vector2(cardSize.X - (65 * scale), cardSize.Y - (22 * scale))); + + // Like button + SafeStyleScope(4, 0, () => { + if (isLiked) + { + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.0f, 0.0f, 0.0f, 0.0f)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.85f, 0.3f, 0.4f, 0.2f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.85f, 0.3f, 0.4f, 0.3f)); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.85f, 0.3f, 0.4f, 1.0f)); + } + else + { + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.0f, 0.0f, 0.0f, 0.0f)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.85f, 0.3f, 0.4f, 0.1f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.85f, 0.3f, 0.4f, 0.2f)); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.6f, 0.6f, 0.6f, 1.0f)); + } + + if (ImGui.Button($"♥##{characterKey}_like", new Vector2(16 * scale, 18 * scale))) + { + bool wasLiked = isLiked; + ToggleLike(characterKey); + + Vector2 effectPos = ImGui.GetItemRectMin() + ImGui.GetItemRectSize() / 2; + string likeEffectKey = $"like_{characterKey}"; + if (!galleryLikeEffects.ContainsKey(likeEffectKey)) + galleryLikeEffects[likeEffectKey] = new LikeSparkEffect(); + galleryLikeEffects[likeEffectKey].Trigger(effectPos, !wasLiked); + + if (useStateFreeze) + { + var currentLikeKey = $"{csCharacterKey}|{characterKey}"; + if (frozenLikedProfiles.ContainsKey(currentLikeKey)) + frozenLikedProfiles.Remove(currentLikeKey); + else + frozenLikedProfiles[currentLikeKey] = true; + } + } + }); + + // Like count + ImGui.SameLine(); + ImGui.SetCursorPosX(ImGui.GetCursorPosX() - (2f * scale)); + ImGui.SetCursorPosY(ImGui.GetCursorPosY() + (1f * scale)); + + var currentProfile = allProfiles.FirstOrDefault(p => GetProfileKey(p) == characterKey); + int displayLikeCount = currentProfile?.LikeCount ?? profile.LikeCount; + ImGui.Text($"{displayLikeCount}"); + + // Favourite button + ImGui.SameLine(); + ImGui.SetCursorPosX(ImGui.GetCursorPosX() + (2f * scale)); + ImGui.SetCursorPosY(ImGui.GetCursorPosY() - (1f * scale)); + + // Favourite button + bool favoriteClicked = false; + SafeStyleScope(4, 0, () => { + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.0f, 0.0f, 0.0f, 0.0f)); + + if (isFavorited) + { + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.9f, 0.7f, 0.2f, 0.2f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.9f, 0.7f, 0.2f, 0.3f)); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.9f, 0.7f, 0.2f, 1.0f)); + } + else + { + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.9f, 0.7f, 0.2f, 0.1f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.9f, 0.7f, 0.2f, 0.2f)); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.6f, 0.6f, 0.6f, 1.0f)); + } + + favoriteClicked = ImGui.Button($"★##{characterKey}_fav", new Vector2(16 * scale, 18 * scale)); + }); + + // Handle the click after styles are safely popped + if (favoriteClicked) + { + bool wasFavorited = isFavorited; + ToggleBookmark(profile); + + Vector2 effectPos = ImGui.GetItemRectMin() + ImGui.GetItemRectSize() / 2; + string favEffectKey = $"fav_{characterKey}"; + if (!galleryFavoriteEffects.ContainsKey(favEffectKey)) + galleryFavoriteEffects[favEffectKey] = new FavoriteSparkEffect(); + galleryFavoriteEffects[favEffectKey].Trigger(effectPos, !wasFavorited); + + if (useStateFreeze) + { + if (frozenFavoritedProfiles.Contains(characterKey)) + frozenFavoritedProfiles.Remove(characterKey); + else + frozenFavoritedProfiles.Add(characterKey); + } + } + + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip(isFavorited ? "Remove from favourites" : "Add to favourites"); + } + + // Right-click context menu for blocking + if (ImGui.IsMouseHoveringRect(cardMin, cardMax) && ImGui.IsMouseClicked(ImGuiMouseButton.Right)) + { + ImGui.OpenPopup($"ProfileContextMenu_{characterKey}"); + } + + if (ImGui.BeginPopupContextItem($"ProfileContextMenu_{characterKey}")) + { + var currentActiveCharacter = GetActiveCharacter(); + bool isOwnProfile = false; + + if (currentActiveCharacter != null) + { + string userMainCharacter = plugin.Configuration.GalleryMainCharacter ?? ""; + if (!string.IsNullOrEmpty(userMainCharacter)) + { + string profilePhysicalName = ""; + if (!string.IsNullOrEmpty(profile.CharacterId) && profile.CharacterId.Contains('_')) + { + var parts = profile.CharacterId.Split('_', 2); + if (parts.Length == 2) + { + profilePhysicalName = parts[1]; + } + } + else + { + profilePhysicalName = $"{profile.CharacterName}@{profile.Server}"; + } + + isOwnProfile = profilePhysicalName == userMainCharacter; + } + } + + if (!isOwnProfile) + { + var profilePlayerKey = ExtractPhysicalCharacterFromId(profile.CharacterId); + + Vector3 separatorColor = new Vector3(0.4f, 0.7f, 1.0f); // Default + if (downloadedProfiles.TryGetValue(characterKey, out var rpProfile)) + { + separatorColor = new Vector3(rpProfile.NameplateColor.X, rpProfile.NameplateColor.Y, rpProfile.NameplateColor.Z); + } + else if (cachedCharacterColors.TryGetValue(characterKey, out var cachedColor)) + { + separatorColor = cachedColor; + } + + // Friend management with icons + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.4f, 0.8f, 0.4f, 1.0f)); + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf007"); + ImGui.PopFont(); + ImGui.PopStyleColor(); + ImGui.SameLine(0, 4 * scale); + + if (!plugin.Configuration.FollowedPlayers.Contains(profilePlayerKey)) + { + if (ImGui.Selectable("Add this player as a friend")) + { + plugin.Configuration.FollowedPlayers.Add(profilePlayerKey); + plugin.Configuration.Save(); + _ = UpdateFriendsOnServer(); + Plugin.ChatGui.Print($"[Gallery] Added friend!"); + } + } + else + { + if (ImGui.Selectable("Remove this player from friends")) + { + plugin.Configuration.FollowedPlayers.Remove(profilePlayerKey); + plugin.Configuration.Save(); + _ = UpdateFriendsOnServer(); + Plugin.ChatGui.Print($"[Gallery] Removed friend."); + } + } + + ImGui.Spacing(); + ImGui.PushStyleColor(ImGuiCol.ChildBg, new Vector4(separatorColor.X, separatorColor.Y, separatorColor.Z, 1.0f)); + ImGui.BeginChild($"##Separator_{characterKey}", new Vector2(ImGui.GetContentRegionAvail().X, 3 * scale), false); + ImGui.EndChild(); + ImGui.PopStyleColor(); + ImGui.Spacing(); + + // Report option with icon + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(1.0f, 0.6f, 0.2f, 1.0f)); + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf071"); // Warning triangle icon + ImGui.PopFont(); + ImGui.PopStyleColor(); + ImGui.SameLine(0, 4 * scale); + + if (ImGui.Selectable("Report this profile")) + { + OpenReportDialog(GetProfileKey(profile), profile.CharacterName); + } + + ImGui.Spacing(); + + // Block option with icon + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.8f, 0.3f, 0.3f, 1.0f)); + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf05e"); + ImGui.PopFont(); + ImGui.PopStyleColor(); + ImGui.SameLine(0, 4 * scale); + + if (ImGui.Selectable("Block this user")) + { + BlockProfile(profile); + } + } + else + { + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.6f, 0.6f, 0.6f, 1.0f)); + ImGui.Text("This is your own profile"); + ImGui.PopStyleColor(); + } + + ImGui.EndPopup(); + } + ImGui.EndChild(); + } + + + private bool IsCharacterRecentlyActive(GalleryProfile profile) + { + try + { + if (!plugin.Configuration.ShowRecentlyActiveStatus) + return false; + + if (!downloadedProfiles.TryGetValue(GetProfileKey(profile), out var rpProfile)) + return false; + + if (rpProfile.LastActiveTime == null) + return false; + + var timeSince = DateTime.UtcNow - rpProfile.LastActiveTime.Value; + return timeSince.TotalMinutes <= 30; + } + catch (Exception ex) + { + Plugin.Log.Debug($"Error checking recently active character: {ex.Message}"); + return false; + } + } + + private IDalamudTextureWrap? GetCorrectProfileTexture(RPProfile? rpProfile, GalleryProfile galleryProfile) + { + var characterKey = GetProfileKey(galleryProfile); + + // Cache the expensive path calculation + if (!imagePathCache.TryGetValue(characterKey, out string? finalImagePath)) + { + string? imagePath = null; + string fallback = Path.Combine(plugin.PluginDirectory, "Assets", "Default.png"); + + if (rpProfile != null && !string.IsNullOrEmpty(rpProfile.CustomImagePath) && File.Exists(rpProfile.CustomImagePath)) + { + imagePath = rpProfile.CustomImagePath; + } + else if (rpProfile != null && !string.IsNullOrEmpty(rpProfile.ProfileImageUrl)) + { + imagePath = GetDownloadedImagePath(rpProfile.ProfileImageUrl); + if (string.IsNullOrEmpty(imagePath)) + return null; + } + else if (!string.IsNullOrEmpty(galleryProfile.ProfileImageUrl)) + { + imagePath = GetDownloadedImagePath(galleryProfile.ProfileImageUrl); + if (string.IsNullOrEmpty(imagePath)) + return null; + } + + finalImagePath = !string.IsNullOrEmpty(imagePath) && File.Exists(imagePath) ? imagePath : fallback; + imagePathCache[characterKey] = finalImagePath; + } + + if (string.IsNullOrEmpty(finalImagePath)) + return null; + + return Plugin.TextureProvider.GetFromFile(finalImagePath).GetWrapOrDefault(); + } + + private string? GetCorrectImagePath(RPProfile? rpProfile, GalleryProfile galleryProfile) + { + if (rpProfile != null && !string.IsNullOrEmpty(rpProfile.CustomImagePath) && File.Exists(rpProfile.CustomImagePath)) + { + return rpProfile.CustomImagePath; + } + else if (rpProfile != null && !string.IsNullOrEmpty(rpProfile.ProfileImageUrl)) + { + return GetDownloadedImagePath(rpProfile.ProfileImageUrl); + } + else if (!string.IsNullOrEmpty(galleryProfile.ProfileImageUrl)) + { + return GetDownloadedImagePath(galleryProfile.ProfileImageUrl); + } + + return null; + } + + private void DrawPortraitImageGalleryOnly(IDalamudTextureWrap texture, GalleryProfile galleryProfile, RPProfile? rpProfile, float portraitSize) + { + float zoom = 1.0f; + Vector2 offset = Vector2.Zero; + + if (rpProfile != null) + { + zoom = Math.Clamp(rpProfile.ImageZoom, 0.5f, 3.0f); + offset = rpProfile.ImageOffset; + } + else if (galleryProfile.ImageZoom != 1.0f || galleryProfile.ImageOffset != Vector2.Zero) + { + zoom = Math.Clamp(galleryProfile.ImageZoom, 0.5f, 3.0f); + offset = galleryProfile.ImageOffset; + } + else + { + zoom = 1.2f; + } + + float scale = portraitSize / RpProfileFrameSize; + offset = offset * scale; + + float texAspect = (float)texture.Width / texture.Height; + float drawWidth, drawHeight; + + if (texAspect >= 1f) + { + drawHeight = portraitSize * zoom; + drawWidth = drawHeight * texAspect; + } + else + { + drawWidth = portraitSize * zoom; + drawHeight = drawWidth / texAspect; + } + + Vector2 drawSize = new(drawWidth, drawHeight); + Vector2 cursor = ImGui.GetCursorScreenPos(); + + ImGui.BeginChild("ImageView", new Vector2(portraitSize), false, + ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse); + + ImGui.SetCursorScreenPos(cursor + offset); + ImGui.Image(texture.ImGuiHandle, drawSize); + ImGui.EndChild(); + } + + private IDalamudTextureWrap? GetProfileTextureForProfile(RPProfile? rpProfile, string? fallbackUrl) + { + string? imagePath = null; + string fallback = Path.Combine(plugin.PluginDirectory, "Assets", "Default.png"); + + if (rpProfile != null && !string.IsNullOrEmpty(rpProfile.CustomImagePath) && File.Exists(rpProfile.CustomImagePath)) + { + imagePath = rpProfile.CustomImagePath; + } + else if (rpProfile != null && !string.IsNullOrEmpty(rpProfile.ProfileImageUrl)) + { + imagePath = GetDownloadedImagePath(rpProfile.ProfileImageUrl); + } + else if (!string.IsNullOrEmpty(fallbackUrl)) + { + imagePath = GetDownloadedImagePath(fallbackUrl); + } + + string finalImagePath = !string.IsNullOrEmpty(imagePath) && File.Exists(imagePath) ? imagePath : fallback; + return Plugin.TextureProvider.GetFromFile(finalImagePath).GetWrapOrDefault(); + } + + private string? GetDownloadedImagePath(string imageUrl) + { + try + { + var hash = Convert.ToBase64String( + System.Security.Cryptography.MD5.HashData( + System.Text.Encoding.UTF8.GetBytes(imageUrl) + ) + ).Replace("/", "_").Replace("+", "-"); + + string fileName = $"RPImage_{hash}.png"; + string localPath = Path.Combine( + Plugin.PluginInterface.GetPluginConfigDirectory(), + fileName + ); + + if (File.Exists(localPath)) + { + File.SetLastAccessTime(localPath, DateTime.Now); + return localPath; + } + + if (imageLoadStarted.ContainsKey(imageUrl)) + { + return null; + } + + var configDir = Plugin.PluginInterface.GetPluginConfigDirectory(); + var existingImages = Directory.GetFiles(configDir, "RPImage*.png"); + long currentCacheSize = existingImages.Sum(f => new FileInfo(f).Length); + + if (currentCacheSize > maxCacheSizeBytes) + { + Plugin.Log.Warning($"[Gallery] Cache size limit reached ({currentCacheSize / (1024 * 1024)}MB), skipping download"); + return null; + } + + imageLoadStarted[imageUrl] = true; + + Task.Run(async () => + { + try + { + await Task.Delay(Random.Shared.Next(100, 500)); + + using var client = new HttpClient(); + client.Timeout = TimeSpan.FromSeconds(15); + var data = await client.GetByteArrayAsync(imageUrl); + + Directory.CreateDirectory(Path.GetDirectoryName(localPath)!); + File.WriteAllBytes(localPath, data); + + File.SetLastAccessTime(localPath, DateTime.Now); + + } + catch (Exception ex) + { + Plugin.Log.Error($"[Gallery] Failed to download image {imageUrl}: {ex.Message}"); + } + finally + { + imageLoadStarted.Remove(imageUrl); + } + }); + + return null; + } + catch (Exception ex) + { + Plugin.Log.Error($"[Gallery] Error in GetDownloadedImagePath: {ex.Message}"); + return null; + } + } + + private IDalamudTextureWrap? GetProfileTexture(string? imageUrl) + { + return GetProfileTextureForProfile(null, imageUrl); + } + + private async void ViewProfile(string characterId) + { + try + { + var rpProfile = await Plugin.DownloadProfileAsync(characterId); + if (rpProfile != null) + { + plugin.RPProfileViewer.SetExternalProfile(rpProfile); + plugin.RPProfileViewer.IsOpen = true; + } + } + catch (Exception ex) + { + Plugin.Log.Error($"Error viewing profile: {ex.Message}"); + } + } + + private void ToggleLike(string characterId) + { + var activeCharacter = GetActiveCharacter(); + if (activeCharacter == null) return; + + string csCharacterKey = activeCharacter.Name; + + var targetProfile = allProfiles.FirstOrDefault(p => GetProfileKey(p) == characterId); + string stableLikeTarget = targetProfile?.CharacterName ?? characterId; + + string currentCharacterLikeKey = $"{csCharacterKey}|{stableLikeTarget}"; + + // Check if ANY of your characters has liked this profile, no like fraud on my watch! + string? existingLikerCharacter = GetWhichOfMyCharactersLikedProfile(characterId); + bool isCurrentlyLiked = existingLikerCharacter != null; + + Plugin.Log.Info($"[Like Debug] CS+ Character: {csCharacterKey}"); + Plugin.Log.Info($"[Like Debug] CharacterId passed: {SanitizeForLogging(characterId)}"); + Plugin.Log.Info($"[Like Debug] Target Profile Name: {stableLikeTarget}"); + Plugin.Log.Info($"[Like Debug] Currently Liked by: {existingLikerCharacter ?? "None"}"); + Plugin.Log.Info($"[Like Debug] Action: {(isCurrentlyLiked ? "Unlike" : "Like")}"); + + if (isCurrentlyLiked) + { + string existingLikeKey = $"{existingLikerCharacter}|{stableLikeTarget}"; + likedProfiles.Remove(existingLikeKey); + plugin.Configuration.LikedGalleryProfiles.Remove(existingLikeKey); + Plugin.Log.Info($"[Like Debug] Removed like key: {existingLikeKey}"); + } + else + { + likedProfiles[currentCharacterLikeKey] = true; + plugin.Configuration.LikedGalleryProfiles.Add(currentCharacterLikeKey); + Plugin.Log.Info($"[Like Debug] Added like key: {currentCharacterLikeKey}"); + } + + plugin.Configuration.Save(); + + Task.Run(async () => + { + try + { + using var httpClient = new HttpClient(); + var endpoint = $"https://character-select-profile-server-production.up.railway.app/gallery/{Uri.EscapeDataString(characterId)}/like"; + var method = isCurrentlyLiked ? HttpMethod.Delete : HttpMethod.Post; + var request = new HttpRequestMessage(method, endpoint); + + request.Headers.Add("X-Character-Key", csCharacterKey); + + Plugin.Log.Info($"[Like Debug] HTTP {method} to gallery like endpoint"); + Plugin.Log.Info($"[Like Debug] Using CS+ Character: {csCharacterKey}"); + + var response = await httpClient.SendAsync(request); + + Plugin.Log.Info($"[Like Debug] Response Status: {response.StatusCode}"); + + if (response.IsSuccessStatusCode) + { + var responseContent = await response.Content.ReadAsStringAsync(); + Plugin.Log.Info($"[Like Debug] Response Content: {responseContent}"); + + var result = JsonConvert.DeserializeObject(responseContent); + + var profile = allProfiles.FirstOrDefault(p => GetProfileKey(p) == characterId); + if (profile != null) + { + Plugin.Log.Info($"[Like Debug] Old Like Count: {profile.LikeCount}"); + profile.LikeCount = result?.LikeCount ?? profile.LikeCount; + Plugin.Log.Info($"[Like Debug] New Like Count: {profile.LikeCount}"); + } + + var filteredProfile = filteredProfiles.FirstOrDefault(p => GetProfileKey(p) == characterId); + if (filteredProfile != null) + { + filteredProfile.LikeCount = result?.LikeCount ?? filteredProfile.LikeCount; + } + } + else + { + Plugin.Log.Warning($"[Like Debug] Server request failed: {response.StatusCode}"); + if (isCurrentlyLiked) + { + string existingLikeKey = $"{existingLikerCharacter}|{stableLikeTarget}"; + likedProfiles[existingLikeKey] = true; + plugin.Configuration.LikedGalleryProfiles.Add(existingLikeKey); + } + else + { + likedProfiles.Remove(currentCharacterLikeKey); + plugin.Configuration.LikedGalleryProfiles.Remove(currentCharacterLikeKey); + } + plugin.Configuration.Save(); + } + } + catch (Exception ex) + { + Plugin.Log.Error($"[Like Debug] Exception: {ex.Message}"); + if (isCurrentlyLiked) + { + string existingLikeKey = $"{existingLikerCharacter}|{stableLikeTarget}"; + likedProfiles[existingLikeKey] = true; + plugin.Configuration.LikedGalleryProfiles.Add(existingLikeKey); + } + else + { + likedProfiles.Remove(currentCharacterLikeKey); + plugin.Configuration.LikedGalleryProfiles.Remove(currentCharacterLikeKey); + } + plugin.Configuration.Save(); + } + }); + } + private string GetLikeKey(string physicalCharacterKey, string characterId) + { + var targetProfile = allProfiles.FirstOrDefault(p => GetProfileKey(p) == characterId); + string stableLikeTarget = targetProfile?.CharacterName ?? characterId; + return $"{physicalCharacterKey}|{stableLikeTarget}"; + } + + private void ToggleBookmark(GalleryProfile profile) + { + var ownerKey = GetActiveCharacter()!.Name; + var galleryKey = GetProfileKey(profile); + + var existing = favoriteSnapshots + .FirstOrDefault(f => + f.OwnerCharacterKey == ownerKey && + GetProfileKey(f) == galleryKey); + + if (existing != null) + { + favoriteSnapshots.Remove(existing); + Plugin.Log.Debug($"[Gallery] Removed favourite: {SanitizeForLogging(galleryKey)} for {ownerKey}"); + } + else + { + if (string.IsNullOrEmpty(profile.ProfileImageUrl)) + { + Plugin.ChatGui.PrintError("[Gallery] Cannot favorite - profile has no custom image"); + return; + } + + bool alreadyExists = favoriteSnapshots.Any(f => + f.OwnerCharacterKey == ownerKey && + GetProfileKey(f) == galleryKey); + + if (alreadyExists) + { + Plugin.Log.Warning($"[Gallery] Attempted to add duplicate favourite: {SanitizeForLogging(galleryKey)} for {ownerKey}"); + return; + } + + var hash = Convert.ToBase64String( + System.Security.Cryptography.MD5 + .HashData(System.Text.Encoding.UTF8.GetBytes(profile.ProfileImageUrl!)) + ) + .Replace("/", "_") + .Replace("+", "-"); + var fileName = $"fav_{ownerKey}_{SanitizeForLogging(galleryKey)}_{hash}.png"; + var localPath = Path.Combine( + Plugin.PluginInterface.GetPluginConfigDirectory(), + fileName + ); + + var snap = new FavoriteSnapshot + { + OwnerCharacterKey = ownerKey, + CharacterId = galleryKey, + CharacterName = profile.CharacterName, + Server = profile.Server, + ProfileImageUrl = profile.ProfileImageUrl, + Tags = profile.Tags, + Bio = profile.Bio, + Race = profile.Race, + Pronouns = profile.Pronouns, + ImageZoom = profile.ImageZoom, + ImageOffset = profile.ImageOffset, + FavoritedAt = DateTime.Now, + LocalImagePath = localPath + }; + + favoriteSnapshots.Add(snap); + Plugin.Log.Debug($"[Gallery] Added favourite: {SanitizeForLogging(galleryKey)} for {ownerKey}"); + + _ = DownloadFavoriteImageAsync(profile.ProfileImageUrl!, localPath) + .ContinueWith(_ => plugin.Configuration.Save()); + } + + plugin.Configuration.FavoriteSnapshots = favoriteSnapshots; + plugin.Configuration.Save(); + + EnsureFavoritesFilteredByCSCharacter(); + } + + // Blocked Tab + private void DrawBlockedTab(float scale) + { + if (blockedProfiles.Count == 0) + { + ImGui.Text("You haven't blocked any profiles yet."); + ImGui.TextDisabled("Right-click on a profile in the Gallery tab to block it."); + return; + } + + ImGui.Text($"Blocked Characters ({blockedProfiles.Count})"); + ImGui.TextDisabled("These characters' profiles are hidden from your gallery view."); + ImGui.Separator(); + + ImGui.BeginChild("BlockedContent", new Vector2(0, 0), false, ImGuiWindowFlags.AlwaysVerticalScrollbar); + + var blockedList = blockedProfiles.ToList(); + for (int i = 0; i < blockedList.Count; i++) + { + var blockedCharacter = blockedList[i]; + + ImGui.PushID($"blocked_{i}"); + + var drawList = ImGui.GetWindowDrawList(); + var cardMin = ImGui.GetCursorScreenPos(); + var cardHeight = 50f * scale; + var cardWidth = ImGui.GetContentRegionAvail().X - (20f * scale); + var cardMax = cardMin + new Vector2(cardWidth, cardHeight); + + var bgColor = new Vector4(0.08f, 0.08f, 0.12f, 0.95f); + var borderColor = new Vector4(0.25f, 0.25f, 0.35f, 0.6f); + + drawList.AddRectFilled(cardMin, cardMax, ImGui.GetColorU32(bgColor), 6f * scale); + drawList.AddRect(cardMin, cardMax, ImGui.GetColorU32(borderColor), 6f * scale, ImDrawFlags.None, 1f * scale); + + var accentColor = new Vector4(0.8f, 0.3f, 0.3f, 1.0f); + drawList.AddRectFilled(cardMin, cardMin + new Vector2(4f * scale, cardHeight), ImGui.GetColorU32(accentColor), 6f * scale, ImDrawFlags.RoundCornersLeft); + + ImGui.BeginChild($"blocked_card_{i}", new Vector2(cardWidth, cardHeight), false); + + ImGui.SetCursorPos(new Vector2(15 * scale, 15 * scale)); + ImGui.TextColored(new Vector4(0.9f, 0.9f, 0.9f, 1.0f), $"[X] {blockedCharacter}"); + + ImGui.SetCursorPos(new Vector2(cardWidth - (85 * scale), 11 * scale)); + + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.8f, 0.3f, 0.3f, 0.8f)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.9f, 0.4f, 0.4f, 1.0f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.7f, 0.2f, 0.2f, 1.0f)); + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 4f * scale); + + bool buttonClicked = ImGui.Button("Unblock", new Vector2(70 * scale, 28 * scale)); + + ImGui.PopStyleVar(); + ImGui.PopStyleColor(3); + + if (buttonClicked) + { + UnblockProfile(blockedCharacter); + break; + } + + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip($"Allow {blockedCharacter}'s profiles to appear in the gallery again"); + } + + ImGui.EndChild(); + + ImGui.PopID(); + + ImGui.Dummy(new Vector2(0, 8f * scale)); + } + + ImGui.EndChild(); + } + + private void DrawFriendsTab(float scale) + { + ImGui.Text("Gallery Friends"); + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Add friends to see all their public Character Select+ profiles"); + } + + // Apply form styling to friend controls + ImGui.PushStyleColor(ImGuiCol.FrameBg, new Vector4(0.16f, 0.16f, 0.16f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.FrameBgHovered, new Vector4(0.22f, 0.22f, 0.22f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.FrameBgActive, new Vector4(0.28f, 0.28f, 0.28f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.2f, 0.2f, 0.2f, 0.8f)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.3f, 0.3f, 0.3f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.4f, 0.4f, 0.4f, 1.0f)); + + // Add friend section + ImGui.SetNextItemWidth(150f * scale); + ImGui.InputTextWithHint("##FriendName", "Player Name", ref addFriendName, 50); + + ImGui.SameLine(); + ImGui.SetNextItemWidth(150f * scale); + ImGui.InputTextWithHint("##FriendServer", "Server", ref addFriendServer, 50); + + ImGui.SameLine(); + if (ImGui.Button("Add Friend")) + { + if (!string.IsNullOrWhiteSpace(addFriendName) && !string.IsNullOrWhiteSpace(addFriendServer)) + { + string playerKey = $"{addFriendName.Trim()}@{addFriendServer.Trim()}"; + + if (!plugin.Configuration.FollowedPlayers.Contains(playerKey)) + { + plugin.Configuration.FollowedPlayers.Add(playerKey); + plugin.Configuration.Save(); + _ = UpdateFriendsOnServer(); + + var playerProfiles = GetPlayerProfiles(playerKey); + + if (playerProfiles.Any()) + { + Plugin.ChatGui.Print($"[Gallery] Added {playerKey} as friend! Found {playerProfiles.Count} character(s)."); + } + else + { + Plugin.ChatGui.Print($"[Gallery] Added {playerKey} as friend. No public profiles found yet."); + } + } + else + { + Plugin.ChatGui.PrintError($"[Gallery] {playerKey} is already your friend."); + } + + addFriendName = ""; + addFriendServer = ""; + } + } + + // Manual refresh button + ImGui.SameLine(); + if (ImGui.Button("Refresh Friends")) + { + _ = RefreshMutualFriends(); + lastMutualFriendsUpdate = DateTime.Now; + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Manually check for friend updates and mutual connections"); + } + + ImGui.PopStyleColor(6); + + ImGui.Separator(); + + // Show friends, hold them close, you never know how long til the next server restart + var mutualFriendProfiles = new List(); + + foreach (var mutualFriend in cachedMutualFriends) + { + var friendProfiles = GetPlayerProfiles(mutualFriend); + mutualFriendProfiles.AddRange(friendProfiles); + } + + if (mutualFriendProfiles.Count > 0) + { + var groupedByPlayer = mutualFriendProfiles + .GroupBy(p => ExtractPhysicalCharacterFromId(p.CharacterId)) + .OrderBy(g => g.Key) + .ToList(); + + ImGui.Text($"Friends ({mutualFriendProfiles.Count} characters from {groupedByPlayer.Count} players)"); + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Players who added you back as a friend"); + } + + ImGui.BeginChild("MutualFriendsContent", new Vector2(0, 0), false, ImGuiWindowFlags.AlwaysVerticalScrollbar); + + // Draw friend profiles + float availableWidth = ImGui.GetContentRegionAvail().X; + var style = ImGui.GetStyle(); + float scrollbarWidth = style.ScrollbarSize; + const float extraNudge = 10f; + float usableWidth = availableWidth - scrollbarWidth - (extraNudge * scale); + + float minCardWidth = 320f * scale; + float maxCardWidth = 400f * scale; + float cardPadding = 15f * scale; + + int cardsPerRow = Math.Max(1, (int)((usableWidth + cardPadding) / (minCardWidth + cardPadding))); + float cardWidth = Math.Min(maxCardWidth, (usableWidth - (cardsPerRow - 1) * cardPadding) / cardsPerRow); + float cardHeight = 120f * scale; + + float totalCardsWidth = cardWidth * cardsPerRow + cardPadding * (cardsPerRow - 1); + float leftoverSpace = usableWidth - totalCardsWidth; + float marginX = leftoverSpace > 0 ? leftoverSpace * 0.5f : style.WindowPadding.X; + + int idx = 0; + foreach (var profile in mutualFriendProfiles.OrderByDescending(p => IsCharacterRecentlyActive(p)).ThenBy(p => p.CharacterName)) + { + if (idx % cardsPerRow == 0) + { + if (idx > 0) ImGui.Spacing(); + ImGui.SetCursorPosX(marginX); + } + else + { + ImGui.SameLine(); + ImGui.SetCursorPosX(ImGui.GetCursorPosX() + cardPadding); + } + + DrawFriendProfileCard(profile, new Vector2(cardWidth, cardHeight), scale); + idx++; + } + + ImGui.EndChild(); + } + else if (plugin.Configuration.FollowedPlayers.Count > 0) + { + ImGui.Text("No mutual friends yet."); + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("None of your friends have added you back yet!"); + } + ImGui.Spacing(); + } + else + { + ImGui.Text("No friends added yet."); + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Add friends above to see their characters here!"); + } + ImGui.Spacing(); + } + } + + private void DrawFriendProfileCard(GalleryProfile profile, Vector2 cardSize, float scale) + { + var characterKey = GetProfileKey(profile); + + var activeCharacter = GetActiveCharacter(); + string csCharacterKey = activeCharacter?.Name ?? "NoCharacter"; + + string likeKey = GetLikeKey(csCharacterKey, characterKey); + + var isLiked = useStateFreeze ? + frozenLikedProfiles.ContainsKey(likeKey) : + IsProfileLikedByAnyOfMyCharacters(characterKey); + + var isFavorited = useStateFreeze ? + frozenFavoritedProfiles.Contains(characterKey) : + currentFavoritedProfiles.Contains(characterKey); + + Vector3 nameplateColor; + + RPProfile? serverProfile = downloadedProfiles.ContainsKey(characterKey) ? downloadedProfiles[characterKey] : null; + if (serverProfile?.NameplateColor != null) + { + var serverColor = serverProfile.NameplateColor; + nameplateColor = new Vector3(serverColor.X, serverColor.Y, serverColor.Z); + cachedCharacterColors[characterKey] = nameplateColor; // Cache me outside, how about that? + Plugin.Log.Debug($"[Gallery] Using server nameplate color for friend {profile.CharacterName}: {nameplateColor}"); + } + else if (!cachedCharacterColors.TryGetValue(characterKey, out nameplateColor)) + { + // Fallback to local config or default + nameplateColor = new Vector3(0.4f, 0.7f, 1.0f); + var config = Plugin.PluginInterface.GetPluginConfig() as Configuration; + var match = config?.Characters.FirstOrDefault(c => c.LastInGameName == characterKey || c.Name == profile.CharacterName); + if (match != null) + { + nameplateColor = match.NameplateColor; + Plugin.Log.Debug($"[Gallery] Using local config nameplate color for friend {profile.CharacterName}: {nameplateColor}"); + } + else + { + Plugin.Log.Debug($"[Gallery] Using default nameplate color for friend {profile.CharacterName}: {nameplateColor}"); + } + + cachedCharacterColors[characterKey] = nameplateColor; + } + + var accentColor = new Vector4(nameplateColor.X, nameplateColor.Y, nameplateColor.Z, 1.0f); + + // Card background with hover effect + var drawList = ImGui.GetWindowDrawList(); + var cardMin = ImGui.GetCursorScreenPos(); + var cardMax = cardMin + cardSize; + + bool isCardHovered = ImGui.IsMouseHoveringRect(cardMin, cardMax); + + var bgColor = isCardHovered + ? new Vector4(0.12f, 0.12f, 0.18f, 0.95f) + : new Vector4(0.08f, 0.08f, 0.12f, 0.95f); + + var borderColor = isCardHovered + ? new Vector4(0.35f, 0.35f, 0.45f, 0.8f) + : new Vector4(0.25f, 0.25f, 0.35f, 0.6f); + + float cornerRadius = 6f * scale; + + // Drop shadow + var shadowOffset = new Vector2(2f * scale, 2f * scale); + var shadowColor = new Vector4(0f, 0f, 0f, 0.2f); + drawList.AddRectFilled(cardMin + shadowOffset, cardMax + shadowOffset, ImGui.GetColorU32(shadowColor), cornerRadius); + + // Main card background + drawList.AddRectFilled(cardMin, cardMax, ImGui.GetColorU32(bgColor), cornerRadius); + + // Accent border + drawList.AddRectFilled(cardMin, cardMin + new Vector2(4f * scale, cardSize.Y), ImGui.GetColorU32(accentColor), cornerRadius, ImDrawFlags.RoundCornersLeft); + + // Card border + drawList.AddRect(cardMin, cardMax, ImGui.GetColorU32(borderColor), cornerRadius, ImDrawFlags.None, 1f * scale); + + // Hover glow + if (isCardHovered) + { + var glowColor = new Vector4(accentColor.X, accentColor.Y, accentColor.Z, 0.15f); + drawList.AddRect(cardMin, cardMax, ImGui.GetColorU32(glowColor), cornerRadius, ImDrawFlags.None, 2f * scale); + } + + string uniqueId = $"friend_card_{characterKey}"; + ImGui.BeginChild(uniqueId, cardSize, false); + + // Profile loading optimization + if (!imageLoadStarted.ContainsKey(characterKey) && + !loadingProfiles.Contains(characterKey) && + lastSuccessfulRefresh != DateTime.MinValue) + { + imageLoadStarted[characterKey] = true; + loadingProfiles.Add(characterKey); + + _ = Task.Run(async () => + { + try + { + await Task.Delay(100); + await LoadProfileWithImageAsync(characterKey); + } + catch (Exception ex) + { + Plugin.Log.Error($"[Gallery] Failed to load profile for {characterKey}: {ex.Message}"); + } + finally + { + loadingProfiles.Remove(characterKey); + } + }); + } + + var imageSize = new Vector2(80 * scale, 80 * scale); + ImGui.SetCursorPos(new Vector2(12 * scale, 16 * scale)); + + RPProfile? fullProfile = downloadedProfiles.ContainsKey(characterKey) ? downloadedProfiles[characterKey] : null; + + var texture = GetCorrectProfileTexture(fullProfile, profile); + + if (texture != null) + { + DrawPortraitImageGalleryOnly(texture, profile, fullProfile, imageSize.X); + + if (ImGui.IsItemClicked(ImGuiMouseButton.Left)) + ViewProfile(characterKey); + else if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + { + var imagePreviewPath = GetCorrectImagePath(fullProfile, profile); + if (!string.IsNullOrEmpty(imagePreviewPath)) + { + imagePreviewUrl = imagePreviewPath; + showImagePreview = true; + } + } + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Left click: View Profile\nRight click: Preview Image"); + } + else + { + // Loading placeholder + var cursor = ImGui.GetCursorScreenPos(); + ImGui.Dummy(imageSize); + + var dl = ImGui.GetWindowDrawList(); + var min = cursor; + var max = cursor + imageSize; + + var loadingBg = new Vector4(0.15f, 0.15f, 0.2f, 0.9f); + var loadingBorder = new Vector4(0.3f, 0.3f, 0.35f, 0.8f); + + dl.AddRectFilled(min, max, ImGui.ColorConvertFloat4ToU32(loadingBg), 4f * scale); + dl.AddRect(min, max, ImGui.ColorConvertFloat4ToU32(loadingBorder), 4f * scale, ImDrawFlags.None, 1f * scale); + + var loadingText = "Loading..."; + var textSize = ImGui.CalcTextSize(loadingText); + var textPos = min + (imageSize - textSize) * 0.5f; + dl.AddText(textPos, ImGui.ColorConvertFloat4ToU32(new Vector4(0.7f, 0.7f, 0.7f, 1f)), loadingText); + + ImGui.SetCursorScreenPos(cursor); + if (ImGui.InvisibleButton($"##LoadingCard{characterKey}", imageSize)) + ViewProfile(characterKey); + } + + // Right side - Character info + ImGui.SameLine(); + ImGui.SetCursorPos(new Vector2(imageSize.X + (20 * scale), 16 * scale)); // Back to normal position + ImGui.BeginGroup(); + + var nameStartPos = ImGui.GetCursorScreenPos(); + + // Draw name and pronouns + var nameText = profile.CharacterName; + var pronounsText = !string.IsNullOrEmpty(profile.Pronouns) ? $" ({profile.Pronouns})" : ""; + + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(accentColor.X * 1.2f, accentColor.Y * 1.2f, accentColor.Z * 1.2f, 1.0f)); + ImGui.PushFont(UiBuilder.DefaultFont); + ImGui.Text(nameText); + ImGui.PopFont(); + ImGui.PopStyleColor(); + + if (!string.IsNullOrEmpty(profile.Pronouns)) + { + var nameSize = ImGui.CalcTextSize(nameText); + var pronounsPos = nameStartPos + new Vector2(nameSize.X + (2f * scale), 1f * scale); + + var pronounsColor = ImGui.GetColorU32(new Vector4(0.7f, 0.7f, 0.8f, 0.9f)); + var smallFont = ImGui.GetFont(); + var smallFontSize = ImGui.GetFontSize() * 0.85f; + + drawList.AddText(smallFont, smallFontSize, pronounsPos, pronounsColor, pronounsText); + } + + // Check for name hover + var nameEndPos = ImGui.GetCursorScreenPos(); + var nameRect = new Vector2(nameEndPos.X - nameStartPos.X, ImGui.GetTextLineHeight()); + bool nameHovered = ImGui.IsMouseHoveringRect(nameStartPos, nameStartPos + nameRect); + + if (nameHovered) + { + ImGui.SetMouseCursor(ImGuiMouseCursor.Hand); + if (ImGui.IsMouseClicked(ImGuiMouseButton.Left)) + { + ViewProfile(characterKey); + } + } + + // Age and Race info + string? age = fullProfile?.Age ?? null; + string? race = fullProfile?.Race ?? profile.Race; + + if (!string.IsNullOrEmpty(age) && !string.IsNullOrEmpty(race)) + { + ImGui.TextColored(new Vector4(0.8f, 0.8f, 0.85f, 1.0f), $"{age} • {race}"); + } + else if (!string.IsNullOrEmpty(race)) + { + ImGui.TextColored(new Vector4(0.8f, 0.8f, 0.85f, 1.0f), race); + } + + // Status/Bio preview + if (!string.IsNullOrEmpty(profile.GalleryStatus)) + { + ImGui.Spacing(); + + float statusMaxWidth = cardSize.X - (imageSize.X + (20 * scale)) - (15f * scale); + + var lines = new List(); + var statusLines = profile.GalleryStatus.Split('\n'); + + for (int i = 0; i < Math.Min(statusLines.Length, 2); i++) + { + var line = statusLines[i].Trim(); + if (!string.IsNullOrEmpty(line)) + { + if (ImGui.CalcTextSize(line).X > statusMaxWidth) + { + while (ImGui.CalcTextSize(line + "...").X > statusMaxWidth && line.Length > 10) + { + line = line.Substring(0, line.Length - 1); + } + line += "..."; + } + lines.Add(line); + } + } + + ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + statusMaxWidth); + foreach (var line in lines) + { + ImGui.TextColored(new Vector4(0.9f, 0.85f, 0.9f, 1.0f), line); + } + ImGui.PopTextWrapPos(); + } + else if (!string.IsNullOrEmpty(profile.Bio)) + { + // Fallback to bio if no status is set + ImGui.Spacing(); + + float bioMaxWidth = cardSize.X - (imageSize.X + (20 * scale)) - (15f * scale); + + var lines = new List(); + var bioWords = profile.Bio.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var currentLine = ""; + int totalWordsProcessed = 0; + + foreach (var word in bioWords) + { + var testLine = string.IsNullOrEmpty(currentLine) ? word : $"{currentLine} {word}"; + var testLineSize = ImGui.CalcTextSize(testLine); + + if (testLineSize.X <= bioMaxWidth) + { + currentLine = testLine; + totalWordsProcessed++; + } + else + { + if (!string.IsNullOrEmpty(currentLine)) + { + lines.Add(currentLine); + currentLine = word; + totalWordsProcessed++; + } + else + { + var truncatedWord = word; + while (ImGui.CalcTextSize(truncatedWord + "...").X > bioMaxWidth && truncatedWord.Length > 3) + { + truncatedWord = truncatedWord.Substring(0, truncatedWord.Length - 1); + } + lines.Add(truncatedWord + "..."); + totalWordsProcessed++; + } + + // Stop if we have 2 lines + if (lines.Count >= 2) break; + } + } + + if (!string.IsNullOrEmpty(currentLine) && lines.Count < 2) + { + lines.Add(currentLine); + } + + if (totalWordsProcessed < bioWords.Length && lines.Count > 0) + { + var lastLineIndex = lines.Count - 1; + var lastLine = lines[lastLineIndex]; + + while (ImGui.CalcTextSize($"\"{lastLine}...\"").X > bioMaxWidth && lastLine.Contains(' ')) + { + var lastSpaceIndex = lastLine.LastIndexOf(' '); + if (lastSpaceIndex > 0) + { + lastLine = lastLine.Substring(0, lastSpaceIndex); + } + else + { + break; + } + } + + lines[lastLineIndex] = lastLine + "..."; + } + + // Draw the bio lines with quotes + ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + bioMaxWidth); + for (int i = 0; i < lines.Count; i++) + { + var line = lines[i]; + if (i == 0) + { + line = $"\"{line}"; + } + if (i == lines.Count - 1) + { + line = $"{line}\""; + } + + ImGui.TextColored(new Vector4(0.9f, 0.9f, 0.85f, 1.0f), line); + } + ImGui.PopTextWrapPos(); + } + + ImGui.EndGroup(); + + if (!string.IsNullOrEmpty(profile.Tags)) + { + ImGui.SetCursorPos(new Vector2(12 * scale, imageSize.Y + (18 * scale))); + float tagsMaxWidth = cardSize.X - (12 * scale) - (70 * scale); + + var tags = profile.Tags.Split(',').Select(t => t.Trim()).Where(t => !string.IsNullOrEmpty(t)).ToList(); + var displayTags = new List(); + float currentWidth = 0; + + foreach (var tag in tags) + { + var tagText = $"Tags: {string.Join(", ", displayTags.Concat(new[] { tag }))}"; + var testWidth = ImGui.CalcTextSize(tagText).X; + + if (testWidth <= tagsMaxWidth) + { + displayTags.Add(tag); + currentWidth = testWidth; + } + else + { + break; + } + } + + if (displayTags.Count > 0) + { + var finalTagText = displayTags.Count < tags.Count ? + $"Tags: {string.Join(", ", displayTags)}..." : + $"Tags: {string.Join(", ", displayTags)}"; + + ImGui.TextColored(new Vector4(0.8f, 0.6f, 1.0f, 1.0f), finalTagText); + } + } + + // Globe icon in top right with "online recently" indicator + var globePos = new Vector2(cardSize.X - (25 * scale), 12 * scale); + ImGui.SetCursorPos(globePos); + + bool isRecentlyActive = IsCharacterRecentlyActive(profile); + var globeColor = isRecentlyActive ? + new Vector4(0.4f, 1.0f, 0.4f, 1.0f) : + new Vector4(0.6f, 0.6f, 0.7f, 1.0f); + + ImGui.PushFont(UiBuilder.IconFont); + ImGui.TextColored(globeColor, "\uf0ac"); + ImGui.PopFont(); + + if (ImGui.IsItemHovered()) + { + if (isRecentlyActive) + ImGui.SetTooltip($"{profile.Server}\n(Recently Active)"); + else + ImGui.SetTooltip(profile.Server); + } + + ImGui.SetCursorPos(new Vector2(cardSize.X - (65 * scale), cardSize.Y - (22 * scale))); + + // Like button + if (isLiked) + { + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.0f, 0.0f, 0.0f, 0.0f)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.85f, 0.3f, 0.4f, 0.2f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.85f, 0.3f, 0.4f, 0.3f)); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.85f, 0.3f, 0.4f, 1.0f)); + } + else + { + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.0f, 0.0f, 0.0f, 0.0f)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.85f, 0.3f, 0.4f, 0.1f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.85f, 0.3f, 0.4f, 0.2f)); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.6f, 0.6f, 0.6f, 1.0f)); + } + + if (ImGui.Button($"♥##{characterKey}_like", new Vector2(16 * scale, 18 * scale))) + { + bool wasLiked = isLiked; + ToggleLike(characterKey); + + Vector2 effectPos = ImGui.GetItemRectMin() + ImGui.GetItemRectSize() / 2; + string likeEffectKey = $"like_{characterKey}"; + if (!galleryLikeEffects.ContainsKey(likeEffectKey)) + galleryLikeEffects[likeEffectKey] = new LikeSparkEffect(); + galleryLikeEffects[likeEffectKey].Trigger(effectPos, !wasLiked); + + if (useStateFreeze) + { + var currentLikeKey = $"{csCharacterKey}|{characterKey}"; + if (frozenLikedProfiles.ContainsKey(currentLikeKey)) + frozenLikedProfiles.Remove(currentLikeKey); + else + frozenLikedProfiles[currentLikeKey] = true; + } + } + ImGui.PopStyleColor(4); + + // Like count + ImGui.SameLine(); + ImGui.SetCursorPosX(ImGui.GetCursorPosX() - (2f * scale)); + ImGui.SetCursorPosY(ImGui.GetCursorPosY() + (1f * scale)); + + var currentProfile = allProfiles.FirstOrDefault(p => GetProfileKey(p) == characterKey); + int displayLikeCount = currentProfile?.LikeCount ?? profile.LikeCount; + ImGui.Text($"{displayLikeCount}"); + + // Favourite button + ImGui.SameLine(); + ImGui.SetCursorPosX(ImGui.GetCursorPosX() + (2f * scale)); + ImGui.SetCursorPosY(ImGui.GetCursorPosY() - (1f * scale)); + + // Always push exactly 4 style colors + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.0f, 0.0f, 0.0f, 0.0f)); + + if (isFavorited) + { + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.9f, 0.7f, 0.2f, 0.2f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.9f, 0.7f, 0.2f, 0.3f)); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.9f, 0.7f, 0.2f, 1.0f)); + } + else + { + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.9f, 0.7f, 0.2f, 0.1f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.9f, 0.7f, 0.2f, 0.2f)); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.6f, 0.6f, 0.6f, 1.0f)); + } + + bool favoriteClicked = ImGui.Button($"★##{characterKey}_fav", new Vector2(16 * scale, 18 * scale)); + + // Always pop exactly 4 style colors + ImGui.PopStyleColor(4); + + // Handle the click after popping colors + if (favoriteClicked) + { + bool wasFavorited = isFavorited; + ToggleBookmark(profile); + + Vector2 effectPos = ImGui.GetItemRectMin() + ImGui.GetItemRectSize() / 2; + string favEffectKey = $"fav_{characterKey}"; + if (!galleryFavoriteEffects.ContainsKey(favEffectKey)) + galleryFavoriteEffects[favEffectKey] = new FavoriteSparkEffect(); + galleryFavoriteEffects[favEffectKey].Trigger(effectPos, !wasFavorited); + + if (useStateFreeze) + { + if (frozenFavoritedProfiles.Contains(characterKey)) + frozenFavoritedProfiles.Remove(characterKey); + else + frozenFavoritedProfiles.Add(characterKey); + } + } + + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip(isFavorited ? "Remove from favourites" : "Add to favourites"); + } + + // Right-click context menu for friend management + if (ImGui.IsMouseHoveringRect(cardMin, cardMax) && ImGui.IsMouseClicked(ImGuiMouseButton.Right)) + { + ImGui.OpenPopup($"FriendContextMenu_{characterKey}"); + } + + if (ImGui.BeginPopupContextItem($"FriendContextMenu_{characterKey}")) + { + var friendPlayerKey = ExtractPhysicalCharacterFromId(profile.CharacterId); + + // Report option with icon + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(1.0f, 0.6f, 0.2f, 1.0f)); + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf071"); // Warning triangle icon + ImGui.PopFont(); + ImGui.PopStyleColor(); + ImGui.SameLine(0, 4 * scale); + + if (ImGui.Selectable("Report this profile")) + { + OpenReportDialog(GetProfileKey(profile), profile.CharacterName); + } + + ImGui.Spacing(); + + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.8f, 0.3f, 0.3f, 1.0f)); + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf007"); + ImGui.PopFont(); + ImGui.PopStyleColor(); + ImGui.SameLine(0, 4 * scale); + + // LONGER BUT STILL SAFE TEXT - matches the main gallery context menu length + if (ImGui.Selectable("Remove this player from friends")) + { + plugin.Configuration.FollowedPlayers.Remove(friendPlayerKey); + plugin.Configuration.Save(); + _ = UpdateFriendsOnServer(); + _ = RefreshMutualFriends(); + Plugin.ChatGui.Print($"[Gallery] Removed friend."); + } + + ImGui.EndPopup(); + } + + ImGui.EndChild(); + } + + private List GetPlayerProfiles(string physicalCharacterKey) + { + return allProfiles.Where(profile => + { + var physicalPart = ExtractPhysicalCharacterFromId(profile.CharacterId); + return physicalPart == physicalCharacterKey; + }).ToList(); + } + + private string ExtractPhysicalCharacterFromId(string characterId) + { + var underscoreIndex = characterId.IndexOf('_'); + if (underscoreIndex > 0 && underscoreIndex < characterId.Length - 1) + { + return characterId.Substring(underscoreIndex + 1); + } + return characterId; + } + + private void DrawPlayerCharacterGrid(List profiles, float scale) + { + if (profiles.Count == 0) return; + + float availableWidth = ImGui.GetContentRegionAvail().X - (20 * scale); + float minCardWidth = 280f * scale; + float cardPadding = 10f * scale; + + int cardsPerRow = Math.Max(1, (int)((availableWidth + cardPadding) / (minCardWidth + cardPadding))); + float cardWidth = (availableWidth - (cardsPerRow - 1) * cardPadding) / cardsPerRow; + float cardHeight = 100f * scale; + + ImGui.Indent(10f * scale); + + int idx = 0; + foreach (var profile in profiles) + { + if (idx % cardsPerRow == 0 && idx > 0) + { + ImGui.Spacing(); + } + + if (idx % cardsPerRow != 0) + { + ImGui.SameLine(); + ImGui.SetCursorPosX(ImGui.GetCursorPosX() + cardPadding); + } + + DrawFollowedCharacterCard(profile, new Vector2(cardWidth, cardHeight), scale); + idx++; + } + + ImGui.Unindent(10f * scale); + } + + private void DrawFollowedCharacterCard(GalleryProfile profile, Vector2 cardSize, float scale) + { + bool isRecentlyActive = IsCharacterRecentlyActive(profile); + + var drawList = ImGui.GetWindowDrawList(); + var cardMin = ImGui.GetCursorScreenPos(); + var cardMax = cardMin + cardSize; + + var bgColor = new Vector4(0.10f, 0.10f, 0.15f, 0.95f); + drawList.AddRectFilled(cardMin, cardMax, ImGui.GetColorU32(bgColor), 6f * scale); + + var borderColor = isRecentlyActive + ? new Vector4(0.4f, 1.0f, 0.4f, 0.8f) + : new Vector4(0.3f, 0.3f, 0.4f, 0.6f); + drawList.AddRect(cardMin, cardMax, ImGui.GetColorU32(borderColor), 6f * scale, ImDrawFlags.None, 1.5f * scale); + + var namePos = cardMin + new Vector2(8 * scale, 8 * scale); + var nameColor = isRecentlyActive + ? new Vector4(0.9f, 1.0f, 0.9f, 1.0f) + : new Vector4(0.8f, 0.8f, 0.8f, 1.0f); + drawList.AddText(namePos, ImGui.GetColorU32(nameColor), profile.CharacterName); + + if (!string.IsNullOrEmpty(profile.GalleryStatus)) + { + var statusPos = cardMin + new Vector2(8 * scale, 28 * scale); + var statusText = profile.GalleryStatus.Length > 40 + ? profile.GalleryStatus.Substring(0, 37) + "..." + : profile.GalleryStatus; + drawList.AddText(statusPos, ImGui.GetColorU32(new Vector4(0.9f, 0.85f, 0.9f, 1.0f)), statusText); + } + else if (!string.IsNullOrEmpty(profile.Bio)) + { + var bioPos = cardMin + new Vector2(8 * scale, 28 * scale); + var bioText = profile.Bio.Length > 40 + ? profile.Bio.Substring(0, 37) + "..." + : profile.Bio; + drawList.AddText(bioPos, ImGui.GetColorU32(new Vector4(0.7f, 0.8f, 0.9f, 1.0f)), bioText); + } + + ImGui.SetCursorScreenPos(cardMin); + ImGui.InvisibleButton($"##FollowedCard_{profile.CharacterId}", cardSize); + + if (ImGui.IsItemClicked(ImGuiMouseButton.Left)) + { + ViewProfile(profile.CharacterId); + } + + if (ImGui.IsItemHovered()) + { + ImGui.BeginTooltip(); + ImGui.Text($"{profile.CharacterName}"); + if (isRecentlyActive) + { + ImGui.TextColored(new Vector4(0.4f, 1.0f, 0.4f, 1.0f), "Recently Active"); + } + ImGui.Text("Click to view full profile"); + ImGui.EndTooltip(); + } + } + + private async Task UpdateFriendsOnServer() + { + try + { + var currentChar = GetCurrentCharacterKey(); + if (string.IsNullOrEmpty(currentChar)) return; + + using var http = new HttpClient(); + var friendsData = new + { + character = currentChar, + following = plugin.Configuration.FollowedPlayers.ToList() + }; + + var json = JsonConvert.SerializeObject(friendsData); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await http.PostAsync( + "https://character-select-profile-server-production.up.railway.app/friends/update-follows", + content); + + if (response.IsSuccessStatusCode) + { + Plugin.Log.Debug($"[Gallery] Updated friends on server for {currentChar}"); + } + } + catch (Exception ex) + { + Plugin.Log.Debug($"[Gallery] Failed to update friends on server: {ex.Message}"); + } + } + + private async Task RefreshMutualFriends() + { + try + { + var currentChar = GetCurrentCharacterKey(); + if (string.IsNullOrEmpty(currentChar)) return; + + using var http = new HttpClient(); + + var followsData = new + { + character = currentChar, + following = plugin.Configuration.FollowedPlayers.ToList() + }; + + var json = JsonConvert.SerializeObject(followsData); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await http.PostAsync( + "https://character-select-profile-server-production.up.railway.app/friends/check-mutual", + content); + + if (response.IsSuccessStatusCode) + { + var responseJson = await response.Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject(responseJson); + + if (result?.mutualFriends != null) + { + cachedMutualFriends = ((Newtonsoft.Json.Linq.JArray)result.mutualFriends) + .Select(x => x.ToString()).ToList(); + } + else + { + cachedMutualFriends = new List(); + } + + Plugin.Log.Debug($"[Gallery] Found {cachedMutualFriends.Count} mutual friends"); + } + } + catch (Exception ex) + { + Plugin.Log.Debug($"[Gallery] Mutual friends check failed: {ex.Message}"); + } + } + + private string GetCurrentCharacterKey() + { + if (Plugin.ClientState.LocalPlayer?.HomeWorld.IsValid == true) + { + var name = Plugin.ClientState.LocalPlayer.Name.TextValue; + var world = Plugin.ClientState.LocalPlayer.HomeWorld.Value.Name.ToString(); + return $"{name}@{world}"; + } + return ""; + } + + // Data management methods + public async Task LoadGalleryData() + { + if (!CanMakeRequest("gallery")) + { + Plugin.Log.Debug("[Gallery] Rate limited - skipping request"); + return; + } + isLoading = true; + ClearPerformanceCaches(); + downloadedProfiles.Clear(); + try + { + using var http = Plugin.CreateAuthenticatedHttpClient(); + var response = await http.GetAsync("https://character-select-profile-server-production.up.railway.app/gallery"); + if (response.IsSuccessStatusCode) + { + var json = await response.Content.ReadAsStringAsync(); + var rawProfiles = JsonConvert.DeserializeObject>(json) ?? new(); + + // Get user's main character setting + var userMain = plugin.Configuration.GalleryMainCharacter; + var userCharacterNames = plugin.Characters.Select(c => c.Name).ToHashSet(); + + Plugin.Log.Debug($"[Gallery] User main character: {userMain ?? "None"}"); + Plugin.Log.Debug($"[Gallery] User CS+ characters: {string.Join(", ", userCharacterNames)}"); + + var processedProfiles = new List(); + + foreach (var profile in rawProfiles) + { + bool isUserCharacter = userCharacterNames.Contains(profile.CharacterName); + + if (isUserCharacter) + { + Plugin.Log.Debug($"[Gallery] Found user's character in gallery: {profile.CharacterName}"); + + if (string.IsNullOrEmpty(userMain)) + { + Plugin.Log.Debug($"[Gallery] Filtering user's character {profile.CharacterName} - no main character set"); + continue; + } + + string? profilePhysicalName = null; + + if (!string.IsNullOrEmpty(profile.CharacterId) && profile.CharacterId.Contains('_')) + { + var parts = profile.CharacterId.Split('_', 2); + if (parts.Length == 2) + { + profilePhysicalName = parts[1]; // This should be "FirstName LastName@Server" + } + } + + if (string.IsNullOrEmpty(profilePhysicalName)) + { + profilePhysicalName = $"{profile.CharacterName}@{profile.Server}"; + Plugin.Log.Warning($"[Gallery] No CharacterId found for {profile.CharacterName}, using fallback: {profilePhysicalName}"); + } + + Plugin.Log.Debug($"[Gallery] Profile physical name: '{profilePhysicalName}', User main: '{userMain}'"); + + if (profilePhysicalName != userMain) + { + Plugin.Log.Debug($"[Gallery] Filtering user's character {profile.CharacterName} - not main character ({profilePhysicalName} != {userMain})"); + continue; + } + + // Check if the CS+ character is set to public sharing + var userCharacter = plugin.Characters.FirstOrDefault(c => c.Name == profile.CharacterName); + var sharing = userCharacter?.RPProfile?.Sharing ?? ProfileSharing.AlwaysShare; + if (sharing != ProfileSharing.ShowcasePublic) + { + Plugin.Log.Debug($"[Gallery] Filtering user's character {profile.CharacterName} - sharing not set to public (is: {sharing})"); + continue; + } + + Plugin.Log.Debug($"[Gallery] ✓ Including user's character {profile.CharacterName} as main character"); + } + + processedProfiles.Add(profile); + } + + allProfiles = processedProfiles; + + ExtractPopularTags(); + FilterProfiles(); + ClearImpossibleZeroLikes(); + + EnsureLikesFilteredByCSCharacter(); + EnsureFavoritesFilteredByCSCharacter(); + + Plugin.Log.Info("[Gallery] Gallery refresh completed, unfreezing button states"); + useStateFreeze = false; + frozenLikedProfiles.Clear(); + frozenFavoritedProfiles.Clear(); + lastSuccessfulRefresh = DateTime.Now; + lastAutoRefresh = DateTime.Now; + + SortProfiles(); + } + else + { + Plugin.Log.Warning($"Gallery request failed: {response.StatusCode}"); + } + } + catch (Exception ex) + { + Plugin.Log.Error($"Failed to load gallery: {ex.Message}"); + } + finally + { + isLoading = false; + } + } + + private void ExtractPopularTags() + { + popularTags = allProfiles + .SelectMany(p => (p.Tags ?? "").Split(',')) + .Select(t => t.Trim()) + .Where(t => !string.IsNullOrEmpty(t)) + .GroupBy(t => t, StringComparer.OrdinalIgnoreCase) + .OrderByDescending(g => g.Count()) + .Take(8) + .Select(g => g.Key) + .ToList(); + } + + private void FilterProfiles() + { + if (string.IsNullOrEmpty(searchFilter)) + { + filteredProfiles = allProfiles.Where(p => !IsProfileBlocked(p)).ToList(); + } + else + { + var filter = searchFilter.ToLowerInvariant(); + filteredProfiles = allProfiles.Where(p => + !IsProfileBlocked(p) && + ((p.CharacterName?.ToLowerInvariant().Contains(filter) ?? false) || + (p.Tags?.ToLowerInvariant().Contains(filter) ?? false) || + (p.Bio?.ToLowerInvariant().Contains(filter) ?? false) || + (p.Race?.ToLowerInvariant().Contains(filter) ?? false) || + (p.Pronouns?.ToLowerInvariant().Contains(filter) ?? false)) + ).ToList(); + } + + SortProfiles(); + } + + private void SortProfiles() + { + filteredProfiles = sortType switch + { + GallerySortType.Popular => filteredProfiles.OrderByDescending(p => p.LikeCount).ToList(), + GallerySortType.Recent => filteredProfiles.OrderByDescending(p => DateTime.Parse(p.LastUpdated)).ToList(), + GallerySortType.Alphabetical => filteredProfiles.OrderBy(p => p.CharacterName).ToList(), + _ => filteredProfiles + }; + } + + private string GetSortDisplayName(GallerySortType sort) => sort switch + { + GallerySortType.Popular => "Most Liked", + GallerySortType.Recent => "Recent", + GallerySortType.Alphabetical => "A-Z", + _ => sort.ToString() + }; + + private Character? GetActiveCharacter() + { + Character? newCharacter = null; + + if (!string.IsNullOrEmpty(plugin.Configuration.LastUsedCharacterKey)) + { + newCharacter = plugin.Characters.FirstOrDefault(c => c.Name == plugin.Configuration.LastUsedCharacterKey); + if (newCharacter != null) + { + if (lastActiveCharacter?.Name != newCharacter.Name) + { + lastActiveCharacter = newCharacter; + } + return newCharacter; + } + } + + newCharacter = plugin.Characters.FirstOrDefault(); + + if (lastActiveCharacter?.Name != newCharacter?.Name) + { + lastActiveCharacter = newCharacter; + } + + return newCharacter; + } + + private void SetCharacterSharing(Character character, ProfileSharing sharing) + { + if (character.RPProfile == null) + character.RPProfile = new RPProfile(); + + character.RPProfile.Sharing = sharing; + plugin.SaveConfiguration(); + _ = Plugin.UploadProfileAsync(character.RPProfile, character.LastInGameName ?? character.Name); + } + + private void LoadFavorites() + { + favoriteSnapshots = plugin.Configuration.FavoriteSnapshots ?? new List(); + EnsureFavoritesFilteredByCSCharacter(); + } + + private async void LoadProfileWithImage(string characterId) + { + if (loadingProfiles.Contains(characterId)) + { + Plugin.Log.Debug($"[Gallery] Already loading profile for {SanitizeForLogging(characterId)}, skipping"); + return; + } + + loadingProfiles.Add(characterId); + + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + var profile = await Plugin.DownloadProfileAsync(characterId); + + if (profile != null && !cts.Token.IsCancellationRequested) + { + downloadedProfiles[characterId] = profile; + Plugin.Log.Debug($"[Gallery] Profile loaded for {SanitizeForLogging(characterId)}"); + } + } + catch (OperationCanceledException) + { + Plugin.Log.Warning($"[Gallery] Profile loading timed out for {SanitizeForLogging(characterId)}"); + } + catch (Exception ex) + { + Plugin.Log.Error($"[Gallery] Failed to load profile for {SanitizeForLogging(characterId)}: {ex.Message}"); + } + finally + { + loadingProfiles.Remove(characterId); + } + } + + private string GetFavoriteLocalPath(string? imageUrl) + { + if (string.IsNullOrEmpty(imageUrl)) + return ""; + var hash = Convert.ToBase64String( + System.Security.Cryptography.MD5.HashData( + System.Text.Encoding.UTF8.GetBytes($"FAV_{imageUrl}") + ) + ).Replace("/", "_").Replace("+", "-"); + var fileName = $"RPImage_FAV_{hash}.png"; + return Path.Combine( + Plugin.PluginInterface.GetPluginConfigDirectory(), + fileName + ); + } + + private void CleanupImageCache() + { + try + { + var configDir = Plugin.PluginInterface.GetPluginConfigDirectory(); + var galleryImages = Directory.GetFiles(configDir, "RPImage_*.png"); + var favoriteImages = Directory.GetFiles(configDir, "RPImage_FAV_*.png"); + var allCacheImages = galleryImages.Concat(favoriteImages).ToArray(); + + Plugin.Log.Info($"[Gallery] Cache cleanup: Found {allCacheImages.Length} cached images"); + + long totalSize = allCacheImages.Sum(file => new FileInfo(file).Length); + Plugin.Log.Info($"[Gallery] Current cache size: {totalSize / (1024 * 1024)}MB"); + + var filesToDelete = new List(); + + var cutoffTime = DateTime.Now - maxImageAge; + foreach (var file in allCacheImages) + { + var fileInfo = new FileInfo(file); + if (fileInfo.LastAccessTime < cutoffTime) + { + filesToDelete.Add(file); + } + } + + if (totalSize > maxCacheSizeBytes) + { + var filesByAge = allCacheImages + .Except(filesToDelete) + .Select(f => new FileInfo(f)) + .OrderBy(f => f.LastAccessTime) + .ToList(); + + long currentSize = totalSize - filesToDelete.Sum(f => new FileInfo(f).Length); + + foreach (var fileInfo in filesByAge) + { + if (currentSize <= maxCacheSizeBytes) break; + + if (!fileInfo.Name.Contains("FAV_") || currentSize > maxCacheSizeBytes * 2) + { + filesToDelete.Add(fileInfo.FullName); + currentSize -= fileInfo.Length; + } + } + } + + foreach (var file in filesToDelete) + { + try + { + File.Delete(file); + Plugin.Log.Debug($"[Gallery] Deleted cached image: {Path.GetFileName(file)}"); + } + catch (Exception ex) + { + Plugin.Log.Warning($"[Gallery] Failed to delete {file}: {ex.Message}"); + } + } + + long newTotalSize = Directory.GetFiles(configDir, "RPImage*.png") + .Sum(file => new FileInfo(file).Length); + + Plugin.Log.Info($"[Gallery] Cache cleanup complete: Deleted {filesToDelete.Count} files, " + + $"new size: {newTotalSize / (1024 * 1024)}MB"); + + lastCacheCleanup = DateTime.Now; + } + catch (Exception ex) + { + Plugin.Log.Error($"[Gallery] Cache cleanup failed: {ex.Message}"); + } + } + + private async Task DownloadFavoriteImageAsync(string url, string path) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; + var data = await client.GetByteArrayAsync(url); + File.WriteAllBytes(path, data); + } + + private void EnsureLikesFilteredByCSCharacter() + { + var activeCharacter = GetActiveCharacter(); + if (activeCharacter == null) + { + likedProfiles.Clear(); + return; + } + + string csCharacterKey = activeCharacter.Name; + + var allLikedProfiles = plugin.Configuration.LikedGalleryProfiles ?? new HashSet(); + + likedProfiles.Clear(); + + foreach (var likeKey in allLikedProfiles) + { + if (likeKey.StartsWith(csCharacterKey + "|")) + { + likedProfiles[likeKey] = true; + } + } + + Plugin.Log.Debug($"[Gallery] Restored {likedProfiles.Count} likes for {csCharacterKey}"); + } + + private bool HasCharacterChanged() + { + var currentActiveCharacter = GetActiveCharacter(); + bool hasChanged = lastActiveCharacter?.Name != currentActiveCharacter?.Name; + + if (hasChanged) + { + var lastCharName = lastActiveCharacter?.Name ?? "null"; + var currentCharName = currentActiveCharacter?.Name ?? "null"; + Plugin.Log.Info($"[Gallery] Character changed from {lastCharName} to {currentCharName}"); + + downloadedProfiles.Clear(); + LoadBlockedProfiles(); + + lastActiveCharacter = currentActiveCharacter; + return true; + } + + return false; + } + + private void ClearImpossibleZeroLikes() + { + var activeCharacter = GetActiveCharacter(); + if (activeCharacter == null) return; + + var impossibleLikes = new List(); + + var myCharacterNames = plugin.Characters.Select(c => c.Name).ToList(); + + foreach (var profile in allProfiles) + { + var profileKey = GetProfileKey(profile); + string stableLikeTarget = profile.CharacterName ?? profileKey; + + foreach (var myCharacterName in myCharacterNames) + { + string likeKey = $"{myCharacterName}|{stableLikeTarget}"; + + if ((likedProfiles.ContainsKey(likeKey) || + (plugin.Configuration.LikedGalleryProfiles?.Contains(likeKey) ?? false)) && + profile.LikeCount == 0) + { + if (DateTime.Now - lastSuccessfulRefresh < TimeSpan.FromMinutes(1) || + (!string.IsNullOrEmpty(profile.LastUpdated) && + DateTime.Parse(profile.LastUpdated) > DateTime.Now.AddDays(-1))) + { + impossibleLikes.Add(likeKey); + Plugin.Log.Info($"[Gallery] Clearing impossible like: {myCharacterName} → {stableLikeTarget}"); + } + } + } + } + + foreach (var impossibleKey in impossibleLikes) + { + likedProfiles.Remove(impossibleKey); + plugin.Configuration.LikedGalleryProfiles?.Remove(impossibleKey); + } + + if (impossibleLikes.Count > 0) + { + plugin.Configuration.Save(); + Plugin.Log.Info($"[Gallery] Cleared {impossibleLikes.Count} like states due to apparent server reset"); + } + } + + private void LoadBlockedProfiles() + { + blockedProfiles = plugin.Configuration.BlockedGalleryProfiles ?? new HashSet(); + } + + private void BlockProfile(GalleryProfile profile) + { + var profileKey = GetProfileKey(profile); + string mainCharacterName = profile.CharacterName ?? SanitizeForLogging(profileKey); + + if (!blockedProfiles.Contains(mainCharacterName)) + { + blockedProfiles.Add(mainCharacterName); + plugin.Configuration.BlockedGalleryProfiles.Add(mainCharacterName); + plugin.Configuration.Save(); + + Plugin.Log.Info($"[Gallery] Blocked all profiles from: {mainCharacterName}"); + FilterProfiles(); + } + } + + private void UnblockProfile(string blockedCharacterName) + { + if (blockedProfiles.Contains(blockedCharacterName)) + { + blockedProfiles.Remove(blockedCharacterName); + plugin.Configuration.BlockedGalleryProfiles.Remove(blockedCharacterName); + plugin.Configuration.Save(); + + Plugin.Log.Info($"[Gallery] Unblocked profiles from: {blockedCharacterName}"); + _ = LoadGalleryData(); + } + } + + private bool IsProfileBlocked(GalleryProfile profile) + { + string mainCharacterName = profile.CharacterName ?? GetProfileKey(profile); + return blockedProfiles.Contains(mainCharacterName); + } + + private bool CanMakeRequest(string endpoint) + { + if (LastRequestTimes.TryGetValue(endpoint, out var lastTime)) + { + if (DateTime.Now - lastTime < MinimumRequestInterval) + { + return false; + } + } + + LastRequestTimes[endpoint] = DateTime.Now; + return true; + } + + private bool IsProfileLikedByAnyOfMyCharacters(string characterKey) + { + var targetProfile = allProfiles.FirstOrDefault(p => GetProfileKey(p) == characterKey); + string stableLikeTarget = targetProfile?.CharacterName ?? characterKey; + + var myCharacterNames = plugin.Characters.Select(c => c.Name).ToList(); + + foreach (var myCharacterName in myCharacterNames) + { + string likeKey = $"{myCharacterName}|{stableLikeTarget}"; + if (likedProfiles.ContainsKey(likeKey) || + (plugin.Configuration.LikedGalleryProfiles?.Contains(likeKey) ?? false)) + { + return true; + } + } + + return false; + } + + private string? GetWhichOfMyCharactersLikedProfile(string characterKey) + { + var targetProfile = allProfiles.FirstOrDefault(p => GetProfileKey(p) == characterKey); + string stableLikeTarget = targetProfile?.CharacterName ?? characterKey; + + var myCharacterNames = plugin.Characters.Select(c => c.Name).ToList(); + + foreach (var myCharacterName in myCharacterNames) + { + string likeKey = $"{myCharacterName}|{stableLikeTarget}"; + if (likedProfiles.ContainsKey(likeKey) || + (plugin.Configuration.LikedGalleryProfiles?.Contains(likeKey) ?? false)) + { + return myCharacterName; + } + } + + return null; + } + private async Task LoadAnnouncements() + { + try + { + using var http = new HttpClient(); + http.Timeout = TimeSpan.FromSeconds(10); + var response = await http.GetAsync("https://character-select-profile-server-production.up.railway.app/announcements"); + if (response.IsSuccessStatusCode) + { + var json = await response.Content.ReadAsStringAsync(); + var serverAnnouncements = JsonConvert.DeserializeObject>(json) ?? new(); + announcements = serverAnnouncements.OrderByDescending(a => a.CreatedAt).ToList(); + lastAnnouncementUpdate = DateTime.Now; + Plugin.Log.Debug($"[Gallery] Loaded {announcements.Count} announcements"); + } + else + { + Plugin.Log.Warning($"[Gallery] Failed to load announcements: {response.StatusCode}"); + } + } + catch (Exception ex) + { + Plugin.Log.Error($"[Gallery] Error loading announcements: {ex.Message}"); + } + } + + // Submit a report to the server + private async Task SubmitReport(string characterId, string characterName, ReportReason reason, string details, string customReason = "") + { + try + { + var activeCharacter = GetActiveCharacter(); + if (activeCharacter == null) + { + Plugin.ChatGui.PrintError("[Gallery] No active character found for reporting"); + return; + } + + string reporterName = activeCharacter.LastInGameName ?? activeCharacter.Name; + string reasonText = reason == ReportReason.Other ? customReason : GetReportReasonText(reason); + + var reportRequest = new ReportRequest + { + ReportedCharacterId = characterId, + ReportedCharacterName = characterName, + ReporterCharacter = reporterName, + Reason = reasonText, + Details = details + }; + + using var http = new HttpClient(); + + var settings = new JsonSerializerSettings + { + ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver() + }; + + var json = JsonConvert.SerializeObject(reportRequest, settings); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await http.PostAsync( + "https://character-select-profile-server-production.up.railway.app/reports", + content); + + if (response.IsSuccessStatusCode) + { + // Close the report dialog + showReportDialog = false; + reportTargetCharacterId = ""; + reportTargetCharacterName = ""; + reportDetails = ""; + customReportReason = ""; + + // Show success confirmation + reportConfirmationMessage = $"Report submitted successfully for {characterName}.\n\nThank you for helping keep the gallery safe!"; + showReportConfirmation = true; + } + else + { + reportConfirmationMessage = $"Failed to submit report: {response.StatusCode}\n\nPlease try again later."; + showReportConfirmation = true; + } + } + catch (Exception ex) + { + Plugin.Log.Error($"[Gallery] Error submitting report: {ex.Message}"); + reportConfirmationMessage = "Failed to submit report due to network error.\n\nPlease check your connection and try again."; + showReportConfirmation = true; + } + } + private void DrawReportConfirmation(float scale) + { + if (!showReportConfirmation || string.IsNullOrEmpty(reportConfirmationMessage)) + return; + + ImGui.SetNextWindowSize(new Vector2(400 * scale, 180 * scale), ImGuiCond.FirstUseEver); + ImGui.SetNextWindowPos(ImGui.GetMainViewport().GetCenter(), ImGuiCond.FirstUseEver, new Vector2(0.5f, 0.5f)); + + ImGui.PushStyleColor(ImGuiCol.WindowBg, new Vector4(0.06f, 0.06f, 0.06f, 0.98f)); + ImGui.PushStyleColor(ImGuiCol.TitleBg, new Vector4(0.12f, 0.12f, 0.12f, 1.0f)); + ImGui.PushStyleColor(ImGuiCol.TitleBgActive, new Vector4(0.18f, 0.18f, 0.18f, 1.0f)); + ImGui.PushStyleColor(ImGuiCol.Separator, new Vector4(0.25f, 0.25f, 0.25f, 0.6f)); + + // Success button styling + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.2f, 0.2f, 0.2f, 0.8f)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.3f, 0.3f, 0.3f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.4f, 0.4f, 0.4f, 1.0f)); + + if (ImGui.Begin("Report Submitted##ReportConfirmation", ref showReportConfirmation, + ImGuiWindowFlags.NoDocking | ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoResize)) + { + ImGui.Spacing(); + + // Subtle success indicator + ImGui.SetCursorPosX((ImGui.GetWindowWidth() - ImGui.CalcTextSize("✓").X) * 0.5f); + ImGui.TextColored(new Vector4(0.4f, 0.8f, 0.4f, 1.0f), "✓"); + + ImGui.Spacing(); + + // Message with your standard text colors + var messageLines = reportConfirmationMessage.Split('\n'); + foreach (var line in messageLines) + { + var lineWidth = ImGui.CalcTextSize(line).X; + ImGui.SetCursorPosX((ImGui.GetWindowWidth() - lineWidth) * 0.5f); + + if (line.Contains("successfully")) + { + ImGui.TextColored(new Vector4(0.92f, 0.92f, 0.92f, 1.0f), line); + } + else + { + ImGui.TextColored(new Vector4(0.7f, 0.7f, 0.7f, 1.0f), line); + } + } + + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + + // Center the OK button + float buttonWidth = 80 * scale; + ImGui.SetCursorPosX((ImGui.GetWindowWidth() - buttonWidth) * 0.5f); + + if (ImGui.Button("OK", new Vector2(buttonWidth, 0))) + { + showReportConfirmation = false; + reportConfirmationMessage = ""; + } + } + ImGui.End(); + + ImGui.PopStyleColor(7); // Pop style colors + } + + // Get user-friendly text for report reasons + private string GetReportReasonText(ReportReason reason) + { + return reason switch + { + ReportReason.InappropriateContent => "Inappropriate Content", + ReportReason.Spam => "Spam", + ReportReason.MaliciousLinks => "Malicious Links", + ReportReason.Other => "Other", + _ => "Other" + }; + } + + // Open the report dialog for a specific character + private void OpenReportDialog(string characterId, string characterName) + { + reportTargetCharacterId = characterId; + reportTargetCharacterName = characterName; + selectedReportReason = ReportReason.InappropriateContent; + customReportReason = ""; + reportDetails = ""; + showReportDialog = true; + } + + // Draw the report dialog popup + private void DrawReportDialog(float scale) + { + if (!showReportDialog || string.IsNullOrEmpty(reportTargetCharacterName)) + return; + + ImGui.SetNextWindowSize(new Vector2(500 * scale, 400 * scale), ImGuiCond.FirstUseEver); + ImGui.SetNextWindowPos(ImGui.GetMainViewport().GetCenter(), ImGuiCond.FirstUseEver, new Vector2(0.5f, 0.5f)); + + // Dark styling for report dialog + ImGui.PushStyleColor(ImGuiCol.WindowBg, new Vector4(0.06f, 0.06f, 0.06f, 0.98f)); + ImGui.PushStyleColor(ImGuiCol.TitleBg, new Vector4(0.8f, 0.3f, 0.3f, 1.0f)); + ImGui.PushStyleColor(ImGuiCol.TitleBgActive, new Vector4(0.9f, 0.4f, 0.4f, 1.0f)); + + if (ImGui.Begin($"Report Profile: {reportTargetCharacterName}##ReportDialog", ref showReportDialog, + ImGuiWindowFlags.NoDocking | ImGuiWindowFlags.NoCollapse)) + { + ImGui.Text($"You are reporting: {reportTargetCharacterName}"); + ImGui.Separator(); + ImGui.Spacing(); + + // Reason selection + ImGui.Text("Reason for report:"); + ImGui.SetNextItemWidth(-1); + + string[] reasonOptions = Enum.GetValues() + .Select(GetReportReasonText) + .ToArray(); + + int selectedIndex = (int)selectedReportReason; + if (ImGui.Combo("##ReportReason", ref selectedIndex, reasonOptions, reasonOptions.Length)) + { + selectedReportReason = (ReportReason)selectedIndex; + } + + // Custom reason input if "Other" is selected + if (selectedReportReason == ReportReason.Other) + { + ImGui.Spacing(); + ImGui.Text("Please specify:"); + ImGui.SetNextItemWidth(-1); + ImGui.InputText("##CustomReason", ref customReportReason, 100); + } + + ImGui.Spacing(); + ImGui.Text("Additional details (optional):"); + ImGui.SetNextItemWidth(-1); + ImGui.InputTextMultiline("##ReportDetails", ref reportDetails, 500, new Vector2(-1, 100 * scale)); + + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + + // Buttons + bool canSubmit = selectedReportReason != ReportReason.Other || !string.IsNullOrWhiteSpace(customReportReason); + + if (!canSubmit) + { + ImGui.BeginDisabled(); + } + + if (ImGui.Button("Submit Report", new Vector2(120 * scale, 0))) + { + _ = SubmitReport(reportTargetCharacterId, reportTargetCharacterName, selectedReportReason, reportDetails, customReportReason); + } + + if (!canSubmit) + { + ImGui.EndDisabled(); + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled)) + { + ImGui.SetTooltip("Please specify a reason when selecting 'Other'"); + } + } + + ImGui.SameLine(); + if (ImGui.Button("Cancel", new Vector2(120 * scale, 0))) + { + showReportDialog = false; + } + + ImGui.Spacing(); + ImGui.TextColored(new Vector4(0.7f, 0.7f, 0.7f, 1.0f), + "Reports are reviewed by administrators. False reports may result in restrictions."); + } + ImGui.End(); + + ImGui.PopStyleColor(3); + } + + // Draw the announcements tab + private void DrawAnnouncementsTab(float scale) + { + // Auto-load announcements if needed + if (DateTime.Now - lastAnnouncementUpdate > announcementUpdateInterval) + { + _ = LoadAnnouncements(); + } + + if (announcements.Count == 0) + { + ImGui.Text("No announcements at this time."); + if (ImGui.Button("Refresh")) + { + _ = LoadAnnouncements(); + } + return; + } + + ImGui.Text($"Announcements ({announcements.Count})"); + if (ImGui.Button("Refresh")) + { + _ = LoadAnnouncements(); + } + + ImGui.Separator(); + ImGui.Spacing(); + + ImGui.BeginChild("AnnouncementsContent", new Vector2(0, 0), false, ImGuiWindowFlags.AlwaysVerticalScrollbar); + + foreach (var announcement in announcements) + { + DrawAnnouncementCard(announcement, scale); + ImGui.Spacing(); + } + + ImGui.EndChild(); + } + + // Draw individual announcement card + private void DrawAnnouncementCard(Announcement announcement, float scale) + { + var drawList = ImGui.GetWindowDrawList(); + var cardMin = ImGui.GetCursorScreenPos(); + var cardWidth = ImGui.GetContentRegionAvail().X - (20 * scale); + var cardHeight = 120 * scale; // Dynamic height based on content + + // Determine colors based on announcement type + Vector4 accentColor = announcement.Type switch + { + "warning" => new Vector4(1.0f, 0.6f, 0.2f, 1.0f), // Orange + "maintenance" => new Vector4(0.8f, 0.3f, 0.3f, 1.0f), // Red + "update" => new Vector4(0.3f, 0.8f, 0.3f, 1.0f), // Green + _ => new Vector4(0.3f, 0.7f, 1.0f, 1.0f) // Blue for info + }; + + var cardMax = cardMin + new Vector2(cardWidth, cardHeight); + var bgColor = new Vector4(0.08f, 0.08f, 0.12f, 0.95f); + var borderColor = new Vector4(accentColor.X, accentColor.Y, accentColor.Z, 0.6f); + + // Draw card background + drawList.AddRectFilled(cardMin, cardMax, ImGui.GetColorU32(bgColor), 8f * scale); + drawList.AddRectFilled(cardMin, cardMin + new Vector2(6f * scale, cardHeight), ImGui.GetColorU32(accentColor), 8f * scale, ImDrawFlags.RoundCornersLeft); + drawList.AddRect(cardMin, cardMax, ImGui.GetColorU32(borderColor), 8f * scale, ImDrawFlags.None, 1f * scale); + + ImGui.BeginChild($"announcement_{announcement.Id}", new Vector2(cardWidth, cardHeight), false); + + // Header with title and type + ImGui.SetCursorPos(new Vector2(15 * scale, 10 * scale)); + ImGui.PushStyleColor(ImGuiCol.Text, accentColor); + ImGui.PushFont(UiBuilder.DefaultFont); + ImGui.Text(announcement.Title); + ImGui.PopFont(); + ImGui.PopStyleColor(); + + // Type label - properly aligned to right edge + string typeText = announcement.Type.ToUpper(); + var typeTextSize = ImGui.CalcTextSize(typeText); + ImGui.SameLine(); + ImGui.SetCursorPosX(cardWidth - typeTextSize.X - (15 * scale)); // 15 = right padding + ImGui.TextColored(new Vector4(0.7f, 0.7f, 0.7f, 1.0f), typeText); + + // Message + ImGui.SetCursorPos(new Vector2(15 * scale, 35 * scale)); + ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + cardWidth - (30 * scale)); + ImGui.TextColored(new Vector4(0.9f, 0.9f, 0.9f, 1.0f), announcement.Message); + ImGui.PopTextWrapPos(); + + // Date + ImGui.SetCursorPos(new Vector2(15 * scale, cardHeight - (25 * scale))); + ImGui.TextColored(new Vector4(0.6f, 0.6f, 0.6f, 1.0f), + $"Posted: {announcement.CreatedAt:MMM dd, yyyy}"); + + ImGui.EndChild(); + } + private string SanitizeForLogging(string characterId) + { + // Extract just the CS+ character name from characterId format: "CSName_PhysicalName@Server" + var underscoreIndex = characterId.IndexOf('_'); + if (underscoreIndex > 0) + { + return characterId.Substring(0, underscoreIndex); + } + + // If no underscore, might be just a character name already + return characterId.Contains('@') ? characterId.Split('@')[0] : characterId; + } + private void SafeStyleScope(int colorCount, int varCount, Action action) + { + try + { + action(); + } + finally + { + if (colorCount > 0) ImGui.PopStyleColor(colorCount); + if (varCount > 0) ImGui.PopStyleVar(varCount); + } + } + + private void ShowErrorMessage(string message) + { + lastErrorMessage = message; + lastErrorTime = DateTime.Now; + } + + private void DrawErrorMessage() + { + if (!string.IsNullOrEmpty(lastErrorMessage) && DateTime.Now - lastErrorTime < TimeSpan.FromSeconds(5)) + { + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(1.0f, 0.4f, 0.4f, 1.0f)); + ImGui.Text($"⚠ {lastErrorMessage}"); + ImGui.PopStyleColor(); + } + } + + public void EmergencyStop() + { + isAutoRefreshing = false; + isLoading = false; + loadingProfiles.Clear(); + imageLoadStarted.Clear(); + Plugin.Log.Warning("[Gallery] Emergency stop activated - all requests halted"); + } + private float GetSafeScale(float baseScale) + { + return Math.Clamp(baseScale, 0.3f, 5.0f); + } + + public override void OnOpen() + { + LoadFavorites(); + LoadBlockedProfiles(); + EnsureLikesFilteredByCSCharacter(); + EnsureFavoritesFilteredByCSCharacter(); + _ = LoadGalleryData(); + _ = LoadAnnouncements(); + if (plugin.Configuration.LastSeenAnnouncements != DateTime.MinValue) + { + lastSeenAnnouncements = plugin.Configuration.LastSeenAnnouncements; + } + } + + public override void OnClose() + { + Task.Run(() => CleanupImageCache()); + base.OnClose(); + } + } +} diff --git a/CharacterSelectPlugin/NPCDialogueProcessor.cs b/CharacterSelectPlugin/NPCDialogueProcessor.cs new file mode 100644 index 0000000..e274567 --- /dev/null +++ b/CharacterSelectPlugin/NPCDialogueProcessor.cs @@ -0,0 +1,1032 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using Dalamud.Hooking; +using Dalamud.Game; +using FFXIVClientStructs.FFXIV.Client.UI.Misc; +using FFXIVClientStructs.FFXIV.Client.System.String; +using FFXIVClientStructs.FFXIV.Client.System.Framework; +using FFXIVClientStructs.STD; +using Dalamud.Plugin.Services; +using System.Runtime.InteropServices; +using System.Linq; + +namespace CharacterSelectPlugin +{ + [StructLayout(LayoutKind.Explicit, Size = 0x20)] + public struct TextDecoderParam + { + [FieldOffset(0x00)] public ulong Value; + [FieldOffset(0x08)] public nuint Self; + [FieldOffset(0x16)] public sbyte Status; + } + + public unsafe class NPCDialogueProcessor : IDisposable + { + private readonly Plugin plugin; + private readonly ISigScanner sigScanner; + private readonly IGameInteropProvider gameInteropProvider; + private readonly IChatGui chatGui; + private readonly IClientState clientState; + private readonly IPluginLog log; + private readonly ICondition condition; + + // Hook delegates - Lua hooks for they/them support + private delegate int GetStringPrototype(RaptureTextModule* textModule, byte* text, void* decoder, Utf8String* stringStruct); + private Hook? getStringHook; + + private delegate byte GetLuaVarPrototype(nint poolBase, nint a2, nint a3); + private Hook? getLuaVarHook; + + // Name replacement byte patterns (from PrefPro) + private static readonly byte[] FullNameBytes = { 0x02, 0x29, 0x03, 0xEB, 0x02, 0x03 }; + private static readonly byte[] FirstNameBytes = { 0x02, 0x2C, 0x0D, 0xFF, 0x07, 0x02, 0x29, 0x03, 0xEB, 0x02, 0x03, 0xFF, 0x02, 0x20, 0x02, 0x03 }; + private static readonly byte[] LastNameBytes = { 0x02, 0x2C, 0x0D, 0xFF, 0x07, 0x02, 0x29, 0x03, 0xEB, 0x02, 0x03, 0xFF, 0x02, 0x20, 0x03, 0x03 }; + + // Comprehensive verb conjugation patterns for they/them + private static readonly Dictionary ConjugationPatterns = new Dictionary + { + { new Regex(@"\bthey\s+(finds)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they find" }, + { new Regex(@"\bthey\s+(goes)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they go" }, + { new Regex(@"\bthey\s+(does)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they do" }, + { new Regex(@"\bthey\s+(has)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they have" }, + { new Regex(@"\bthey\s+(is)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they are" }, + { new Regex(@"\bthey\s+(was)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they were" }, + { new Regex(@"\bthey\s+(says)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they say" }, + { new Regex(@"\bthey\s+(knows)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they know" }, + { new Regex(@"\bthey\s+(comes)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they come" }, + { new Regex(@"\bthey\s+(takes)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they take" }, + { new Regex(@"\bthey\s+(makes)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they make" }, + { new Regex(@"\bthey\s+(gets)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they get" }, + { new Regex(@"\bthey\s+(gives)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they give" }, + { new Regex(@"\bthey\s+(wants)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they want" }, + { new Regex(@"\bthey\s+(needs)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they need" }, + { new Regex(@"\bthey\s+(likes)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they like" }, + { new Regex(@"\bthey\s+(loves)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they love" }, + { new Regex(@"\bthey\s+(feels)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they feel" }, + { new Regex(@"\bthey\s+(thinks)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they think" }, + { new Regex(@"\bthey\s+(believes)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they believe" }, + { new Regex(@"\bthey\s+(understands)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they understand" }, + { new Regex(@"\bthey\s+(sees)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they see" }, + { new Regex(@"\bthey\s+(hears)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they hear" }, + { new Regex(@"\bthey\s+(speaks)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they speak" }, + { new Regex(@"\bthey\s+(tells)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they tell" }, + { new Regex(@"\bthey\s+(asks)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they ask" }, + { new Regex(@"\bthey\s+(works)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they work" }, + { new Regex(@"\bthey\s+(lives)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they live" }, + { new Regex(@"\bthey\s+(runs)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they run" }, + { new Regex(@"\bthey\s+(walks)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they walk" }, + { new Regex(@"\bthey\s+(stands)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they stand" }, + { new Regex(@"\bthey\s+(sits)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they sit" }, + { new Regex(@"\bthey\s+(travels)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they travel" }, + { new Regex(@"\bthey\s+(arrives)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they arrive" }, + { new Regex(@"\bthey\s+(leaves)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they leave" }, + { new Regex(@"\bthey\s+(returns)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they return" }, + { new Regex(@"\bthey\s+(helps)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they help" }, + { new Regex(@"\bthey\s+(saves)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they save" }, + { new Regex(@"\bthey\s+(protects)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they protect" }, + { new Regex(@"\bthey\s+(attacks)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they attack" }, + { new Regex(@"\bthey\s+(defends)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they defend" }, + { new Regex(@"\bthey\s+(uses)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they use" }, + { new Regex(@"\bthey\s+(carries)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they carry" }, + { new Regex(@"\bthey\s+(wears)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they wear" }, + { new Regex(@"\bthey\s+(holds)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they hold" }, + { new Regex(@"\bthey\s+(opens)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they open" }, + { new Regex(@"\bthey\s+(closes)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they close" }, + { new Regex(@"\bthey\s+(enters)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they enter" }, + { new Regex(@"\bthey\s+(follows)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they follow" }, + { new Regex(@"\bthey\s+(leads)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they lead" }, + { new Regex(@"\bthey\s+(learns)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they learn" }, + { new Regex(@"\bthey\s+(teaches)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they teach" }, + { new Regex(@"\bthey\s+(grows)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they grow" }, + { new Regex(@"\bthey\s+(changes)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they change" }, + { new Regex(@"\bthey\s+(becomes)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they become" }, + { new Regex(@"\bthey\s+(remains)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they remain" }, + { new Regex(@"\bthey\s+(waits)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they wait" }, + { new Regex(@"\bthey\s+(watches)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they watch" }, + { new Regex(@"\bthey\s+(looks)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they look" }, + { new Regex(@"\bthey\s+(appears)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they appear" }, + { new Regex(@"\bthey\s+(seems)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they seem" }, + { new Regex(@"\bthey\s+(creates)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they create" }, + { new Regex(@"\bthey\s+(builds)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they build" }, + { new Regex(@"\bthey\s+(fixes)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they fix" }, + { new Regex(@"\bthey\s+(breaks)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they break" }, + { new Regex(@"\bthey\s+(wins)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they win" }, + { new Regex(@"\bthey\s+(loses)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they lose" }, + { new Regex(@"\bthey\s+(fights)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they fight" }, + { new Regex(@"\bthey\s+(decides)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they decide" }, + { new Regex(@"\bthey\s+(chooses)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they choose" }, + { new Regex(@"\bthey\s+(tries)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they try" }, + { new Regex(@"\bthey\s+(succeeds)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they succeed" }, + { new Regex(@"\bthey\s+(fails)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they fail" }, + { new Regex(@"\bthey\s+(discovers)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they discover" }, + { new Regex(@"\bthey\s+(explores)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they explore" }, + { new Regex(@"\bthey\s+(adventures)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they adventure" }, + { new Regex(@"\bthey\s+(journeys)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they journey" }, + { new Regex(@"\bthey's\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), "they're" } + }; + + // Pre-compiled regex patterns for better performance + private static readonly Regex HeRegex = new Regex(@"\bhe\b", RegexOptions.Compiled); + private static readonly Regex HeCapitalRegex = new Regex(@"\bHe\b", RegexOptions.Compiled); + private static readonly Regex SheRegex = new Regex(@"\bshe\b", RegexOptions.Compiled); + private static readonly Regex SheCapitalRegex = new Regex(@"\bShe\b", RegexOptions.Compiled); + private static readonly Regex HisRegex = new Regex(@"\bhis\b", RegexOptions.Compiled); + private static readonly Regex HisCapitalRegex = new Regex(@"\bHis\b", RegexOptions.Compiled); + private static readonly Regex LadRegex = new Regex(@"\blad\b", RegexOptions.Compiled); + private static readonly Regex LadCapitalRegex = new Regex(@"\bLad\b", RegexOptions.Compiled); + // Context-aware "her" patterns - distinguishes possessive vs object + private static readonly Regex HerPossessiveRegex = new Regex(@"\bher(?=\s+(?!a\b|an\b|the\b)[A-Z][a-z]+)", RegexOptions.Compiled); + private static readonly Regex HerPossessiveCapitalRegex = new Regex(@"\bHer(?=\s+(?!a\b|an\b|the\b)[A-Z][a-z]+)", RegexOptions.Compiled); + private static readonly Regex HerPossessiveLowerRegex = new Regex(@"\bher(?=\s+(?!a\b|an\b|the\b|to\b|and\b|or\b|but\b)[a-z]+)", RegexOptions.Compiled); + private static readonly Regex HerObjectRegex = new Regex(@"\bher\b", RegexOptions.Compiled); + private static readonly Regex HerObjectCapitalRegex = new Regex(@"\bHer\b", RegexOptions.Compiled); + + private static readonly Regex HimRegex = new Regex(@"\bhim\b", RegexOptions.Compiled); + private static readonly Regex HimCapitalRegex = new Regex(@"\bHim\b", RegexOptions.Compiled); + private static readonly Regex HimselfRegex = new Regex(@"\bhimself\b", RegexOptions.Compiled); + private static readonly Regex HimselfCapitalRegex = new Regex(@"\bHimself\b", RegexOptions.Compiled); + private static readonly Regex HerselfRegex = new Regex(@"\bherself\b", RegexOptions.Compiled); + private static readonly Regex HerselfCapitalRegex = new Regex(@"\bHerself\b", RegexOptions.Compiled); + + // Title regex patterns + private static readonly Regex WomanRegex = new Regex(@"\bwoman\b", RegexOptions.Compiled); + private static readonly Regex WomanCapitalRegex = new Regex(@"\bWoman\b", RegexOptions.Compiled); + private static readonly Regex ManRegex = new Regex(@"\bman\b", RegexOptions.Compiled); + private static readonly Regex ManCapitalRegex = new Regex(@"\bMan\b", RegexOptions.Compiled); + private static readonly Regex LadyRegex = new Regex(@"\blady\b", RegexOptions.Compiled); + private static readonly Regex LadyCapitalRegex = new Regex(@"\bLady\b", RegexOptions.Compiled); + private static readonly Regex SirRegex = new Regex(@"\bsir\b", RegexOptions.Compiled); + private static readonly Regex SirCapitalRegex = new Regex(@"\bSir\b", RegexOptions.Compiled); + private static readonly Regex MistressRegex = new Regex(@"\bmistress\b", RegexOptions.Compiled); + private static readonly Regex MistressCapitalRegex = new Regex(@"\bMistress\b", RegexOptions.Compiled); + private static readonly Regex MasterRegex = new Regex(@"\bmaster\b", RegexOptions.Compiled); + private static readonly Regex MasterCapitalRegex = new Regex(@"\bMaster\b", RegexOptions.Compiled); + private static readonly Regex GirlRegex = new Regex(@"\bgirl\b", RegexOptions.Compiled); + private static readonly Regex GirlCapitalRegex = new Regex(@"\bGirl\b", RegexOptions.Compiled); + private static readonly Regex BoyRegex = new Regex(@"\bboy\b", RegexOptions.Compiled); + private static readonly Regex BoyCapitalRegex = new Regex(@"\bBoy\b", RegexOptions.Compiled); + private static readonly Regex MadamRegex = new Regex(@"\bmadam\b", RegexOptions.Compiled); + private static readonly Regex MadamCapitalRegex = new Regex(@"\bMadam\b", RegexOptions.Compiled); + private static readonly Regex DameRegex = new Regex(@"\bdame\b", RegexOptions.Compiled); + private static readonly Regex DameCapitalRegex = new Regex(@"\bDame\b", RegexOptions.Compiled); + private static readonly Regex LassRegex = new Regex(@"\blass\b", RegexOptions.Compiled); + private static readonly Regex LassCapitalRegex = new Regex(@"\bLass\b", RegexOptions.Compiled); + private static readonly Regex MaidenRegex = new Regex(@"\bmaiden\b", RegexOptions.Compiled); + private static readonly Regex MaidenCapitalRegex = new Regex(@"\bMaiden\b", RegexOptions.Compiled); + private static readonly Regex BrotherRegex = new Regex(@"\bbrother\b", RegexOptions.Compiled); + private static readonly Regex BrotherCapitalRegex = new Regex(@"\bBrother\b", RegexOptions.Compiled); + private static readonly Regex SisterRegex = new Regex(@"\bsister\b", RegexOptions.Compiled); + private static readonly Regex SisterCapitalRegex = new Regex(@"\bSister\b", RegexOptions.Compiled); + + public NPCDialogueProcessor(Plugin plugin, ISigScanner sigScanner, IGameInteropProvider gameInteropProvider, + IChatGui chatGui, IClientState clientState, IPluginLog log, ICondition condition) + { + this.plugin = plugin; + this.sigScanner = sigScanner; + this.gameInteropProvider = gameInteropProvider; + this.chatGui = chatGui; + this.clientState = clientState; + this.log = log; + this.condition = condition; + + try + { + InitializeHooks(); + log.Info("[Dialogue] Multi-pronoun dialogue processor initialized."); + } + catch (Exception ex) + { + log.Error($"[Dialogue] Failed to initialize processor: {ex.Message}"); + } + } + + private void InitializeHooks() + { + // Main text processing hook (PrefPro my beloved) + var getStringSignature = "48 89 5C 24 ?? 48 89 6C 24 ?? 48 89 74 24 ?? 57 48 83 EC 20 83 B9 ?? ?? ?? ?? ?? 49 8B F9 49 8B F0 48 8B EA 48 8B D9 75 09 48 8B 01 FF 90"; + var getStringPtr = sigScanner.ScanText(getStringSignature); + getStringHook = gameInteropProvider.HookFromAddress(getStringPtr, GetStringDetour); + getStringHook.Enable(); + + // Add Lua hook for they/them gender forcing + try + { + var getLuaVar = "E8 ?? ?? ?? ?? 48 85 DB 74 1B 48 8D 8F"; + var getLuaVarPtr = sigScanner.ScanText(getLuaVar); + getLuaVarHook = gameInteropProvider.HookFromAddress(getLuaVarPtr, GetLuaVarDetour); + getLuaVarHook.Enable(); + log.Info("[Dialogue] Lua gender override hook enabled."); + } + catch (Exception ex) + { + log.Warning($"[Dialogue] Failed to initialize Lua hooks: {ex.Message}"); + } + } + + private bool IsInCutscene() + { + if (condition == null) return false; + + return condition[Dalamud.Game.ClientState.Conditions.ConditionFlag.OccupiedInCutSceneEvent] || + condition[Dalamud.Game.ClientState.Conditions.ConditionFlag.WatchingCutscene] || + condition[Dalamud.Game.ClientState.Conditions.ConditionFlag.WatchingCutscene78] || + condition[Dalamud.Game.ClientState.Conditions.ConditionFlag.OccupiedInQuestEvent]; + } + + private bool IsChat(string textString) + { + if (string.IsNullOrEmpty(textString)) return false; + + // These are dialogue + if (textString.Contains("woman�man") || + textString.Contains("women�men") || + textString.Contains("herself�himself")) + return false; + + // Skip if it's clearly chat log content, not live dialogue + // Long narrative text with ellipses and chat markers + if (textString.Contains("����") && + textString.Contains("...") && + textString.Length > 150) + return true; + + // Specific narrative patterns that indicate chat log content + if (textString.Contains("...") && textString.Length > 100 && ( + textString.Contains("they carved") || + textString.Contains("did they put") || + textString.Contains("tempestuous winds") || + textString.Contains("dread wyrm") || + textString.Contains("hard-fought victory") || + textString.Contains("secrets laid bare"))) + return true; + + // Standard chat patterns + if (textString.Contains("[Mare Synchronos]") || + textString.Contains("[Mare]") || + textString.Contains("Mare:") || + textString.Contains("is now online") || + textString.Contains("is now offline")) + return true; + + if (textString.StartsWith("H") && textString.EndsWith("H") && textString.Length > 2) + return true; + + var cleanText = Regex.Replace(textString, @"[^\u0020-\u007E]", ""); + if (Regex.IsMatch(cleanText, @"^[A-Z][a-z]+\s+[A-Z][a-z]+\s*:")) + return true; + if (Regex.IsMatch(textString, @"^[A-Z][a-z]+\s+[A-Z][a-z]+\s*:")) + return true; + + if (textString.StartsWith("[") || + textString.StartsWith("<") || + textString.StartsWith("/") || + textString.Contains(">>") || + textString.Contains("<<") || + textString.Contains(" says") || + textString.Contains(" tells") || + Regex.IsMatch(textString, @"^\s*\[.*?\]")) + return true; + + var text = textString.Trim(); + if (text.Length < 50) + { + if (text.StartsWith("...") || text.EndsWith("...")) return true; + if (text.StartsWith("*") || text.EndsWith("*")) return true; + if (text.StartsWith("(") && text.EndsWith(")")) return true; + if (text.Contains("((") || text.Contains("))")) return true; + } + + return false; + } + + // Chat detection for post-processing + private bool IsDefinitelyChat(string text) + { + if (string.IsNullOrEmpty(text)) return false; + + // Unicode characters for better pattern matching + var cleanText = Regex.Replace(text, @"[^\u0020-\u007E]", ""); + + // Player Name: message + if (Regex.IsMatch(cleanText, @"[A-Z][a-z]+\s+[A-Z][a-z]+\s*:")) + return true; + + // Chat commands + if (text.StartsWith("/say") || text.StartsWith("/tell") || text.StartsWith("/shout") || + text.StartsWith("/yell") || text.StartsWith("/party") || text.StartsWith("/fc") || + text.StartsWith("/ls") || text.StartsWith("/cwls")) + return true; + + // Chat channel indicators + if (text.Contains("[Say]") || text.Contains("[Yell]") || text.Contains("[Shout]") || + text.Contains("[Tell]") || text.Contains("[Party]") || text.Contains("[FC]") || + text.Contains("[LS") || text.Contains("[CWLS") || text.Contains("[Novice")) + return true; + + return false; + } + + private bool IsUIElement(string textString) + { + var text = textString.Trim(); + + // Skip if too short + if (text.Length < 10) return true; + + // Skip job/class names + var jobNames = new[] { "Paladin", "Warrior", "Dark Knight", "Gunbreaker", "White Mage", "Scholar", + "Astrologian", "Sage", "Monk", "Dragoon", "Ninja", "Samurai", "Reaper", + "Black Mage", "Summoner", "Red Mage", "Blue Mage", "Bard", "Machinist", + "Dancer", "Carpenter", "Blacksmith", "Armorer", "Goldsmith", "Leatherworker", + "Weaver", "Alchemist", "Culinarian", "Miner", "Botanist", "Fisher", + "Gladiator", "Marauder", "Conjurer", "Thaumaturge", "Pugilist", "Lancer", + "Rogue", "Archer" }; + + foreach (var job in jobNames) + { + if (text.Contains(job)) return true; + } + + // Skip numbers/stats + if (Regex.IsMatch(text, @"^\d+(\.\d+)?$")) return true; + if (Regex.IsMatch(text, @"^\d+/\d+$")) return true; + + // Skip time stamps + if (Regex.IsMatch(text, @"\d+:\d+")) return true; + + // Skip world names + if (text.Contains("World") || text.Contains("Server")) return true; + + // Skip single words (likely UI labels) + if (!text.Contains(" ") && text.Length < 15) return true; + + return false; + } + + // Check if text contains emote patterns that shouldn't be touched + private bool ContainsEmotePattern(string text) + { + // FFXIV emote pattern: H��I��emoteNameIH + if (Regex.IsMatch(text, @"H[^\w]*I[^\w]*\w+I[^\w]*H")) + { + return true; + } + return false; + } + + // Check if text has player-specific patterns + private bool HasPlayerSpecificPattern(string text) + { + // Patterns that clearly refer to the player even without gender selection flags + var playerPatterns = new[] + { + @"\b(men|women|man|woman|sir|madam|master|mistress|lady|lord)\s+(like\s+you|such\s+as\s+you)\b", + @"\bgood\s+(man|woman|sir|madam|master|mistress|lady|lord)\b", + @"\b(thank\s+you|well\s+done|excellent),?\s+(man|woman|sir|madam|master|mistress|lady|lord)\b", + @"\byou\s+(are|were)\s+(a|an)?\s*(man|woman|sir|madam|master|mistress|lady|lord)\b", + @"\b(listen|hear\s+me),?\s+(man|woman|sir|madam|master|mistress|lady|lord)\b" + }; + + foreach (var pattern in playerPatterns) + { + if (Regex.IsMatch(text, pattern, RegexOptions.IgnoreCase)) + { + return true; + } + } + return false; + } + + // Check if text refers to NPCs + private bool IsNPCReference(string text) + { + // Patterns that refer to NPCs, not the player + var npcPatterns = new[] + { + @"\b(a|an|the|this|that)\s+(suspicious|strange|mysterious|unknown|dead|evil|certain|particular)\s+(man|woman|person)\b" + }; + + foreach (var pattern in npcPatterns) + { + if (Regex.IsMatch(text, pattern, RegexOptions.IgnoreCase)) + { + return true; + } + } + return false; + } + + // Main text processing detour + private int GetStringDetour(RaptureTextModule* textModule, byte* text, void* decoder, Utf8String* stringStruct) + { + + try + { + var textSpan = MemoryMarshal.CreateReadOnlySpanFromNullTerminated(text); + var textString = System.Text.Encoding.UTF8.GetString(textSpan); + + if (!plugin.Configuration.EnableDialogueIntegration) + return getStringHook!.Original(textModule, text, decoder, stringStruct); + + var activeCharacter = plugin.GetActiveCharacter(); + if (activeCharacter?.RPProfile?.Pronouns == null) + return getStringHook!.Original(textModule, text, decoder, stringStruct); + + // Only process text with gender/name flags (0x02) + if (!textSpan.Contains((byte)0x02)) + { + // DEBUG: Log text without 0x02 flags that contains gendered terms + if (textString.Contains("sir") || textString.Contains("lady") || textString.Contains("master") || textString.Contains("mistress")) + { + } + return getStringHook!.Original(textModule, text, decoder, stringStruct); + } + + // Skip if text contains emote patterns + if (ContainsEmotePattern(textString)) + { + return getStringHook!.Original(textModule, text, decoder, stringStruct); + } + + // Check for player tags in original text before processing + var originalHex = Convert.ToHexString(System.Text.Encoding.UTF8.GetBytes(textString)); + + // Use hex flags + bool hasPlayerNameFlags = originalHex.Contains("022C0D") || originalHex.Contains("022903"); + bool hasDirectPlayerFlags = originalHex.Contains("022003"); + bool mentionsPlayerName = textString.Contains(activeCharacter.Name) || + textString.Contains(activeCharacter.Name.Split(' ')[0]); + + // Detect visible gender selection patterns + bool hasVisibleGenderSelection = + // Check for both words in same string + (textString.Contains("women") && textString.Contains("men")) || + (textString.Contains("woman") && textString.Contains("man")) || + (textString.Contains("Mistress") && textString.Contains("Master")) || + (textString.Contains("herself") && textString.Contains("himself")) || + (textString.Contains("her") && textString.Contains("his") && textString.Length < 200) || // Short text with both pronouns + // Look for separator characters that indicate selections + textString.Contains("�") || // Any gender selection separator + // Specific patterns seen in logs + textString.Contains("��madam�sir") || + textString.Contains("madam�sir") || + // Original patterns as backup + textString.Contains("��women�men") || + textString.Contains("women�men") || + textString.Contains("��woman�man") || + textString.Contains("woman�man") || + textString.Contains("��Mistress�Master") || + textString.Contains("Mistress�Master"); + + // Add player-specific pattern detection + bool hasPlayerSpecificPattern = HasPlayerSpecificPattern(textString); + + // Check if this is an NPC reference that shouldn't be changed + bool isNPCReference = IsNPCReference(textString); + + // Replace pronouns if player indicators detected & it's not an NPC reference + bool shouldReplacePronouns = (hasDirectPlayerFlags || mentionsPlayerName || + hasVisibleGenderSelection || hasPlayerSpecificPattern) && !isNPCReference; + + + var pronounSet = PronounParser.Parse(activeCharacter.RPProfile.Pronouns); + + + // Skip chat messages + if (IsChat(textString)) + return getStringHook!.Original(textModule, text, decoder, stringStruct); + + // Skip UI elements + if (IsUIElement(textString)) + return getStringHook!.Original(textModule, text, decoder, stringStruct); + + // Only process if we're in a cutscene or it looks like proper dialogue + if (!IsInCutscene() && textString.Length < 30) + return getStringHook!.Original(textModule, text, decoder, stringStruct); + + // Handle name replacement first (exactly like bestie, PrefPro) + if (plugin.Configuration.ReplaceNameInDialogue && !string.IsNullOrEmpty(activeCharacter.Name)) + HandleNameReplacement(ref text, activeCharacter); + + // Process the result + var result = getStringHook!.Original(textModule, text, decoder, stringStruct); + + // Post process: Direct string replacement for pronouns + if (stringStruct != null && stringStruct->BufUsed > 0 && shouldReplacePronouns) + { + var gameGeneratedText = stringStruct->ToString(); + + // Skip if post-processed text contains emote patterns + if (ContainsEmotePattern(gameGeneratedText)) + { + log.Info($"[EMOTE SKIP POST] Skipping emote in post-process: '{gameGeneratedText.Substring(0, Math.Min(50, gameGeneratedText.Length))}'"); + return result; + } + + // Safety net for post-processing + if (IsDefinitelyChat(gameGeneratedText)) + { + return result; + } + + // Only process readable text that looks like dialogue + if (!string.IsNullOrEmpty(gameGeneratedText) && + gameGeneratedText.Length > 15 && + !gameGeneratedText.Contains("0x") && + !gameGeneratedText.Contains("+") && + gameGeneratedText.Contains(" ") && + !IsUIElement(gameGeneratedText) && + !IsChat(gameGeneratedText)) + { + + + // Process pronouns + var processed = ProcessPronounsAndTitles(gameGeneratedText, pronounSet, activeCharacter); + + if (processed != gameGeneratedText) + { + log.Info($"[Dialogue] CHANGED: '{gameGeneratedText}' -> '{processed}'"); + SafeSetString(stringStruct, processed); + } + else if (gameGeneratedText.Contains("men") || gameGeneratedText.Contains("sir") || + gameGeneratedText.Contains("woman") || gameGeneratedText.Contains("master")) + { + } + } + } + + return result; + } + catch (Exception ex) + { + log.Error($"[Dialogue] Error in GetStringDetour: {ex.Message}"); + return getStringHook!.Original(textModule, text, decoder, stringStruct); + } + } + + // Process all pronouns using PronounSet + private string ProcessPronounsAndTitles(string text, PronounSet pronounSet, Character activeCharacter) + { + var processed = text; + if (Regex.IsMatch(text, @"\b[A-Z][a-z]{4,}\s+and\s+(his|her|their)\b")) + { + return processed; // Don't replace pronouns in NPC contexts + } + // Skip if text contains flag codes + if (text.Contains("+0%") || text.Contains("0x") || text.Length < 10) + return processed; + + // Skip if text contains emote patterns + if (ContainsEmotePattern(text)) + return processed; + + // Skip UI elements + if (IsUIElement(text)) + return processed; + + // Skip if this is clearly an NPC reference + if (IsNPCReference(text)) + { + return processed; + } + + // Get neutral title for replacements + var neutralTitle = "adventurer"; + if (plugin.Configuration.EnableAdvancedTitleReplacement) + { + neutralTitle = plugin.Configuration.GetGenderNeutralTitle().ToLower(); + } + var capitalizedNeutralTitle = char.ToUpper(neutralTitle[0]) + neutralTitle.Substring(1); + + + // Replace pronouns using PronounSet + var subjectLower = pronounSet.Subject.ToLower(); + var subjectCapital = char.ToUpper(pronounSet.Subject[0]) + pronounSet.Subject.Substring(1).ToLower(); + var possessiveLower = pronounSet.Possessive.ToLower(); + var possessiveCapital = char.ToUpper(pronounSet.Possessive[0]) + pronounSet.Possessive.Substring(1).ToLower(); + var objectLower = pronounSet.Object.ToLower(); + var objectCapital = char.ToUpper(pronounSet.Object[0]) + pronounSet.Object.Substring(1).ToLower(); + var reflexiveLower = pronounSet.Reflexive.ToLower(); + var reflexiveCapital = char.ToUpper(pronounSet.Reflexive[0]) + pronounSet.Reflexive.Substring(1).ToLower(); + + // Replace basic pronouns - only if ReplacePronounsInDialogue is enabled + if (plugin.Configuration.ReplacePronounsInDialogue) + { + processed = HeRegex.Replace(processed, subjectLower); + processed = HeCapitalRegex.Replace(processed, subjectCapital); + processed = SheRegex.Replace(processed, subjectLower); + processed = SheCapitalRegex.Replace(processed, subjectCapital); + + // Replace possessive pronouns + processed = HisRegex.Replace(processed, possessiveLower); + processed = HisCapitalRegex.Replace(processed, possessiveCapital); + + // Context-aware "her" replacement + processed = HerPossessiveRegex.Replace(processed, possessiveLower); + processed = HerPossessiveCapitalRegex.Replace(processed, possessiveCapital); + processed = HerPossessiveLowerRegex.Replace(processed, possessiveLower); + + // Object "her" -> object pronoun + processed = HerObjectRegex.Replace(processed, objectLower); + processed = HerObjectCapitalRegex.Replace(processed, objectCapital); + + // Object pronouns + processed = HimRegex.Replace(processed, objectLower); + processed = HimCapitalRegex.Replace(processed, objectCapital); + + // Reflexive pronouns + processed = HimselfRegex.Replace(processed, reflexiveLower); + processed = HimselfCapitalRegex.Replace(processed, reflexiveCapital); + processed = HerselfRegex.Replace(processed, reflexiveLower); + processed = HerselfCapitalRegex.Replace(processed, reflexiveCapital); + } + + // Title replacement + if (plugin.Configuration.ReplaceGenderedTerms) + { + // Handle gender selection patterns with separators + processed = Regex.Replace(processed, @"��woman�man", neutralTitle, RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"woman�man", neutralTitle, RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"��women�men", neutralTitle + "s", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"women�men", neutralTitle + "s", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"��madam�sir", neutralTitle, RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"madam�sir", neutralTitle, RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"��Mistress�Master", capitalizedNeutralTitle, RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"Mistress�Master", capitalizedNeutralTitle, RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"��Master�Mistress", capitalizedNeutralTitle, RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"Master�Mistress", capitalizedNeutralTitle, RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"��sir�madam", neutralTitle, RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"sir�madam", neutralTitle, RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"��man�woman", neutralTitle, RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"man�woman", neutralTitle, RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"��men�women", neutralTitle + "s", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"men�women", neutralTitle + "s", RegexOptions.IgnoreCase); + + // Handle player-specific patterns with context-aware replacement + processed = Regex.Replace(processed, @"\b(men|women|man|woman)\s+(like\s+you)\b", + $"{neutralTitle}s like you", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"\bgood\s+(man|woman|sir|madam|master|mistress|lady|lord)\b", + $"good {neutralTitle}", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"\bGood\s+(man|woman|sir|madam|master|mistress|lady|lord)\b", + $"Good {neutralTitle}"); + + // Handle individual words as before (user wants neutral terms) - only if not NPC reference + processed = SirRegex.Replace(processed, neutralTitle); + processed = SirCapitalRegex.Replace(processed, capitalizedNeutralTitle); + processed = MasterRegex.Replace(processed, neutralTitle); + processed = MasterCapitalRegex.Replace(processed, capitalizedNeutralTitle); + processed = MistressRegex.Replace(processed, neutralTitle); + processed = MistressCapitalRegex.Replace(processed, capitalizedNeutralTitle); + processed = MadamRegex.Replace(processed, neutralTitle); + processed = MadamCapitalRegex.Replace(processed, capitalizedNeutralTitle); + processed = DameRegex.Replace(processed, neutralTitle); + processed = DameCapitalRegex.Replace(processed, capitalizedNeutralTitle); + processed = LadyRegex.Replace(processed, neutralTitle); + processed = LadyCapitalRegex.Replace(processed, capitalizedNeutralTitle); + processed = BrotherRegex.Replace(processed, neutralTitle); + processed = BrotherCapitalRegex.Replace(processed, capitalizedNeutralTitle); + processed = SisterRegex.Replace(processed, neutralTitle); + processed = SisterCapitalRegex.Replace(processed, capitalizedNeutralTitle); + + // Replace gendered titles/nouns (only when neutral terms enabled) + processed = WomanRegex.Replace(processed, neutralTitle); + processed = WomanCapitalRegex.Replace(processed, capitalizedNeutralTitle); + processed = ManRegex.Replace(processed, neutralTitle); + processed = ManCapitalRegex.Replace(processed, capitalizedNeutralTitle); + processed = Regex.Replace(processed, @"\bmen\b", neutralTitle + "s", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"\bMen\b", capitalizedNeutralTitle + "s"); + processed = Regex.Replace(processed, @"\bwomen\b", neutralTitle + "s", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"\bWomen\b", capitalizedNeutralTitle + "s"); + processed = GirlRegex.Replace(processed, neutralTitle); + processed = GirlCapitalRegex.Replace(processed, capitalizedNeutralTitle); + processed = BoyRegex.Replace(processed, neutralTitle); + processed = BoyCapitalRegex.Replace(processed, capitalizedNeutralTitle); + processed = LassRegex.Replace(processed, neutralTitle); + processed = LassCapitalRegex.Replace(processed, capitalizedNeutralTitle); + processed = MaidenRegex.Replace(processed, neutralTitle); + processed = MaidenCapitalRegex.Replace(processed, capitalizedNeutralTitle); + } + else + { + // Natural gendered terms - let Lua hook do the work, with fallbacks + if (pronounSet.Subject.Equals("she", StringComparison.OrdinalIgnoreCase)) + { + // Handle gender selection patterns for she/her + processed = Regex.Replace(processed, @"��man�woman", "woman", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"man�woman", "woman", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"��woman�man", "woman", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"woman�man", "woman", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"��men�women", "women", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"men�women", "women", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"��women�men", "women", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"women�men", "women", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"��sir�madam", "madam", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"sir�madam", "madam", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"��madam�sir", "madam", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"madam�sir", "madam", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"��Master�Mistress", "Mistress", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"Master�Mistress", "Mistress", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"��Mistress�Master", "Mistress", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"Mistress�Master", "Mistress", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"��lass�lad", "lass", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"lass�lad", "lass", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"��lad�lass", "lass", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"lad�lass", "lass", RegexOptions.IgnoreCase); + // Handle player-specific patterns + processed = Regex.Replace(processed, @"\b(men|man)\s+(like\s+you)\b", "women like you", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"\bgood\s+(man|sir|master|lord)\b", "good woman", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"\bGood\s+(man|sir|master|lord)\b", "Good woman"); + + // Fallback individual word replacements if Lua hook failed + processed = Regex.Replace(processed, @"\bsir\b", "madam", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"\bSir\b", "Madam"); + processed = Regex.Replace(processed, @"\bbrother\b", "sister", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"\bBrother\b", "Sister"); + processed = Regex.Replace(processed, @"\bmaster\b", "mistress", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"\bMaster\b", "Mistress"); + processed = Regex.Replace(processed, @"\bmen\b", "women", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"\bMen\b", "Women"); + processed = Regex.Replace(processed, @"\bman\b", "woman", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"\bMan\b", "Woman"); + // Basic pronoun fixes + processed = Regex.Replace(processed, @"\bhe\b", "she", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"\bHe\b", "She"); + processed = Regex.Replace(processed, @"\bhim\b", "her", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"\bHim\b", "Her"); + processed = Regex.Replace(processed, @"\bhis\b", "her", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"\bHis\b", "Her"); + } + else if (pronounSet.Subject.Equals("he", StringComparison.OrdinalIgnoreCase)) + { + // Handle gender selection patterns for he/him + processed = Regex.Replace(processed, @"��woman�man", "man", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"woman�man", "man", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"��man�woman", "man", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"man�woman", "man", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"��women�men", "men", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"women�men", "men", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"��men�women", "men", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"men�women", "men", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"��madam�sir", "sir", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"madam�sir", "sir", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"��sir�madam", "sir", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"sir�madam", "sir", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"��Mistress�Master", "Master", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"Mistress�Master", "Master", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"��Master�Mistress", "Master", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"Master�Mistress", "Master", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"��lass�lad", "lad", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"lass�lad", "lad", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"��lad�lass", "lad", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"lad�lass", "lad", RegexOptions.IgnoreCase); + // Handle player-specific patterns + processed = Regex.Replace(processed, @"\b(women|woman)\s+(like\s+you)\b", "men like you", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"\bgood\s+(woman|madam|mistress|lady)\b", "good man", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"\bGood\s+(woman|madam|mistress|lady)\b", "Good man"); + + // Fallback individual word replacements if Lua hook failed + processed = Regex.Replace(processed, @"\bmadam\b", "sir", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"\bMadam\b", "Sir"); + processed = Regex.Replace(processed, @"\bsister\b", "brother", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"\bSister\b", "Brother"); + processed = Regex.Replace(processed, @"\bmistress\b", "master", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"\bMistress\b", "Master"); + processed = Regex.Replace(processed, @"\bwomen\b", "men", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"\bWomen\b", "Men"); + processed = Regex.Replace(processed, @"\bwoman\b", "man", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"\bWoman\b", "Man"); + // Basic pronoun fixes + processed = Regex.Replace(processed, @"\bshe\b", "he", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"\bShe\b", "He"); + processed = Regex.Replace(processed, @"\bher\b", "his", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"\bHer\b", "His"); + } + else if (pronounSet.Subject.Equals("they", StringComparison.OrdinalIgnoreCase)) + { + // Handle gender selection patterns for they/them (same as neutral) + processed = Regex.Replace(processed, @"��woman�man", neutralTitle, RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"woman�man", neutralTitle, RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"��women�men", neutralTitle + "s", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"women�men", neutralTitle + "s", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"��madam�sir", neutralTitle, RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"madam�sir", neutralTitle, RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"��sir�madam", neutralTitle, RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"sir�madam", neutralTitle, RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"��Mistress�Master", capitalizedNeutralTitle, RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"Mistress�Master", capitalizedNeutralTitle, RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"��Master�Mistress", capitalizedNeutralTitle, RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"Master�Mistress", capitalizedNeutralTitle, RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"��man�woman", neutralTitle, RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"man�woman", neutralTitle, RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"��men�women", neutralTitle + "s", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"men�women", neutralTitle + "s", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"��lass�lad", neutralTitle, RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"lass�lad", neutralTitle, RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"��lad�lass", neutralTitle, RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"lad�lass", neutralTitle, RegexOptions.IgnoreCase); + // Handle player-specific patterns for they/them + processed = Regex.Replace(processed, @"\b(men|women|man|woman)\s+(like\s+you)\b", + $"{neutralTitle}s like you", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"\bgood\s+(man|woman|sir|madam|master|mistress|lady|lord)\b", + $"good {neutralTitle}", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"\bGood\s+(man|woman|sir|madam|master|mistress|lady|lord)\b", + $"Good {neutralTitle}"); + + // Full neutral processing for they/them + processed = SirRegex.Replace(processed, neutralTitle); + processed = SirCapitalRegex.Replace(processed, capitalizedNeutralTitle); + processed = MasterRegex.Replace(processed, neutralTitle); + processed = MasterCapitalRegex.Replace(processed, capitalizedNeutralTitle); + processed = MistressRegex.Replace(processed, neutralTitle); + processed = MistressCapitalRegex.Replace(processed, capitalizedNeutralTitle); + processed = MadamRegex.Replace(processed, neutralTitle); + processed = MadamCapitalRegex.Replace(processed, capitalizedNeutralTitle); + processed = DameRegex.Replace(processed, neutralTitle); + processed = DameCapitalRegex.Replace(processed, capitalizedNeutralTitle); + processed = LadyRegex.Replace(processed, neutralTitle); + processed = LadyCapitalRegex.Replace(processed, capitalizedNeutralTitle); + processed = WomanRegex.Replace(processed, neutralTitle); + processed = WomanCapitalRegex.Replace(processed, capitalizedNeutralTitle); + processed = ManRegex.Replace(processed, neutralTitle); + processed = ManCapitalRegex.Replace(processed, capitalizedNeutralTitle); + + // Handle plurals for they/them + processed = Regex.Replace(processed, @"\bmen\b", neutralTitle + "s", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"\bMen\b", capitalizedNeutralTitle + "s"); + processed = Regex.Replace(processed, @"\bwomen\b", neutralTitle + "s", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"\bWomen\b", capitalizedNeutralTitle + "s"); + + // Fallback: Handle cases where Lua hook didn't work + processed = Regex.Replace(processed, @"\bvaliant men\b", $"valiant {neutralTitle}s", RegexOptions.IgnoreCase); + processed = Regex.Replace(processed, @"\bvaliant women\b", $"valiant {neutralTitle}s", RegexOptions.IgnoreCase); + } + } + // Apply verb conjugation fixes (only for they/them) + if (pronounSet.Subject.Equals("they", StringComparison.OrdinalIgnoreCase)) + { + foreach (var kvp in ConjugationPatterns) + { + processed = kvp.Key.Replace(processed, kvp.Value); + } + } + + return processed; + } + + // Lua variable detour for pronoun gender forcing + private byte GetLuaVarDetour(nint poolBase, IntPtr a2, IntPtr a3) + { + try + { + var activeCharacter = plugin.GetActiveCharacter(); + if (activeCharacter?.RPProfile?.Pronouns != null && plugin.Configuration.EnableDialogueIntegration) + { + var pronounSet = PronounParser.Parse(activeCharacter.RPProfile.Pronouns); + var oldGender = GetLuaVarGender(poolBase); + int newGender = oldGender; + + // Force correct gender variant based on pronouns + if (pronounSet.Subject.Equals("she", StringComparison.OrdinalIgnoreCase)) + { + newGender = 1; // Force female variants ("women", "she") + } + else if (pronounSet.Subject.Equals("he", StringComparison.OrdinalIgnoreCase)) + { + newGender = 0; // Force male variants ("men", "he") + } + else if (pronounSet.Subject.Equals("they", StringComparison.OrdinalIgnoreCase)) + { + newGender = 1; // Force female for they/them (then post-process) + } + + if (newGender != oldGender) + { + SetLuaVarGender(poolBase, newGender); + var returnValue = getLuaVarHook!.Original(poolBase, a2, a3); + SetLuaVarGender(poolBase, oldGender); + return returnValue; + } + } + + return getLuaVarHook!.Original(poolBase, a2, a3); + } + catch (Exception ex) + { + log.Error($"[Dialogue] Error in GetLuaVarDetour: {ex.Message}"); + return getLuaVarHook!.Original(poolBase, a2, a3); + } + } + + // Helper methods for Lua gender manipulation + private int GetLuaVarGender(nint poolBase) + { + var genderVarId = 0x1B; + return *(int*)(poolBase + 4 * genderVarId); + } + + private void SetLuaVarGender(nint poolBase, int gender) + { + var genderVarId = 0x1B; + *(int*)(poolBase + 4 * genderVarId) = gender; + } + + // Name replacement using byte patterns (exactly like PrefPro, the best there ever was) + private void HandleNameReplacement(ref byte* text, Character character) + { + try + { + var playerName = clientState.LocalPlayer?.Name.TextValue; + if (string.IsNullOrEmpty(playerName)) return; + + var textSpan = MemoryMarshal.CreateReadOnlySpanFromNullTerminated(text); + if (!textSpan.Contains((byte)0x02)) return; + + var seString = Dalamud.Game.Text.SeStringHandling.SeString.Parse(text, textSpan.Length); + var payloads = seString.Payloads; + bool replaced = false; + + var csCharacterName = character.Name; + var nameParts = csCharacterName.Split(' '); + var csFirstName = nameParts[0]; + var csLastName = nameParts.Length > 1 ? nameParts[1] : ""; + + for (int i = 0; i < payloads.Count; i++) + { + var payload = payloads[i]; + if (payload.Type == Dalamud.Game.Text.SeStringHandling.PayloadType.Unknown) + { + var payloadBytes = payload.Encode(); + var payloadHex = Convert.ToHexString(payloadBytes); + + if (payloadHex.Contains(Convert.ToHexString(FullNameBytes))) + { + payloads[i] = new Dalamud.Game.Text.SeStringHandling.Payloads.TextPayload(csCharacterName); + replaced = true; + } + else if (payloadHex.Contains(Convert.ToHexString(FirstNameBytes))) + { + payloads[i] = new Dalamud.Game.Text.SeStringHandling.Payloads.TextPayload(csFirstName); + replaced = true; + } + else if (payloadHex.Contains(Convert.ToHexString(LastNameBytes)) && !string.IsNullOrEmpty(csLastName)) + { + payloads[i] = new Dalamud.Game.Text.SeStringHandling.Payloads.TextPayload(csLastName); + replaced = true; + } + else + { + + } + } + } + + if (!replaced) return; + + var newBytes = seString.EncodeWithNullTerminator(); + var originalLength = textSpan.Length + 1; + + if (newBytes.Length <= originalLength) + newBytes.CopyTo(new Span(text, originalLength)); + else + { + var newText = (byte*)Marshal.AllocHGlobal(newBytes.Length); + newBytes.CopyTo(new Span(newText, newBytes.Length)); + text = newText; + } + } + catch (Exception ex) + { + log.Warning($"[Dialogue] Name replacement failed: {ex.Message}"); + } + } + + private void SafeSetString(Utf8String* stringStruct, string newText) + { + try + { + if (stringStruct != null && !string.IsNullOrEmpty(newText)) + { + stringStruct->SetString(newText); + } + } + catch (Exception ex) + { + log.Error($"[Dialogue] Failed to set string: {ex.Message}"); + } + } + + private static bool ByteArrayEquals(ReadOnlySpan a1, ReadOnlySpan a2) + { + return a1.SequenceEqual(a2); + } + + public void Dispose() + { + getStringHook?.Disable(); + getStringHook?.Dispose(); + getLuaVarHook?.Disable(); + getLuaVarHook?.Dispose(); + } + } +} diff --git a/CharacterSelectPlugin/NuGet.config b/CharacterSelectPlugin/NuGet.config new file mode 100644 index 0000000..ae18729 --- /dev/null +++ b/CharacterSelectPlugin/NuGet.config @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/CharacterSelectPlugin/PersistentPoseRestorer.cs b/CharacterSelectPlugin/PersistentPoseRestorer.cs index f13f32b..92b3a21 100644 --- a/CharacterSelectPlugin/PersistentPoseRestorer.cs +++ b/CharacterSelectPlugin/PersistentPoseRestorer.cs @@ -1,89 +1,49 @@ -// CharacterSelectPlugin/Managers/PoseRestorer.cs +using CharacterSelectPlugin.Managers; +using CharacterSelectPlugin; using Dalamud.Plugin.Services; using FFXIVClientStructs.FFXIV.Client.Game.Control; -using FFXIVClientStructs.FFXIV.Client.Game.UI; using System; -namespace CharacterSelectPlugin.Managers; - -public unsafe class PoseRestorer +public unsafe class SimplifiedPoseRestorer { private readonly IClientState clientState; - private readonly Plugin plugin; + private readonly ImprovedPoseManager poseManager; - public PoseRestorer(IClientState clientState, Plugin plugin) + public SimplifiedPoseRestorer(IClientState clientState, ImprovedPoseManager poseManager) { this.clientState = clientState; - this.plugin = plugin; + this.poseManager = poseManager; } public void RestorePosesFor(Character character) { - if (clientState.LocalPlayer == null) return; - + if (clientState.LocalPlayer == null) + return; Plugin.Framework.RunOnTick(() => { - ApplyPose(character); - }); + ApplyCharacterPoses(character); + }, delayTicks: 30); } - private void ApplyPose(Character character) + private void ApplyCharacterPoses(Character character) { - var local = clientState.LocalPlayer; - if (local == null || local.Address == IntPtr.Zero) + if (clientState.LocalPlayer?.Address == IntPtr.Zero) return; - var charPtr = (FFXIVClientStructs.FFXIV.Client.Game.Character.Character*)local.Address; - - // This also ensures you're not in cutscene or a bad player state - if (charPtr->GameObject.ObjectIndex == 0xFFFF) - return; - - TrySetPose(EmoteController.PoseType.Idle, character.IdlePoseIndex, charPtr); - TrySetPose(EmoteController.PoseType.Sit, character.SitPoseIndex, charPtr); - TrySetPose(EmoteController.PoseType.GroundSit, character.GroundSitPoseIndex, charPtr); - TrySetPose(EmoteController.PoseType.Doze, character.DozePoseIndex, charPtr); - } - - private void TrySetPose(EmoteController.PoseType type, byte desired, FFXIVClientStructs.FFXIV.Client.Game.Character.Character* charPtr) - { - if (desired >= 254) return; - - byte current = PlayerState.Instance()->SelectedPoses[(int)type]; - if (current == desired) return; - - PlayerState.Instance()->SelectedPoses[(int)type] = desired; - - switch (type) + var poses = new[] { - case EmoteController.PoseType.Idle: - plugin.Configuration.LastIdlePoseAppliedByPlugin = desired; - break; - case EmoteController.PoseType.Sit: - plugin.Configuration.LastSitPoseAppliedByPlugin = desired; - break; - case EmoteController.PoseType.GroundSit: - plugin.Configuration.LastGroundSitPoseAppliedByPlugin = desired; - break; - case EmoteController.PoseType.Doze: - plugin.Configuration.LastDozePoseAppliedByPlugin = desired; - break; - } - - plugin.Configuration.Save(); - - if (TranslatePoseState(charPtr->ModeParam) == type) - charPtr->EmoteController.CPoseState = desired; - } - - private EmoteController.PoseType TranslatePoseState(byte state) - { - return state switch - { - 1 => EmoteController.PoseType.GroundSit, - 2 => EmoteController.PoseType.Sit, - 3 => EmoteController.PoseType.Doze, - _ => EmoteController.PoseType.Idle + (EmoteController.PoseType.Idle, character.IdlePoseIndex), + (EmoteController.PoseType.Sit, character.SitPoseIndex), + (EmoteController.PoseType.GroundSit, character.GroundSitPoseIndex), + (EmoteController.PoseType.Doze, character.DozePoseIndex) }; + + foreach (var (type, index) in poses) + { + if (index < 7) + { + poseManager.ApplyPose(type, index); + } + } } } diff --git a/CharacterSelectPlugin/Plugin.cs b/CharacterSelectPlugin/Plugin.cs index 6a4a2ab..39ee7a7 100644 --- a/CharacterSelectPlugin/Plugin.cs +++ b/CharacterSelectPlugin/Plugin.cs @@ -20,7 +20,10 @@ using System.Text; using Newtonsoft.Json; using Dalamud.Game.ClientState.Objects; using static FFXIVClientStructs.FFXIV.Client.Game.Control.EmoteController; -using static Lumina.Data.Parsing.Layer.LayerCommon; +using FFXIVClientStructs.FFXIV.Client.UI.Misc; +using FFXIVClientStructs.FFXIV.Client.UI.Shell; +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Game; namespace CharacterSelectPlugin { @@ -32,29 +35,31 @@ namespace CharacterSelectPlugin [PluginService] internal static IClientState ClientState { get; private set; } = null!; [PluginService] internal static IDataManager DataManager { get; private set; } = null!; [PluginService] internal static IPluginLog Log { get; private set; } = null!; - [PluginService] private static IChatGui ChatGui { get; set; } = null!; + [PluginService] internal static IChatGui ChatGui { get; set; } = null!; [PluginService] internal static IFramework Framework { get; private set; } = null!; [PluginService] internal static IContextMenu ContextMenu { get; private set; } = null!; [PluginService] internal static IObjectTable ObjectTable { get; private set; } = null!; [PluginService] internal static ITargetManager TargetManager { get; private set; } = null!; + [PluginService] internal static IGameInteropProvider GameInteropProvider { get; private set; } = null!; + [PluginService] internal static ISigScanner SigScanner { get; private set; } = null!; + [PluginService] internal static ICondition Condition { get; private set; } = null!; - - public static readonly string CurrentPluginVersion = "1.1.1.3"; // Match repo.json and .csproj version + public static readonly string CurrentPluginVersion = "2.0.0.3"; // Match repo.json and .csproj version private const string CommandName = "/select"; public Configuration Configuration { get; init; } public readonly WindowSystem WindowSystem = new("CharacterSelectPlugin"); - private MainWindow MainWindow { get; init; } + public MainWindow MainWindow { get; init; } public QuickSwitchWindow QuickSwitchWindow { get; set; } // Quick Switch Window public PatchNotesWindow PatchNotesWindow { get; private set; } = null!; public RPProfileWindow RPProfileEditor { get; private set; } public RPProfileViewWindow RPProfileViewer { get; private set; } + public GalleryWindow GalleryWindow { get; private set; } = null!; + public TutorialManager TutorialManager { get; private set; } = null!; + public enum SortType { Manual, Favorites, Alphabetical, Recent, Oldest } - - - // Character data storage public List Characters => Configuration.Characters; public Vector3 NewCharacterColor { get; set; } = new Vector3(1.0f, 1.0f, 1.0f); // Default to white @@ -74,11 +79,12 @@ namespace CharacterSelectPlugin public Vector3 NewCharacterHonorificColor { get; set; } = new Vector3(1.0f, 1.0f, 1.0f); public Vector3 NewCharacterHonorificGlow { get; set; } = new Vector3(0.0f, 0.0f, 0.0f); // Default to no glow public string NewCharacterMoodlePreset { get; set; } = ""; - public PoseManager PoseManager { get; private set; } = null!; + public ImprovedPoseManager PoseManager { get; private set; } = null!; public byte NewCharacterIdlePoseIndex { get; set; } = 0; - public PoseRestorer PoseRestorer { get; private set; } = null!; + public SimplifiedPoseRestorer PoseRestorer { get; private set; } = null!; private bool shouldApplyPoses = false; private DateTime loginTime; + private uint CurrentJobId; private ICallGateSubscriber? requestProfile; private ICallGateProvider? provideProfile; @@ -88,7 +94,6 @@ namespace CharacterSelectPlugin public List KnownTags => Configuration.KnownTags; public string NewCharacterAutomation { get; set; } = ""; private int framesSinceLogin = 0; - private uint CurrentJobId; internal byte lastPoseAppliedByPlugin = 255; internal byte lastIdlePoseForcedByPlugin = 255; internal bool shouldReapplyPoseForLogin = false; @@ -112,10 +117,14 @@ namespace CharacterSelectPlugin private string? _pendingSessionCharacterName = null; private float secondsSinceLogin = 0; private bool isLoginComplete = false; + public bool IsSecretMode { get; set; } = false; private Character? activeCharacter = null!; + private string lastExecutedGearsetCommand = ""; + private DateTime lastGearsetCommandTime = DateTime.MinValue; + private readonly Dictionary lastAppliedByJob = new(); public void RefreshTreeItems(Character character) { - // Intentionally empty — DrawDesignPanel rebuilds its own list each frame. + } public bool IsAddCharacterWindowOpen { get; set; } = false; @@ -125,16 +134,87 @@ namespace CharacterSelectPlugin public int ProfileColumns { get; set; } = 3; // Number of profiles per row public float ProfileSpacing { get; set; } = 10.0f; // Default spacing + // Tutorial + public Vector2? MainWindowPos { get; set; } + public Vector2? MainWindowSize { get; set; } + public Vector2? AddCharacterButtonPos { get; set; } + public Vector2? AddCharacterButtonSize { get; set; } + public Vector2? CharacterNameFieldPos { get; set; } + public Vector2? CharacterNameFieldSize { get; set; } + public Vector2? PenumbraFieldPos { get; set; } + public Vector2? PenumbraFieldSize { get; set; } + public Vector2? GlamourerFieldPos { get; set; } + public Vector2? GlamourerFieldSize { get; set; } + public Vector2? SaveButtonPos { get; set; } + public Vector2? SaveButtonSize { get; set; } + public Vector2? FirstCharacterDesignsButtonPos { get; set; } + public Vector2? FirstCharacterDesignsButtonSize { get; set; } + public Vector2? DesignPanelAddButtonPos { get; set; } + public Vector2? DesignPanelAddButtonSize { get; set; } + public Vector2? DesignNameFieldPos { get; set; } + public Vector2? DesignNameFieldSize { get; set; } + public Vector2? DesignGlamourerFieldPos { get; set; } + public Vector2? DesignGlamourerFieldSize { get; set; } + public Vector2? SaveDesignButtonPos { get; set; } + public Vector2? SaveDesignButtonSize { get; set; } + public bool IsDesignPanelOpen { get; set; } = false; + public bool IsEditDesignWindowOpen { get; set; } = false; + public string EditedDesignName { get; set; } = ""; + public string EditedGlamourerDesign { get; set; } = ""; + public Vector2? RPProfileButtonPos { get; set; } + public Vector2? RPProfileButtonSize { get; set; } + public Vector2? EditProfileButtonPos { get; set; } + public Vector2? EditProfileButtonSize { get; set; } + public bool IsRPProfileViewerOpen { get; set; } = false; + public bool IsRPProfileEditorOpen { get; set; } = false; + public Vector2? RPBioFieldPos { get; set; } + public Vector2? RPBioFieldSize { get; set; } + public Vector2? RPSharingDropdownPos { get; set; } + public Vector2? RPSharingDropdownSize { get; set; } + public Vector2? SaveRPProfileButtonPos { get; set; } + public Vector2? SaveRPProfileButtonSize { get; set; } + public Vector2? RPPronounsFieldPos { get; set; } + public Vector2? RPPronounsFieldSize { get; set; } + public Vector2? RPProfileViewWindowPos { get; set; } + public Vector2? RPProfileViewWindowSize { get; set; } + public Vector2? RPProfileEditorWindowPos { get; set; } + public Vector2? RPProfileEditorWindowSize { get; set; } + public Vector2? RPBackgroundDropdownPos { get; set; } + public Vector2? RPBackgroundDropdownSize { get; set; } + public Vector2? RPVisualEffectsPos { get; set; } + public Vector2? RPVisualEffectsSize { get; set; } + public Vector2? SettingsButtonPos { get; set; } + public Vector2? SettingsButtonSize { get; set; } + public Vector2? QuickSwitchButtonPos { get; set; } + public Vector2? QuickSwitchButtonSize { get; set; } + public Vector2? GalleryButtonPos { get; set; } + public Vector2? GalleryButtonSize { get; set; } + private NPCDialogueProcessor? dialogueProcessor; + public bool NewCharacterIsAdvancedMode { get; set; } = false; - public unsafe Plugin() + public unsafe Plugin(IGameInteropProvider gameInteropProvider) { - Configuration = Configuration.Load(PluginInterface); + try + { + GameInteropProvider = gameInteropProvider; + var existingConfig = PluginInterface.GetPluginConfig() as Configuration; + if (existingConfig != null) + { + BackupManager.CreateBackupIfNeeded(existingConfig, CurrentPluginVersion); + } + } + catch (Exception ex) + { + Plugin.Log.Warning($"[Backup] Could not create pre-load backup: {ex.Message}"); + } + + // Load configuration + Configuration = LoadConfigurationSafely(); EnsureConfigurationDefaults(); Configuration = Configuration.Load(PluginInterface); EnsureConfigurationDefaults(); - - // Patch macros only after loading config + setting defaults + // Patch macros only after loading config + setting foreach (var character in Configuration.Characters) { var newMacro = SanitizeMacro(character.Macros, character); @@ -152,7 +232,7 @@ namespace CharacterSelectPlugin if (string.IsNullOrWhiteSpace(macroToPatch)) continue; - string updated = SanitizeDesignMacro(macroToPatch, design, character, Configuration.EnableAutomations); // <-- pass character too + string updated = SanitizeDesignMacro(macroToPatch, design, character, Configuration.EnableAutomations); if (updated != macroToPatch) { @@ -163,22 +243,8 @@ namespace CharacterSelectPlugin } } } - // SortOrder fallback - if (Configuration.CurrentSortIndex == (int)MainWindow.SortType.Manual) - { - for (int i = 0; i < Configuration.Characters.Count; i++) - { - if (Configuration.Characters[i].SortOrder == 0 && i != 0) - { - Configuration.Characters[i].SortOrder = i; - } - } - } - - // Optionally save once Configuration.Save(); - try { var assembly = System.Reflection.Assembly.Load("System.Windows.Forms"); @@ -192,11 +258,11 @@ namespace CharacterSelectPlugin Plugin.Log.Error($"❌ Failed to load System.Windows.Forms: {ex.Message}"); } - PoseManager = new PoseManager(ClientState, Framework, ChatGui, CommandManager, this); - PoseRestorer = new PoseRestorer(ClientState, this); + PoseManager = new ImprovedPoseManager(ClientState, Framework, this); + PoseRestorer = new SimplifiedPoseRestorer(ClientState, PoseManager); - // Initialize the MainWindow and ConfigWindow properly - MainWindow = new MainWindow(this); + // Initialize the MainWindow and ConfigWindow + MainWindow = new Windows.MainWindow(this); MainWindow.SortCharacters(); QuickSwitchWindow = new QuickSwitchWindow(this); // Quick Switch Window QuickSwitchWindow.IsOpen = Configuration.IsQuickSwitchWindowOpen; // Restore last open state @@ -207,18 +273,32 @@ namespace CharacterSelectPlugin RPProfileViewer = new RPProfileViewWindow(this); WindowSystem.AddWindow(RPProfileViewer); - // This player REGISTERING their profile, if someone else requests it + GalleryWindow = new GalleryWindow(this); + WindowSystem.AddWindow(GalleryWindow); + TutorialManager = new TutorialManager(this); + MigrateBackgroundImageNames(); + + // This player registering their profile, if someone else requests it provideProfile = PluginInterface.GetIpcProvider("CharacterSelect.RPProfile.Provide"); provideProfile.RegisterFunc(HandleProfileRequest); - // This player SENDING a request to another + // This player sending a request to another requestProfile = PluginInterface.GetIpcSubscriber("CharacterSelect.RPProfile.Provide"); // Patch Notes PatchNotesWindow = new PatchNotesWindow(this); if (Configuration.LastSeenVersion != CurrentPluginVersion) + { + PatchNotesWindow.OpenMainMenuOnClose = true; PatchNotesWindow.IsOpen = true; - //PatchNotesWindow.IsOpen = true; + } + // PatchNotesWindow.IsOpen = true; + // Tutorial system - show on first launch + + //if (!Configuration.HasSeenTutorial && Configuration.ShowTutorialOnStartup) + //{ + // TutorialManager.StartTutorial(); + //} WindowSystem.AddWindow(MainWindow); WindowSystem.AddWindow(QuickSwitchWindow); // Quick Switch Window @@ -234,6 +314,11 @@ namespace CharacterSelectPlugin HelpMessage = "Opens the Quick Character Switcher UI." }); + CommandManager.AddHandler("/gallery", new CommandInfo(OnGalleryCommand) + { + HelpMessage = "Opens the Character Showcase Gallery" + }); + PluginInterface.UiBuilder.Draw += DrawUI; PluginInterface.UiBuilder.OpenConfigUi += ToggleQuickSwitchUI; @@ -266,7 +351,7 @@ namespace CharacterSelectPlugin CommandManager.AddHandler("/select", new CommandInfo(OnSelectCommand) { - HelpMessage = "Use /select to apply a profile." + HelpMessage = "Use /select [Design Name] to apply a profile, or /select random for random selection." }); // Idles CommandManager.AddHandler("/sidle", new CommandInfo((_, args) => @@ -344,8 +429,32 @@ namespace CharacterSelectPlugin contextMenuManager = new ContextMenuManager(this, Plugin.ContextMenu); this.CleanupUnusedProfileImages(); + try + { + dialogueProcessor = new NPCDialogueProcessor( + this, + SigScanner, + GameInteropProvider, + ChatGui, + ClientState, + Log, + Condition + ); + } + catch (Exception ex) + { + Log.Error($"Failed to initialize dialogue processor: {ex.Message}"); + } } + public static HttpClient CreateAuthenticatedHttpClient() + { + var client = new HttpClient(); + client.DefaultRequestHeaders.Add("X-Plugin-Auth", "cs-plus-gallery-client"); + client.DefaultRequestHeaders.Add("User-Agent", "CharacterSelectPlus/1.2.0"); + client.Timeout = TimeSpan.FromSeconds(15); + return client; + } private void OnLogin() { @@ -359,6 +468,14 @@ namespace CharacterSelectPlugin shouldApplyPoses = true; suppressIdleSaveForFrames = 60; secondsSinceLogin = 0f; + + var id = ClientState.LocalPlayer.ClassJob.RowId; + if (Configuration.LastKnownJobId == 0 && id != 0) + { + Configuration.LastKnownJobId = id; + Configuration.Save(); + Plugin.Log.Debug($"[JobSwitch] Primed LastKnownJobId on login = {id}"); + } } @@ -414,7 +531,6 @@ namespace CharacterSelectPlugin }; // Force CPoseState to match current selected pose - // (only if plugin isn’t trying to override it) if (currentSelected < 7) character->EmoteController.CPoseState = currentSelected; } @@ -423,10 +539,22 @@ namespace CharacterSelectPlugin { QuickSwitchWindow.IsOpen = !QuickSwitchWindow.IsOpen; // Toggle Window On/Off } + private void OnGalleryCommand(string command, string args) + { + // Emergency stop if costs are too high, I am broke! + if (args.Equals("stop", StringComparison.OrdinalIgnoreCase)) + { + GalleryWindow.EmergencyStop(); + ChatGui.Print("[Character Select+] Gallery emergency stop activated!"); + return; + } + + GalleryWindow.IsOpen = !GalleryWindow.IsOpen; + } public void ApplyProfile(Character character, int designIndex) { activeCharacter = character; - // ABSOLUTE FAIL-SAFE: Do nothing if world isn’t ready + // Do nothing if world isn't ready, honestly I don't know if it will ever truly be ready for...you! You special thing. if (!ClientState.IsLoggedIn || ClientState.TerritoryType == 0 || ClientState.LocalPlayer == null || @@ -436,6 +564,7 @@ namespace CharacterSelectPlugin Plugin.Log.Debug("[ApplyProfile] Skipped: Player not fully loaded."); return; } + if (ClientState.LocalPlayer is { } player && player.HomeWorld.IsValid) { string localName = player.Name.TextValue; @@ -452,8 +581,8 @@ namespace CharacterSelectPlugin foreach (var oldKey in toRemove) ActiveProfilesByPlayerName.Remove(oldKey); - // Register the new key - active character mapping - ActiveProfilesByPlayerName[fullKey] = newProfileKey; + // Register key + ActiveProfilesByPlayerName[fullKey] = character.Name; string pluginCharacterKey = $"{character.Name}@{worldName}"; // plugin character identity character.LastInGameName = $"{localName}@{worldName}"; // who is currently logged in @@ -464,32 +593,50 @@ namespace CharacterSelectPlugin Plugin.Log.Debug($"[SetActiveCharacter] Updated LastUsedCharacterKey = {fullKey}"); Plugin.Log.Debug($"[ApplyProfile] Set LastInGameName = {character.LastInGameName} for profile {character.Name}"); + bool shouldUploadToGallery = ShouldUploadToGallery(character, fullKey); - Plugin.Log.Debug($"[ApplyProfile] Set LastInGameName = {fullKey} for profile {character.Name}"); - var profileToSend = new RPProfile + if (shouldUploadToGallery) { - Pronouns = character.RPProfile?.Pronouns, - Gender = character.RPProfile?.Gender, - Age = character.RPProfile?.Age, - Race = character.RPProfile?.Race, - Orientation = character.RPProfile?.Orientation, - Relationship = character.RPProfile?.Relationship, - Occupation = character.RPProfile?.Occupation, - Abilities = character.RPProfile?.Abilities, - Bio = character.RPProfile?.Bio, - Tags = character.RPProfile?.Tags, - CustomImagePath = !string.IsNullOrEmpty(character.RPProfile?.CustomImagePath) - ? character.RPProfile.CustomImagePath - : character.ImagePath, - ImageZoom = character.RPProfile?.ImageZoom ?? 1.0f, - ImageOffset = character.RPProfile?.ImageOffset ?? Vector2.Zero, - Sharing = character.RPProfile?.Sharing ?? ProfileSharing.AlwaysShare, - ProfileImageUrl = character.RPProfile?.ProfileImageUrl, - CharacterName = character.Name, // force correct name - NameplateColor = character.NameplateColor // force correct colour - }; + var profileToSend = new RPProfile + { + Pronouns = character.RPProfile?.Pronouns, + Gender = character.RPProfile?.Gender, + Age = character.RPProfile?.Age, + Race = character.RPProfile?.Race, + Orientation = character.RPProfile?.Orientation, + Relationship = character.RPProfile?.Relationship, + Occupation = character.RPProfile?.Occupation, + Abilities = character.RPProfile?.Abilities, + Bio = character.RPProfile?.Bio, + Tags = character.RPProfile?.Tags, + CustomImagePath = !string.IsNullOrEmpty(character.RPProfile?.CustomImagePath) + ? character.RPProfile.CustomImagePath + : character.ImagePath, + ImageZoom = character.RPProfile?.ImageZoom ?? 1.0f, + ImageOffset = character.RPProfile?.ImageOffset ?? Vector2.Zero, + Sharing = character.RPProfile?.Sharing ?? ProfileSharing.AlwaysShare, + ProfileImageUrl = character.RPProfile?.ProfileImageUrl, + CharacterName = character.Name, // force correct name + NameplateColor = character.RPProfile?.ProfileColor ?? character.NameplateColor, // force correct colour + GalleryStatus = character.GalleryStatus, + Links = character.RPProfile?.Links, - _ = Plugin.UploadProfileAsync(profileToSend, character.LastInGameName ?? character.Name); + // Include background and effects data + BackgroundImage = character.RPProfile?.BackgroundImage ?? character.BackgroundImage, + Effects = character.RPProfile?.Effects ?? character.Effects ?? new ProfileEffects(), + + // Include animation theme for backwards compatibility + AnimationTheme = character.RPProfile?.AnimationTheme ?? ProfileAnimationTheme.CircuitBoard, + LastActiveTime = Configuration.ShowRecentlyActiveStatus ? DateTime.UtcNow : null + }; + + _ = Plugin.UploadProfileAsync(profileToSend, character.LastInGameName ?? character.Name); + Plugin.Log.Info($"[ApplyProfile] ✓ Uploaded profile for {character.Name} (main character: {fullKey})"); + } + else + { + Plugin.Log.Info($"[ApplyProfile] ⚠ Skipped gallery upload for {character.Name} (not on main character or not public)"); + } } SaveConfiguration(); if (character == null) return; @@ -503,7 +650,7 @@ namespace CharacterSelectPlugin ExecuteMacro(character.Designs[designIndex].Macro); } - // Only apply idle pose if it's NOT "None" + // Only apply idle pose if it's not "None" if (isLoginComplete) { // Apply poses immediately @@ -525,9 +672,39 @@ namespace CharacterSelectPlugin activeCharacter = character; shouldApplyPoses = true; } + this.QuickSwitchWindow.UpdateSelectionFromCharacter(character); SaveConfiguration(); - } + + private bool ShouldUploadToGallery(Character character, string currentPhysicalCharacter) + { + // Is there a main character set? + var userMain = Configuration.GalleryMainCharacter; + if (string.IsNullOrEmpty(userMain)) + { + Plugin.Log.Debug($"[ShouldUpload] No main character set - not uploading {character.Name}"); + return false; + } + + // Are we currently on the main character? + if (currentPhysicalCharacter != userMain) + { + Plugin.Log.Debug($"[ShouldUpload] Current character '{currentPhysicalCharacter}' != main '{userMain}' - not uploading {character.Name}"); + return false; + } + + // Is this CS+ character set to public sharing? + var sharing = character.RPProfile?.Sharing ?? ProfileSharing.AlwaysShare; + if (sharing != ProfileSharing.ShowcasePublic) + { + Plugin.Log.Debug($"[ShouldUpload] Character '{character.Name}' sharing is '{sharing}' (not public) - not uploading"); + return false; + } + + Plugin.Log.Debug($"[ShouldUpload] ✓ All checks passed - will upload {character.Name} as {currentPhysicalCharacter}"); + return true; + } + private void EnsureConfigurationDefaults() { bool updated = false; @@ -583,9 +760,6 @@ namespace CharacterSelectPlugin ProfileSpacing = 10.0f; // Default value if missing updated = true; } - - - // Only save if anything was updated if (updated) Configuration.Save(); } @@ -597,8 +771,11 @@ namespace CharacterSelectPlugin MainWindow.Dispose(); CommandManager.RemoveHandler(CommandName); CommandManager.RemoveHandler("/spose"); + CommandManager.RemoveHandler("/gallery"); contextMenuManager?.Dispose(); Framework.Update += FrameworkUpdate; + PoseManager?.Dispose(); + dialogueProcessor?.Dispose(); try { string sessionFilePath = Path.Combine(PluginInterface.GetPluginConfigDirectory(), "boot_session.txt"); @@ -618,12 +795,10 @@ namespace CharacterSelectPlugin { if (string.IsNullOrWhiteSpace(args)) { - // Open the plugin UI ToggleMainUI(); } else { - // Argument given - Try to apply a character profile OnSelectCommand(command, args); } } @@ -632,11 +807,18 @@ namespace CharacterSelectPlugin { if (string.IsNullOrWhiteSpace(args)) { - ChatGui.PrintError("[Character Select+] Usage: /select [Optional Design Name]"); + ChatGui.PrintError("[Character Select+] Usage: /select [Optional Design Name] or /select random"); return; } - // Match either "quoted strings" or bare words + // Handle random selection + if (args.Trim().Equals("random", StringComparison.OrdinalIgnoreCase)) + { + SelectRandomCharacterAndDesign(); + return; + } + + // Rest of the existing method remains the same... var matches = Regex.Matches(args, "\"([^\"]+)\"|\\S+") .Cast() .Select(m => m.Groups[1].Success ? m.Groups[1].Value : m.Value) @@ -644,7 +826,7 @@ namespace CharacterSelectPlugin if (matches.Length < 1) { - ChatGui.PrintError("[Character Select+] Invalid usage. Use /select [Optional Design Name]"); + ChatGui.PrintError("[Character Select+] Invalid usage. Use /select [Optional Design Name] or /select random"); return; } @@ -652,7 +834,7 @@ namespace CharacterSelectPlugin string? designName = matches.Length > 1 ? string.Join(" ", matches.Skip(1)) : null; var character = Characters.FirstOrDefault(c => - c.Name.Equals(characterName, StringComparison.OrdinalIgnoreCase)); + c.Name.Equals(characterName, StringComparison.OrdinalIgnoreCase)); if (character == null) { @@ -662,8 +844,7 @@ namespace CharacterSelectPlugin if (string.IsNullOrWhiteSpace(designName)) { - ChatGui.Print($"[Character Select+] Applying profile: {character.Name}"); - ApplyProfile(character, -1); // -1 skips design + ApplyProfile(character, -1); } else { @@ -685,6 +866,7 @@ namespace CharacterSelectPlugin private void DrawUI() { WindowSystem.Draw(); + TutorialManager.DrawTutorialOverlay(); // Track and persist Quick Switch window state bool currentState = QuickSwitchWindow.IsOpen; @@ -729,10 +911,11 @@ namespace CharacterSelectPlugin NewCharacterAutomation // Glamourer Automations ) { - IdlePoseIndex = NewCharacterIdlePoseIndex, // IdLES + IdlePoseIndex = NewCharacterIdlePoseIndex, + IsAdvancedMode = NewCharacterIsAdvancedMode, // Use the plugin property Tags = string.IsNullOrWhiteSpace(NewCharacterTag) - ? new List() - : NewCharacterTag.Split(',').Select(f => f.Trim()).ToList() + ? new List() + : NewCharacterTag.Split(',').Select(f => f.Trim()).ToList() }; // Auto-create a Design based on Glamourer Design if available @@ -761,9 +944,9 @@ namespace CharacterSelectPlugin Configuration.Characters.Add(newCharacter); SaveConfiguration(); - // Reset Fields AFTER Saving + // Reset Fields after Saving NewCharacterName = ""; - NewCharacterMacros = ""; // But don't wipe macros too early! + NewCharacterMacros = ""; NewCharacterImagePath = null; NewCharacterDesigns.Clear(); NewPenumbraCollection = ""; @@ -793,15 +976,110 @@ namespace CharacterSelectPlugin ExecuteMacro(macroText, null, null); } public void ExecuteMacro(string macroText, Character? character, string? designName) + { + ExecuteMacro(macroText, character, designName, false); + } + + public unsafe void ExecuteMacro(string macroText, Character? character, string? designName, bool filterJobChanges = true) { if (string.IsNullOrWhiteSpace(macroText)) return; + // Always filter job changes by default + if (filterJobChanges) + { + macroText = FilterJobChangeCommands(macroText); + + if (string.IsNullOrWhiteSpace(macroText)) + { + Log.Debug("[ExecuteMacro] All commands were filtered out"); + return; + } + } + else + { + Log.Debug($"[ExecuteMacro] ELSE BRANCH - Manual application detected"); + + if (character != null && !string.IsNullOrEmpty(designName)) + { + Log.Debug($"[ExecuteMacro] Checking for recent application..."); + + // Get the target gearset + string targetGearset = GetTargetGearsetFromMacro(macroText); + + if (!string.IsNullOrEmpty(targetGearset)) + { + string trackingKey = $"{character.Name}_{designName}_{targetGearset}"; + + if (lastAppliedByJob.ContainsKey(trackingKey) && + (DateTime.Now - lastAppliedByJob[trackingKey].time).TotalSeconds < 60) + { + Log.Debug($"[ExecuteMacro] Same design+gearset applied recently, filtering job changes"); + macroText = FilterJobChangeCommands(macroText); + Log.Debug($"[ExecuteMacro] Filtered macro: {macroText.Replace("\n", " | ")}"); + } + + // Update tracking with string key + lastAppliedByJob[trackingKey] = (character.Name, designName, DateTime.Now); + Log.Debug($"[ExecuteMacro] Updated tracking for key: {trackingKey}"); + } + } + } + var pluginCommands = new List(); + var gameCommands = new List(); + + // Track gearset usage for future filtering + var currentJobId = ClientState.LocalPlayer?.ClassJob.RowId ?? 0; + + // Check if this macro contains wait commands + bool containsWaitCommands = macroText.Split('\n') + .Any(line => line.Trim().StartsWith("/wait", StringComparison.OrdinalIgnoreCase)); + + // Separate plugin commands from game commands foreach (var raw in macroText.Split('\n')) { var cmd = raw.Trim(); if (cmd.Length == 0) continue; - CommandManager.ProcessCommand(cmd); + + // Check for duplicate gearset commands FIRST + if (IsGearsetChangeCommand(cmd)) + { + // ... keep your existing gearset tracking code here + } + + // KEY CHANGE: If macro contains waits, send ALL commands through game system + if (containsWaitCommands) + { + if (cmd.StartsWith("/")) + { + gameCommands.Add(cmd); + Log.Debug($"Queued for sequential execution: '{cmd}'"); + } + } + else + { + // Normal execution: immediate plugin commands + bool handledByPlugin = CommandManager.ProcessCommand(cmd); + + if (handledByPlugin) + { + Log.Debug($"Plugin command executed: '{cmd}'"); + } + else if (cmd.StartsWith("/")) + { + gameCommands.Add(cmd); + Log.Debug($"Queued game command: '{cmd}'"); + } + else + { + Log.Debug($"Skipping non-command text: '{cmd}'"); + } + } + } + + if (gameCommands.Count > 0) + { + ExecuteGameCommands(gameCommands); } if (character != null) @@ -812,42 +1090,202 @@ namespace CharacterSelectPlugin { Configuration.LastUsedDesignCharacterKey = character.Name; Configuration.LastUsedDesignByCharacter[character.Name] = designName; - Plugin.Log.Debug($"[MacroTracker] Saved last design {designName} for {character.Name}"); + Log.Debug($"[MacroTracker] Saved last design {designName} for {character.Name}"); } else { Configuration.LastUsedDesignCharacterKey = null; Configuration.LastUsedDesignByCharacter.Remove(character.Name); - Plugin.Log.Debug($"[MacroTracker] Cleared design for {character.Name}"); + Log.Debug($"[MacroTracker] Cleared design for {character.Name}"); } Configuration.Save(); } } + private string GetTargetGearsetFromMacro(string macro) + { + var lines = macro.Split('\n'); + foreach (var line in lines) + { + var cmd = line.Trim(); + if (IsGearsetChangeCommand(cmd)) + { + var gearsetNumber = ExtractGearsetNumber(cmd); + return gearsetNumber?.ToString() ?? ""; + } + } + return ""; + } + + // Execute game commands using the macro system + private unsafe void ExecuteGameCommands(List commands) + { + if (commands.Count == 0) return; + if (commands.Count > 15) + { + Plugin.Log.Warning($"Too many game commands ({commands.Count}), max is 15. Truncating."); + commands = commands.Take(15).ToList(); + } + + var raptureShellModule = RaptureShellModule.Instance(); + if (raptureShellModule == null) + { + Plugin.Log.Warning("Could not get RaptureShellModule instance"); + return; + } + var macro = new RaptureMacroModule.Macro(); + macro.Name.Ctor(); + foreach (ref var line in macro.Lines) + { + line.Ctor(); + } + + try + { + // Set up the macro lines + for (int i = 0; i < commands.Count && i < 15; i++) + { + var cmd = commands[i]; + if (string.IsNullOrWhiteSpace(cmd)) + { + macro.Lines[i].Clear(); + continue; + } + + var encoded = System.Text.Encoding.UTF8.GetBytes(cmd + "\0"); + if (encoded.Length == 0) + { + macro.Lines[i].Clear(); + continue; + } + + fixed (byte* encodedPtr = encoded) + { + macro.Lines[i].SetString(encodedPtr); + } + } + + // Execute the macro + raptureShellModule->ExecuteMacro(¯o); + Plugin.Log.Debug($"Executed {commands.Count} game commands via macro system"); + } + catch (Exception ex) + { + Plugin.Log.Error($"Failed to execute game commands: {ex.Message}"); + } + finally + { + // Clean up the macro object + foreach (ref var line in macro.Lines) + { + line.Dtor(); + } + } + } public void SaveConfiguration() { - var profileImageScaleProperty = Configuration.GetType().GetProperty("ProfileImageScale"); - if (profileImageScaleProperty != null && profileImageScaleProperty.CanWrite) + try { - profileImageScaleProperty.SetValue(Configuration, ProfileImageScale); - } + // Update properties first + var profileImageScaleProperty = Configuration.GetType().GetProperty("ProfileImageScale"); + if (profileImageScaleProperty != null && profileImageScaleProperty.CanWrite) + { + profileImageScaleProperty.SetValue(Configuration, ProfileImageScale); + } - var profileColumnsProperty = Configuration.GetType().GetProperty("ProfileColumns"); - if (profileColumnsProperty != null && profileColumnsProperty.CanWrite) + var profileColumnsProperty = Configuration.GetType().GetProperty("ProfileColumns"); + if (profileColumnsProperty != null && profileColumnsProperty.CanWrite) + { + profileColumnsProperty.SetValue(Configuration, ProfileColumns); + } + + var profileSpacingProperty = Configuration.GetType().GetProperty("ProfileSpacing"); + if (profileSpacingProperty != null && profileSpacingProperty.CanWrite) + { + profileSpacingProperty.SetValue(Configuration, ProfileSpacing); + } + + // Save configuration + Configuration.Save(); + + // Create backup occasionally (roughly every 10th save) + if (DateTime.Now.Millisecond % 100 == 0) + { + BackupManager.CreateBackupIfNeeded(Configuration, CurrentPluginVersion); + } + } + catch (Exception ex) { - profileColumnsProperty.SetValue(Configuration, ProfileColumns); - } + Plugin.Log.Error($"[Config] Failed to save configuration: {ex.Message}"); - // Profile Spacing - var profileSpacingProperty = Configuration.GetType().GetProperty("ProfileSpacing"); - if (profileSpacingProperty != null && profileSpacingProperty.CanWrite) + // Create emergency backup + try + { + BackupManager.CreateEmergencyBackup(Configuration); + } + catch (Exception backupEx) + { + Plugin.Log.Error($"[Config] Emergency backup also failed: {backupEx.Message}"); + } + } + } + private Configuration LoadConfigurationSafely() + { + try { - profileSpacingProperty.SetValue(Configuration, ProfileSpacing); + // Try to load normal configuration + var config = Configuration.Load(PluginInterface); + Plugin.Log.Debug("[Config] Configuration loaded successfully"); + return config; } + catch (Exception ex) + { + Plugin.Log.Error($"[Config] Failed to load configuration: {ex.Message}"); - Configuration.Save(); + // Try to restore from backup + Plugin.Log.Info("[Config] Attempting to restore from backup..."); + var backupConfig = BackupManager.RestoreFromBackup(); + + if (backupConfig != null) + { + Plugin.Log.Info("[Config] Configuration restored from backup successfully!"); + return backupConfig; + } + else + { + Plugin.Log.Warning("[Config] Backup restoration failed, creating new configuration"); + return new Configuration(PluginInterface); + } + } + } + + public void CreateManualBackup() + { + try + { + BackupManager.CreateEmergencyBackup(Configuration); + Plugin.Log.Info("[Backup] Manual backup created successfully"); + } + catch (Exception ex) + { + Plugin.Log.Error($"[Backup] Manual backup failed: {ex.Message}"); + } + } + + public BackupInfo GetBackupInfo() + { + return BackupManager.GetBackupInfo(); + } + public void OpenDesignPanel(int characterIndex) + { + MainWindow.OpenDesignPanel(characterIndex); + } + + public void OpenEditCharacterWindow(int index) + { + MainWindow.OpenEditCharacterWindow(index); } public static string SanitizeMacro(string macro, Character character) { @@ -864,11 +1302,21 @@ namespace CharacterSelectPlugin } } - + // Remove old pose commands and replace with new ones (always do this) lines = lines .Where(l => !l.TrimStart().StartsWith("/savepose", StringComparison.OrdinalIgnoreCase)) .ToList(); + // Migrate old pose commands to new ones (always do this) + for (int i = 0; i < lines.Count; i++) + { + lines[i] = lines[i] + .Replace("/spose", "/sidle") + .Replace("/sitpose", "/ssit") + .Replace("/groundsitpose", "/sgroundsit") + .Replace("/dozepose", "/sdoze"); + } + // Insert /glamour automation enable {X} after last /glamour apply if (PluginInterface.GetPluginConfig() is Configuration config && config.EnableAutomations) { @@ -886,13 +1334,32 @@ namespace CharacterSelectPlugin string automationLine = $"/glamour automation enable {automation}"; if (lastGlamourIndex != -1) - lines.Insert(lastGlamourIndex + 1, automationLine); // Insert after the last /glamour apply + lines.Insert(lastGlamourIndex + 1, automationLine); else - lines.Insert(0, automationLine); // Fallback + lines.Insert(0, automationLine); } } - // Always ensure these are present + // For Advanced Mode characters + if (character.IsAdvancedMode) + { + // Only ensure redraw is present if there are any plugin commands + bool hasPluginCommands = lines.Any(l => + l.StartsWith("/penumbra", StringComparison.OrdinalIgnoreCase) || + l.StartsWith("/glamour", StringComparison.OrdinalIgnoreCase) || + l.StartsWith("/customize", StringComparison.OrdinalIgnoreCase) || + l.StartsWith("/honorific", StringComparison.OrdinalIgnoreCase) || + l.StartsWith("/moodle", StringComparison.OrdinalIgnoreCase)); + + if (hasPluginCommands && !lines.Any(l => l.Contains("/penumbra redraw self"))) + { + lines.Add("/penumbra redraw self"); + } + + return string.Join("\n", lines); + } + + // For non-Advanced Mode characters, do full sanitization AddOrReplace("/customize profile disable "); AddOrReplace("/honorific force clear"); AddOrReplace("/moodle remove self preset all"); @@ -900,6 +1367,7 @@ namespace CharacterSelectPlugin if (!lines.Any(l => l.Contains("/penumbra redraw self"))) lines.Add("/penumbra redraw self"); + // Handle Customize+ profile enabling if (!string.IsNullOrWhiteSpace(character.CustomizeProfile)) { string enableLine = $"/customize profile enable , {character.CustomizeProfile}"; @@ -912,18 +1380,10 @@ namespace CharacterSelectPlugin lines.Insert(0, enableLine); } } - // Migrate old pose commands to new ones - for (int i = 0; i < lines.Count; i++) - { - lines[i] = lines[i] - .Replace("/spose", "/sidle") - .Replace("/sitpose", "/ssit") - .Replace("/groundsitpose", "/sgroundsit") - .Replace("/dozepose", "/sdoze"); - } return string.Join("\n", lines); } + public static string SanitizeDesignMacro(string macro, CharacterDesign design, Character character, bool enableAutomations) { var lines = macro.Split('\n').Select(l => l.Trim()).ToList(); @@ -945,7 +1405,7 @@ namespace CharacterSelectPlugin lines.Add($"/glamour automation enable {automationName}"); } - // Fix Customize+ lines to always disable first, then enable (if needed) + // Customize+ lines to always disable first, then enable (if needed) // Remove ALL existing customize lines lines.RemoveAll(l => l.StartsWith("/customize profile", StringComparison.OrdinalIgnoreCase)); @@ -971,15 +1431,16 @@ namespace CharacterSelectPlugin public void OpenRPProfileWindow(Character character) { - RPProfileViewer.IsOpen = false; // Close first to reset state - RPProfileViewer.SetCharacter(character); // Set new character - RPProfileViewer.IsOpen = true; // Reopen fresh + RPProfileViewer.IsOpen = false; + RPProfileViewer.SetCharacter(character); + RPProfileViewer.IsOpen = true; } public void OpenRPProfileViewWindow(Character character) { RPProfileViewer.SetCharacter(character); RPProfileViewer.IsOpen = true; + IsRPProfileViewerOpen = true; } private RPProfile HandleProfileRequest(string requestedName) { @@ -998,7 +1459,7 @@ namespace CharacterSelectPlugin lookupKey = overrideName; } - // Find the matching character with LastInGameName == lookupKey + // Find the matching character with LastInGameName var character = Characters.FirstOrDefault(c => string.Equals(c.LastInGameName, lookupKey, StringComparison.OrdinalIgnoreCase)); @@ -1062,7 +1523,7 @@ namespace CharacterSelectPlugin // Try to get local name first string? localName = ClientState.LocalPlayer?.Name.TextValue; - // If player is trying to view their own profile, skip IPC + // If player is trying to view their own profile if (ActiveProfilesByPlayerName.TryGetValue(targetName, out var overrideName)) { var character = Characters.FirstOrDefault(c => @@ -1134,7 +1595,7 @@ namespace CharacterSelectPlugin // This is what OnLogin uses to find the right profile character.LastInGameName = pluginCharacterKey; - // This is the key logic: player → selected plugin character + // This is the key logic: player -> selected plugin character Configuration.LastUsedCharacterByPlayer[fullKey] = pluginCharacterKey; // Write the session info file so the plugin remembers the last applied character name @@ -1143,36 +1604,50 @@ namespace CharacterSelectPlugin Configuration.Save(); - // These log lines now match and won’t be skipped + // These log lines now match and won't be skipped Plugin.Log.Debug($"[SetActiveCharacter] Saved: {fullKey} → {pluginCharacterKey}"); Plugin.Log.Debug($"[SetActiveCharacter] Set LastInGameName = {pluginCharacterKey} for profile {character.Name}"); + // Only upload if we should upload to gallery + bool shouldUploadToGallery = ShouldUploadToGallery(character, fullKey); - - var profileToSend = new RPProfile + if (shouldUploadToGallery) { - Pronouns = character.RPProfile?.Pronouns, - Gender = character.RPProfile?.Gender, - Age = character.RPProfile?.Age, - Race = character.RPProfile?.Race, - Orientation = character.RPProfile?.Orientation, - Relationship = character.RPProfile?.Relationship, - Occupation = character.RPProfile?.Occupation, - Abilities = character.RPProfile?.Abilities, - Bio = character.RPProfile?.Bio, - Tags = character.RPProfile?.Tags, - CustomImagePath = !string.IsNullOrEmpty(character.RPProfile?.CustomImagePath) - ? character.RPProfile.CustomImagePath - : character.ImagePath, - ImageZoom = character.RPProfile?.ImageZoom ?? 1.0f, - ImageOffset = character.RPProfile?.ImageOffset ?? Vector2.Zero, - Sharing = character.RPProfile?.Sharing ?? ProfileSharing.AlwaysShare, - ProfileImageUrl = character.RPProfile?.ProfileImageUrl, - CharacterName = character.Name, // force correct name - NameplateColor = character.NameplateColor // force correct colour - }; + var profileToSend = new RPProfile + { + Pronouns = character.RPProfile?.Pronouns, + Gender = character.RPProfile?.Gender, + Age = character.RPProfile?.Age, + Race = character.RPProfile?.Race, + Orientation = character.RPProfile?.Orientation, + Relationship = character.RPProfile?.Relationship, + Occupation = character.RPProfile?.Occupation, + Abilities = character.RPProfile?.Abilities, + Bio = character.RPProfile?.Bio, + Tags = character.RPProfile?.Tags, + CustomImagePath = !string.IsNullOrEmpty(character.RPProfile?.CustomImagePath) + ? character.RPProfile.CustomImagePath + : character.ImagePath, + ImageZoom = character.RPProfile?.ImageZoom ?? 1.0f, + ImageOffset = character.RPProfile?.ImageOffset ?? Vector2.Zero, + Sharing = character.RPProfile?.Sharing ?? ProfileSharing.AlwaysShare, + ProfileImageUrl = character.RPProfile?.ProfileImageUrl, + CharacterName = character.Name, // force correct name + NameplateColor = character.RPProfile?.ProfileColor ?? character.NameplateColor, // force correct colour + BackgroundImage = character.RPProfile?.BackgroundImage ?? character.BackgroundImage, + Effects = character.RPProfile?.Effects ?? character.Effects ?? new ProfileEffects(), + GalleryStatus = character.GalleryStatus, + Links = character.RPProfile?.Links, + LastActiveTime = Configuration.ShowRecentlyActiveStatus ? DateTime.UtcNow : null + }; - _ = Plugin.UploadProfileAsync(profileToSend, character.LastInGameName ?? character.Name); + _ = Plugin.UploadProfileAsync(profileToSend, character.LastInGameName ?? character.Name); + Plugin.Log.Info($"[SetActiveCharacter] ✓ Uploaded profile for {character.Name}"); + } + else + { + Plugin.Log.Info($"[SetActiveCharacter] ⚠ Skipped gallery upload for {character.Name}"); + } } } public async Task TryRequestRPProfile(string targetName) @@ -1193,7 +1668,6 @@ namespace CharacterSelectPlugin public static async Task UploadProfileAsync(RPProfile profile, string characterName) { - // Declare here so we can dispose after the upload completes Stream? imageStream = null; StreamContent? imageContent = null; @@ -1205,12 +1679,13 @@ namespace CharacterSelectPlugin // Get character match from config var config = PluginInterface.GetPluginConfig() as Configuration; Character? match = config?.Characters.FirstOrDefault(c => c.LastInGameName == characterName); - var rpMatch = match?.RPProfile; if (match != null) { + // Only set character name if it's not already set profile.CharacterName ??= match.Name; + // Only set nameplate colour if it's not set (all zeros) if (profile.NameplateColor.X <= 0f && profile.NameplateColor.Y <= 0f && profile.NameplateColor.Z <= 0f) @@ -1218,13 +1693,21 @@ namespace CharacterSelectPlugin profile.NameplateColor = match.NameplateColor; } - if (rpMatch != null) + // Ensure background and effects are included in upload + if (profile.Effects == null && match.Effects != null) { - if (Math.Abs(profile.ImageZoom) < 0.01f) - profile.ImageZoom = rpMatch.ImageZoom; - - if (profile.ImageOffset == Vector2.Zero) - profile.ImageOffset = rpMatch.ImageOffset; + profile.Effects = new ProfileEffects + { + CircuitBoard = match.Effects.CircuitBoard, + Fireflies = match.Effects.Fireflies, + FallingLeaves = match.Effects.FallingLeaves, + Butterflies = match.Effects.Butterflies, + Bats = match.Effects.Bats, + Fire = match.Effects.Fire, + Smoke = match.Effects.Smoke, + ColorScheme = match.Effects.ColorScheme, + CustomParticleColor = match.Effects.CustomParticleColor + }; } } @@ -1239,7 +1722,7 @@ namespace CharacterSelectPlugin imagePathToUpload = match.ImagePath; } - // Attach image if found (no 'using' here, so it isn't disposed too early) + // Attach image if found if (!string.IsNullOrEmpty(imagePathToUpload)) { imageStream = File.OpenRead(imagePathToUpload); @@ -1249,55 +1732,65 @@ namespace CharacterSelectPlugin form.Add(imageContent, "image", $"{Guid.NewGuid()}.png"); } - // Upload JSON + // Upload JSON - The profile parameter already has the correct data! string json = JsonConvert.SerializeObject(profile); form.Add(new StringContent(json, Encoding.UTF8, "application/json"), "profile"); - // Send - string urlSafeName = Uri.EscapeDataString(characterName); - var response = await http.PostAsync( - $"https://character-select-profile-server-production.up.railway.app/upload/{urlSafeName}", - form - ); + // Add a flag to indicate this is an update, not a new profile + form.Add(new StringContent("true", Encoding.UTF8, "text/plain"), "isUpdate"); + + // Send both CS+ character name and physical character name + form.Add(new StringContent(profile.CharacterName ?? "Unknown", Encoding.UTF8, "text/plain"), "csCharacterName"); + + string urlSafeName = Uri.EscapeDataString(characterName); + + // Use PUT for updates instead of POST to preserve likes + var request = new HttpRequestMessage(HttpMethod.Put, $"https://character-select-profile-server-production.up.railway.app/upload/{urlSafeName}") + { + Content = form + }; + + Plugin.Log.Info($"[UploadProfile] Updating profile for CS+ character '{profile.CharacterName}' as physical character '{characterName}'"); + + var response = await http.SendAsync(request); - // Now that the request has been sent, we can dispose the image content/stream imageContent?.Dispose(); imageStream?.Dispose(); // Process response var responseJson = await response.Content.ReadAsStringAsync(); - var updated = JsonConvert.DeserializeObject(responseJson); - if (updated?.ProfileImageUrl is { Length: > 0 }) + if (response.IsSuccessStatusCode) { - profile.ProfileImageUrl = updated.ProfileImageUrl; - if (rpMatch != null) - { - match.RPProfile.ProfileImageUrl = updated.ProfileImageUrl; - Plugin.Log.Debug( - $"[UploadProfile] Updated ProfileImageUrl for {characterName} = {updated.ProfileImageUrl}" - ); - config?.Save(); - } - } + var updated = JsonConvert.DeserializeObject(responseJson); - if (!response.IsSuccessStatusCode) - Plugin.Log.Warning( - $"[UploadProfile] Failed to upload profile for {characterName}: {response.StatusCode}" - ); + if (updated?.ProfileImageUrl is { Length: > 0 }) + { + profile.ProfileImageUrl = updated.ProfileImageUrl; + + // Update the stored profile with the new image URL + if (match?.RPProfile != null) + { + match.RPProfile.ProfileImageUrl = updated.ProfileImageUrl; + Plugin.Log.Debug($"[UploadProfile] Updated ProfileImageUrl for {characterName} = {updated.ProfileImageUrl}"); + config?.Save(); + } + } + + Plugin.Log.Info($"[UploadProfile] Successfully updated profile for CS+ character {profile.CharacterName} as {characterName}"); + } else - Plugin.Log.Debug( - $"[UploadProfile] Successfully uploaded profile for {characterName}" - ); + { + Plugin.Log.Warning($"[UploadProfile] Failed to upload profile for {characterName}: {response.StatusCode}"); + Plugin.Log.Warning($"[UploadProfile] Server response: {responseJson}"); + } } catch (NullReferenceException nre) { - // missing sub‐fields can produce NREs—debug‐log and continue Plugin.Log.Debug($"[UploadProfile] NullReference for {characterName}: {nre.Message}"); } catch (Exception ex) { - // other errors still get logged as errors Plugin.Log.Error($"[UploadProfile] Exception: {ex}"); } } @@ -1340,7 +1833,22 @@ namespace CharacterSelectPlugin } string json = await response.Content.ReadAsStringAsync(); - return RPProfileJson.Deserialize(json); + var profile = RPProfileJson.Deserialize(json); + + // Ensure we got all the background and effects data + if (profile != null) + { + Plugin.Log.Debug($"[DownloadProfile] Downloaded profile"); + Plugin.Log.Debug($"[DownloadProfile] Profile belongs to CS+ character: {profile.CharacterName}"); + Plugin.Log.Debug($"[DownloadProfile] BackgroundImage: {profile.BackgroundImage ?? "null"}"); + Plugin.Log.Debug($"[DownloadProfile] Effects: {(profile.Effects != null ? "present" : "null")}"); + if (profile.Effects != null) + { + Plugin.Log.Debug($"[DownloadProfile] Effects - Fireflies: {profile.Effects.Fireflies}, Leaves: {profile.Effects.FallingLeaves}, etc."); + } + } + + return profile; } catch (Exception ex) { @@ -1368,7 +1876,7 @@ namespace CharacterSelectPlugin line.StartsWith("/spose", StringComparison.OrdinalIgnoreCase) ) { - continue; // Omit this line entirely + continue; } // Rewriting self-targeting lines to @@ -1445,26 +1953,27 @@ namespace CharacterSelectPlugin var player = ClientState.LocalPlayer!; uint currentJobId = player.ClassJob.RowId; + if (Configuration.ReapplyDesignOnJobChange && CurrentJobId != 0 && currentJobId != CurrentJobId) { Plugin.Log.Debug($"[JobSwitch] Detected job change: {CurrentJobId} → {currentJobId}"); - if (Configuration.EnableAutomations) + + if (!Configuration.EnableAutomations) { - bool reapplied = false; + var reapplied = false; if (!string.IsNullOrEmpty(Configuration.LastUsedDesignCharacterKey) && Configuration.LastUsedDesignByCharacter.TryGetValue(Configuration.LastUsedDesignCharacterKey, out var designName)) { var designCharacter = Characters.FirstOrDefault(c => c.Name == Configuration.LastUsedDesignCharacterKey); var design = designCharacter?.Designs.FirstOrDefault(d => d.Name == designName); - Plugin.Log.Debug($"{design}"); if (design != null) { - Plugin.Log.Debug($"[JobSwitch] Reapplying design {design.Name} for {designCharacter.Name}"); - ExecuteMacro(design.Macro, designCharacter, design.Name); + Plugin.Log.Debug($"[JobSwitch] Reapplying design {design.Name} for {designCharacter.Name} (filtered)"); + ExecuteMacro(design.Macro, designCharacter, design.Name, filterJobChanges: true); reapplied = true; } } @@ -1474,12 +1983,11 @@ namespace CharacterSelectPlugin var character = Characters.FirstOrDefault(c => c.Name == Configuration.LastUsedCharacterKey); if (character != null) { - Plugin.Log.Debug($"[JobSwitch] Reapplying character macro for {character.Name}"); - ExecuteMacro(character.Macros, character, null); + Plugin.Log.Debug($"[JobSwitch] Reapplying character macro for {character.Name} (filtered)"); + ExecuteMacro(character.Macros, character, null, filterJobChanges: true); reapplied = true; } } - } } @@ -1618,26 +2126,28 @@ namespace CharacterSelectPlugin ClientState.TerritoryType != 0 && (Configuration.EnableLoginDelay ? DateTime.Now - loginTime > TimeSpan.FromSeconds(3) : true)) // give a short delay to load { - if (Configuration.LastUsedCharacterByPlayer.TryGetValue(fullKey, out var lastUsedKey)) + // Main Character Only Logic + if (Configuration.EnableMainCharacterOnly && !string.IsNullOrEmpty(Configuration.MainCharacterName)) { - var character = Characters.FirstOrDefault(c => - $"{c.Name}@{ClientState.LocalPlayer!.HomeWorld.Value.Name}" == lastUsedKey); - - if (character != null) + // Only apply the designated main character + var mainCharacter = Characters.FirstOrDefault(c => c.Name == Configuration.MainCharacterName); + if (mainCharacter != null) { - Plugin.Log.Debug($"[AutoLoad] ✅ Applying {character.Name} for {fullKey}"); - ApplyProfile(character, -1); - lastAppliedCharacter = fullKey; // mark it + Plugin.Log.Debug($"[AutoLoad-Main] ✅ Applying main character {mainCharacter.Name} for {fullKey}"); + ApplyProfile(mainCharacter, -1); + lastAppliedCharacter = fullKey; } - else if (lastAppliedCharacter != $"!notfound:{lastUsedKey}") + else { - Plugin.Log.Debug($"[AutoLoad] ❌ No match found for {lastUsedKey}"); - lastAppliedCharacter = $"!notfound:{lastUsedKey}"; // mark it so it doesn't log again + Plugin.Log.Debug($"[AutoLoad-Main] ❌ Main character '{Configuration.MainCharacterName}' not found, falling back to normal behavior"); + // Fall back to normal behavior if main character doesn't exist + ApplyLastUsedCharacter(fullKey); } } else { - Plugin.Log.Debug($"[AutoLoad] ❌ No previous character stored for {fullKey}"); + // Normal behavior: apply last used character + ApplyLastUsedCharacter(fullKey); } } } @@ -1685,6 +2195,280 @@ namespace CharacterSelectPlugin framesSinceLogin = 0; } } + private void ApplyLastUsedCharacter(string fullKey) + { + if (Configuration.LastUsedCharacterByPlayer.TryGetValue(fullKey, out var lastUsedKey)) + { + var character = Characters.FirstOrDefault(c => + $"{c.Name}@{ClientState.LocalPlayer!.HomeWorld.Value.Name}" == lastUsedKey); + + if (character != null) + { + Plugin.Log.Debug($"[AutoLoad] ✅ Applying {character.Name} for {fullKey}"); + ApplyProfile(character, -1); + lastAppliedCharacter = fullKey; // mark it + } + else if (lastAppliedCharacter != $"!notfound:{lastUsedKey}") + { + Plugin.Log.Debug($"[AutoLoad] ❌ No match found for {lastUsedKey}"); + lastAppliedCharacter = $"!notfound:{lastUsedKey}"; // mark it so it doesn't log again + } + } + else + { + Plugin.Log.Debug($"[AutoLoad] ❌ No previous character stored for {fullKey}"); + } + } + public string FilterJobChangeCommands(string macro) + { + if (string.IsNullOrWhiteSpace(macro)) + return macro; + + try + { + var lines = macro.Split('\n'); + var filteredLines = new List(); + + foreach (var line in lines) + { + var trimmedLine = line.Trim(); + + // Simply skip ALL gearset change commands during reapplication + if (IsGearsetChangeCommand(trimmedLine)) + { + Log.Debug($"[JobFilter] Skipping gearset command during reapplication: {trimmedLine}"); + continue; // Skip this line entirely + } + + filteredLines.Add(line); + } + + string result = string.Join("\n", filteredLines); + Log.Debug($"[JobFilter] Filtered {lines.Length - filteredLines.Count} gearset commands"); + return result; + } + catch (Exception ex) + { + Log.Error($"[JobFilter] Error filtering: {ex.Message}"); + return macro; // Return original on error + } + } + + + private bool IsGearsetChangeCommand(string command) + { + var trimmed = command.Trim(); + return trimmed.StartsWith("/gearset change", StringComparison.OrdinalIgnoreCase) || + trimmed.StartsWith("/gs change", StringComparison.OrdinalIgnoreCase); + } + + private uint? ExtractGearsetNumber(string command) + { + try + { + // Extract gearset number from command + var parts = command.Split(' ', StringSplitOptions.RemoveEmptyEntries); + + if (parts.Length < 3) + return null; + + var target = parts[2]; // Usually the third part contains the gearset number + + // If it's a number, return it + if (uint.TryParse(target, out uint gearsetNumber)) + { + return gearsetNumber; + } + + return null; + } + catch + { + return null; + } + } + public void SelectRandomCharacterAndDesign() + { + var random = new Random(); + + // Get available characters based on settings + var availableCharacters = Configuration.RandomSelectionFavoritesOnly + ? Characters.Where(c => c.IsFavorite).ToList() + : Characters.ToList(); + + // Fallback to all characters if no favourites exist + if (availableCharacters.Count == 0 && Configuration.RandomSelectionFavoritesOnly) + { + availableCharacters = Characters.ToList(); + ChatGui.Print("[Character Select+] No favourite characters found, selecting from all characters."); + } + + if (availableCharacters.Count == 0) + { + ChatGui.PrintError("[Character Select+] No characters available for random selection."); + return; + } + + // Select random character + var selectedCharacter = availableCharacters[random.Next(availableCharacters.Count)]; + + // Get available designs for the selected character + var availableDesigns = Configuration.RandomSelectionFavoritesOnly + ? selectedCharacter.Designs.Where(d => d.IsFavorite).ToList() + : selectedCharacter.Designs.ToList(); + + // Fallback to all designs if no favourites exist + if (availableDesigns.Count == 0 && Configuration.RandomSelectionFavoritesOnly) + { + availableDesigns = selectedCharacter.Designs.ToList(); + } + + // Apply character first + ExecuteMacro(selectedCharacter.Macros, selectedCharacter, null); + SetActiveCharacter(selectedCharacter); + + string message = $"[Character Select+] Random selection: {selectedCharacter.Name}"; + + // Apply random design if available + if (availableDesigns.Count > 0) + { + var selectedDesign = availableDesigns[random.Next(availableDesigns.Count)]; + ExecuteMacro(selectedDesign.Macro, selectedCharacter, selectedDesign.Name); + message += $" with design '{selectedDesign.Name}'"; + + // Update last used design tracking + Configuration.LastUsedDesignCharacterKey = selectedCharacter.Name; + Configuration.LastUsedDesignByCharacter[selectedCharacter.Name] = selectedDesign.Name; + } + else + { + message += " (no designs available)"; + } + + // Apply poses if login is complete + if (isLoginComplete) + { + if (selectedCharacter.IdlePoseIndex < 7) + { + PoseManager.ApplyPose(PoseType.Idle, selectedCharacter.IdlePoseIndex); + Configuration.LastIdlePoseAppliedByPlugin = selectedCharacter.IdlePoseIndex; + Configuration.Save(); + } + PoseRestorer.RestorePosesFor(selectedCharacter); + } + else + { + activeCharacter = selectedCharacter; + shouldApplyPoses = true; + } + + ChatGui.Print(message); + SaveConfiguration(); + } + public string? GetTargetedPlayerName() + { + try + { + var target = TargetManager.Target; + + if (target == null || target.ObjectKind != Dalamud.Game.ClientState.Objects.Enums.ObjectKind.Player) + return null; + + if (target is IPlayerCharacter player) + { + string characterName = player.Name.TextValue; + string worldName = player.HomeWorld.Value.Name.ToString(); + + if (!string.IsNullOrWhiteSpace(characterName) && !string.IsNullOrWhiteSpace(worldName)) + { + return $"{characterName}@{worldName}"; + } + } + } + catch (Exception ex) + { + Log.Error($"Error getting targeted player name: {ex.Message}"); + } + + return null; + } + private void MigrateBackgroundImageNames() + { + bool configChanged = false; + + foreach (var character in Configuration.Characters) + { + if (!string.IsNullOrEmpty(character.BackgroundImage)) + { + string oldName = character.BackgroundImage; + string newName = oldName; + + // Convert PNG to JPG + if (oldName.EndsWith(".png", StringComparison.OrdinalIgnoreCase)) + { + newName = oldName.Substring(0, oldName.Length - 4) + ".jpg"; + } + // Add .jpg if no extension + else if (!oldName.Contains(".")) + { + newName = oldName + ".jpg"; + } + + if (newName != oldName) + { + character.BackgroundImage = newName; + configChanged = true; + Log.Info($"Migrated background: {oldName} -> {newName}"); + } + } + + if (character.RPProfile != null && !string.IsNullOrEmpty(character.RPProfile.BackgroundImage)) + { + string oldName = character.RPProfile.BackgroundImage; + string newName = oldName; + + if (oldName.EndsWith(".png", StringComparison.OrdinalIgnoreCase)) + { + newName = oldName.Substring(0, oldName.Length - 4) + ".jpg"; + } + else if (!oldName.Contains(".")) + { + newName = oldName + ".jpg"; + } + + if (newName != oldName) + { + character.RPProfile.BackgroundImage = newName; + configChanged = true; + Log.Info($"Migrated RP profile background: {oldName} -> {newName}"); + } + } + } + + if (configChanged) + { + SaveConfiguration(); + Log.Info("Background migration completed and saved"); + } + } + + public Character? GetActiveCharacter() + { + if (ClientState.LocalPlayer?.HomeWorld.IsValid != true) + return null; + + string localName = ClientState.LocalPlayer.Name.TextValue; + string worldName = ClientState.LocalPlayer.HomeWorld.Value.Name.ToString(); + string fullKey = $"{localName}@{worldName}"; + + // Find which CS+ character is currently active for this physical character + if (ActiveProfilesByPlayerName.TryGetValue(fullKey, out var activeCharacterName)) + { + return Characters.FirstOrDefault(c => c.Name == activeCharacterName); + } + + return null; + } private EmoteController.PoseType TranslatePoseState(byte state) { diff --git a/CharacterSelectPlugin/PoseManager.cs b/CharacterSelectPlugin/PoseManager.cs index a1361e9..cc796d9 100644 --- a/CharacterSelectPlugin/PoseManager.cs +++ b/CharacterSelectPlugin/PoseManager.cs @@ -1,80 +1,161 @@ using Dalamud.Plugin.Services; using FFXIVClientStructs.FFXIV.Client.Game.Control; using FFXIVClientStructs.FFXIV.Client.Game.UI; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; namespace CharacterSelectPlugin.Managers; -public unsafe class PoseManager +public unsafe class ImprovedPoseManager { private readonly IClientState clientState; private readonly IFramework framework; - private readonly IChatGui chatGui; - private readonly ICommandManager commandManager; private readonly Plugin plugin; + private readonly PoseState currentState = new(); + private DateTime lastPoseChange = DateTime.MinValue; + private const float POSE_CHANGE_COOLDOWN = 0.5f; // Prevent rapid changes - public PoseManager(IClientState clientState, IFramework framework, IChatGui chatGui, ICommandManager commandManager, Plugin plugin) + public ImprovedPoseManager(IClientState clientState, IFramework framework, Plugin plugin) { this.clientState = clientState; this.framework = framework; - this.chatGui = chatGui; - this.commandManager = commandManager; this.plugin = plugin; + + framework.Update += OnFrameworkUpdate; } - public void ApplyPose(EmoteController.PoseType type, byte index) + public bool ApplyPose(EmoteController.PoseType type, byte index) { - Plugin.Log.Debug($"[ApplyPose] Applying {type} pose {index}"); + if (!CanApplyPose(type, index)) + return false; - if (index >= 7 || clientState.LocalPlayer == null) + try + { + var success = SetPoseInternal(type, index); + if (success) + { + currentState.SetPluginControlled(type, index); + lastPoseChange = DateTime.Now; + SavePoseToConfig(type, index); + Plugin.Log.Debug($"[PoseManager] Successfully applied {type} pose {index}"); + } + return success; + } + catch (Exception ex) + { + Plugin.Log.Error($"[PoseManager] Failed to apply pose: {ex.Message}"); + return false; + } + } + + private bool CanApplyPose(EmoteController.PoseType type, byte index) + { + if (index >= 7) + { + Plugin.Log.Debug($"[PoseManager] Invalid pose index: {index}"); + return false; + } + + if (clientState.LocalPlayer?.Address == IntPtr.Zero) + { + Plugin.Log.Debug("[PoseManager] Player not available"); + return false; + } + + // Cooldown to prevent spam + if ((DateTime.Now - lastPoseChange).TotalSeconds < POSE_CHANGE_COOLDOWN) + { + Plugin.Log.Debug("[PoseManager] Pose change on cooldown"); + return false; + } + + return true; + } + + private bool SetPoseInternal(EmoteController.PoseType type, byte index) + { + var playerState = PlayerState.Instance(); + if (playerState == null) + return false; + + var character = (FFXIVClientStructs.FFXIV.Client.Game.Character.Character*)clientState.LocalPlayer!.Address; + if (character == null || character->GameObject.ObjectIndex == 0xFFFF) + return false; + + playerState->SelectedPoses[(int)type] = index; + + // Only update CPoseState if we're currently in that pose mode + var currentMode = TranslatePoseState(character->ModeParam); + if (currentMode == type) + { + character->EmoteController.CPoseState = index; + } + + return true; + } + + private void OnFrameworkUpdate(IFramework framework) + { + if (!plugin.Configuration.EnablePoseAutoSave || !clientState.IsLoggedIn) return; - var charPtr = (FFXIVClientStructs.FFXIV.Client.Game.Character.Character*)clientState.LocalPlayer.Address; + try + { + UpdatePoseTracking(); + } + catch (Exception ex) + { + Plugin.Log.Error($"[PoseManager] Framework update error: {ex.Message}"); + } + } - // Apply to memory - PlayerState.Instance()->SelectedPoses[(int)type] = index; + private void UpdatePoseTracking() + { + var playerState = PlayerState.Instance(); + if (playerState == null) + return; - if (TranslatePoseState(charPtr->ModeParam) == type) - charPtr->EmoteController.CPoseState = index; + foreach (EmoteController.PoseType type in Enum.GetValues()) + { + var currentPose = playerState->SelectedPoses[(int)type]; + var lastKnown = currentState.GetLastKnown(type); - // Persist if plugin-driven + // Detect user-initiated changes + if (currentPose != lastKnown && + !currentState.IsPluginControlled(type) && + currentPose < 7) + { + Plugin.Log.Debug($"[PoseManager] User changed {type} pose to {currentPose}"); + SavePoseToConfig(type, currentPose); + currentState.SetUserControlled(type, currentPose); + } + } + } + + private void SavePoseToConfig(EmoteController.PoseType type, byte index) + { switch (type) { case EmoteController.PoseType.Idle: plugin.Configuration.DefaultPoses.Idle = index; plugin.Configuration.LastIdlePoseAppliedByPlugin = index; - plugin.lastSeenIdlePose = index; - plugin.suppressIdleSaveForFrames = 60; // longer block - Plugin.Log.Debug($"[ApplyPose] Idle pose set to {index} and suppressed for 60 frames."); break; - case EmoteController.PoseType.Sit: plugin.Configuration.DefaultPoses.Sit = index; - plugin.lastSeenSitPose = index; - plugin.suppressSitSaveForFrames = 60; - Plugin.Log.Debug($"[ApplyPose] Sit pose set to {index} and suppressed for 60 frames."); break; - case EmoteController.PoseType.GroundSit: plugin.Configuration.DefaultPoses.GroundSit = index; - plugin.lastSeenGroundSitPose = index; - plugin.suppressGroundSitSaveForFrames = 60; - Plugin.Log.Debug($"[ApplyPose] Ground Sit pose set to {index} and suppressed for 60 frames."); break; - case EmoteController.PoseType.Doze: plugin.Configuration.DefaultPoses.Doze = index; - plugin.lastSeenDozePose = index; - plugin.suppressDozeSaveForFrames = 60; - Plugin.Log.Debug($"[ApplyPose] Doze pose set to {index} and suppressed for 60 frames."); break; } - // This makes the change persist! plugin.Configuration.Save(); } - private EmoteController.PoseType TranslatePoseState(byte state) { return state switch @@ -86,10 +167,47 @@ public unsafe class PoseManager }; } - - public byte GetPose(EmoteController.PoseType type) - => PlayerState.Instance()->CurrentPose(type); - - public byte GetSelectedPose(EmoteController.PoseType type) - => PlayerState.Instance()->SelectedPoses[(int)type]; + public void Dispose() + { + framework.Update -= OnFrameworkUpdate; + } +} + +public class PoseState +{ + private readonly Dictionary lastKnownPoses = new(); + private readonly Dictionary pluginControlled = new(); + private readonly Dictionary lastChangeTime = new(); + + public void SetPluginControlled(EmoteController.PoseType type, byte pose) + { + lastKnownPoses[type] = pose; + pluginControlled[type] = true; + lastChangeTime[type] = DateTime.Now; + + var timer = new System.Timers.Timer(2000); + timer.Elapsed += (sender, e) => { + pluginControlled[type] = false; + timer.Dispose(); + }; + timer.AutoReset = false; + timer.Start(); + } + + public void SetUserControlled(EmoteController.PoseType type, byte pose) + { + lastKnownPoses[type] = pose; + pluginControlled[type] = false; + lastChangeTime[type] = DateTime.Now; + } + + public byte GetLastKnown(EmoteController.PoseType type) + { + return lastKnownPoses.TryGetValue(type, out byte value) ? value : (byte)255; + } + + public bool IsPluginControlled(EmoteController.PoseType type) + { + return pluginControlled.TryGetValue(type, out bool value) && value; + } } diff --git a/CharacterSelectPlugin/PronounParser.cs b/CharacterSelectPlugin/PronounParser.cs new file mode 100644 index 0000000..0fcd628 --- /dev/null +++ b/CharacterSelectPlugin/PronounParser.cs @@ -0,0 +1,102 @@ +using System; +using System.Linq; +using System.Text.RegularExpressions; + +namespace CharacterSelectPlugin +{ + public class PronounSet + { + public string Subject { get; set; } = "they"; // they/she/he + public string Object { get; set; } = "them"; // them/her/him + public string Possessive { get; set; } = "their"; // their/her/his + public string PossessivePronoun { get; set; } = "theirs"; // theirs/hers/his + public string Reflexive { get; set; } = "themselves"; // themselves/herself/himself + public bool IsPlural { get; set; } = true; // for verb conjugation + + // Verb forms for this pronoun set + public string BeVerb + { + get { return IsPlural ? "are" : "is"; } + } + + public string HaveVerb + { + get { return IsPlural ? "have" : "has"; } + } + + public string WereVerb + { + get { return IsPlural ? "were" : "was"; } + } + } + + public static class PronounParser + { + public static PronounSet Parse(string? pronounString) + { + if (string.IsNullOrWhiteSpace(pronounString)) + return new PronounSet + { + Subject = "they", + Object = "them", + Possessive = "their", + PossessivePronoun = "theirs", + Reflexive = "themselves", + IsPlural = true + }; + + string input = pronounString.Trim().ToLower(); + + if (input.Contains("/")) + { + string[] parts = input.Split('/'); + if (parts.Length >= 2) + { + string subject = parts[0].Trim(); + string objectPronoun = parts[1].Trim(); + return CreatePronounSet(subject, objectPronoun); + } + } + + return CreatePronounSet(input, null); + } + + private static PronounSet CreatePronounSet(string subject, string objectHint) + { + PronounSet pronounSet = new PronounSet(); + + switch (subject.ToLower()) + { + case "she": + pronounSet.Subject = "she"; + pronounSet.Object = "her"; + pronounSet.Possessive = "her"; + pronounSet.PossessivePronoun = "hers"; + pronounSet.Reflexive = "herself"; + pronounSet.IsPlural = false; + break; + + case "he": + pronounSet.Subject = "he"; + pronounSet.Object = "him"; + pronounSet.Possessive = "his"; + pronounSet.PossessivePronoun = "his"; + pronounSet.Reflexive = "himself"; + pronounSet.IsPlural = false; + break; + + case "they": + default: + pronounSet.Subject = "they"; + pronounSet.Object = "them"; + pronounSet.Possessive = "their"; + pronounSet.PossessivePronoun = "theirs"; + pronounSet.Reflexive = "themselves"; + pronounSet.IsPlural = true; + break; + } + + return pronounSet; + } + } +} diff --git a/CharacterSelectPlugin/QuickSwitchWindow.cs b/CharacterSelectPlugin/QuickSwitchWindow.cs index 3c81d82..ac6c835 100644 --- a/CharacterSelectPlugin/QuickSwitchWindow.cs +++ b/CharacterSelectPlugin/QuickSwitchWindow.cs @@ -13,6 +13,7 @@ namespace CharacterSelectPlugin.Windows private int selectedDesignIndex = -1; private int lastAppliedCharacterIndex = -1; private bool hasAppliedMacroThisSession = false; + private bool hasInitializedSelection = false; public QuickSwitchWindow(Plugin plugin) : base("Quick Character Switch", ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoResize) @@ -27,7 +28,14 @@ namespace CharacterSelectPlugin.Windows public override void Draw() { - // ── Compact Quick Switch toggle ── + // Initialize selection on first draw or when characters are available + if (!hasInitializedSelection && plugin.Characters.Count > 0) + { + InitializeLastUsedSelection(); + hasInitializedSelection = true; + } + + // Compact Quick Switch toggle if (plugin.Configuration.QuickSwitchCompact) { // No title‐bar, no resize, no scrollbar, no background @@ -35,6 +43,7 @@ namespace CharacterSelectPlugin.Windows .NoTitleBar | ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoScrollbar + | ImGuiWindowFlags.NoScrollWithMouse | ImGuiWindowFlags.NoBackground; SizeConstraints = new WindowSizeConstraints { @@ -46,7 +55,8 @@ namespace CharacterSelectPlugin.Windows { // Full window this.Flags = ImGuiWindowFlags.NoResize - | ImGuiWindowFlags.NoScrollbar; + | ImGuiWindowFlags.NoScrollbar + | ImGuiWindowFlags.NoScrollWithMouse; SizeConstraints = new WindowSizeConstraints { MinimumSize = new System.Numerics.Vector2(360, 55), @@ -107,9 +117,9 @@ namespace CharacterSelectPlugin.Windows if (ImGui.BeginCombo("##DesignDropdown", GetSelectedDesignName(selectedCharacter), ImGuiComboFlags.HeightRegular)) { var orderedDesigns = selectedCharacter.Designs - .Select((d, index) => new { Design = d, OriginalIndex = index }) - .OrderBy(x => x.Design.Name, StringComparer.OrdinalIgnoreCase) - .ToList(); + .Select((d, index) => new { Design = d, OriginalIndex = index }) + .OrderBy(x => x.Design.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); for (int j = 0; j < orderedDesigns.Count; j++) { @@ -155,7 +165,7 @@ namespace CharacterSelectPlugin.Windows ImGui.EndDisabled(); } - // Nameplate Colour Bar (Appears below dropdowns if a character is selected) + // Nameplate Colour Bar if (selectedCharacterIndex >= 0) { Vector4 charColor = GetNameplateColor(plugin.Characters[selectedCharacterIndex]); @@ -167,8 +177,137 @@ namespace CharacterSelectPlugin.Windows } } + // Initialize the dropdown selections based on last used character + private void InitializeLastUsedSelection() + { + try + { + Plugin.Log.Debug("[QuickSwitch] Initializing last used selection..."); + // Try to get the last used character for the current player + if (Plugin.ClientState.LocalPlayer?.HomeWorld.IsValid == true) + { + string localName = Plugin.ClientState.LocalPlayer.Name.TextValue; + string worldName = Plugin.ClientState.LocalPlayer.HomeWorld.Value.Name.ToString(); + string fullKey = $"{localName}@{worldName}"; + if (plugin.Configuration.LastUsedCharacterByPlayer.TryGetValue(fullKey, out var lastUsedKey)) + { + // Find character by the stored key format + var character = plugin.Characters.FirstOrDefault(c => + $"{c.Name}@{worldName}" == lastUsedKey); + + if (character != null) + { + selectedCharacterIndex = plugin.Characters.IndexOf(character); + Plugin.Log.Debug($"[QuickSwitch] Found last used character: {character.Name} at index {selectedCharacterIndex}"); + + // Try to set last used design for this character + if (plugin.Configuration.LastUsedDesignByCharacter.TryGetValue(character.Name, out var lastDesignName)) + { + var design = character.Designs.FirstOrDefault(d => d.Name == lastDesignName); + if (design != null) + { + selectedDesignIndex = character.Designs.IndexOf(design); + Plugin.Log.Debug($"[QuickSwitch] Found last used design: {lastDesignName} at index {selectedDesignIndex}"); + } + } + return; + } + } + } + + // Try the global last used character key + if (!string.IsNullOrEmpty(plugin.Configuration.LastUsedCharacterKey)) + { + var character = plugin.Characters.FirstOrDefault(c => c.Name == plugin.Configuration.LastUsedCharacterKey); + if (character != null) + { + selectedCharacterIndex = plugin.Characters.IndexOf(character); + Plugin.Log.Debug($"[QuickSwitch] Found global last used character: {character.Name} at index {selectedCharacterIndex}"); + + // Also try to get last design for this character + if (plugin.Configuration.LastUsedDesignByCharacter.TryGetValue(character.Name, out var lastDesignName)) + { + var design = character.Designs.FirstOrDefault(d => d.Name == lastDesignName); + if (design != null) + { + selectedDesignIndex = character.Designs.IndexOf(design); + Plugin.Log.Debug($"[QuickSwitch] Found last used design for global character: {lastDesignName} at index {selectedDesignIndex}"); + } + } + return; + } + } + + // Try main character if set + if (!string.IsNullOrEmpty(plugin.Configuration.MainCharacterName)) + { + var mainCharacter = plugin.Characters.FirstOrDefault(c => c.Name == plugin.Configuration.MainCharacterName); + if (mainCharacter != null) + { + selectedCharacterIndex = plugin.Characters.IndexOf(mainCharacter); + Plugin.Log.Debug($"[QuickSwitch] Defaulting to main character: {mainCharacter.Name} at index {selectedCharacterIndex}"); + return; + } + } + + // Default to first character + if (plugin.Characters.Count > 0) + { + selectedCharacterIndex = 0; + Plugin.Log.Debug($"[QuickSwitch] Defaulting to first character: {plugin.Characters[0].Name}"); + } + + Plugin.Log.Debug($"[QuickSwitch] Final selection - Character: {selectedCharacterIndex}, Design: {selectedDesignIndex}"); + } + catch (Exception ex) + { + Plugin.Log.Error($"[QuickSwitch] Error initializing selection: {ex.Message}"); + // Fallback to first character if anything goes wrong + if (plugin.Characters.Count > 0) + { + selectedCharacterIndex = 0; + } + } + } + + // Public method to refresh the selection + public void RefreshSelection() + { + hasInitializedSelection = false; + } + + public void UpdateSelectionFromCharacter(Character character) + { + if (character == null) return; + + var index = plugin.Characters.IndexOf(character); + if (index >= 0) + { + selectedCharacterIndex = index; + + // Try to maintain the last used design for this character + if (plugin.Configuration.LastUsedDesignByCharacter.TryGetValue(character.Name, out var lastDesignName)) + { + var design = character.Designs.FirstOrDefault(d => d.Name == lastDesignName); + if (design != null) + { + selectedDesignIndex = character.Designs.IndexOf(design); + } + else + { + selectedDesignIndex = -1; + } + } + else + { + selectedDesignIndex = -1; + } + + Plugin.Log.Debug($"[QuickSwitch] Updated selection to character: {character.Name} (index {selectedCharacterIndex})"); + } + } private Vector4 GetNameplateColor(Character character) { @@ -188,14 +327,13 @@ namespace CharacterSelectPlugin.Windows ? character.Designs[selectedDesignIndex].Name : "Select Design"; } + private Vector4 GetContrastingTextColor(Vector4 bgColor) { - // Calculate luminance (brightness perception) float brightness = (0.299f * bgColor.X + 0.587f * bgColor.Y + 0.114f * bgColor.Z); - return brightness > 0.5f ? new Vector4(0, 0, 0, 1) : new Vector4(1, 1, 1, 1); // Black for bright backgrounds, white for dark + return brightness > 0.5f ? new Vector4(0, 0, 0, 1) : new Vector4(1, 1, 1, 1); // } - private void ApplySelection() { if (selectedCharacterIndex < 0 || selectedCharacterIndex >= plugin.Characters.Count) @@ -206,30 +344,52 @@ namespace CharacterSelectPlugin.Windows plugin.ExecuteMacro(character.Macros, character, null); plugin.SetActiveCharacter(character); - var profileToSend = new RPProfile + if (Plugin.ClientState.LocalPlayer is { } player && player.HomeWorld.IsValid) { - Pronouns = character.RPProfile?.Pronouns, - Gender = character.RPProfile?.Gender, - Age = character.RPProfile?.Age, - Race = character.RPProfile?.Race, - Orientation = character.RPProfile?.Orientation, - Relationship = character.RPProfile?.Relationship, - Occupation = character.RPProfile?.Occupation, - Abilities = character.RPProfile?.Abilities, - Bio = character.RPProfile?.Bio, - Tags = character.RPProfile?.Tags, - CustomImagePath = !string.IsNullOrEmpty(character.RPProfile?.CustomImagePath) - ? character.RPProfile.CustomImagePath - : character.ImagePath, - ImageZoom = character.RPProfile?.ImageZoom ?? 1.0f, - ImageOffset = character.RPProfile?.ImageOffset ?? Vector2.Zero, - Sharing = character.RPProfile?.Sharing ?? ProfileSharing.AlwaysShare, - ProfileImageUrl = character.RPProfile?.ProfileImageUrl, - CharacterName = character.Name, - NameplateColor = character.NameplateColor - }; + string localName = player.Name.TextValue; + string worldName = player.HomeWorld.Value.Name.ToString(); + string fullKey = $"{localName}@{worldName}"; - _ = Plugin.UploadProfileAsync(profileToSend, character.LastInGameName ?? character.Name); + bool shouldUploadToGallery = ShouldUploadToGallery(character, fullKey); + + if (shouldUploadToGallery) + { + var profileToSend = new RPProfile + { + Pronouns = character.RPProfile?.Pronouns, + Gender = character.RPProfile?.Gender, + Age = character.RPProfile?.Age, + Race = character.RPProfile?.Race, + Orientation = character.RPProfile?.Orientation, + Relationship = character.RPProfile?.Relationship, + Occupation = character.RPProfile?.Occupation, + Abilities = character.RPProfile?.Abilities, + Bio = character.RPProfile?.Bio, + Tags = character.RPProfile?.Tags, + CustomImagePath = !string.IsNullOrEmpty(character.RPProfile?.CustomImagePath) + ? character.RPProfile.CustomImagePath + : character.ImagePath, + ImageZoom = character.RPProfile?.ImageZoom ?? 1.0f, + ImageOffset = character.RPProfile?.ImageOffset ?? Vector2.Zero, + Sharing = character.RPProfile?.Sharing ?? ProfileSharing.AlwaysShare, + ProfileImageUrl = character.RPProfile?.ProfileImageUrl, + CharacterName = character.Name, + NameplateColor = character.RPProfile?.ProfileColor ?? character.NameplateColor, + BackgroundImage = character.BackgroundImage, + Effects = character.Effects ?? new ProfileEffects(), + GalleryStatus = character.GalleryStatus, + Links = character.RPProfile?.Links, + LastActiveTime = plugin.Configuration.ShowRecentlyActiveStatus ? DateTime.UtcNow : null + }; + + _ = Plugin.UploadProfileAsync(profileToSend, character.LastInGameName ?? character.Name); + Plugin.Log.Info($"[QuickSwitch] ✓ Uploaded profile for {character.Name}"); + } + else + { + Plugin.Log.Info($"[QuickSwitch] ⚠ Skipped gallery upload for {character.Name} (not on main character or not public)"); + } + } // Always apply the design if selected if (selectedDesignIndex >= 0 && selectedDesignIndex < character.Designs.Count) @@ -246,5 +406,34 @@ namespace CharacterSelectPlugin.Windows else Plugin.Log.Debug("[QuickSwitch] Skipping idle pose restore — IdlePoseIndex is None."); } + + private bool ShouldUploadToGallery(Character character, string currentPhysicalCharacter) + { + // Is there a main character set? + var userMain = plugin.Configuration.GalleryMainCharacter; + if (string.IsNullOrEmpty(userMain)) + { + Plugin.Log.Debug($"[QuickSwitch-ShouldUpload] No main character set - not uploading {character.Name}"); + return false; + } + + // Are we currently on the main character? + if (currentPhysicalCharacter != userMain) + { + Plugin.Log.Debug($"[QuickSwitch-ShouldUpload] Current character '{currentPhysicalCharacter}' != main '{userMain}' - not uploading {character.Name}"); + return false; + } + + // Is this CS+ character set to public sharing? + var sharing = character.RPProfile?.Sharing ?? ProfileSharing.AlwaysShare; + if (sharing != ProfileSharing.ShowcasePublic) + { + Plugin.Log.Debug($"[QuickSwitch-ShouldUpload] Character '{character.Name}' sharing is '{sharing}' (not public) - not uploading"); + return false; + } + + Plugin.Log.Debug($"[QuickSwitch-ShouldUpload] ✓ All checks passed - will upload {character.Name} as {currentPhysicalCharacter}"); + return true; + } } } diff --git a/CharacterSelectPlugin/RPProfile.cs b/CharacterSelectPlugin/RPProfile.cs index 756ff3f..27011d0 100644 --- a/CharacterSelectPlugin/RPProfile.cs +++ b/CharacterSelectPlugin/RPProfile.cs @@ -1,4 +1,5 @@ using Newtonsoft.Json; +using System; using System.Numerics; namespace CharacterSelectPlugin @@ -15,7 +16,7 @@ namespace CharacterSelectPlugin public string? Bio { get; set; } public string? Tags { get; set; } - public string? CustomImagePath { get; set; } // Optional override for profile image + public string? CustomImagePath { get; set; } public float ImageZoom { get; set; } = 1.0f; public Vector2 ImageOffset { get; set; } = Vector2.Zero; public ProfileSharing Sharing { get; set; } = ProfileSharing.AlwaysShare; @@ -23,7 +24,16 @@ namespace CharacterSelectPlugin public string? CharacterName { get; set; } public Vector3Serializable NameplateColor { get; set; } = new(0.3f, 0.7f, 1f); public string? Race { get; set; } + public DateTime? LastActiveTime { get; set; } = null; + public string? BackgroundImage { get; set; } = null; + public ProfileEffects Effects { get; set; } = new ProfileEffects(); + public Vector3Serializable? ProfileColor { get; set; } = null; + public string? GalleryStatus { get; set; } + public string? Links { get; set; } + + [JsonIgnore] + public ProfileAnimationTheme? AnimationTheme { get; set; } = null; public bool IsEmpty() { @@ -36,14 +46,84 @@ namespace CharacterSelectPlugin && string.IsNullOrWhiteSpace(Occupation) && string.IsNullOrWhiteSpace(Abilities) && string.IsNullOrWhiteSpace(Bio) - && string.IsNullOrWhiteSpace(Tags); + && string.IsNullOrWhiteSpace(Tags) + && string.IsNullOrWhiteSpace(GalleryStatus); + } + + public void MigrateFromLegacyTheme() + { + if (AnimationTheme.HasValue && string.IsNullOrEmpty(BackgroundImage)) + { + switch (AnimationTheme.Value) + { + case ProfileAnimationTheme.Nature: + BackgroundImage = "forest_background.png"; + Effects.Fireflies = true; + Effects.FallingLeaves = true; + break; + case ProfileAnimationTheme.DarkGothic: + BackgroundImage = "gothic_background.png"; + Effects.Bats = true; + Effects.Fire = true; + Effects.Smoke = true; + break; + case ProfileAnimationTheme.MagicalParticles: + BackgroundImage = "magical_background.png"; + Effects.Fireflies = true; + Effects.Butterflies = true; + break; + case ProfileAnimationTheme.CircuitBoard: + case ProfileAnimationTheme.Minimalist: + BackgroundImage = null; + break; + } + AnimationTheme = null; + } } } + + public class ProfileEffects + { + public bool CircuitBoard { get; set; } = false; + public bool Fireflies { get; set; } = false; + public bool FallingLeaves { get; set; } = false; + public bool Butterflies { get; set; } = false; + public bool Bats { get; set; } = false; + public bool Fire { get; set; } = false; + public bool Smoke { get; set; } = false; + + // Colour customization for particles + public ParticleColorScheme ColorScheme { get; set; } = ParticleColorScheme.Auto; + public Vector3Serializable CustomParticleColor { get; set; } = new(1f, 1f, 1f); + } + + public enum ParticleColorScheme + { + Auto,// Automatically match the background/theme + Warm, // Oranges/golds - good for desert/Ul'dah + Cool, // Blues/teals - good for water/Limsa + Forest, // Greens - good for nature/Gridania + Magical, // Purples/blues - good for magical areas + Winter, // White/silver - good for snow/Ishgard + Custom // Use CustomParticleColour + } + public enum ProfileSharing { AlwaysShare, - NeverShare + NeverShare, + ShowcasePublic } + + public enum ProfileAnimationTheme + { + CircuitBoard, + Minimalist, + Nature, + DarkGothic, + MagicalParticles + } + public static class RPProfileJson { public static string Serialize(RPProfile profile) @@ -53,9 +133,12 @@ namespace CharacterSelectPlugin public static RPProfile? Deserialize(string json) { - return JsonConvert.DeserializeObject(json); + var profile = JsonConvert.DeserializeObject(json); + profile?.MigrateFromLegacyTheme(); + return profile; } } + public struct Vector3Serializable { public float X; diff --git a/CharacterSelectPlugin/TutorialManager.cs b/CharacterSelectPlugin/TutorialManager.cs new file mode 100644 index 0000000..f98ad76 --- /dev/null +++ b/CharacterSelectPlugin/TutorialManager.cs @@ -0,0 +1,2927 @@ +using ImGuiNET; +using System.Numerics; +using Dalamud.Interface; +using System; +using System.Linq; + +namespace CharacterSelectPlugin +{ + public enum TutorialStep + { + Welcome = 0, + AddCharacter = 1, + FillCharacterForm = 2, + ExploreOtherFields = 3, + SaveCharacter = 4, + CharacterSavedDialog = 5, + ClickDesignsButton = 6, + AddNewDesign = 7, + FillDesignForm = 8, + ExploreDesignOptions = 9, + SaveDesign = 10, + DesignManagement = 11, + ClickRPProfileButton = 12, + ClickEditProfile = 13, + StartWithPronouns = 14, + AddBio = 15, + ExploreImageOptions = 16, + ExploreVisualOptions = 17, + SetPrivacyAndSave = 18, + ExploreMainFeatures = 19, + SettingsOverview = 20, + QuickSwitchOverview = 21, + GalleryOverview = 22, + Complete = 23 + } + + public class TutorialManager + { + private readonly Plugin plugin; + private float lastFieldCheckTime = 0f; + private const float FIELD_CHECK_DELAY = 1.0f; // Let users type! + + public bool IsActive => plugin.Configuration.TutorialActive; + public TutorialStep CurrentStep => (TutorialStep)plugin.Configuration.CurrentTutorialStep; + + public TutorialManager(Plugin plugin) + { + this.plugin = plugin; + } + + public void StartTutorial() + { + Plugin.Log.Info("[Tutorial] Starting tutorial"); + plugin.Configuration.TutorialActive = true; + plugin.Configuration.CurrentTutorialStep = 0; + plugin.Configuration.HasSeenTutorial = false; + plugin.Configuration.Save(); + } + + public void NextStep() + { + plugin.Configuration.CurrentTutorialStep++; + plugin.Configuration.Save(); + Plugin.Log.Info($"[Tutorial] Advanced to step {plugin.Configuration.CurrentTutorialStep}"); + } + + public void EndTutorial() + { + plugin.Configuration.TutorialActive = false; + plugin.Configuration.HasSeenTutorial = true; + plugin.Configuration.Save(); + Plugin.Log.Info("[Tutorial] Tutorial ended"); + } + + public void DrawTutorialOverlay() + { + if (!IsActive) return; + + var dpiScale = ImGui.GetIO().DisplayFramebufferScale.X; + var uiScale = plugin.Configuration.UIScaleMultiplier; + var totalScale = GetSafeScale(dpiScale * uiScale); + + CheckTutorialProgression(); + + switch (CurrentStep) + { + case TutorialStep.Welcome: + DrawWelcomeStep(totalScale); + break; + case TutorialStep.AddCharacter: + if (!plugin.IsAddCharacterWindowOpen) + { + DrawAddCharacterHighlight(totalScale); + } + break; + case TutorialStep.FillCharacterForm: + DrawCharacterFormHelp(totalScale); + break; + case TutorialStep.ExploreOtherFields: + DrawOtherFieldsHelp(totalScale); + break; + case TutorialStep.SaveCharacter: + DrawSaveCharacterHelp(totalScale); + break; + case TutorialStep.CharacterSavedDialog: + DrawCharacterSavedDialog(totalScale); + break; + case TutorialStep.ClickDesignsButton: + DrawClickDesignsButtonHelp(totalScale); + break; + case TutorialStep.AddNewDesign: + DrawAddNewDesignHelp(totalScale); + break; + case TutorialStep.FillDesignForm: + DrawFillDesignFormHelp(totalScale); + break; + case TutorialStep.ExploreDesignOptions: + DrawExploreDesignOptionsHelp(totalScale); + break; + case TutorialStep.SaveDesign: + DrawSaveDesignHelp(totalScale); + break; + case TutorialStep.DesignManagement: + DrawDesignManagementHelp(totalScale); + break; + case TutorialStep.ClickRPProfileButton: + DrawClickRPProfileButtonHelp(totalScale); + break; + case TutorialStep.ClickEditProfile: + DrawClickEditProfileHelp(totalScale); + break; + case TutorialStep.StartWithPronouns: + DrawStartWithPronounsHelp(totalScale); + break; + case TutorialStep.AddBio: + DrawAddBioHelp(totalScale); + break; + case TutorialStep.ExploreImageOptions: + DrawExploreImageOptionsHelp(totalScale); + break; + case TutorialStep.ExploreVisualOptions: + DrawExploreVisualOptionsHelp(totalScale); + break; + case TutorialStep.SetPrivacyAndSave: + DrawSaveRPProfileHelp(totalScale); + break; + case TutorialStep.ExploreMainFeatures: + DrawExploreMainFeaturesHelp(totalScale); + break; + case TutorialStep.SettingsOverview: + DrawSettingsOverviewHelp(totalScale); + break; + case TutorialStep.QuickSwitchOverview: + DrawQuickSwitchOverviewHelp(totalScale); + break; + case TutorialStep.GalleryOverview: + DrawGalleryOverviewHelp(totalScale); + break; + case TutorialStep.Complete: + DrawTutorialCompleteHelp(totalScale); + break; + } + } + + private void CheckTutorialProgression() + { + switch (CurrentStep) + { + case TutorialStep.AddCharacter: + if (plugin.IsAddCharacterWindowOpen) + { + Plugin.Log.Info("[Tutorial] Add Character window opened, advancing to form step"); + NextStep(); + } + break; + + case TutorialStep.FillCharacterForm: + if (!plugin.IsAddCharacterWindowOpen) + { + if (plugin.Characters.Count > 0) + { + plugin.Configuration.CurrentTutorialStep = (int)TutorialStep.CharacterSavedDialog; + plugin.Configuration.Save(); + } + else + { + plugin.Configuration.CurrentTutorialStep = (int)TutorialStep.AddCharacter; + plugin.Configuration.Save(); + } + } + else + { + float currentTime = (float)ImGui.GetTime(); + if (currentTime - lastFieldCheckTime > FIELD_CHECK_DELAY) + { + if (AreRequiredFieldsFilled()) + { + NextStep(); + } + } + + if (!AreRequiredFieldsFilled()) + { + lastFieldCheckTime = currentTime; + } + } + break; + + case TutorialStep.ExploreOtherFields: + if (!plugin.IsAddCharacterWindowOpen) + { + if (plugin.Characters.Count > 0) + { + plugin.Configuration.CurrentTutorialStep = (int)TutorialStep.CharacterSavedDialog; + plugin.Configuration.Save(); + } + else + { + plugin.Configuration.CurrentTutorialStep = (int)TutorialStep.AddCharacter; + plugin.Configuration.Save(); + } + } + break; + + case TutorialStep.SaveCharacter: + if (!plugin.IsAddCharacterWindowOpen) + { + if (plugin.Characters.Count > 0) + { + NextStep(); + } + else + { + plugin.Configuration.CurrentTutorialStep = (int)TutorialStep.AddCharacter; + } + plugin.Configuration.Save(); + } + break; + + case TutorialStep.ClickDesignsButton: + if (plugin.IsDesignPanelOpen) + { + NextStep(); + } + break; + + case TutorialStep.AddNewDesign: + if (plugin.IsEditDesignWindowOpen) + { + NextStep(); + } + break; + + case TutorialStep.FillDesignForm: + if (!plugin.IsEditDesignWindowOpen) + { + // Check if a design was actually created + if (plugin.Characters.Count > 0 && plugin.Characters[0].Designs.Count > 0) + { + plugin.Configuration.CurrentTutorialStep = (int)TutorialStep.DesignManagement; + plugin.Configuration.Save(); + } + else + { + plugin.Configuration.CurrentTutorialStep = (int)TutorialStep.AddNewDesign; + plugin.Configuration.Save(); + } + } + else + { + float currentTime = (float)ImGui.GetTime(); + if (currentTime - lastFieldCheckTime > FIELD_CHECK_DELAY) + { + if (AreRequiredDesignFieldsFilled()) + { + NextStep(); + } + } + + if (!AreRequiredDesignFieldsFilled()) + { + lastFieldCheckTime = currentTime; + } + } + break; + + case TutorialStep.ExploreDesignOptions: + if (!plugin.IsEditDesignWindowOpen) + { + if (plugin.Characters.Count > 0 && plugin.Characters[0].Designs.Count > 0) + { + // Don't skip to Complete + plugin.Configuration.CurrentTutorialStep = (int)TutorialStep.DesignManagement; + plugin.Configuration.Save(); + } + else + { + plugin.Configuration.CurrentTutorialStep = (int)TutorialStep.AddNewDesign; + plugin.Configuration.Save(); + } + } + break; + + case TutorialStep.SaveDesign: + if (!plugin.IsEditDesignWindowOpen) + { + if (plugin.Characters.Count > 0 && plugin.Characters[0].Designs.Count > 0) + { + NextStep(); + } + else + { + plugin.Configuration.CurrentTutorialStep = (int)TutorialStep.AddNewDesign; + } + plugin.Configuration.Save(); + } + break; + case TutorialStep.DesignManagement: + break; + case TutorialStep.ClickRPProfileButton: + if (plugin.IsRPProfileViewerOpen) + { + NextStep(); + } + break; + + case TutorialStep.ClickEditProfile: + if (plugin.IsRPProfileEditorOpen) + { + NextStep(); + } + break; + + case TutorialStep.StartWithPronouns: + if (!plugin.IsRPProfileEditorOpen) + { + plugin.Configuration.CurrentTutorialStep = (int)TutorialStep.ClickEditProfile; + plugin.Configuration.Save(); + } + break; + + case TutorialStep.AddBio: + if (!plugin.IsRPProfileEditorOpen) + { + plugin.Configuration.CurrentTutorialStep = (int)TutorialStep.ClickEditProfile; + plugin.Configuration.Save(); + } + else + { + // Check if bio has content + var activeCharacter = plugin.Characters.FirstOrDefault(); + if (activeCharacter?.RPProfile != null && !string.IsNullOrWhiteSpace(activeCharacter.RPProfile.Bio)) + { + NextStep(); + } + } + break; + + case TutorialStep.ExploreImageOptions: + if (!plugin.IsRPProfileEditorOpen) + { + plugin.Configuration.CurrentTutorialStep = (int)TutorialStep.ClickEditProfile; + plugin.Configuration.Save(); + } + break; + + case TutorialStep.ExploreVisualOptions: + if (!plugin.IsRPProfileEditorOpen) + { + plugin.Configuration.CurrentTutorialStep = (int)TutorialStep.ClickEditProfile; + plugin.Configuration.Save(); + } + break; + + case TutorialStep.SetPrivacyAndSave: + if (!plugin.IsRPProfileEditorOpen) + { + NextStep(); + } + break; + + case TutorialStep.Complete: + break; + case TutorialStep.ExploreMainFeatures: + break; + + case TutorialStep.SettingsOverview: + break; + + case TutorialStep.QuickSwitchOverview: + break; + + case TutorialStep.GalleryOverview: + break; + } + } + + private bool AreRequiredFieldsFilled() + { + // Check if the three required fields have meaningful content + bool hasName = !string.IsNullOrWhiteSpace(plugin.NewCharacterName) && plugin.NewCharacterName.Length >= 2; + bool hasPenumbra = !string.IsNullOrWhiteSpace(plugin.NewPenumbraCollection) && plugin.NewPenumbraCollection.Length >= 2; + bool hasGlamourer = !string.IsNullOrWhiteSpace(plugin.NewGlamourerDesign) && plugin.NewGlamourerDesign.Length >= 2; + + return hasName && hasPenumbra && hasGlamourer; + } + private bool AreRequiredDesignFieldsFilled() + { + bool hasName = !string.IsNullOrWhiteSpace(plugin.EditedDesignName) && plugin.EditedDesignName.Length >= 2; + bool hasGlamourer = !string.IsNullOrWhiteSpace(plugin.EditedGlamourerDesign) && plugin.EditedGlamourerDesign.Length >= 2; + return hasName && hasGlamourer; + } + + private void DrawWelcomeStep(float scale) + { + var viewport = ImGui.GetMainViewport(); + var center = viewport.GetCenter(); + ImGui.SetNextWindowPos(center, ImGuiCond.Always, new Vector2(0.5f, 0.5f)); + ImGui.SetNextWindowSize(new Vector2(480 * scale, 360 * scale), ImGuiCond.Always); + + ImGui.PushStyleColor(ImGuiCol.WindowBg, new Vector4(0.05f, 0.05f, 0.08f, 0.95f)); + ImGui.PushStyleColor(ImGuiCol.Border, new Vector4(0.3f, 0.3f, 0.4f, 0.8f)); + ImGui.PushStyleColor(ImGuiCol.TitleBg, new Vector4(0.08f, 0.08f, 0.12f, 1.0f)); + ImGui.PushStyleColor(ImGuiCol.TitleBgActive, new Vector4(0.12f, 0.12f, 0.18f, 1.0f)); + ImGui.PushStyleVar(ImGuiStyleVar.WindowBorderSize, 1f * scale); // Scale border + ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 6f * scale); // Scale rounding + ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(15 * scale, 15 * scale)); + + ImGui.SetNextWindowFocus(); + + if (ImGui.Begin("Welcome to Character Select+ v1.2!", ImGuiWindowFlags.NoCollapse | + ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoMove)) + { + // Icons + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf005"); // Star icon + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.85f, 0.85f, 0.9f, 1.0f), "Welcome to Character Select+ v1.2!"); + + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + + ImGui.PushTextWrapPos(ImGui.GetContentRegionAvail().X); + ImGui.TextColored(new Vector4(0.8f, 0.8f, 0.85f, 1.0f), "Ready to create your first Character? Don't worry, it's easier than explaining why you need 47 different glamour plates:"); + ImGui.PopTextWrapPos(); + + ImGui.Spacing(); + ImGui.Indent(15 * scale); + ImGui.BulletText("Creating your first Character profile"); + ImGui.BulletText("Adding Glamourer designs"); + ImGui.BulletText("Setting up your RP Profile with backgrounds & animations"); + ImGui.BulletText("Exploring the character gallery"); + ImGui.BulletText("(Close other windows during the tutorial for the best experience)"); + ImGui.Unindent(15 * scale); + + ImGui.Spacing(); + ImGui.Spacing(); + + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf0eb"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.6f, 0.7f, 0.8f, 1.0f), "Don't worry - you can end this tutorial at anytime and explore on your own!"); + + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + + float buttonWidth = 120f * scale; + float spacing = 20f * scale; + float totalWidth = (buttonWidth * 2) + spacing; + float startX = (ImGui.GetContentRegionAvail().X - totalWidth) * 0.5f; + + ImGui.SetCursorPosX(startX); + + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.15f, 0.4f, 0.15f, 0.8f)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.2f, 0.5f, 0.2f, 1f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.1f, 0.35f, 0.1f, 1f)); + + if (ImGui.Button("Start Tutorial", new Vector2(buttonWidth, 28 * scale))) + { + NextStep(); + } + ImGui.PopStyleColor(3); + + ImGui.SameLine(); + ImGui.SetCursorPosX(startX + buttonWidth + spacing); + + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.2f, 0.2f, 0.25f, 0.8f)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.25f, 0.25f, 0.3f, 1f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.15f, 0.15f, 0.2f, 1f)); + + if (ImGui.Button("Skip Tutorial", new Vector2(buttonWidth, 28 * scale))) + { + EndTutorial(); + } + ImGui.PopStyleColor(3); + + ImGui.End(); + } + + ImGui.PopStyleVar(3); + ImGui.PopStyleColor(4); + } + + private void DrawAddCharacterHighlight(float scale) + { + var buttonInfo = GetAddCharacterButtonInfo(); + + if (buttonInfo.HasValue) + { + var (buttonPos, buttonSize) = buttonInfo.Value; + + DrawInstructionPopup("Create Your First Character", + "Click the 'Add Character' button to create your first Character profile.", + buttonPos, buttonSize, scale); + + HighlightButton(buttonPos, buttonSize, scale); + } + else + { + Plugin.Log.Debug("[Tutorial] Button position not found, using fallback"); + DrawInstructionPopup("Create Your First Character", + "Look for the 'Add Character' button and click it to create your first Character profile.", + null, null, scale); + } + } + + private (Vector2 pos, Vector2 size)? GetAddCharacterButtonInfo() + { + if (plugin.AddCharacterButtonPos.HasValue && plugin.AddCharacterButtonSize.HasValue) + { + return (plugin.AddCharacterButtonPos.Value, plugin.AddCharacterButtonSize.Value); + } + return null; + } + + private void DrawInstructionPopup(string title, string instruction, Vector2? buttonPos = null, Vector2? buttonSize = null, float scale = 1.0f) + { + Vector2 popupPos; + + if (buttonPos.HasValue && buttonSize.HasValue) + { + popupPos = new Vector2( + buttonPos.Value.X + buttonSize.Value.X + (30 * scale), + buttonPos.Value.Y + (20 * scale) + ); + + var viewport = ImGui.GetMainViewport(); + if (popupPos.Y < viewport.Pos.Y + (50 * scale)) + popupPos.Y = viewport.Pos.Y + (50 * scale); + if (popupPos.X + (300 * scale) > viewport.Pos.X + viewport.Size.X) + popupPos.X = buttonPos.Value.X - (330 * scale); + } + else + { + var viewport = ImGui.GetMainViewport(); + popupPos = new Vector2(viewport.Pos.X + (200 * scale), viewport.Pos.Y + (80 * scale)); + } + + ImGui.SetNextWindowPos(popupPos); + ImGui.SetNextWindowSize(new Vector2(300 * scale, 140 * scale), ImGuiCond.Always); + + ImGui.PushStyleColor(ImGuiCol.WindowBg, new Vector4(0.05f, 0.05f, 0.08f, 0.95f)); + ImGui.PushStyleColor(ImGuiCol.Border, new Vector4(0.3f, 0.3f, 0.4f, 0.8f)); + ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 6f * scale); + ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(15 * scale, 15 * scale)); + + // Window close detection + bool windowOpen = true; + if (ImGui.Begin($"Tutorial: {title}", ref windowOpen, ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoMove)) + { + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf007"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.85f, 0.85f, 0.9f, 1.0f), title); + + ImGui.Separator(); + ImGui.Spacing(); + + ImGui.PushTextWrapPos(ImGui.GetContentRegionAvail().X); + ImGui.TextColored(new Vector4(0.8f, 0.8f, 0.85f, 1.0f), instruction); + ImGui.PopTextWrapPos(); + + ImGui.Spacing(); + + if (ImGui.Button("End Tutorial", new Vector2(100 * scale, 25 * scale))) + { + EndTutorial(); + } + + ImGui.End(); + } + + if (!windowOpen) + { + EndTutorial(); + } + + ImGui.PopStyleVar(2); + ImGui.PopStyleColor(2); + } + public void DrawRPProfileOverlays() + { + if (!IsActive) return; + + var dpiScale = ImGui.GetIO().DisplayFramebufferScale.X; + var uiScale = plugin.Configuration.UIScaleMultiplier; + var totalScale = dpiScale * uiScale; + + Plugin.Log.Info($"[Tutorial] RP Profile Overlay - Step: {CurrentStep}, Editor Open: {plugin.IsRPProfileEditorOpen}"); + + switch (CurrentStep) + { + case TutorialStep.StartWithPronouns: + DrawStartWithPronounsHelp(totalScale); + break; + case TutorialStep.AddBio: + DrawAddBioHelp(totalScale); + break; + case TutorialStep.ExploreImageOptions: + DrawExploreImageOptionsHelp(totalScale); + break; + case TutorialStep.ExploreVisualOptions: + DrawExploreVisualOptionsHelp(totalScale); + break; + case TutorialStep.SetPrivacyAndSave: + DrawSaveRPProfileHelp(totalScale); + break; + } + } + + + private void HighlightButton(Vector2 buttonPos, Vector2 buttonSize, float scale) + { + var dl = ImGui.GetForegroundDrawList(); + + float time = (float)ImGui.GetTime(); + float pulse = 0.5f + 0.5f * (float)Math.Sin(time * 3.0f); + var glowColor = new Vector4(0.3f, 0.6f, 1.0f, pulse * 0.6f); + + for (int i = 0; i < 3; i++) + { + float expansion = (i * 6f + pulse * 4f) * scale; + dl.AddRect( + buttonPos - new Vector2(expansion, expansion), + buttonPos + buttonSize + new Vector2(expansion, expansion), + ImGui.ColorConvertFloat4ToU32(new Vector4(glowColor.X, glowColor.Y, glowColor.Z, glowColor.W * (1f - i * 0.3f))), + 4f * scale, ImDrawFlags.None, 2f * scale + ); + } + + var arrowEnd = buttonPos + new Vector2(-10 * scale, buttonSize.Y / 2); + var arrowStart = arrowEnd + new Vector2(-25 * scale, 0); + dl.AddLine(arrowStart, arrowEnd, ImGui.ColorConvertFloat4ToU32(new Vector4(0.9f, 0.9f, 0.4f, 1f)), 3f * scale); + + var arrowHead1 = arrowEnd + new Vector2(-8 * scale, -4 * scale); + var arrowHead2 = arrowEnd + new Vector2(-8 * scale, 4 * scale); + dl.AddLine(arrowEnd, arrowHead1, ImGui.ColorConvertFloat4ToU32(new Vector4(0.9f, 0.9f, 0.4f, 1f)), 3f * scale); + dl.AddLine(arrowEnd, arrowHead2, ImGui.ColorConvertFloat4ToU32(new Vector4(0.9f, 0.9f, 0.4f, 1f)), 3f * scale); + } + + + private void DrawCharacterFormHelp(float scale) + { + if (!plugin.IsAddCharacterWindowOpen) return; + + + Vector2 popupPos; + if (plugin.CharacterNameFieldPos.HasValue && plugin.CharacterNameFieldSize.HasValue) + { + popupPos = new Vector2( + plugin.CharacterNameFieldPos.Value.X + plugin.CharacterNameFieldSize.Value.X + (50 * scale), + plugin.CharacterNameFieldPos.Value.Y - (50 * scale) + ); + } + else if (plugin.PenumbraFieldPos.HasValue && plugin.PenumbraFieldSize.HasValue) + { + popupPos = new Vector2( + plugin.PenumbraFieldPos.Value.X + plugin.PenumbraFieldSize.Value.X + (50 * scale), + plugin.PenumbraFieldPos.Value.Y - (50 * scale) + ); + } + else + { + var viewport = ImGui.GetMainViewport(); + popupPos = new Vector2(viewport.Pos.X + (500 * scale), viewport.Pos.Y + (200 * scale)); + } + + ImGui.SetNextWindowPos(popupPos); + ImGui.SetNextWindowSize(new Vector2(540 * scale, 280 * scale), ImGuiCond.Always); // Scale window + + ImGui.PushStyleColor(ImGuiCol.WindowBg, new Vector4(0.05f, 0.05f, 0.08f, 0.95f)); + ImGui.PushStyleColor(ImGuiCol.Border, new Vector4(0.3f, 0.6f, 0.3f, 0.8f)); + ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 6f * scale); + ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(15 * scale, 15 * scale)); + + if (ImGui.Begin("Tutorial: Required Fields", ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoMove)) + { + // Icons + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf040"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.7f, 0.9f, 0.7f, 1.0f), "Fill Required Fields"); + + ImGui.Separator(); + ImGui.Spacing(); + + ImGui.TextColored(new Vector4(0.8f, 0.8f, 0.85f, 1.0f), "Fill in these required fields (* marked) to create your character:"); + ImGui.Spacing(); + + // Required fields list + ImGui.BulletText("Character Name* - Your OC's name"); + ImGui.BulletText("Penumbra Collection* - Must match exactly"); + ImGui.BulletText("Glamourer Design* - Must match exactly"); + + ImGui.Spacing(); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(1.0f, 0.8f, 0.3f, 1.0f)); + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf071"); // Warning triangle + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.Text(" Names must match EXACTLY - we're more picky than a cat choosing where to nap!"); + ImGui.PopStyleColor(); + + ImGui.Spacing(); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.9f, 0.7f, 0.3f, 1.0f)); + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf005"); // Star icon + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.9f, 0.7f, 0.3f, 1.0f), " Required fields will glow to guide you!"); + ImGui.PopStyleColor(); + ImGui.Spacing(); + + if (ImGui.Button("End Tutorial", new Vector2(100 * scale, 25 * scale))) + { + EndTutorial(); + } + + ImGui.End(); + } + + ImGui.PopStyleVar(2); + ImGui.PopStyleColor(2); + HighlightRequiredFields(scale); + } + + private void DrawOtherFieldsHelp(float scale) + { + if (!plugin.IsAddCharacterWindowOpen) return; + + Vector2 popupPos; + if (plugin.CharacterNameFieldPos.HasValue && plugin.CharacterNameFieldSize.HasValue) + { + popupPos = new Vector2( + plugin.CharacterNameFieldPos.Value.X + plugin.CharacterNameFieldSize.Value.X + (50 * scale), + plugin.CharacterNameFieldPos.Value.Y + (100 * scale) + ); + } + else + { + var viewport = ImGui.GetMainViewport(); + popupPos = new Vector2(viewport.Pos.X + (500 * scale), viewport.Pos.Y + (300 * scale)); + } + + ImGui.SetNextWindowPos(popupPos); + ImGui.SetNextWindowSize(new Vector2(480 * scale, 400 * scale), ImGuiCond.Always); + + ImGui.PushStyleColor(ImGuiCol.WindowBg, new Vector4(0.05f, 0.05f, 0.08f, 0.95f)); + ImGui.PushStyleColor(ImGuiCol.Border, new Vector4(0.4f, 0.6f, 0.8f, 0.8f)); + ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 6f * scale); + ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(15 * scale, 15 * scale)); + + if (ImGui.Begin("Tutorial: Optional Features", ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoMove)) + { + // Icons + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf53f"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.7f, 0.8f, 1.0f, 1.0f), "Explore Optional Features"); + + ImGui.Separator(); + ImGui.Spacing(); + + ImGui.TextColored(new Vector4(0.8f, 0.8f, 0.85f, 1.0f), "Excellent! You've survived the mandatory paperwork. Now for the fun stuff:"); + ImGui.Spacing(); + + // Optional features + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf53f"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.9f, 0.7f, 0.3f, 1.0f), "Nameplate Colour"); + + ImGui.PushTextWrapPos(ImGui.GetContentRegionAvail().X - (20 * scale)); + ImGui.TextColored(new Vector4(0.7f, 0.7f, 0.75f, 1.0f), "Because your Character deserves a fabulous frame colour (I said colour, not color!)"); + ImGui.PopTextWrapPos(); + ImGui.Spacing(); + + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf03e"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.9f, 0.7f, 0.3f, 1.0f), "Choose Image"); + + ImGui.PushTextWrapPos(ImGui.GetContentRegionAvail().X - (20 * scale)); + ImGui.TextColored(new Vector4(0.7f, 0.7f, 0.75f, 1.0f), "Upload a custom picture instead of the default placeholder"); + ImGui.PopTextWrapPos(); + ImGui.Spacing(); + + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf013"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.9f, 0.7f, 0.3f, 1.0f), "Advanced Mode"); + + ImGui.PushTextWrapPos(ImGui.GetContentRegionAvail().X - (20 * scale)); + ImGui.TextColored(new Vector4(0.7f, 0.7f, 0.75f, 1.0f), "For power users! Manually edit macros and add custom commands (i.e job change)"); + ImGui.PopTextWrapPos(); + ImGui.Spacing(); + + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf0eb"); // Lightbulb icon + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.6f, 0.8f, 0.6f, 1.0f), " You can always edit these later! For now, let's save your Character."); + + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf0eb"); // Lightbulb icon + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.6f, 0.8f, 0.6f, 1.0f), " You can also use '/select Character Name' to switch to your character!."); + + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + float buttonWidth = 120f * scale; + float spacing = 15f * scale; + float totalWidth = (buttonWidth * 2) + spacing; + float startX = (ImGui.GetContentRegionAvail().X - totalWidth) * 0.5f; + + ImGui.SetCursorPosX(startX); + + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.15f, 0.4f, 0.15f, 0.8f)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.2f, 0.5f, 0.2f, 1f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.1f, 0.35f, 0.1f, 1f)); + + if (ImGui.Button("Continue", new Vector2(buttonWidth, 25 * scale))) + { + NextStep(); + } + ImGui.PopStyleColor(3); + + ImGui.SameLine(); + ImGui.SetCursorPosX(startX + buttonWidth + spacing); + + if (ImGui.Button("End Tutorial", new Vector2(buttonWidth, 25 * scale))) + { + EndTutorial(); + } + + ImGui.End(); + } + + ImGui.PopStyleVar(2); + ImGui.PopStyleColor(2); + } + + private void DrawSaveCharacterHelp(float scale) + { + if (!plugin.IsAddCharacterWindowOpen) return; + + Vector2 popupPos; + if (plugin.SaveButtonPos.HasValue && plugin.SaveButtonSize.HasValue) + { + popupPos = new Vector2( + plugin.SaveButtonPos.Value.X + plugin.SaveButtonSize.Value.X + (30 * scale), + plugin.SaveButtonPos.Value.Y - (100 * scale) + ); + } + else + { + var viewport = ImGui.GetMainViewport(); + popupPos = new Vector2(viewport.Pos.X + (500 * scale), viewport.Pos.Y + (400 * scale)); + } + + ImGui.SetNextWindowPos(popupPos); + ImGui.SetNextWindowSize(new Vector2(500 * scale, 240 * scale), ImGuiCond.Always); + + ImGui.PushStyleColor(ImGuiCol.WindowBg, new Vector4(0.05f, 0.05f, 0.08f, 0.95f)); + ImGui.PushStyleColor(ImGuiCol.Border, new Vector4(0.3f, 0.8f, 0.3f, 0.8f)); + ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 6f * scale); + ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(15 * scale, 15 * scale)); + + if (ImGui.Begin("Tutorial: Save Character", ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoMove)) + { + // Icons + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf00c"); // Check mark icon + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.7f, 1.0f, 0.7f, 1.0f), "Ready to Save!"); + + ImGui.Separator(); + ImGui.Spacing(); + + ImGui.TextColored(new Vector4(0.8f, 0.8f, 0.85f, 1.0f), "Perfect! Time to make it official - your Character's birth certificate awaits!"); + + ImGui.Spacing(); + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf0eb"); // Lightbulb icon + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.6f, 0.8f, 0.6f, 1.0f), " After saving, you can:"); + ImGui.BulletText("Create Designs for different outfits"); + ImGui.BulletText("Set up RP Profiles with backgrounds & animations"); + ImGui.BulletText("Explore the character gallery"); + + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + + float buttonWidth = 120f; + float startX = (ImGui.GetContentRegionAvail().X - buttonWidth) * 0.5f; + ImGui.SetCursorPosX(startX); + + if (ImGui.Button("End Tutorial", new Vector2(buttonWidth, 25 * scale))) + { + EndTutorial(); + } + + ImGui.End(); + } + + ImGui.PopStyleVar(2); + ImGui.PopStyleColor(2); + + HighlightSaveButton(scale); + } + private void DrawCharacterSavedDialog(float scale) + { + var viewport = ImGui.GetMainViewport(); + var center = viewport.GetCenter(); + ImGui.SetNextWindowPos(center, ImGuiCond.Always, new Vector2(0.5f, 0.5f)); + ImGui.SetNextWindowSize(new Vector2(520 * scale, 240 * scale), ImGuiCond.Always); + + ImGui.PushStyleColor(ImGuiCol.WindowBg, new Vector4(0.05f, 0.05f, 0.08f, 0.95f)); + ImGui.PushStyleColor(ImGuiCol.Border, new Vector4(0.3f, 0.8f, 0.3f, 0.8f)); + ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 6f * scale); + ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(15 * scale, 15 * scale)); + + ImGui.SetNextWindowFocus(); + + if (ImGui.Begin("Character Created!", ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoMove)) + { + // Icons + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf00c"); // Checkmark icon + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.7f, 1.0f, 0.7f, 1.0f), "Character Created Successfully!"); + + ImGui.Separator(); + ImGui.Spacing(); + + ImGui.TextColored(new Vector4(0.8f, 0.8f, 0.85f, 1.0f), "Congratulations! Your digital offspring has been successfully birthed into existence."); + + ImGui.Spacing(); + ImGui.TextColored(new Vector4(0.8f, 0.8f, 0.85f, 1.0f), "What would you like to do next?"); + ImGui.Spacing(); + + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf553"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.9f, 0.7f, 0.3f, 1.0f), "Continue: Learn to create Designs (outfits) for your Character"); + + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf00c"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.9f, 0.7f, 0.3f, 1.0f), "Finish: End the tutorial and explore on your own"); + + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + + float buttonWidth = 150f * scale; + float spacing = 20f * scale; + float totalWidth = (buttonWidth * 2) + spacing; + float startX = (ImGui.GetContentRegionAvail().X - totalWidth) * 0.5f; + + ImGui.SetCursorPosX(startX); + + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.15f, 0.4f, 0.15f, 0.8f)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.2f, 0.5f, 0.2f, 1f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.1f, 0.35f, 0.1f, 1f)); + + if (ImGui.Button("Continue to Designs", new Vector2(buttonWidth, 28 * scale))) + { + NextStep(); + } + ImGui.PopStyleColor(3); + + ImGui.SameLine(); + ImGui.SetCursorPosX(startX + buttonWidth + spacing); + + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.2f, 0.2f, 0.25f, 0.8f)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.25f, 0.25f, 0.3f, 1f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.15f, 0.15f, 0.2f, 1f)); + + if (ImGui.Button("Finish Tutorial", new Vector2(buttonWidth, 28 * scale))) + { + EndTutorial(); + } + ImGui.PopStyleColor(3); + + ImGui.End(); + } + + ImGui.PopStyleVar(2); + ImGui.PopStyleColor(2); + } + + private void DrawClickDesignsButtonHelp(float scale) + { + // Position next to the first character's designs button + Vector2 popupPos; + if (plugin.FirstCharacterDesignsButtonPos.HasValue && plugin.FirstCharacterDesignsButtonSize.HasValue) + { + popupPos = new Vector2( + plugin.FirstCharacterDesignsButtonPos.Value.X + plugin.FirstCharacterDesignsButtonSize.Value.X + (30 * scale), + plugin.FirstCharacterDesignsButtonPos.Value.Y - (50 * scale) + ); + } + else + { + var viewport = ImGui.GetMainViewport(); + popupPos = new Vector2(viewport.Pos.X + (400 * scale), viewport.Pos.Y + (300 * scale)); + } + + ImGui.SetNextWindowPos(popupPos); + ImGui.SetNextWindowSize(new Vector2(460 * scale, 170 * scale), ImGuiCond.Always); + + ImGui.PushStyleColor(ImGuiCol.WindowBg, new Vector4(0.05f, 0.05f, 0.08f, 0.95f)); + ImGui.PushStyleColor(ImGuiCol.Border, new Vector4(0.6f, 0.4f, 0.8f, 0.8f)); // Purple border + ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 6f * scale); + ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(15 * scale, 15 * scale)); + + if (ImGui.Begin("Tutorial: Add Design", ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoMove)) + { + // Icons + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf553"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.8f, 0.6f, 1.0f, 1.0f), "Create Your First Design"); + + ImGui.Separator(); + ImGui.Spacing(); + + ImGui.PushTextWrapPos(ImGui.GetContentRegionAvail().X - 10); + ImGui.TextColored(new Vector4(0.8f, 0.8f, 0.85f, 1.0f), "Brilliant! Your character exists, but they're probably feeling a bit naked. Let's fix that wardrobe situation."); + ImGui.PopTextWrapPos(); + + ImGui.Spacing(); + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf005"); // Star icon + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.9f, 0.7f, 0.3f, 1.0f), " Click the 'Designs' button on your Character!"); + + ImGui.Spacing(); + if (ImGui.Button("End Tutorial", new Vector2(100 * scale, 25 * scale))) + { + EndTutorial(); + } + + ImGui.End(); + } + + ImGui.PopStyleVar(2); + ImGui.PopStyleColor(2); + + // Highlight the designs button + if (plugin.FirstCharacterDesignsButtonPos.HasValue && plugin.FirstCharacterDesignsButtonSize.HasValue) + { + HighlightButton(plugin.FirstCharacterDesignsButtonPos.Value, plugin.FirstCharacterDesignsButtonSize.Value, scale); + } + } + + private void DrawAddNewDesignHelp(float scale) + { + Vector2 popupPos; + if (plugin.DesignPanelAddButtonPos.HasValue && plugin.DesignPanelAddButtonSize.HasValue) + { + popupPos = new Vector2( + plugin.DesignPanelAddButtonPos.Value.X + (300 * scale), + plugin.DesignPanelAddButtonPos.Value.Y - (30 * scale) + ); + + var viewport = ImGui.GetMainViewport(); + if (popupPos.X + (440 * scale) > viewport.Pos.X + viewport.Size.X) + { + popupPos.X = plugin.DesignPanelAddButtonPos.Value.X - (460 * scale); + } + } + else + { + var viewport = ImGui.GetMainViewport(); + popupPos = new Vector2(viewport.Pos.X + (700 * scale), viewport.Pos.Y + (200 * scale)); + } + + ImGui.SetNextWindowPos(popupPos); + ImGui.SetNextWindowSize(new Vector2(440 * scale, 180 * scale), ImGuiCond.Always); + + ImGui.PushStyleColor(ImGuiCol.WindowBg, new Vector4(0.05f, 0.05f, 0.08f, 0.95f)); + ImGui.PushStyleColor(ImGuiCol.Border, new Vector4(0.4f, 0.8f, 0.4f, 0.8f)); + ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 6f * scale); + ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(15 * scale, 15 * scale)); + + if (ImGui.Begin("Tutorial: Add Design", + ImGuiWindowFlags.NoCollapse | + ImGuiWindowFlags.NoResize | + ImGuiWindowFlags.NoMove | + ImGuiWindowFlags.NoFocusOnAppearing | + ImGuiWindowFlags.NoBringToFrontOnFocus)) + { + // Icons + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf067"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.6f, 1.0f, 0.6f, 1.0f), "Add New Design"); + + ImGui.Separator(); + ImGui.Spacing(); + + ImGui.TextColored(new Vector4(0.8f, 0.8f, 0.85f, 1.0f), "Perfect! Now click the '+' button to create a new Design."); + ImGui.Spacing(); + + // Use proper bullet + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf005"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.9f, 0.7f, 0.3f, 1.0f), " Designs let you have multiple outfits per Character!"); + + ImGui.Spacing(); + if (ImGui.Button("End Tutorial", new Vector2(100 * scale, 25 * scale))) + { + EndTutorial(); + } + + ImGui.End(); + } + + ImGui.PopStyleVar(2); + ImGui.PopStyleColor(2); + + if (plugin.DesignPanelAddButtonPos.HasValue && plugin.DesignPanelAddButtonSize.HasValue) + { + HighlightButtonWithLeftwardArrow(plugin.DesignPanelAddButtonPos.Value, plugin.DesignPanelAddButtonSize.Value, popupPos, scale); + } + } + + private void DrawFillDesignFormHelp(float scale) + { + if (!plugin.IsEditDesignWindowOpen) return; + + Vector2 popupPos; + if (plugin.DesignNameFieldPos.HasValue && plugin.DesignNameFieldSize.HasValue) + { + popupPos = new Vector2( + plugin.DesignNameFieldPos.Value.X + plugin.DesignNameFieldSize.Value.X + (50 * scale), + plugin.DesignNameFieldPos.Value.Y - (50 * scale) + ); + } + else + { + var viewport = ImGui.GetMainViewport(); + popupPos = new Vector2(viewport.Pos.X + (500 * scale), viewport.Pos.Y + (300 * scale)); + } + + ImGui.SetNextWindowPos(popupPos); + ImGui.SetNextWindowSize(new Vector2(520 * scale, 220 * scale), ImGuiCond.Always); + + ImGui.PushStyleColor(ImGuiCol.WindowBg, new Vector4(0.05f, 0.05f, 0.08f, 0.95f)); + ImGui.PushStyleColor(ImGuiCol.Border, new Vector4(0.3f, 0.6f, 0.3f, 0.8f)); + ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 6f * scale); + ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(15 * scale, 15 * scale)); + + if (ImGui.Begin("Tutorial: Design Fields", ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoMove)) + { + // Icons + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf040"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.7f, 0.9f, 0.7f, 1.0f), "Fill Design Fields"); + + ImGui.Separator(); + ImGui.Spacing(); + + ImGui.TextColored(new Vector4(0.8f, 0.8f, 0.85f, 1.0f), "Time for some fashion paperwork (yes, even virtual clothes need documentation):"); + ImGui.Spacing(); + + ImGui.BulletText("Design Name* - e.g. 'Casual Outfit'"); + ImGui.BulletText("Glamourer Design* - Must match exactly"); + + ImGui.Spacing(); + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf005"); // Star icon + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.9f, 0.7f, 0.3f, 1.0f), " Required fields will glow!"); + + ImGui.Spacing(); + if (ImGui.Button("End Tutorial", new Vector2(100 * scale, 25 * scale))) + { + EndTutorial(); + } + + ImGui.End(); + } + + ImGui.PopStyleVar(2); + ImGui.PopStyleColor(2); + + HighlightRequiredDesignFields(scale); + } + + private void DrawExploreDesignOptionsHelp(float scale) + { + if (!plugin.IsEditDesignWindowOpen) return; + + Vector2 popupPos; + if (plugin.DesignNameFieldPos.HasValue && plugin.DesignNameFieldSize.HasValue) + { + popupPos = new Vector2( + plugin.DesignNameFieldPos.Value.X + plugin.DesignNameFieldSize.Value.X + (50 * scale), + plugin.DesignNameFieldPos.Value.Y + (100 * scale) + ); + } + else + { + var viewport = ImGui.GetMainViewport(); + popupPos = new Vector2(viewport.Pos.X + (500 * scale), viewport.Pos.Y + (350 * scale)); + } + + ImGui.SetNextWindowPos(popupPos); + ImGui.SetNextWindowSize(new Vector2(520 * scale, 360 * scale), ImGuiCond.Always); + + ImGui.PushStyleColor(ImGuiCol.WindowBg, new Vector4(0.05f, 0.05f, 0.08f, 0.95f)); + ImGui.PushStyleColor(ImGuiCol.Border, new Vector4(0.4f, 0.6f, 0.8f, 0.8f)); + ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 6f * scale); + ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(15 * scale, 15 * scale)); + + if (ImGui.Begin("Tutorial: Design Options", ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoMove)) + { + // Icons + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf53f"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.7f, 0.8f, 1.0f, 1.0f), "Design Options"); + + ImGui.Separator(); + ImGui.Spacing(); + + ImGui.TextColored(new Vector4(0.8f, 0.8f, 0.85f, 1.0f), "Great! Here are additional options for Designs:"); + ImGui.Spacing(); + + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf013"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.9f, 0.7f, 0.3f, 1.0f), "Glamourer Automation"); + + ImGui.TextColored(new Vector4(0.7f, 0.7f, 0.75f, 1.0f), "Apply Glamourer Automations when switching to this Design"); + ImGui.Spacing(); + + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf007"); // User icon + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.9f, 0.7f, 0.3f, 1.0f), "Customize+ Profile"); + + ImGui.TextColored(new Vector4(0.7f, 0.7f, 0.75f, 1.0f), "Apply a specific Customize+ Profile when switching to this Design"); + ImGui.Spacing(); + + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf302"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.9f, 0.7f, 0.3f, 1.0f), "Preview Image"); + + ImGui.TextColored(new Vector4(0.7f, 0.7f, 0.75f, 1.0f), "Shows when hovering over the Apply Design button"); + ImGui.Spacing(); + + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf0eb"); // Lightbulb icon + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.6f, 0.8f, 0.6f, 1.0f), " You can also use '/select Character Name Design Name'!"); + + ImGui.Spacing(); + + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf0eb"); // Lightbulb icon + ImGui.PopFont(); + + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.6f, 0.8f, 0.6f, 1.0f), " These are optional - let's save your Design!"); + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + + float buttonWidth = 120f; + float spacing = 15f; + float totalWidth = (buttonWidth * 2) + spacing; + float startX = (ImGui.GetContentRegionAvail().X - totalWidth) * 0.5f; + + ImGui.SetCursorPosX(startX); + + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.15f, 0.4f, 0.15f, 0.8f)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.2f, 0.5f, 0.2f, 1f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.1f, 0.35f, 0.1f, 1f)); + + if (ImGui.Button("Continue", new Vector2(buttonWidth, 25 * scale))) + { + NextStep(); + } + ImGui.PopStyleColor(3); + + ImGui.SameLine(); + ImGui.SetCursorPosX(startX + buttonWidth + spacing); + + if (ImGui.Button("End Tutorial", new Vector2(buttonWidth, 25 * scale))) + { + EndTutorial(); + } + + ImGui.End(); + } + + ImGui.PopStyleVar(2); + ImGui.PopStyleColor(2); + } + + private void DrawSaveDesignHelp(float scale) + { + if (!plugin.IsEditDesignWindowOpen) return; + + Vector2 popupPos; + if (plugin.SaveDesignButtonPos.HasValue && plugin.SaveDesignButtonSize.HasValue) + { + popupPos = new Vector2( + plugin.SaveDesignButtonPos.Value.X + plugin.SaveDesignButtonSize.Value.X + (30 * scale), + plugin.SaveDesignButtonPos.Value.Y - (100 * scale) + ); + } + else + { + var viewport = ImGui.GetMainViewport(); + popupPos = new Vector2(viewport.Pos.X + (500 * scale), viewport.Pos.Y + (400 * scale)); + } + + ImGui.SetNextWindowPos(popupPos); + ImGui.SetNextWindowSize(new Vector2(520 * scale, 240 * scale), ImGuiCond.Always); + + ImGui.PushStyleColor(ImGuiCol.WindowBg, new Vector4(0.05f, 0.05f, 0.08f, 0.95f)); + ImGui.PushStyleColor(ImGuiCol.Border, new Vector4(0.3f, 0.8f, 0.3f, 0.8f)); + ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 6f * scale); + ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(15 * scale, 15 * scale)); + + if (ImGui.Begin("Tutorial: Save Design", ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoMove)) + { + // Icons + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf0c7"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.7f, 1.0f, 0.7f, 1.0f), "Save Your Design!"); + + ImGui.Separator(); + ImGui.Spacing(); + + ImGui.TextColored(new Vector4(0.8f, 0.8f, 0.85f, 1.0f), "Perfect! Now click 'Save Design' to create your first Design!"); + + ImGui.Spacing(); + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf0eb"); // Lightbulb icon + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.6f, 0.8f, 0.6f, 1.0f), " After saving, you can:"); + ImGui.BulletText("Create more Designs for different occasions"); + ImGui.BulletText("Apply Designs by clicking the Apply Design button (checkmark) in the list"); + ImGui.BulletText("Set up RP Profiles and explore the gallery"); + + ImGui.Spacing(); + if (ImGui.Button("End Tutorial", new Vector2(100 * scale, 25 * scale))) + { + EndTutorial(); + } + + ImGui.End(); + } + + ImGui.PopStyleVar(2); + ImGui.PopStyleColor(2); + + if (plugin.SaveDesignButtonPos.HasValue && plugin.SaveDesignButtonSize.HasValue) + { + HighlightButton(plugin.SaveDesignButtonPos.Value, plugin.SaveDesignButtonSize.Value, scale); + } + } + + private void DrawDesignManagementHelp(float scale) + { + Vector2 popupPos; + if (plugin.IsDesignPanelOpen && plugin.DesignNameFieldPos.HasValue) + { + popupPos = new Vector2( + plugin.DesignNameFieldPos.Value.X + (300 * scale), + plugin.DesignNameFieldPos.Value.Y - (100 * scale) + ); + } + else + { + var viewport = ImGui.GetMainViewport(); + popupPos = new Vector2(viewport.Pos.X + (400 * scale), viewport.Pos.Y + (200 * scale)); + } + + ImGui.SetNextWindowPos(popupPos); + ImGui.SetNextWindowSize(new Vector2(560 * scale, 560 * scale), ImGuiCond.Always); + + ImGui.PushStyleColor(ImGuiCol.WindowBg, new Vector4(0.05f, 0.05f, 0.08f, 0.95f)); + ImGui.PushStyleColor(ImGuiCol.Border, new Vector4(0.8f, 0.6f, 0.2f, 0.8f)); // Gold border + ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 6f * scale); + ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(15 * scale, 15 * scale)); + + if (ImGui.Begin("Tutorial: Design Management", ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoMove)) + { + // Icons + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf091"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(1.0f, 0.8f, 0.4f, 1.0f), "Congratulations!"); + + ImGui.Separator(); + ImGui.Spacing(); + + ImGui.TextColored(new Vector4(0.8f, 0.8f, 0.85f, 1.0f), "You've created your first Character and Design! Here's what else you can do:"); + ImGui.Spacing(); + + // Design Management Features + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf07b"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.9f, 0.7f, 0.3f, 1.0f), "Add Folders"); + + ImGui.TextColored(new Vector4(0.7f, 0.7f, 0.75f, 1.0f), "Click the folder icon to organize your Designs into categories"); + ImGui.Spacing(); + + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf302"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.9f, 0.7f, 0.3f, 1.0f), "Preview Images"); + + ImGui.TextColored(new Vector4(0.7f, 0.7f, 0.75f, 1.0f), "Hover over the ✓ (apply) button to see preview images you've added"); + ImGui.Spacing(); + + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf040"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.9f, 0.7f, 0.3f, 1.0f), "Edit & Delete"); + + ImGui.TextColored(new Vector4(0.7f, 0.7f, 0.75f, 1.0f), "Use the edit and delete buttons when hovering over Designs"); + ImGui.Spacing(); + + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf07d"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.9f, 0.7f, 0.3f, 1.0f), "Drag & Drop Reordering"); + + ImGui.TextColored(new Vector4(0.7f, 0.7f, 0.75f, 1.0f), "Switch to 'Manual' sorting, then drag those colourful handles like you're solving a puzzle"); + ImGui.Spacing(); + + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf005"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.9f, 0.7f, 0.3f, 1.0f), "Favourites"); + + ImGui.TextColored(new Vector4(0.7f, 0.7f, 0.75f, 1.0f), "Click the star icon to mark Designs as favourites"); + ImGui.Spacing(); + + ImGui.Separator(); + ImGui.Spacing(); + + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf0eb"); // Lightbulb icon + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.6f, 0.8f, 0.6f, 1.0f), " Next steps:"); + ImGui.BulletText("Set up RP Profiles with backgrounds & effects"); + ImGui.BulletText("Explore the Character Gallery"); + ImGui.BulletText("Create more Characters and Designs"); + + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + + float buttonWidth = 150f; + float spacing = 15f; + float totalWidth = (buttonWidth * 2) + spacing; + float startX = (ImGui.GetContentRegionAvail().X - totalWidth) * 0.5f; + + ImGui.SetCursorPosX(startX); + + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.15f, 0.4f, 0.15f, 0.8f)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.2f, 0.5f, 0.2f, 1f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.1f, 0.35f, 0.1f, 1f)); + + if (ImGui.Button("Continue to RP Profiles", new Vector2(buttonWidth, 30))) + { + NextStep(); + } + ImGui.PopStyleColor(3); + + ImGui.SameLine(); + ImGui.SetCursorPosX(startX + buttonWidth + spacing); + + if (ImGui.Button("End Tutorial Here", new Vector2(buttonWidth, 30))) + { + EndTutorial(); + } + + ImGui.End(); + } + + ImGui.PopStyleVar(2); + ImGui.PopStyleColor(2); + } + + private void DrawClickRPProfileButtonHelp(float scale) + { + var buttonInfo = GetRPProfileButtonInfo(); + + if (buttonInfo.HasValue) + { + var (buttonPos, buttonSize) = buttonInfo.Value; + DrawInstructionPopup("View RP Profile", + "Click the ID card icon next to your Character's name to view their RP Profile.", + buttonPos, buttonSize, scale); + + HighlightButton(buttonPos, buttonSize, scale); + } + else + { + DrawInstructionPopup("View RP Profile", + "Look for the ID card icon next to your Character's name and click it to view their RP Profile.", + null, null, scale); + } + } + + private void DrawClickEditProfileHelp(float scale) + { + Vector2 popupPos; + + if (plugin.RPProfileViewWindowPos.HasValue && plugin.RPProfileViewWindowSize.HasValue) + { + popupPos = new Vector2( + plugin.RPProfileViewWindowPos.Value.X + plugin.RPProfileViewWindowSize.Value.X + (20f * scale), + plugin.RPProfileViewWindowPos.Value.Y + (100f * scale) + ); + } + else + { + var viewport = ImGui.GetMainViewport(); + popupPos = new Vector2(viewport.Pos.X + viewport.Size.X - 400f, viewport.Pos.Y + (150f * scale)); + } + + ImGui.SetNextWindowPos(popupPos); + ImGui.SetNextWindowSize(new Vector2(350 * scale, 160 * scale), ImGuiCond.Always); + + ImGui.PushStyleColor(ImGuiCol.WindowBg, new Vector4(0.05f, 0.05f, 0.08f, 0.98f)); + ImGui.PushStyleColor(ImGuiCol.Border, new Vector4(0.9f, 0.7f, 0.2f, 0.9f)); + ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 6f * scale); + ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(15 * scale, 15 * scale)); + + if (ImGui.Begin("Tutorial: Edit RP Profile", + ImGuiWindowFlags.NoCollapse | + ImGuiWindowFlags.NoResize | + ImGuiWindowFlags.NoMove | + ImGuiWindowFlags.NoFocusOnAppearing)) + { + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf040"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.7f, 1.0f, 0.7f, 1.0f), "Edit RP Profile"); + + ImGui.Separator(); + ImGui.Spacing(); + + ImGui.TextColored(new Vector4(0.8f, 0.8f, 0.85f, 1.0f), "Click the 'Edit Profile' button to start customizing!"); + + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + + float buttonWidth = 100f; + float startX = (ImGui.GetContentRegionAvail().X - buttonWidth) * 0.5f; + ImGui.SetCursorPosX(startX); + + if (ImGui.Button("End Tutorial", new Vector2(buttonWidth, 25 * scale))) + { + EndTutorial(); + } + + ImGui.End(); + } + + ImGui.PopStyleVar(2); + ImGui.PopStyleColor(2); + + var buttonInfo = GetEditProfileButtonInfo(); + if (buttonInfo.HasValue) + { + var (buttonPos, buttonSize) = buttonInfo.Value; + HighlightButton(buttonPos, buttonSize, scale); + } + } + + private void DrawStartWithPronounsHelp(float scale) + { + if (!plugin.IsRPProfileEditorOpen) return; + + Vector2 popupPos; + + if (plugin.RPProfileEditorWindowPos.HasValue && plugin.RPProfileEditorWindowSize.HasValue) + { + popupPos = new Vector2( + plugin.RPProfileEditorWindowPos.Value.X + plugin.RPProfileEditorWindowSize.Value.X, + plugin.RPProfileEditorWindowPos.Value.Y + 80f + ); + } + else + { + // Fallback positioning + var viewport = ImGui.GetMainViewport(); + popupPos = new Vector2(viewport.Pos.X + viewport.Size.X - 320f, viewport.Pos.Y + 80f); + } + + ImGui.SetNextWindowPos(popupPos); + ImGui.SetNextWindowSize(new Vector2(320 * scale, 160 * scale), ImGuiCond.Always); + + ImGui.PushStyleColor(ImGuiCol.WindowBg, new Vector4(0.05f, 0.05f, 0.08f, 0.98f)); + ImGui.PushStyleColor(ImGuiCol.Border, new Vector4(0.9f, 0.7f, 0.2f, 0.9f)); + ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 6f * scale); + ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(15 * scale, 8 * scale)); + + if (ImGui.Begin("Tutorial: Start Here", + ImGuiWindowFlags.NoCollapse | + ImGuiWindowFlags.NoResize | + ImGuiWindowFlags.NoMove | + ImGuiWindowFlags.NoFocusOnAppearing | + ImGuiWindowFlags.NoBringToFrontOnFocus)) + { + // Icons + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf256"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.7f, 0.9f, 0.7f, 1.0f), "Start with Pronouns"); + + ImGui.Spacing(); + ImGui.TextColored(new Vector4(0.8f, 0.8f, 0.85f, 1.0f), "Great! Start by filling in the Pronouns field."); + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf0eb"); // Lightbulb icon + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.9f, 0.7f, 0.3f, 1.0f), " Freeform fields - enter anything you like"); + + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + + float buttonWidth = 85f; + float spacing = 10f; + float totalWidth = (buttonWidth * 2) + spacing; + float startX = (ImGui.GetContentRegionAvail().X - totalWidth) * 0.5f; + + ImGui.SetCursorPosX(startX); + + if (ImGui.Button("Continue", new Vector2(buttonWidth, 20 * scale))) + { + NextStep(); + } + ImGui.SameLine(); + if (ImGui.Button("End Tutorial", new Vector2(buttonWidth, 20 * scale))) + { + EndTutorial(); + } + + ImGui.End(); + } + + ImGui.PopStyleVar(2); + ImGui.PopStyleColor(2); + + if (plugin.RPPronounsFieldPos.HasValue && plugin.RPPronounsFieldSize.HasValue) + { + var dl = ImGui.GetForegroundDrawList(); + float time = (float)ImGui.GetTime(); + float pulse = 0.4f + 0.4f * (float)Math.Sin(time * 2.5f); + var glowColor = new Vector4(0.9f, 0.7f, 0.2f, pulse * 0.8f); + + for (int i = 0; i < 3; i++) + { + float expansion = i * 4f + 2f; + dl.AddRect( + plugin.RPPronounsFieldPos.Value - new Vector2(expansion, expansion), + plugin.RPPronounsFieldPos.Value + plugin.RPPronounsFieldSize.Value + new Vector2(expansion, expansion), + ImGui.ColorConvertFloat4ToU32(new Vector4(glowColor.X, glowColor.Y, glowColor.Z, glowColor.W * (1f - i * 0.3f))), + 3f, ImDrawFlags.None, 2f + ); + } + + var fieldCenter = plugin.RPPronounsFieldPos.Value + (plugin.RPPronounsFieldSize.Value * 0.5f); + var tutorialLeft = new Vector2(popupPos.X, popupPos.Y + 80f); + + dl.AddLine(tutorialLeft, fieldCenter, ImGui.ColorConvertFloat4ToU32(new Vector4(0.9f, 0.9f, 0.4f, 1f)), 2f); + + var direction = Vector2.Normalize(fieldCenter - tutorialLeft); + var arrowHead1 = fieldCenter - direction * 8f + new Vector2(-direction.Y, direction.X) * 4f; + var arrowHead2 = fieldCenter - direction * 8f + new Vector2(direction.Y, -direction.X) * 4f; + dl.AddLine(fieldCenter, arrowHead1, ImGui.ColorConvertFloat4ToU32(new Vector4(0.9f, 0.9f, 0.4f, 1f)), 2f); + dl.AddLine(fieldCenter, arrowHead2, ImGui.ColorConvertFloat4ToU32(new Vector4(0.9f, 0.9f, 0.4f, 1f)), 2f); + } + } + + private void DrawAddBioHelp(float scale) + { + if (!plugin.IsRPProfileEditorOpen) return; + + Vector2 popupPos; + + if (plugin.RPProfileEditorWindowPos.HasValue && plugin.RPProfileEditorWindowSize.HasValue) + { + popupPos = new Vector2( + plugin.RPProfileEditorWindowPos.Value.X + plugin.RPProfileEditorWindowSize.Value.X + (50f * scale), + plugin.RPProfileEditorWindowPos.Value.Y + (80f * scale) + ); + } + else + { + var viewport = ImGui.GetMainViewport(); + popupPos = new Vector2(viewport.Pos.X + viewport.Size.X - 320f, viewport.Pos.Y + 120f); + } + + ImGui.SetNextWindowPos(popupPos); + ImGui.SetNextWindowSize(new Vector2(300 * scale, 260 * scale), ImGuiCond.Always); + + ImGui.PushStyleColor(ImGuiCol.WindowBg, new Vector4(0.05f, 0.05f, 0.08f, 0.98f)); + ImGui.PushStyleColor(ImGuiCol.Border, new Vector4(0.9f, 0.7f, 0.2f, 0.9f)); + ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 6f * scale); + ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(15 * scale, 15 * scale)); + + if (ImGui.Begin("Tutorial: Character Bio", + ImGuiWindowFlags.NoCollapse | + ImGuiWindowFlags.NoResize | + ImGuiWindowFlags.NoMove | + ImGuiWindowFlags.NoFocusOnAppearing | + ImGuiWindowFlags.NoBringToFrontOnFocus)) + { + // Icons + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf02d"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.7f, 0.8f, 1.0f, 1.0f), "Add Character Bio"); + + ImGui.Separator(); + ImGui.Spacing(); + + ImGui.PushTextWrapPos(ImGui.GetContentRegionAvail().X); + ImGui.TextColored(new Vector4(0.8f, 0.8f, 0.85f, 1.0f), "The bio is where you describe your Character's personality, background, and story."); + ImGui.PopTextWrapPos(); + + ImGui.Spacing(); + + ImGui.TextColored(new Vector4(0.9f, 0.7f, 0.3f, 1.0f), "💡 Tips for great bios:"); + ImGui.BulletText("Keep it concise but interesting"); + ImGui.BulletText("Include personality traits"); + ImGui.BulletText("Add hooks for RP interactions"); + + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + + float buttonWidth = 120f * scale; + float spacing = 15f; + float totalWidth = (buttonWidth * 2) + spacing; + float startX = (ImGui.GetContentRegionAvail().X - totalWidth) * 0.5f; + + ImGui.SetCursorPosX(startX); + + if (ImGui.Button("Continue", new Vector2(buttonWidth, 25 * scale))) + { + NextStep(); + } + + ImGui.SameLine(); + ImGui.SetCursorPosX(startX + buttonWidth + spacing); + + if (ImGui.Button("End Tutorial", new Vector2(buttonWidth, 25 * scale))) + { + EndTutorial(); + } + + ImGui.End(); + } + + ImGui.PopStyleVar(2); + ImGui.PopStyleColor(2); + + if (plugin.RPBioFieldPos.HasValue && plugin.RPBioFieldSize.HasValue) + { + var dl = ImGui.GetForegroundDrawList(); + float time = (float)ImGui.GetTime(); + float pulse = 0.4f + 0.4f * (float)Math.Sin(time * 2.5f); + var glowColor = new Vector4(0.9f, 0.7f, 0.2f, pulse * 0.8f); + + // Glow around the bio field + for (int i = 0; i < 3; i++) + { + float expansion = i * 4f + 2f; + dl.AddRect( + plugin.RPBioFieldPos.Value - new Vector2(expansion, expansion), + plugin.RPBioFieldPos.Value + plugin.RPBioFieldSize.Value + new Vector2(expansion, expansion), + ImGui.ColorConvertFloat4ToU32(new Vector4(glowColor.X, glowColor.Y, glowColor.Z, glowColor.W * (1f - i * 0.3f))), + 3f, ImDrawFlags.None, 2f + ); + } + + var arrowStart = plugin.RPBioFieldPos.Value + new Vector2(plugin.RPBioFieldSize.Value.X + 35, plugin.RPBioFieldSize.Value.Y / 2); + var arrowEnd = arrowStart + new Vector2(20 * scale, 0 * scale); + dl.AddLine(arrowStart, arrowEnd, ImGui.ColorConvertFloat4ToU32(new Vector4(0.9f, 0.9f, 0.4f, 1f)), 2f); + + var arrowHead1 = arrowStart + new Vector2(6, -4); + var arrowHead2 = arrowStart + new Vector2(6 * scale, 4 * scale); + dl.AddLine(arrowStart, arrowHead1, ImGui.ColorConvertFloat4ToU32(new Vector4(0.9f, 0.9f, 0.4f, 1f)), 2f); + dl.AddLine(arrowStart, arrowHead2, ImGui.ColorConvertFloat4ToU32(new Vector4(0.9f, 0.9f, 0.4f, 1f)), 2f); + } + } + + private void DrawExploreImageOptionsHelp(float scale) + { + if (!plugin.IsRPProfileEditorOpen) return; + + Vector2 popupPos; + + if (plugin.RPProfileEditorWindowPos.HasValue && plugin.RPProfileEditorWindowSize.HasValue) + { + popupPos = new Vector2( + plugin.RPProfileEditorWindowPos.Value.X + (50f * scale), + plugin.RPProfileEditorWindowPos.Value.Y - (180f * scale) + ); + } + else + { + var viewport = ImGui.GetMainViewport(); + popupPos = new Vector2(viewport.Pos.X + viewport.Size.X - 450f, viewport.Pos.Y + 80f); + } + + ImGui.SetNextWindowPos(popupPos); + ImGui.SetNextWindowSize(new Vector2(500 * scale, 180 * scale), ImGuiCond.Always); + + ImGui.PushStyleColor(ImGuiCol.WindowBg, new Vector4(0.05f, 0.05f, 0.08f, 0.98f)); + ImGui.PushStyleColor(ImGuiCol.Border, new Vector4(0.9f, 0.7f, 0.2f, 0.9f)); + ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 6f * scale); + ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(15 * scale, 8 * scale)); + + if (ImGui.Begin("Tutorial: Image Options", ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoMove)) + { + // Icons + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf03e"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(1.0f, 0.7f, 0.8f, 1.0f), "Upload a new image or stick with what you've got"); + + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf030"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.8f, 0.8f, 0.85f, 1.0f), "Upload your own image or keep the one you already chose."); + + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf00e"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.8f, 0.8f, 0.85f, 1.0f), "Position & Zoom - Adjust cropping and positioning"); + + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf0eb"); // Lightbulb icon + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.6f, 0.8f, 0.6f, 1.0f), " These settings are optional - you can adjust them later!"); + + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + + float buttonWidth = 85f; + float spacing = 10f; + float totalWidth = (buttonWidth * 2) + spacing; + float startX = (ImGui.GetContentRegionAvail().X - totalWidth) * 0.5f; + + ImGui.SetCursorPosX(startX); + + if (ImGui.Button("Continue", new Vector2(buttonWidth, 20 * scale))) + { + NextStep(); + } + ImGui.SameLine(); + if (ImGui.Button("End Tutorial", new Vector2(buttonWidth, 20 * scale))) + { + EndTutorial(); + } + + ImGui.End(); + } + + ImGui.PopStyleVar(2); + ImGui.PopStyleColor(2); + } + + private void DrawExploreVisualOptionsHelp(float scale) + { + if (!plugin.IsRPProfileEditorOpen) return; + + Vector2 popupPos; + + if (plugin.RPProfileEditorWindowPos.HasValue && plugin.RPProfileEditorWindowSize.HasValue) + { + popupPos = new Vector2( + plugin.RPProfileEditorWindowPos.Value.X + (100f * scale), + plugin.RPProfileEditorWindowPos.Value.Y - (200f * scale) + ); + } + else + { + var viewport = ImGui.GetMainViewport(); + popupPos = new Vector2(viewport.Pos.X + viewport.Size.X - (480f * scale), viewport.Pos.Y + (120f * scale)); + } + + ImGui.SetNextWindowPos(popupPos); + ImGui.SetNextWindowSize(new Vector2(540 * scale, 240 * scale), ImGuiCond.Always); + + ImGui.PushStyleColor(ImGuiCol.WindowBg, new Vector4(0.05f, 0.05f, 0.08f, 0.98f)); + ImGui.PushStyleColor(ImGuiCol.Border, new Vector4(0.9f, 0.7f, 0.2f, 0.9f)); + ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 6f * scale); + ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(15 * scale, 8 * scale)); + + if (ImGui.Begin("Tutorial: Backgrounds & Animations", + ImGuiWindowFlags.NoCollapse | + ImGuiWindowFlags.NoResize | + ImGuiWindowFlags.NoMove | + ImGuiWindowFlags.NoFocusOnAppearing | + ImGuiWindowFlags.NoBringToFrontOnFocus)) + { + // Icons + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf53f"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(1.0f, 0.7f, 0.8f, 1.0f), "Backgrounds & Animations"); + + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf6fc"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.8f, 0.8f, 0.85f, 1.0f), "Background Images - Choose from 50+ FFXIV locations"); + + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf0d0"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.PushTextWrapPos(ImGui.GetContentRegionAvail().X); + ImGui.TextColored(new Vector4(0.8f, 0.8f, 0.85f, 1.0f), "Animated Effects - Make it rain butterflies, because why shouldn't your character live in a Disney movie?"); + ImGui.PopTextWrapPos(); + + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf53f"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.8f, 0.8f, 0.85f, 1.0f), "Animated Effects - Choose to match particle colours to your background"); + + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf0eb"); // Lightbulb icon + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.6f, 0.8f, 0.6f, 1.0f), " Mix and match effects to create your perfect aesthetic!"); + + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + + float buttonWidth = 85f; + float spacing = 10f; + float totalWidth = (buttonWidth * 2) + spacing; + float startX = (ImGui.GetContentRegionAvail().X - totalWidth) * 0.5f; + + ImGui.SetCursorPosX(startX); + + if (ImGui.Button("Continue", new Vector2(buttonWidth, 20 * scale))) + { + NextStep(); + } + ImGui.SameLine(); + if (ImGui.Button("End Tutorial", new Vector2(100 * scale, 20 * scale))) + { + EndTutorial(); + } + + ImGui.End(); + } + + ImGui.PopStyleVar(2); + ImGui.PopStyleColor(2); + + HighlightBackgroundAndEffectsWithConnectors(popupPos, scale); + } + + private void DrawSaveRPProfileHelp(float scale) + { + if (!plugin.IsRPProfileEditorOpen) return; + + Vector2 popupPos; + + if (plugin.RPProfileEditorWindowPos.HasValue && plugin.RPProfileEditorWindowSize.HasValue) + { + popupPos = new Vector2( + plugin.RPProfileEditorWindowPos.Value.X + (plugin.RPProfileEditorWindowSize.Value.X * 0.5f) - 225f, + plugin.RPProfileEditorWindowPos.Value.Y + plugin.RPProfileEditorWindowSize.Value.Y + 10f + ); + } + else + { + var viewport = ImGui.GetMainViewport(); + popupPos = new Vector2(viewport.Pos.X + viewport.Size.X - 470f, viewport.Pos.Y + 400f); + } + + ImGui.SetNextWindowPos(popupPos); + ImGui.SetNextWindowSize(new Vector2(580 * scale, 320 * scale), ImGuiCond.Always); + + ImGui.PushStyleColor(ImGuiCol.WindowBg, new Vector4(0.05f, 0.05f, 0.08f, 0.98f)); + ImGui.PushStyleColor(ImGuiCol.Border, new Vector4(0.9f, 0.7f, 0.2f, 0.9f)); + ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 6f * scale); + ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(15 * scale, 8 * scale)); + + if (ImGui.Begin("Tutorial: Save RP Profile", + ImGuiWindowFlags.NoCollapse | + ImGuiWindowFlags.NoResize | + ImGuiWindowFlags.NoMove | + ImGuiWindowFlags.NoFocusOnAppearing | + ImGuiWindowFlags.NoBringToFrontOnFocus)) + { + // Icons + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf0c7"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.7f, 1.0f, 0.7f, 1.0f), "Save Your RP Profile!"); + + ImGui.Spacing(); + ImGui.TextColored(new Vector4(0.8f, 0.8f, 0.85f, 1.0f), "Perfect! Before saving, check your Profile Sharing setting:"); + ImGui.Spacing(); + + // Sharing options explanation + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf070"); // Eye-slash icon for never share + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.9f, 0.7f, 0.3f, 1.0f), "Private"); + ImGui.TextColored(new Vector4(0.7f, 0.7f, 0.75f, 1.0f), "Only you can see your Profile - keeps it completely hidden"); + ImGui.Spacing(); + + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf059"); // Question circle icon for share when requested + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.9f, 0.7f, 0.3f, 1.0f), "Direct Sharing"); + ImGui.TextColored(new Vector4(0.7f, 0.7f, 0.75f, 1.0f), "Viewable via friend's list or chat commands - won't appear in Gallery"); + ImGui.Spacing(); + + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf0ac"); // Globe icon for public + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.9f, 0.7f, 0.3f, 1.0f), "Public"); + ImGui.TextColored(new Vector4(0.7f, 0.7f, 0.75f, 1.0f), "Goes public for all to admire - time to show off your creative genius!"); + ImGui.Spacing(); + + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf00c"); // Check mark icon + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.6f, 0.8f, 0.6f, 1.0f), " You've learned: Characters, Designs, RP Profiles, and Backgrounds + Animations!"); + + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf005"); // Star icon + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.9f, 0.7f, 0.3f, 1.0f), " Ready to explore the Gallery and Quick Switch features!"); + + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + + float skipWidth = 100f; + float startX = (ImGui.GetContentRegionAvail().X - skipWidth) * 0.5f; + ImGui.SetCursorPosX(startX); + + if (ImGui.Button("End Tutorial", new Vector2(skipWidth, 25))) + { + EndTutorial(); + } + + ImGui.End(); + } + + ImGui.PopStyleVar(2); + ImGui.PopStyleColor(2); + + HighlightPrivacyAndSaveWithConnectors(popupPos, scale); + } + + private void DrawExploreMainFeaturesHelp(float scale) + { + var viewport = ImGui.GetMainViewport(); + var center = viewport.GetCenter(); + ImGui.SetNextWindowPos(center, ImGuiCond.Always, new Vector2(0.5f, 0.5f)); + ImGui.SetNextWindowSize(new Vector2(520 * scale, 340 * scale), ImGuiCond.Always); + + ImGui.PushStyleColor(ImGuiCol.WindowBg, new Vector4(0.05f, 0.05f, 0.08f, 0.95f)); + ImGui.PushStyleColor(ImGuiCol.Border, new Vector4(0.8f, 0.6f, 0.2f, 0.8f)); + ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 6f * scale); + ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(15 * scale, 15 * scale)); + + if (ImGui.Begin("Tutorial: Explore Main Features", ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoMove)) + { + // Icons + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf091"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(1.0f, 0.8f, 0.4f, 1.0f), "Great Job! You've learned the basics!"); + + ImGui.Separator(); + ImGui.Spacing(); + + ImGui.TextColored(new Vector4(0.8f, 0.8f, 0.85f, 1.0f), "Before we finish, let's explore some powerful features:"); + ImGui.Spacing(); + + ImGui.Indent(15); + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf013"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.9f, 0.7f, 0.3f, 1.0f), "Settings"); + + ImGui.TextColored(new Vector4(0.7f, 0.7f, 0.75f, 1.0f), "Customize how Character Select+ works for you"); + ImGui.Spacing(); + + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf0e7"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.9f, 0.7f, 0.3f, 1.0f), "Quick Switch"); + + ImGui.TextColored(new Vector4(0.7f, 0.7f, 0.75f, 1.0f), "A compact window with dropdowns to instantly change Characters and Designs."); + ImGui.Spacing(); + + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf302"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.9f, 0.7f, 0.3f, 1.0f), "Gallery"); + + ImGui.TextColored(new Vector4(0.7f, 0.7f, 0.75f, 1.0f), "Browse and discover amazing community Characters"); + ImGui.Unindent(15); + + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + + float buttonWidth = 120f; + float spacing = 20f; + float totalWidth = (buttonWidth * 2) + spacing; + float startX = (ImGui.GetContentRegionAvail().X - totalWidth) * 0.5f; + + ImGui.SetCursorPosX(startX); + + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.15f, 0.4f, 0.15f, 0.8f)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.2f, 0.5f, 0.2f, 1f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.1f, 0.35f, 0.1f, 1f)); + + if (ImGui.Button("Show Me!", new Vector2(buttonWidth, 28))) + { + NextStep(); + } + ImGui.PopStyleColor(3); + + ImGui.SameLine(); + ImGui.SetCursorPosX(startX + buttonWidth + spacing); + + if (ImGui.Button("Finish Tutorial", new Vector2(buttonWidth, 28))) + { + EndTutorial(); + } + + ImGui.End(); + } + + ImGui.PopStyleVar(2); + ImGui.PopStyleColor(2); + } + + private void DrawSettingsOverviewHelp(float scale) + { + Vector2 popupPos; + if (plugin.SettingsButtonPos.HasValue && plugin.SettingsButtonSize.HasValue) + { + popupPos = new Vector2( + plugin.SettingsButtonPos.Value.X + plugin.SettingsButtonSize.Value.X + (30f * scale), + plugin.SettingsButtonPos.Value.Y + (80f * scale) + ); + } + else + { + var viewport = ImGui.GetMainViewport(); + popupPos = new Vector2(viewport.Pos.X + (100f * scale), viewport.Pos.Y + (600f * scale)); + } + + ImGui.SetNextWindowPos(popupPos); + ImGui.SetNextWindowSize(new Vector2(560 * scale, 300 * scale), ImGuiCond.Always); + + ImGui.PushStyleColor(ImGuiCol.WindowBg, new Vector4(0.05f, 0.05f, 0.08f, 0.98f)); + ImGui.PushStyleColor(ImGuiCol.Border, new Vector4(0.6f, 0.4f, 0.8f, 0.9f)); + ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 6f * scale); + ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(15 * scale, 15 * scale)); + + if (ImGui.Begin("Tutorial: Settings Overview", ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoMove)) + { + // Icons + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf013"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.8f, 0.6f, 1.0f, 1.0f), "Settings Overview"); + + ImGui.Separator(); + ImGui.Spacing(); + + ImGui.TextColored(new Vector4(0.8f, 0.8f, 0.85f, 1.0f), "Settings: where perfectionists go to spend 3 hours tweaking things that were already fine:"); + ImGui.Spacing(); + + ImGui.BulletText("Profile display - sizes, spacing, and grid layout options"); + ImGui.BulletText("Glamourer Automations - opt-in to enable automation features"); + ImGui.BulletText("Auto-apply behaviours - Character on login, Designs on job change"); + ImGui.BulletText("Quick Switch compactness and visual feedback effects"); + ImGui.BulletText("UI scaling and sorting preferences for the main window"); + ImGui.BulletText("Immersive Dialogue options to use your CS+ Character's name and pronouns"); + + ImGui.Spacing(); + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf0eb"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.6f, 0.8f, 0.6f, 1.0f), " Test out what settings work best for you!"); + + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + + float buttonWidth = 120f; + float spacing = 15f; + float totalWidth = (buttonWidth * 2) + spacing; + float startX = (ImGui.GetContentRegionAvail().X - totalWidth) * 0.5f; + + ImGui.SetCursorPosX(startX); + + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.15f, 0.4f, 0.15f, 0.8f)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.2f, 0.5f, 0.2f, 1f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.1f, 0.35f, 0.1f, 1f)); + + if (ImGui.Button("Continue", new Vector2(buttonWidth, 25 * scale))) + { + NextStep(); + } + ImGui.PopStyleColor(3); + + ImGui.SameLine(); + ImGui.SetCursorPosX(startX + buttonWidth + spacing); + + if (ImGui.Button("End Tutorial", new Vector2(buttonWidth, 25 * scale))) + { + EndTutorial(); + } + + ImGui.End(); + } + + ImGui.PopStyleVar(2); + ImGui.PopStyleColor(2); + + if (plugin.SettingsButtonPos.HasValue && plugin.SettingsButtonSize.HasValue) + { + HighlightButtonWithProperArrow(plugin.SettingsButtonPos.Value, plugin.SettingsButtonSize.Value, popupPos, scale); + } + } + + private void DrawQuickSwitchOverviewHelp(float scale) + { + Vector2 popupPos; + if (plugin.QuickSwitchButtonPos.HasValue && plugin.QuickSwitchButtonSize.HasValue) + { + popupPos = new Vector2( + plugin.QuickSwitchButtonPos.Value.X - (100f * scale), + plugin.QuickSwitchButtonPos.Value.Y + plugin.QuickSwitchButtonSize.Value.Y + (60f * scale) + ); + } + else + { + var viewport = ImGui.GetMainViewport(); + popupPos = new Vector2(viewport.Pos.X + (200f * scale), viewport.Pos.Y + (200f * scale)); + } + + ImGui.SetNextWindowPos(popupPos); + ImGui.SetNextWindowSize(new Vector2(560 * scale, 300 * scale), ImGuiCond.Always); + + ImGui.PushStyleColor(ImGuiCol.WindowBg, new Vector4(0.05f, 0.05f, 0.08f, 0.98f)); + ImGui.PushStyleColor(ImGuiCol.Border, new Vector4(0.2f, 0.8f, 0.9f, 0.9f)); + ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 6f * scale); + ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(15 * scale, 15 * scale)); + + if (ImGui.Begin("Tutorial: Quick Switch", ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoMove)) + { + // Fixed Icons + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf0e7"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.4f, 0.9f, 1.0f, 1.0f), "Quick Switch - Lightning Fast Changes"); + + ImGui.Separator(); + ImGui.Spacing(); + + ImGui.TextColored(new Vector4(0.8f, 0.8f, 0.85f, 1.0f), "Quick Switch: for when you need to change characters faster than you change your mind:"); + ImGui.Spacing(); + + ImGui.BulletText("A simple character dropdown to pick any Character"); + ImGui.BulletText("A design dropdown to choose Designs for that Character"); + ImGui.BulletText("An 'Apply' button to instantly switch - no main window needed!"); + ImGui.BulletText("Stays open while you play for rapid Character/Design changes"); + ImGui.BulletText("You can also use '/selectswitch' to open this window!"); + + ImGui.Spacing(); + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf0eb"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.6f, 0.8f, 0.6f, 1.0f), " Perfect for when the RP plot twist requires you to suddenly be someone else entirely!"); + + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + + float buttonWidth = 120f; + float spacing = 15f; + float totalWidth = (buttonWidth * 2) + spacing; + float startX = (ImGui.GetContentRegionAvail().X - totalWidth) * 0.5f; + + ImGui.SetCursorPosX(startX); + + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.15f, 0.4f, 0.15f, 0.8f)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.2f, 0.5f, 0.2f, 1f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.1f, 0.35f, 0.1f, 1f)); + + if (ImGui.Button("Continue", new Vector2(buttonWidth, 25 * scale))) + { + NextStep(); + } + ImGui.PopStyleColor(3); + + ImGui.SameLine(); + ImGui.SetCursorPosX(startX + buttonWidth + spacing); + + if (ImGui.Button("End Tutorial", new Vector2(buttonWidth, 25 * scale))) + { + EndTutorial(); + } + + ImGui.End(); + } + + ImGui.PopStyleVar(2); + ImGui.PopStyleColor(2); + + if (plugin.QuickSwitchButtonPos.HasValue && plugin.QuickSwitchButtonSize.HasValue) + { + HighlightButtonWithUpwardArrow(plugin.QuickSwitchButtonPos.Value, plugin.QuickSwitchButtonSize.Value, popupPos, scale); + } + } + + + private void DrawGalleryOverviewHelp(float scale) + { + Vector2 popupPos; + if (plugin.GalleryButtonPos.HasValue && plugin.GalleryButtonSize.HasValue) + { + popupPos = new Vector2( + plugin.GalleryButtonPos.Value.X - (100f * scale), + plugin.GalleryButtonPos.Value.Y + plugin.GalleryButtonSize.Value.Y + (60f * scale) + ); + } + else + { + var viewport = ImGui.GetMainViewport(); + popupPos = new Vector2(viewport.Pos.X + (300f * scale), viewport.Pos.Y + (200f * scale)); + } + + ImGui.SetNextWindowPos(popupPos); + ImGui.SetNextWindowSize(new Vector2(560 * scale, 360 * scale), ImGuiCond.Always); + + ImGui.PushStyleColor(ImGuiCol.WindowBg, new Vector4(0.05f, 0.05f, 0.08f, 0.98f)); + ImGui.PushStyleColor(ImGuiCol.Border, new Vector4(0.9f, 0.5f, 0.7f, 0.9f)); + ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 6f * scale); + ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(15 * scale, 15 * scale)); + + if (ImGui.Begin("Tutorial: Gallery Discovery", ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoMove)) + { + // Icons + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf302"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(1.0f, 0.7f, 0.8f, 1.0f), "Gallery - Discover & Share"); + + ImGui.Separator(); + ImGui.Spacing(); + + ImGui.TextColored(new Vector4(0.8f, 0.8f, 0.85f, 1.0f), "The Gallery: basically Instagram for your FFXIV characters (but with better lighting):"); + ImGui.Spacing(); + + ImGui.BulletText("Browse other people's creative genius and feel both inspired and intimidated"); + ImGui.BulletText("Set your Main Character in the settings tab to participate!"); + ImGui.BulletText("Click any Profile to view their full RP Profile with all details"); + ImGui.BulletText("Like profiles to show appreciation - likes are tracked publicly"); + ImGui.BulletText("Favourite profiles to save a snapshot (won't change if they edit)"); + ImGui.BulletText("Right-click profile images to see the full uncropped picture"); + ImGui.BulletText("Search and filter by character name, race, or custom tags"); + ImGui.BulletText("Add and Block users to curate your experience."); + ImGui.BulletText("You can even use '/gallery' to open this window!"); + + ImGui.Spacing(); + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf0eb"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.6f, 0.8f, 0.6f, 1.0f), " Remember to set your RP Profile sharing to 'Public' to appear here!"); + + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + + float buttonWidth = 120f; + float spacing = 15f; + float totalWidth = (buttonWidth * 2) + spacing; + float startX = (ImGui.GetContentRegionAvail().X - totalWidth) * 0.5f; + + ImGui.SetCursorPosX(startX); + + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.15f, 0.4f, 0.15f, 0.8f)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.2f, 0.5f, 0.2f, 1f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.1f, 0.35f, 0.1f, 1f)); + + if (ImGui.Button("Continue", new Vector2(buttonWidth, 25 * scale))) + { + NextStep(); + } + ImGui.PopStyleColor(3); + + ImGui.SameLine(); + ImGui.SetCursorPosX(startX + buttonWidth + spacing); + + if (ImGui.Button("End Tutorial", new Vector2(buttonWidth, 25 * scale))) + { + EndTutorial(); + } + + ImGui.End(); + } + + ImGui.PopStyleVar(2); + ImGui.PopStyleColor(2); + + if (plugin.GalleryButtonPos.HasValue && plugin.GalleryButtonSize.HasValue) + { + HighlightButtonWithUpwardArrow(plugin.GalleryButtonPos.Value, plugin.GalleryButtonSize.Value, popupPos, scale); + } + } + private void HighlightButtonWithLeftwardArrow(Vector2 buttonPos, Vector2 buttonSize, Vector2 tutorialPos, float scale) + { + var dl = ImGui.GetForegroundDrawList(); + float time = (float)ImGui.GetTime(); + float pulse = 0.5f + 0.5f * (float)Math.Sin(time * 3.0f); + var glowColor = new Vector4(0.3f, 0.6f, 1.0f, pulse * 0.6f); + + // Glow around button + for (int i = 0; i < 3; i++) + { + float expansion = i * 6f + pulse * 4f; + dl.AddRect( + buttonPos - new Vector2(expansion, expansion), + buttonPos + buttonSize + new Vector2(expansion, expansion), + ImGui.ColorConvertFloat4ToU32(new Vector4(glowColor.X, glowColor.Y, glowColor.Z, glowColor.W * (1f - i * 0.3f))), + 4f, ImDrawFlags.None, 2f + ); + } + + var buttonCenter = buttonPos + (buttonSize * 0.5f); + var tutorialLeft = new Vector2(tutorialPos.X, tutorialPos.Y + 70f); + + dl.AddLine(tutorialLeft, buttonCenter, ImGui.ColorConvertFloat4ToU32(new Vector4(0.9f, 0.9f, 0.4f, 1f)), 2f); + + var direction = Vector2.Normalize(buttonCenter - tutorialLeft); + var arrowHead1 = buttonCenter - direction * 8f + new Vector2(-direction.Y, direction.X) * 4f; + var arrowHead2 = buttonCenter - direction * 8f + new Vector2(direction.Y, -direction.X) * 4f; + dl.AddLine(buttonCenter, arrowHead1, ImGui.ColorConvertFloat4ToU32(new Vector4(0.9f, 0.9f, 0.4f, 1f)), 2f); + dl.AddLine(buttonCenter, arrowHead2, ImGui.ColorConvertFloat4ToU32(new Vector4(0.9f, 0.9f, 0.4f, 1f)), 2f); + } + + private void HighlightButtonWithProperArrow(Vector2 buttonPos, Vector2 buttonSize, Vector2 tutorialPos, float scale) + { + var dl = ImGui.GetForegroundDrawList(); + float time = (float)ImGui.GetTime(); + float pulse = 0.5f + 0.5f * (float)Math.Sin(time * 3.0f); + var glowColor = new Vector4(0.3f, 0.6f, 1.0f, pulse * 0.6f); + + // Glow around button + for (int i = 0; i < 3; i++) + { + float expansion = i * 6f + pulse * 4f; + dl.AddRect( + buttonPos - new Vector2(expansion, expansion), + buttonPos + buttonSize + new Vector2(expansion, expansion), + ImGui.ColorConvertFloat4ToU32(new Vector4(glowColor.X, glowColor.Y, glowColor.Z, glowColor.W * (1f - i * 0.3f))), + 4f, ImDrawFlags.None, 2f + ); + } + + var buttonCenter = buttonPos + (buttonSize * 0.5f); + var tutorialSide = new Vector2(tutorialPos.X, tutorialPos.Y + 120f); + + dl.AddLine(tutorialSide, buttonCenter, ImGui.ColorConvertFloat4ToU32(new Vector4(0.9f, 0.9f, 0.4f, 1f)), 2f); + + var direction = Vector2.Normalize(buttonCenter - tutorialSide); + var arrowHead1 = buttonCenter - direction * 8f + new Vector2(-direction.Y, direction.X) * 4f; + var arrowHead2 = buttonCenter - direction * 8f + new Vector2(direction.Y, -direction.X) * 4f; + dl.AddLine(buttonCenter, arrowHead1, ImGui.ColorConvertFloat4ToU32(new Vector4(0.9f, 0.9f, 0.4f, 1f)), 2f); + dl.AddLine(buttonCenter, arrowHead2, ImGui.ColorConvertFloat4ToU32(new Vector4(0.9f, 0.9f, 0.4f, 1f)), 2f); + } + + private void HighlightButtonWithUpwardArrow(Vector2 buttonPos, Vector2 buttonSize, Vector2 tutorialPos, float scale) + { + var dl = ImGui.GetForegroundDrawList(); + float time = (float)ImGui.GetTime(); + float pulse = 0.5f + 0.5f * (float)Math.Sin(time * 3.0f); + var glowColor = new Vector4(0.3f, 0.6f, 1.0f, pulse * 0.6f); + + // Glow around button + for (int i = 0; i < 3; i++) + { + float expansion = i * 6f + pulse * 4f; + dl.AddRect( + buttonPos - new Vector2(expansion, expansion), + buttonPos + buttonSize + new Vector2(expansion, expansion), + ImGui.ColorConvertFloat4ToU32(new Vector4(glowColor.X, glowColor.Y, glowColor.Z, glowColor.W * (1f - i * 0.3f))), + 4f, ImDrawFlags.None, 2f + ); + } + + var buttonCenter = buttonPos + (buttonSize * 0.5f); + var tutorialTop = new Vector2(tutorialPos.X + (200f * scale), tutorialPos.Y); + + dl.AddLine(tutorialTop, buttonCenter, ImGui.ColorConvertFloat4ToU32(new Vector4(0.9f, 0.9f, 0.4f, 1f)), 2f); + + var direction = Vector2.Normalize(buttonCenter - tutorialTop); + var arrowHead1 = buttonCenter - direction * 8f + new Vector2(-direction.Y, direction.X) * 4f; + var arrowHead2 = buttonCenter - direction * 8f + new Vector2(direction.Y, -direction.X) * 4f; + dl.AddLine(buttonCenter, arrowHead1, ImGui.ColorConvertFloat4ToU32(new Vector4(0.9f, 0.9f, 0.4f, 1f)), 2f); + dl.AddLine(buttonCenter, arrowHead2, ImGui.ColorConvertFloat4ToU32(new Vector4(0.9f, 0.9f, 0.4f, 1f)), 2f); + } + private void DrawTutorialCompleteHelp(float scale) + { + var viewport = ImGui.GetMainViewport(); + var center = viewport.GetCenter(); + ImGui.SetNextWindowPos(center, ImGuiCond.Always, new Vector2(0.5f, 0.5f)); + ImGui.SetNextWindowSize(new Vector2(600 * scale, 300 * scale), ImGuiCond.Always); + + ImGui.PushStyleColor(ImGuiCol.WindowBg, new Vector4(0.05f, 0.05f, 0.08f, 0.95f)); + ImGui.PushStyleColor(ImGuiCol.Border, new Vector4(0.3f, 0.8f, 0.3f, 0.8f)); // Green border + ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 6f * scale); + ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(15 * scale, 15 * scale)); + + if (ImGui.Begin("Tutorial Complete!", ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoMove)) + { + // Fixed Icons + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf091"); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.7f, 1.0f, 0.7f, 1.0f), "Congratulations!"); + + ImGui.Separator(); + ImGui.Spacing(); + + ImGui.PushTextWrapPos(ImGui.GetContentRegionAvail().X); + ImGui.TextColored(new Vector4(0.8f, 0.8f, 0.85f, 1.0f), "You've graduated from Character Select+ University! Your diploma includes mastery of:"); + ImGui.PopTextWrapPos(); + + ImGui.Spacing(); + ImGui.BulletText("Creating Characters"); + ImGui.BulletText("Adding Designs for your Character"); + ImGui.BulletText("Setting up rich RP Profiles that are unique to you"); + ImGui.BulletText("Using Settings, Quick Switch, and the Gallery"); + + ImGui.Spacing(); + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf0eb"); // Lightbulb icon + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.6f, 0.8f, 0.6f, 1.0f), " Now go forth and create Characters so amazing they'll make other players green with envy!"); + + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + + float buttonWidth = 120f; + float startX = (ImGui.GetContentRegionAvail().X - buttonWidth) * 0.5f; + ImGui.SetCursorPosX(startX); + + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.15f, 0.4f, 0.15f, 0.8f)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.2f, 0.5f, 0.2f, 1f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.1f, 0.35f, 0.1f, 1f)); + + if (ImGui.Button("Finish", new Vector2(buttonWidth, 28))) + { + EndTutorial(); + } + ImGui.PopStyleColor(3); + + ImGui.End(); + } + + ImGui.PopStyleVar(2); + ImGui.PopStyleColor(2); + } + + // Helper methods for button info + private (Vector2 pos, Vector2 size)? GetRPProfileButtonInfo() + { + if (plugin.RPProfileButtonPos.HasValue && plugin.RPProfileButtonSize.HasValue) + { + return (plugin.RPProfileButtonPos.Value, plugin.RPProfileButtonSize.Value); + } + return null; + } + + private (Vector2 pos, Vector2 size)? GetEditProfileButtonInfo() + { + if (plugin.EditProfileButtonPos.HasValue && plugin.EditProfileButtonSize.HasValue) + { + return (plugin.EditProfileButtonPos.Value, plugin.EditProfileButtonSize.Value); + } + return null; + } + + private void HighlightRequiredFields(float scale) + { + var dl = ImGui.GetForegroundDrawList(); + float time = (float)ImGui.GetTime(); + float pulse = 0.4f + 0.4f * (float)Math.Sin(time * 2.5f); + var glowColor = new Vector4(0.9f, 0.7f, 0.2f, pulse * 0.8f); // Golden glow + + if (string.IsNullOrWhiteSpace(plugin.NewCharacterName) && + plugin.CharacterNameFieldPos.HasValue && plugin.CharacterNameFieldSize.HasValue) + { + HighlightInputField(dl, plugin.CharacterNameFieldPos.Value, plugin.CharacterNameFieldSize.Value, glowColor, "Character Name", scale); + } + + if (string.IsNullOrWhiteSpace(plugin.NewPenumbraCollection) && + plugin.PenumbraFieldPos.HasValue && plugin.PenumbraFieldSize.HasValue) + { + HighlightInputField(dl, plugin.PenumbraFieldPos.Value, plugin.PenumbraFieldSize.Value, glowColor, "Penumbra Collection", scale); + } + + if (string.IsNullOrWhiteSpace(plugin.NewGlamourerDesign) && + plugin.GlamourerFieldPos.HasValue && plugin.GlamourerFieldSize.HasValue) + { + HighlightInputField(dl, plugin.GlamourerFieldPos.Value, plugin.GlamourerFieldSize.Value, glowColor, "Glamourer Design", scale); + } + } + + private void HighlightRequiredDesignFields(float scale) + { + var dl = ImGui.GetForegroundDrawList(); + float time = (float)ImGui.GetTime(); + float pulse = 0.4f + 0.4f * (float)Math.Sin(time * 2.5f); + var glowColor = new Vector4(0.9f, 0.7f, 0.2f, pulse * 0.8f); + + if (string.IsNullOrWhiteSpace(plugin.EditedDesignName) && + plugin.DesignNameFieldPos.HasValue && plugin.DesignNameFieldSize.HasValue) + { + HighlightInputField(dl, plugin.DesignNameFieldPos.Value, plugin.DesignNameFieldSize.Value, glowColor, "Design Name", scale); + } + + if (string.IsNullOrWhiteSpace(plugin.EditedGlamourerDesign) && + plugin.DesignGlamourerFieldPos.HasValue && plugin.DesignGlamourerFieldSize.HasValue) + { + HighlightInputField(dl, plugin.DesignGlamourerFieldPos.Value, plugin.DesignGlamourerFieldSize.Value, glowColor, "Glamourer Design", scale); + } + } + + private void HighlightInputField(ImDrawListPtr dl, Vector2 fieldPos, Vector2 fieldSize, Vector4 glowColor, string fieldName, float scale) + { + // Glow around the input field + for (int i = 0; i < 3; i++) + { + float expansion = (i * 4f + 2f) * scale; + dl.AddRect( + fieldPos - new Vector2(expansion, expansion), + fieldPos + fieldSize + new Vector2(expansion, expansion), + ImGui.ColorConvertFloat4ToU32(new Vector4(glowColor.X, glowColor.Y, glowColor.Z, glowColor.W * (1f - i * 0.3f))), + 3f * scale, ImDrawFlags.None, 2f * scale + ); + } + + var arrowStart = fieldPos + new Vector2(fieldSize.X + (35 * scale), fieldSize.Y / 2); + var arrowEnd = arrowStart + new Vector2(20 * scale, 0); + dl.AddLine(arrowStart, arrowEnd, ImGui.ColorConvertFloat4ToU32(new Vector4(0.9f, 0.9f, 0.4f, 1f)), 2f * scale); + + var arrowHead1 = arrowStart + new Vector2(6 * scale, -4 * scale); + var arrowHead2 = arrowStart + new Vector2(6 * scale, 4 * scale); + dl.AddLine(arrowStart, arrowHead1, ImGui.ColorConvertFloat4ToU32(new Vector4(0.9f, 0.9f, 0.4f, 1f)), 2f * scale); + dl.AddLine(arrowStart, arrowHead2, ImGui.ColorConvertFloat4ToU32(new Vector4(0.9f, 0.9f, 0.4f, 1f)), 2f * scale); + } + + private void HighlightSaveButton(float scale) + { + if (plugin.SaveButtonPos.HasValue && plugin.SaveButtonSize.HasValue) + { + HighlightButton(plugin.SaveButtonPos.Value, plugin.SaveButtonSize.Value, scale); + } + } + + private void HighlightBackgroundAndEffectsWithConnectors(Vector2 tutorialPos, float scale) + { + var dl = ImGui.GetForegroundDrawList(); + float time = (float)ImGui.GetTime(); + float pulse = 0.4f + 0.4f * (float)Math.Sin(time * 2.5f); + var glowColor = new Vector4(0.2f, 0.8f, 0.9f, pulse * 0.8f); + + // Highlight Background dropdown + if (plugin.RPBackgroundDropdownPos.HasValue && plugin.RPBackgroundDropdownSize.HasValue) + { + // Glow around the dropdown + for (int i = 0; i < 3; i++) + { + float expansion = i * 4f + 2f; + dl.AddRect( + plugin.RPBackgroundDropdownPos.Value - new Vector2(expansion, expansion), + plugin.RPBackgroundDropdownPos.Value + plugin.RPBackgroundDropdownSize.Value + new Vector2(expansion, expansion), + ImGui.ColorConvertFloat4ToU32(new Vector4(glowColor.X, glowColor.Y, glowColor.Z, glowColor.W * (1f - i * 0.3f))), + 3f, ImDrawFlags.None, 2f + ); + } + + var dropdownCenter = plugin.RPBackgroundDropdownPos.Value + (plugin.RPBackgroundDropdownSize.Value * 0.5f); + var tutorialBottom = new Vector2(tutorialPos.X + (200f * scale), tutorialPos.Y + (200f * scale)); + + dl.AddLine(tutorialBottom, dropdownCenter, ImGui.ColorConvertFloat4ToU32(new Vector4(0.4f, 0.9f, 1f, 1f)), 2f); + + var direction = Vector2.Normalize(dropdownCenter - tutorialBottom); + var arrowHead1 = dropdownCenter - direction * 8f + new Vector2(-direction.Y, direction.X) * 4f; + var arrowHead2 = dropdownCenter - direction * 8f + new Vector2(direction.Y, -direction.X) * 4f; + dl.AddLine(dropdownCenter, arrowHead1, ImGui.ColorConvertFloat4ToU32(new Vector4(0.4f, 0.9f, 1f, 1f)), 2f); + dl.AddLine(dropdownCenter, arrowHead2, ImGui.ColorConvertFloat4ToU32(new Vector4(0.4f, 0.9f, 1f, 1f)), 2f); + } + + // Highlight Visual Effects section + if (plugin.RPVisualEffectsPos.HasValue && plugin.RPVisualEffectsSize.HasValue) + { + // Glow around the entire visual effects section + for (int i = 0; i < 3; i++) + { + float expansion = i * 4f + 2f; + dl.AddRect( + plugin.RPVisualEffectsPos.Value - new Vector2(expansion, expansion), + plugin.RPVisualEffectsPos.Value + plugin.RPVisualEffectsSize.Value + new Vector2(expansion, expansion), + ImGui.ColorConvertFloat4ToU32(new Vector4(glowColor.X, glowColor.Y, glowColor.Z, glowColor.W * (1f - i * 0.3f))), + 3f, ImDrawFlags.None, 2f + ); + } + + var effectsCenter = plugin.RPVisualEffectsPos.Value + (plugin.RPVisualEffectsSize.Value * 0.5f); + var tutorialBottom = new Vector2(tutorialPos.X + (350f * scale), tutorialPos.Y + (200f * scale) ); + + dl.AddLine(tutorialBottom, effectsCenter, ImGui.ColorConvertFloat4ToU32(new Vector4(0.4f, 0.9f, 1f, 1f)), 2f); + + var direction = Vector2.Normalize(effectsCenter - tutorialBottom); + var arrowHead1 = effectsCenter - direction * 8f + new Vector2(-direction.Y, direction.X) * 4f; + var arrowHead2 = effectsCenter - direction * 8f + new Vector2(direction.Y, -direction.X) * 4f; + dl.AddLine(effectsCenter, arrowHead1, ImGui.ColorConvertFloat4ToU32(new Vector4(0.4f, 0.9f, 1f, 1f)), 2f); + dl.AddLine(effectsCenter, arrowHead2, ImGui.ColorConvertFloat4ToU32(new Vector4(0.4f, 0.9f, 1f, 1f)), 2f); + } + } + + private void HighlightPrivacyAndSaveWithConnectors(Vector2 tutorialPos, float scale) + { + var dl = ImGui.GetForegroundDrawList(); + float time = (float)ImGui.GetTime(); + float pulse = 0.4f + 0.4f * (float)Math.Sin(time * 2.5f); + + // Highlight Privacy/Sharing dropdown with purple glow + if (plugin.RPSharingDropdownPos.HasValue && plugin.RPSharingDropdownSize.HasValue) + { + var privacyGlowColor = new Vector4(0.8f, 0.4f, 0.9f, pulse * 0.8f); + + // Glow around the privacy dropdown + for (int i = 0; i < 3; i++) + { + float expansion = i * 4f + 2f; + dl.AddRect( + plugin.RPSharingDropdownPos.Value - new Vector2(expansion, expansion), + plugin.RPSharingDropdownPos.Value + plugin.RPSharingDropdownSize.Value + new Vector2(expansion, expansion), + ImGui.ColorConvertFloat4ToU32(new Vector4(privacyGlowColor.X, privacyGlowColor.Y, privacyGlowColor.Z, privacyGlowColor.W * (1f - i * 0.3f))), + 3f, ImDrawFlags.None, 2f + ); + } + + var dropdownCenter = plugin.RPSharingDropdownPos.Value + (plugin.RPSharingDropdownSize.Value * 0.5f); + var tutorialTop = new Vector2(tutorialPos.X + (150f * scale), tutorialPos.Y); + + dl.AddLine(tutorialTop, dropdownCenter, ImGui.ColorConvertFloat4ToU32(new Vector4(0.9f, 0.6f, 1f, 1f)), 2f); + + var direction = Vector2.Normalize(dropdownCenter - tutorialTop); + var arrowHead1 = dropdownCenter - direction * 8f + new Vector2(-direction.Y, direction.X) * 4f; + var arrowHead2 = dropdownCenter - direction * 8f + new Vector2(direction.Y, -direction.X) * 4f; + dl.AddLine(dropdownCenter, arrowHead1, ImGui.ColorConvertFloat4ToU32(new Vector4(0.9f, 0.6f, 1f, 1f)), 2f); + dl.AddLine(dropdownCenter, arrowHead2, ImGui.ColorConvertFloat4ToU32(new Vector4(0.9f, 0.6f, 1f, 1f)), 2f); + } + + // Highlight Save button with green glow + if (plugin.SaveRPProfileButtonPos.HasValue && plugin.SaveRPProfileButtonSize.HasValue) + { + var saveGlowColor = new Vector4(0.3f, 0.8f, 0.3f, pulse * 0.6f); + + // Glow highlight around save button + for (int i = 0; i < 3; i++) + { + float expansion = i * 6f + pulse * 4f; + dl.AddRect( + plugin.SaveRPProfileButtonPos.Value - new Vector2(expansion, expansion), + plugin.SaveRPProfileButtonPos.Value + plugin.SaveRPProfileButtonSize.Value + new Vector2(expansion, expansion), + ImGui.ColorConvertFloat4ToU32(new Vector4(saveGlowColor.X, saveGlowColor.Y, saveGlowColor.Z, saveGlowColor.W * (1f - i * 0.3f))), + 4f, ImDrawFlags.None, 2f + ); + } + + var buttonCenter = plugin.SaveRPProfileButtonPos.Value + (plugin.SaveRPProfileButtonSize.Value * 0.5f); + var tutorialTop = new Vector2(tutorialPos.X + (300f * scale), tutorialPos.Y); + + dl.AddLine(tutorialTop, buttonCenter, ImGui.ColorConvertFloat4ToU32(new Vector4(0.6f, 1f, 0.6f, 1f)), 3f); + + var direction = Vector2.Normalize(buttonCenter - tutorialTop); + var arrowHead1 = buttonCenter - direction * 8f + new Vector2(-direction.Y, direction.X) * 4f; + var arrowHead2 = buttonCenter - direction * 8f + new Vector2(direction.Y, -direction.X) * 4f; + dl.AddLine(buttonCenter, arrowHead1, ImGui.ColorConvertFloat4ToU32(new Vector4(0.6f, 1f, 0.6f, 1f)), 3f); + dl.AddLine(buttonCenter, arrowHead2, ImGui.ColorConvertFloat4ToU32(new Vector4(0.6f, 1f, 0.6f, 1f)), 3f); + } + } + private float GetSafeScale(float baseScale) + { + return Math.Clamp(baseScale, 0.3f, 5.0f); + } + } +} diff --git a/CharacterSelectPlugin/Windows/Components/CharacterForm.cs b/CharacterSelectPlugin/Windows/Components/CharacterForm.cs new file mode 100644 index 0000000..6418e73 --- /dev/null +++ b/CharacterSelectPlugin/Windows/Components/CharacterForm.cs @@ -0,0 +1,1398 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Numerics; +using System.Threading; +using System.Windows.Forms; +using ImGuiNET; +using Dalamud.Interface; +using CharacterSelectPlugin.Windows.Styles; + +namespace CharacterSelectPlugin.Windows.Components +{ + public class CharacterForm : IDisposable + { + private Plugin plugin; + private UIStyles uiStyles; + + // Form state + public bool IsEditWindowOpen { get; private set; } = false; + private int selectedCharacterIndex = -1; + private bool isSecretMode = false; + private bool isAdvancedModeCharacter = false; + private string? pendingImagePath = null; + + // Edit fields + private string editedCharacterName = ""; + private string editedCharacterMacros = ""; + private string? editedCharacterImagePath = null; + private Vector3 editedCharacterColor = new Vector3(1.0f, 1.0f, 1.0f); + private string editedCharacterPenumbra = ""; + private string editedCharacterGlamourer = ""; + private string editedCharacterCustomize = ""; + private string editedCharacterTag = ""; + private string editedCharacterAutomation = ""; + private string editedCharacterMoodlePreset = ""; + + // Honorific fields + private string editedCharacterHonorificTitle = ""; + private string editedCharacterHonorificPrefix = "Prefix"; + private string editedCharacterHonorificSuffix = "Suffix"; + private Vector3 editedCharacterHonorificColor = new Vector3(1.0f, 1.0f, 1.0f); + private Vector3 editedCharacterHonorificGlow = new Vector3(1.0f, 1.0f, 1.0f); + + // Temp fields for live updates + private string tempHonorificTitle = ""; + private string tempHonorificPrefix = "Prefix"; + private string tempHonorificSuffix = "Suffix"; + private Vector3 tempHonorificColor = new Vector3(1.0f, 1.0f, 1.0f); + private Vector3 tempHonorificGlow = new Vector3(1.0f, 1.0f, 1.0f); + private string tempMoodlePreset = ""; + private string advancedCharacterMacroText = ""; + + public CharacterForm(Plugin plugin, UIStyles uiStyles) + { + this.plugin = plugin; + this.uiStyles = uiStyles; + } + + public void Dispose() + { + } + + public void Draw() + { + if (!plugin.IsAddCharacterWindowOpen && !IsEditWindowOpen) + return; + + var dpiScale = ImGui.GetIO().DisplayFramebufferScale.X; + var uiScale = plugin.Configuration.UIScaleMultiplier; + var totalScale = GetSafeScale(dpiScale * uiScale); + + // Shhhh...it's a secret + if (plugin.IsSecretMode && !isSecretMode) + { + isSecretMode = true; + if (!IsEditWindowOpen) + { + plugin.NewCharacterMacros = GenerateSecretMacro(); + } + plugin.IsSecretMode = false; + } + + uiStyles.PushFormStyle(); + + try + { + float baseLines = 26f; + if (isAdvancedModeCharacter) + baseLines += 6f; + + float maxContentHeight = ImGui.GetTextLineHeightWithSpacing() * baseLines; + float availableHeight = ImGui.GetContentRegionAvail().Y - ImGui.GetFrameHeightWithSpacing() * 2.5f; + float scrollHeight = Math.Min(maxContentHeight, availableHeight); + + ImGui.BeginChild("CharacterFormScrollable", new Vector2(0, scrollHeight), true, ImGuiWindowFlags.AlwaysVerticalScrollbar); + DrawCharacterFormContent(totalScale); + ImGui.EndChild(); + } + finally + { + uiStyles.PopFormStyle(); + } + } + + private void DrawCharacterFormContent(float scale) + { + float labelWidth = 130 * scale; + float inputWidth = 250 * scale; + float inputOffset = 10 * scale; + + string tempName = IsEditWindowOpen ? editedCharacterName : plugin.NewCharacterName; + string tempMacros = IsEditWindowOpen ? editedCharacterMacros : plugin.NewCharacterMacros; + string? imagePath = IsEditWindowOpen ? editedCharacterImagePath : plugin.NewCharacterImagePath; + string tempPenumbra = IsEditWindowOpen ? editedCharacterPenumbra : plugin.NewPenumbraCollection; + string tempGlamourer = IsEditWindowOpen ? editedCharacterGlamourer : plugin.NewGlamourerDesign; + string tempCustomize = IsEditWindowOpen ? editedCharacterCustomize : plugin.NewCustomizeProfile; + Vector3 tempColor = IsEditWindowOpen ? editedCharacterColor : plugin.NewCharacterColor; + string tempTag = IsEditWindowOpen ? editedCharacterTag : plugin.NewCharacterTag; + + // Character Name + DrawFormField("Character Name*", labelWidth, inputWidth, inputOffset, () => + { + ImGui.InputText("##CharacterName", ref tempName, 50); + plugin.CharacterNameFieldPos = ImGui.GetItemRectMin(); + plugin.CharacterNameFieldSize = ImGui.GetItemRectSize(); + + if (IsEditWindowOpen) editedCharacterName = tempName; + else plugin.NewCharacterName = tempName; + }, "Enter your OC's name or nickname for profile here.", scale); + + ImGui.Separator(); + + // Character Tags + DrawFormField("Character Tags", labelWidth, inputWidth, inputOffset, () => + { + ImGui.InputTextWithHint("##Tags", "e.g. Casual, Battle, Beach", ref tempTag, 100); + + if (IsEditWindowOpen) editedCharacterTag = tempTag; + else plugin.NewCharacterTag = tempTag; + }, "You can assign multiple tags by separating them with commas.\nExamples: Casual, Favourites, Seasonal", scale); + + ImGui.Separator(); + + // Nameplate Colour + DrawFormField("Nameplate Color", labelWidth, inputWidth, inputOffset, () => + { + ImGui.ColorEdit3("##NameplateColor", ref tempColor); + + if (IsEditWindowOpen) editedCharacterColor = tempColor; + else plugin.NewCharacterColor = tempColor; + }, "Affects your character's nameplate under their profile picture in Character Select+.", scale); + + ImGui.Separator(); + + // Penumbra Collection + DrawFormField("Penumbra Collection*", labelWidth, inputWidth, inputOffset, () => + { + ImGui.InputText("##PenumbraCollection", ref tempPenumbra, 50); + plugin.PenumbraFieldPos = ImGui.GetItemRectMin(); + plugin.PenumbraFieldSize = ImGui.GetItemRectSize(); + + string oldValue = IsEditWindowOpen ? editedCharacterPenumbra : plugin.NewPenumbraCollection; + if (oldValue != tempPenumbra) + { + if (IsEditWindowOpen) + { + editedCharacterPenumbra = tempPenumbra; + if (isAdvancedModeCharacter) + { + UpdateAdvancedMacroPenumbra(tempPenumbra); + } + else + { + editedCharacterMacros = GenerateMacro(); + } + } + else + { + plugin.NewPenumbraCollection = tempPenumbra; + if (isAdvancedModeCharacter) + { + UpdateAdvancedMacroPenumbra(tempPenumbra); + plugin.NewCharacterMacros = advancedCharacterMacroText; + } + else + { + plugin.NewCharacterMacros = isSecretMode ? GenerateSecretMacro() : GenerateMacro(); + } + } + } + }, "Enter the name of the Penumbra collection to apply to this character.\nMust be entered EXACTLY as it is named in Penumbra!", scale); + + ImGui.Separator(); + + // Glamourer Design + DrawFormField("Glamourer Design*", labelWidth, inputWidth, inputOffset, () => + { + ImGui.InputText("##GlamourerDesign", ref tempGlamourer, 50); + plugin.GlamourerFieldPos = ImGui.GetItemRectMin(); + plugin.GlamourerFieldSize = ImGui.GetItemRectSize(); + + string oldValue = IsEditWindowOpen ? editedCharacterGlamourer : plugin.NewGlamourerDesign; + if (oldValue != tempGlamourer) + { + if (IsEditWindowOpen) + { + editedCharacterGlamourer = tempGlamourer; + if (isAdvancedModeCharacter) + { + UpdateAdvancedMacroGlamourer(oldValue, tempGlamourer); + } + else + { + editedCharacterMacros = GenerateMacro(); + } + } + else + { + plugin.NewGlamourerDesign = tempGlamourer; + if (isAdvancedModeCharacter) + { + UpdateAdvancedMacroGlamourer(oldValue, tempGlamourer); + plugin.NewCharacterMacros = advancedCharacterMacroText; + } + else + { + plugin.NewCharacterMacros = isSecretMode ? GenerateSecretMacro() : GenerateMacro(); + } + } + } + }, "Enter the name of the Glamourer design to apply to this character.\nMust be entered EXACTLY as it is named in Glamourer!\nNote: You can add additional designs later.", scale); + + ImGui.Separator(); + + // Automation (if enabled) + if (plugin.Configuration.EnableAutomations) + { + DrawAutomationField(labelWidth, inputWidth, inputOffset, scale); + ImGui.Separator(); + } + + // Customize+ Profile + DrawCustomizeField(labelWidth, inputWidth, inputOffset, scale); + ImGui.Separator(); + + // Honorific Section + DrawHonorificSection(labelWidth, inputWidth, inputOffset, scale); + ImGui.Separator(); + + // Moodle Preset + DrawMoodleField(labelWidth, inputWidth, inputOffset, scale); + ImGui.Separator(); + + // Idle Pose + DrawIdlePoseField(labelWidth, inputWidth, inputOffset, scale); + ImGui.Separator(); + + // Image Selection + DrawImageSelection(scale); + ImGui.Separator(); + + // Advanced Mode Toggle + DrawAdvancedModeSection(scale); + ImGui.Separator(); + + // Buttons! + DrawActionButtons(scale); + } + + private void DrawFormField(string label, float labelWidth, float inputWidth, float inputOffset, + System.Action drawInput, string tooltip, float scale) + { + ImGui.SetCursorPosX(10 * scale); + ImGui.Text(label); + ImGui.SameLine(labelWidth); + ImGui.SetCursorPosX(labelWidth + inputOffset); + ImGui.SetNextItemWidth(inputWidth); + + drawInput(); + + // Tooltip + ImGui.SameLine(); + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf05a"); + ImGui.PopFont(); + + if (ImGui.IsItemHovered()) + { + ImGui.BeginTooltip(); + ImGui.PushTextWrapPos(300 * scale); + ImGui.TextUnformatted(tooltip); + ImGui.PopTextWrapPos(); + ImGui.EndTooltip(); + } + } + + private void DrawAutomationField(float labelWidth, float inputWidth, float inputOffset, float scale) + { + string tempCharacterAutomation = IsEditWindowOpen ? editedCharacterAutomation : plugin.NewCharacterAutomation; + + DrawFormField("Glam. Automation", labelWidth, inputWidth, inputOffset, () => + { + if (ImGui.InputText("##Glam.Automation", ref tempCharacterAutomation, 50)) + { + string oldValue = IsEditWindowOpen ? editedCharacterAutomation : plugin.NewCharacterAutomation; + if (oldValue != tempCharacterAutomation) + { + if (IsEditWindowOpen) + { + editedCharacterAutomation = tempCharacterAutomation; + if (isAdvancedModeCharacter) + { + UpdateAdvancedMacroAutomation(tempCharacterAutomation); + } + else + { + editedCharacterMacros = GenerateMacro(); + } + } + else + { + plugin.NewCharacterAutomation = tempCharacterAutomation; + if (isAdvancedModeCharacter) + { + UpdateAdvancedMacroAutomation(tempCharacterAutomation); + plugin.NewCharacterMacros = advancedCharacterMacroText; + } + else + { + plugin.NewCharacterMacros = isSecretMode ? GenerateSecretMacro() : GenerateMacro(); + } + } + } + } + }, "Enter the name of a Glamourer Automation profile to apply when this character is activated.\nDesign-level automations override this if both are set.\nLeave blank to default to a fallback profile named 'None'.", scale); + } + + private void DrawCustomizeField(float labelWidth, float inputWidth, float inputOffset, float scale) + { + string tempCustomize = IsEditWindowOpen ? editedCharacterCustomize : plugin.NewCustomizeProfile; + + DrawFormField("Customize+ Profile", labelWidth, inputWidth, inputOffset, () => + { + if (ImGui.InputText("##CustomizeProfile", ref tempCustomize, 50)) + { + string oldValue = IsEditWindowOpen ? editedCharacterCustomize : plugin.NewCustomizeProfile; + if (oldValue != tempCustomize) + { + if (IsEditWindowOpen) + { + editedCharacterCustomize = tempCustomize; + if (isAdvancedModeCharacter) + { + UpdateAdvancedMacroCustomize(tempCustomize); + } + else + { + editedCharacterMacros = GenerateMacro(); + } + } + else + { + plugin.NewCustomizeProfile = tempCustomize; + if (isAdvancedModeCharacter) + { + UpdateAdvancedMacroCustomize(tempCustomize); + plugin.NewCharacterMacros = advancedCharacterMacroText; + } + else + { + plugin.NewCharacterMacros = isSecretMode ? GenerateSecretMacro() : GenerateMacro(); + } + } + } + } + }, "Enter the name of the Customize+ profile to apply to this character.\nMust be entered EXACTLY as it is named in Customize+!", scale); + } + + private void DrawHonorificSection(float labelWidth, float inputWidth, float inputOffset, float scale) + { + ImGui.SetCursorPosX(10 * scale); + ImGui.Text("Honorific Title"); + ImGui.SameLine(); + ImGui.SetCursorPosX(labelWidth + inputOffset); + ImGui.SetNextItemWidth(inputWidth); + + bool changed = false; + + // Title input + changed |= ImGui.InputText("##HonorificTitle", ref tempHonorificTitle, 50); + + ImGui.SameLine(); + ImGui.SetNextItemWidth(80 * scale); + if (ImGui.BeginCombo("##HonorificPlacement", tempHonorificPrefix)) + { + foreach (var opt in new[] { "Prefix", "Suffix" }) + { + if (ImGui.Selectable(opt, tempHonorificPrefix == opt)) + { + tempHonorificPrefix = opt; + tempHonorificSuffix = opt; + changed = true; + } + } + ImGui.EndCombo(); + } + + // Colour pickers + ImGui.SameLine(); + ImGui.SetNextItemWidth(40 * scale); + changed |= ImGui.ColorEdit3("##HonorificColor", ref tempHonorificColor, ImGuiColorEditFlags.NoInputs); + + ImGui.SameLine(); + ImGui.SetNextItemWidth(40 * scale); + changed |= ImGui.ColorEdit3("##HonorificGlow", ref tempHonorificGlow, ImGuiColorEditFlags.NoInputs); + + if (changed) + { + UpdateHonorificData(); + + // Always update advanced macro when in advanced mode + if (isAdvancedModeCharacter) + { + UpdateAdvancedMacroHonorific(); + if (!IsEditWindowOpen) + { + plugin.NewCharacterMacros = advancedCharacterMacroText; + } + } + else + { + if (IsEditWindowOpen) + { + editedCharacterMacros = GenerateMacro(); + } + else + { + plugin.NewCharacterMacros = isSecretMode ? GenerateSecretMacro() : GenerateMacro(); + } + } + } + + // Tooltip + ImGui.SameLine(); + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf05a"); + ImGui.PopFont(); + + if (ImGui.IsItemHovered()) + { + ImGui.BeginTooltip(); + ImGui.PushTextWrapPos(300 * scale); + ImGui.TextUnformatted("This will set a forced title when you switch to this character.\nThe dropdown selects if the title appears above (prefix) or below (suffix) your name in-game.\nUse the Honorific plug-in's 'Clear' button if you need to remove it."); + ImGui.PopTextWrapPos(); + ImGui.EndTooltip(); + } + } + + private void DrawMoodleField(float labelWidth, float inputWidth, float inputOffset, float scale) + { + DrawFormField("Moodle Preset", labelWidth, inputWidth, inputOffset, () => + { + if (ImGui.InputText("##MoodlePreset", ref tempMoodlePreset, 50)) + { + if (IsEditWindowOpen) + editedCharacterMoodlePreset = tempMoodlePreset; + else + plugin.NewCharacterMoodlePreset = tempMoodlePreset; + + if (isAdvancedModeCharacter) + { + UpdateAdvancedMacroMoodle(tempMoodlePreset); + if (!IsEditWindowOpen) + { + plugin.NewCharacterMacros = advancedCharacterMacroText; + } + } + else + { + if (IsEditWindowOpen) + { + editedCharacterMacros = GenerateMacro(); + } + else + { + plugin.NewCharacterMacros = isSecretMode ? GenerateSecretMacro() : GenerateMacro(); + } + } + } + }, "Enter the Moodle preset name exactly as saved in the Moodle plugin.\nExample: 'HappyFawn' will apply the preset named 'HappyFawn'.", scale); + } + + private void DrawIdlePoseField(float labelWidth, float inputWidth, float inputOffset, float scale) + { + ImGui.SetCursorPosX(10 * scale); + ImGui.Text("Idle Pose"); + ImGui.SameLine(); + ImGui.SetCursorPosX(labelWidth + inputOffset); + ImGui.SetNextItemWidth(inputWidth); + + string[] poseOptions = { "None", "0", "1", "2", "3", "4", "5", "6" }; + byte storedIndex = IsEditWindowOpen + ? plugin.Characters[selectedCharacterIndex].IdlePoseIndex + : plugin.NewCharacterIdlePoseIndex; + + int dropdownIndex = storedIndex == 7 ? 0 : storedIndex + 1; + + if (ImGui.BeginCombo("##IdlePose", poseOptions[dropdownIndex])) + { + for (int i = 0; i < poseOptions.Length; i++) + { + bool selected = i == dropdownIndex; + if (ImGui.Selectable(poseOptions[i], selected)) + { + byte newIndex = (byte)(i == 0 ? 7 : i - 1); + byte currentIndex = IsEditWindowOpen + ? plugin.Characters[selectedCharacterIndex].IdlePoseIndex + : plugin.NewCharacterIdlePoseIndex; + + if (currentIndex != newIndex) + { + if (IsEditWindowOpen) + plugin.Characters[selectedCharacterIndex].IdlePoseIndex = newIndex; + else + plugin.NewCharacterIdlePoseIndex = newIndex; + + if (isAdvancedModeCharacter) + { + UpdateAdvancedMacroIdlePose(newIndex); + if (!IsEditWindowOpen) + { + plugin.NewCharacterMacros = advancedCharacterMacroText; + } + } + else + { + if (IsEditWindowOpen) + { + editedCharacterMacros = GenerateMacro(); + } + else + { + plugin.NewCharacterMacros = isSecretMode ? GenerateSecretMacro() : GenerateMacro(); + } + } + } + } + if (selected) ImGui.SetItemDefaultFocus(); + } + ImGui.EndCombo(); + } + + // Tooltip + ImGui.SameLine(); + ImGui.PushFont(UiBuilder.IconFont); + ImGui.TextUnformatted("\uf05a"); + ImGui.PopFont(); + + if (ImGui.IsItemHovered()) + { + ImGui.BeginTooltip(); + ImGui.PushTextWrapPos(300 * scale); + ImGui.TextUnformatted("Sets your character's idle pose (0–6).\nChoose 'None' if you don't want Character Select+ to change your idle."); + ImGui.PopTextWrapPos(); + ImGui.EndTooltip(); + } + } + + private void DrawImageSelection(float scale) + { + if (ImGui.Button("Choose Image", new Vector2(0, 25 * scale))) + { + try + { + Thread thread = new Thread(() => + { + try + { + using (OpenFileDialog openFileDialog = new OpenFileDialog()) + { + openFileDialog.Filter = "PNG files (*.png)|*.png"; + openFileDialog.Title = "Select Character Image"; + + if (openFileDialog.ShowDialog() == DialogResult.OK) + { + lock (this) + { + pendingImagePath = openFileDialog.FileName; + } + } + } + } + catch (Exception ex) + { + Plugin.Log.Error($"Error opening file picker: {ex.Message}"); + } + }); + + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + } + catch (Exception ex) + { + Plugin.Log.Error($"Critical file picker error: {ex.Message}"); + } + } + + // Apply pending image + if (pendingImagePath != null) + { + lock (this) + { + if (IsEditWindowOpen) + editedCharacterImagePath = pendingImagePath; + else + plugin.NewCharacterImagePath = pendingImagePath; + + pendingImagePath = null; + } + } + + // Show image preview + DrawImagePreview(scale); + } + + private void DrawImagePreview(float scale) + { + string pluginDirectory = plugin.PluginDirectory; + string defaultImagePath = Path.Combine(pluginDirectory, "Assets", "Default.png"); + + string? imagePath = IsEditWindowOpen ? editedCharacterImagePath : plugin.NewCharacterImagePath; + string finalImagePath = !string.IsNullOrEmpty(imagePath) && File.Exists(imagePath) + ? imagePath + : defaultImagePath; + + if (!string.IsNullOrEmpty(finalImagePath) && File.Exists(finalImagePath)) + { + var texture = Plugin.TextureProvider.GetFromFile(finalImagePath).GetWrapOrDefault(); + if (texture != null) + { + float originalWidth = texture.Width; + float originalHeight = texture.Height; + float maxSize = 100f * scale; + + float aspectRatio = originalWidth / originalHeight; + float displayWidth, displayHeight; + + if (aspectRatio > 1) + { + displayWidth = maxSize; + displayHeight = maxSize / aspectRatio; + } + else + { + displayHeight = maxSize; + displayWidth = maxSize * aspectRatio; + } + + var cursorPos = ImGui.GetCursorScreenPos(); + var imageEnd = cursorPos + new Vector2(displayWidth, displayHeight); + + uiStyles.DrawGlowingBorder( + cursorPos - new Vector2(2 * scale, 2 * scale), + imageEnd + new Vector2(2 * scale, 2 * scale), + new Vector3(0.5f, 0.5f, 0.5f), + 0.3f, + false, + scale + ); + + ImGui.Image(texture.ImGuiHandle, new Vector2(displayWidth, displayHeight)); + } + else + { + ImGui.Text($"Failed to load image: {Path.GetFileName(finalImagePath)}"); + } + } + else + { + ImGui.Text("No Image Available"); + } + } + + private void DrawAdvancedModeSection(float scale) + { + if (ImGui.Button(isAdvancedModeCharacter ? "Exit Advanced Mode" : "Advanced Mode", new Vector2(0, 25 * scale))) + { + isAdvancedModeCharacter = !isAdvancedModeCharacter; + + // Update the character's advanced mode flag + if (IsEditWindowOpen && selectedCharacterIndex >= 0 && selectedCharacterIndex < plugin.Characters.Count) + { + plugin.Characters[selectedCharacterIndex].IsAdvancedMode = isAdvancedModeCharacter; + plugin.SaveConfiguration(); + } + + if (isAdvancedModeCharacter) + { + // When entering advanced mode, use existing macro if available, otherwise generate + if (IsEditWindowOpen) + { + advancedCharacterMacroText = !string.IsNullOrWhiteSpace(editedCharacterMacros) + ? editedCharacterMacros + : GenerateMacro(); + } + else + { + advancedCharacterMacroText = !string.IsNullOrWhiteSpace(plugin.NewCharacterMacros) + ? plugin.NewCharacterMacros + : (isSecretMode ? GenerateSecretMacro() : GenerateMacro()); + plugin.NewCharacterMacros = advancedCharacterMacroText; + } + } + else + { + // When exiting advanced mode, preserve the current macro state + if (IsEditWindowOpen) + { + editedCharacterMacros = advancedCharacterMacroText; + } + else + { + plugin.NewCharacterMacros = advancedCharacterMacroText; + } + } + } + + // Tooltip + ImGui.SameLine(); + ImGui.SetCursorPosX(ImGui.GetCursorPosX() + (5 * scale)); + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf05a"); + ImGui.PopFont(); + + if (ImGui.IsItemHovered()) + { + ImGui.BeginTooltip(); + ImGui.PushTextWrapPos(300 * scale); + ImGui.TextUnformatted("⚠️ Do not touch this unless you know what you're doing."); + ImGui.PopTextWrapPos(); + ImGui.EndTooltip(); + } + + // Advanced mode editor + if (isAdvancedModeCharacter) + { + ImGui.Text("Edit Macro Manually:"); + + ImGui.PushStyleColor(ImGuiCol.FrameBg, new Vector4(0.1f, 0.1f, 0.1f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.9f, 0.9f, 0.9f, 1.0f)); + + ImGui.InputTextMultiline("##AdvancedCharacterMacro", ref advancedCharacterMacroText, 2000, + new Vector2(500 * scale, 150 * scale), ImGuiInputTextFlags.AllowTabInput); + + ImGui.PopStyleColor(2); + + // Real-time sync when user types in advanced mode + if (!IsEditWindowOpen) + { + plugin.NewCharacterMacros = advancedCharacterMacroText; + } + else + { + editedCharacterMacros = advancedCharacterMacroText; + } + } + } + private float GetSafeScale(float baseScale) + { + return Math.Clamp(baseScale, 0.3f, 5.0f); // Prevent extreme scaling + } + + private void DrawActionButtons(float scale) + { + string tempName = IsEditWindowOpen ? editedCharacterName : plugin.NewCharacterName; + string tempPenumbra = IsEditWindowOpen ? editedCharacterPenumbra : plugin.NewPenumbraCollection; + string tempGlamourer = IsEditWindowOpen ? editedCharacterGlamourer : plugin.NewGlamourerDesign; + + bool canSaveCharacter = !string.IsNullOrWhiteSpace(tempName) && + !string.IsNullOrWhiteSpace(tempPenumbra) && + !string.IsNullOrWhiteSpace(tempGlamourer); + + uiStyles.PushDarkButtonStyle(scale); + + if (!canSaveCharacter) + ImGui.BeginDisabled(); + + if (ImGui.Button(IsEditWindowOpen ? "Save Changes" : "Save Character", new Vector2(0, 30 * scale))) + { + if (IsEditWindowOpen) + { + SaveEditedCharacter(); + } + else + { + string finalMacro; + if (isAdvancedModeCharacter) + { + finalMacro = advancedCharacterMacroText; + } + else + { + finalMacro = plugin.NewCharacterMacros; + } + + plugin.SaveNewCharacter(finalMacro); + } + + CloseForm(); + } + + plugin.SaveButtonPos = ImGui.GetItemRectMin(); + plugin.SaveButtonSize = ImGui.GetItemRectSize(); + + if (!canSaveCharacter) + ImGui.EndDisabled(); + + ImGui.SameLine(); + + if (ImGui.Button("Cancel", new Vector2(0, 30 * scale))) + { + CloseForm(); + } + + uiStyles.PopDarkButtonStyle(); + } + + // Advanced mode update methods + private void UpdateAdvancedMacroPenumbra(string collection) + { + advancedCharacterMacroText = PatchMacroLine( + advancedCharacterMacroText, + "/penumbra collection", + $"/penumbra collection individual | {collection} | self" + ); + + advancedCharacterMacroText = UpdateCollectionInLines( + advancedCharacterMacroText, + "/penumbra bulktag disable", + collection + ); + + advancedCharacterMacroText = UpdateCollectionInLines( + advancedCharacterMacroText, + "/penumbra bulktag enable", + collection + ); + } + + private void UpdateAdvancedMacroGlamourer(string oldGlamourer, string newGlamourer) + { + var lines = advancedCharacterMacroText.Split('\n').ToList(); + + // Find and replace the main glamour apply line (not "no clothes") + bool foundExistingLine = false; + for (int i = 0; i < lines.Count; i++) + { + var line = lines[i].TrimStart(); + if (line.StartsWith("/glamour apply", StringComparison.OrdinalIgnoreCase) && + !line.Contains("no clothes", StringComparison.OrdinalIgnoreCase)) + { + lines[i] = $"/glamour apply {newGlamourer} | self"; + foundExistingLine = true; + break; + } + } + + // Update bulktag enable line if it exists (for secret mode....shhh! how can it stay a secret if I keep mentioning it??) + for (int i = 0; i < lines.Count; i++) + { + var line = lines[i].TrimStart(); + if (line.StartsWith("/penumbra bulktag enable", StringComparison.OrdinalIgnoreCase)) + { + var parts = line.Split('|'); + if (parts.Length >= 2) + { + var collection = parts[0].Replace("/penumbra bulktag enable", "").Trim(); + lines[i] = $"/penumbra bulktag enable {collection} | {newGlamourer}"; + } + break; + } + } + + if (!foundExistingLine && !string.IsNullOrWhiteSpace(newGlamourer)) + { + var insertPos = GetProperInsertPosition(lines, "/glamour apply"); + lines.Insert(insertPos, $"/glamour apply {newGlamourer} | self"); + } + + advancedCharacterMacroText = string.Join("\n", lines); + } + + private void UpdateAdvancedMacroAutomation(string automation) + { + var line = string.IsNullOrWhiteSpace(automation) + ? "/glamour automation enable None" + : $"/glamour automation enable {automation}"; + + advancedCharacterMacroText = PatchMacroLine( + advancedCharacterMacroText, + "/glamour automation enable", + line + ); + } + + private void UpdateAdvancedMacroCustomize(string customize) + { + advancedCharacterMacroText = PatchMacroLine( + advancedCharacterMacroText, + "/customize profile disable", + "/customize profile disable " + ); + + if (!string.IsNullOrWhiteSpace(customize)) + { + advancedCharacterMacroText = PatchMacroLine( + advancedCharacterMacroText, + "/customize profile enable", + $"/customize profile enable , {customize}" + ); + } + else + { + advancedCharacterMacroText = string.Join("\n", + advancedCharacterMacroText + .Split('\n') + .Where(l => !l.TrimStart().StartsWith("/customize profile enable")) + ); + } + } + + private void UpdateAdvancedMacroHonorific() + { + var lines = advancedCharacterMacroText.Split('\n').ToList(); + + var clearIdx = lines.FindIndex(l => + l.TrimStart().StartsWith("/honorific force clear", StringComparison.OrdinalIgnoreCase)); + + if (clearIdx < 0) + { + var insertPos = GetProperInsertPosition(lines, "/honorific force clear"); + lines.Insert(insertPos, "/honorific force clear"); + clearIdx = insertPos; + } + + if (!string.IsNullOrWhiteSpace(tempHonorificTitle)) + { + var c = tempHonorificColor; + var g = tempHonorificGlow; + string colorHex = $"#{(int)(c.X * 255):X2}{(int)(c.Y * 255):X2}{(int)(c.Z * 255):X2}"; + string glowHex = $"#{(int)(g.X * 255):X2}{(int)(g.Y * 255):X2}{(int)(g.Z * 255):X2}"; + string setLine = $"/honorific force set {tempHonorificTitle} | {tempHonorificPrefix} | {colorHex} | {glowHex}"; + + var setIdx = lines.FindIndex(l => + l.TrimStart().StartsWith("/honorific force set", StringComparison.OrdinalIgnoreCase)); + + if (setIdx >= 0) + { + lines[setIdx] = setLine; + } + else + { + lines.Insert(clearIdx + 1, setLine); + } + } + else + { + lines.RemoveAll(l => l.TrimStart().StartsWith("/honorific force set", StringComparison.OrdinalIgnoreCase)); + } + + advancedCharacterMacroText = string.Join("\n", lines); + } + + + private void UpdateAdvancedMacroMoodle(string preset) + { + var lines = advancedCharacterMacroText.Split('\n').ToList(); + + var removeIdx = lines.FindIndex(l => + l.TrimStart().StartsWith("/moodle remove self preset all", StringComparison.OrdinalIgnoreCase)); + + if (removeIdx < 0) + { + var insertPos = GetProperInsertPosition(lines, "/moodle remove"); + lines.Insert(insertPos, "/moodle remove self preset all"); + removeIdx = insertPos; + } + + if (!string.IsNullOrWhiteSpace(preset)) + { + string applyLine = $"/moodle apply self preset \"{preset}\""; + var applyIdx = lines.FindIndex(l => + l.TrimStart().StartsWith("/moodle apply self preset", StringComparison.OrdinalIgnoreCase)); + + if (applyIdx >= 0) + { + lines[applyIdx] = applyLine; + } + else + { + lines.Insert(removeIdx + 1, applyLine); + } + } + else + { + lines.RemoveAll(l => l.TrimStart().StartsWith("/moodle apply self preset", StringComparison.OrdinalIgnoreCase)); + } + + advancedCharacterMacroText = string.Join("\n", lines); + } + + private void UpdateAdvancedMacroIdlePose(byte poseIndex) + { + var lines = advancedCharacterMacroText.Split('\n').ToList(); + + if (poseIndex != 7) + { + string sidleLine = $"/sidle {poseIndex}"; + var sidleIdx = lines.FindIndex(l => + l.TrimStart().StartsWith("/sidle", StringComparison.OrdinalIgnoreCase)); + + if (sidleIdx >= 0) + { + lines[sidleIdx] = sidleLine; + } + else + { + var insertPos = GetProperInsertPosition(lines, "/sidle"); + lines.Insert(insertPos, sidleLine); + } + } + else + { + // Remove any existing sidle line when pose is "None" + lines.RemoveAll(l => l.TrimStart().StartsWith("/sidle", StringComparison.OrdinalIgnoreCase)); + } + + advancedCharacterMacroText = string.Join("\n", lines); + } + + private void UpdateHonorificData() + { + if (IsEditWindowOpen) + { + editedCharacterHonorificTitle = tempHonorificTitle; + editedCharacterHonorificPrefix = tempHonorificPrefix; + editedCharacterHonorificSuffix = tempHonorificSuffix; + editedCharacterHonorificColor = tempHonorificColor; + editedCharacterHonorificGlow = tempHonorificGlow; + } + else + { + plugin.NewCharacterHonorificTitle = tempHonorificTitle; + plugin.NewCharacterHonorificPrefix = tempHonorificPrefix; + plugin.NewCharacterHonorificSuffix = tempHonorificSuffix; + plugin.NewCharacterHonorificColor = tempHonorificColor; + plugin.NewCharacterHonorificGlow = tempHonorificGlow; + } + } + private string PatchMacroLine(string existing, string prefix, string replacement) + { + var lines = existing.Split('\n').ToList(); + var idx = lines.FindIndex(l => l.TrimStart().StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); + + if (idx >= 0) + { + lines[idx] = replacement; + } + else + { + int insertPosition = GetProperInsertPosition(lines, prefix); + lines.Insert(insertPosition, replacement); + } + + return string.Join("\n", lines); + } + + private int GetProperInsertPosition(List lines, string prefix) + { + var order = new[] + { + "/penumbra collection", + "/penumbra bulktag disable", + "/penumbra bulktag enable", + "/glamour apply no clothes", + "/glamour apply", + "/glamour automation enable", + "/customize profile disable", + "/customize profile enable", + "/honorific force clear", + "/honorific force set", + "/moodle remove", + "/moodle apply", + "/sidle", + "/penumbra redraw" + }; + + int targetOrder = Array.FindIndex(order, o => prefix.StartsWith(o, StringComparison.OrdinalIgnoreCase)); + if (targetOrder == -1) return lines.Count; + + for (int i = 0; i < lines.Count; i++) + { + var line = lines[i].TrimStart(); + int lineOrder = Array.FindIndex(order, o => line.StartsWith(o, StringComparison.OrdinalIgnoreCase)); + + if (lineOrder > targetOrder || lineOrder == -1) + { + return i; + } + } + + return lines.Count; + } + + private string UpdateCollectionInLines(string existing, string prefix, string newCollection) + { + var lines = existing.Split('\n').Select(line => + { + var trimmed = line.TrimStart(); + if (trimmed.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + var rest = trimmed.Substring(prefix.Length).TrimStart(); + var afterCollection = rest.IndexOf('|') >= 0 + ? rest.Substring(rest.IndexOf('|')) + : rest.Substring(rest.IndexOf(' ')); + return $"{prefix} {newCollection} {afterCollection}"; + } + return line; + }); + return string.Join("\n", lines); + } + + private string GenerateMacro() + { + string penumbra = IsEditWindowOpen ? editedCharacterPenumbra : plugin.NewPenumbraCollection; + string glamourer = IsEditWindowOpen ? editedCharacterGlamourer : plugin.NewGlamourerDesign; + string customize = IsEditWindowOpen ? editedCharacterCustomize : plugin.NewCustomizeProfile; + string honorificTitle = IsEditWindowOpen ? editedCharacterHonorificTitle : plugin.NewCharacterHonorificTitle; + string honorificPrefix = IsEditWindowOpen ? editedCharacterHonorificPrefix : plugin.NewCharacterHonorificPrefix; + Vector3 honorificColor = IsEditWindowOpen ? editedCharacterHonorificColor : plugin.NewCharacterHonorificColor; + Vector3 honorificGlow = IsEditWindowOpen ? editedCharacterHonorificGlow : plugin.NewCharacterHonorificGlow; + string automation = IsEditWindowOpen ? editedCharacterAutomation : plugin.NewCharacterAutomation; + string moodlePreset = IsEditWindowOpen ? editedCharacterMoodlePreset : plugin.NewCharacterMoodlePreset; + int idlePose = IsEditWindowOpen ? plugin.Characters[selectedCharacterIndex].IdlePoseIndex : plugin.NewCharacterIdlePoseIndex; + + if (string.IsNullOrWhiteSpace(penumbra) || string.IsNullOrWhiteSpace(glamourer)) + return "/penumbra redraw self"; + + string macro = $"/penumbra collection individual | {penumbra} | self\n"; + macro += $"/glamour apply {glamourer} | self\n"; + + if (plugin.Configuration.EnableAutomations) + { + if (string.IsNullOrWhiteSpace(automation)) + macro += "/glamour automation enable None\n"; + else + macro += $"/glamour automation enable {automation}\n"; + } + + macro += "/customize profile disable \n"; + if (!string.IsNullOrWhiteSpace(customize)) + macro += $"/customize profile enable , {customize}\n"; + + macro += "/honorific force clear\n"; + if (!string.IsNullOrWhiteSpace(honorificTitle)) + { + string colorHex = $"#{(int)(honorificColor.X * 255):X2}{(int)(honorificColor.Y * 255):X2}{(int)(honorificColor.Z * 255):X2}"; + string glowHex = $"#{(int)(honorificGlow.X * 255):X2}{(int)(honorificGlow.Y * 255):X2}{(int)(honorificGlow.Z * 255):X2}"; + macro += $"/honorific force set {honorificTitle} | {honorificPrefix} | {colorHex} | {glowHex}\n"; + } + + macro += "/moodle remove self preset all\n"; + if (!string.IsNullOrWhiteSpace(moodlePreset)) + macro += $"/moodle apply self preset \"{moodlePreset}\"\n"; + + if (idlePose != 7) + macro += $"/sidle {idlePose}\n"; + + macro += "/penumbra redraw self"; + + return macro; + } + + private string GenerateSecretMacro() + { + string penumbra = IsEditWindowOpen ? editedCharacterPenumbra : plugin.NewPenumbraCollection; + string glamourer = IsEditWindowOpen ? editedCharacterGlamourer : plugin.NewGlamourerDesign; + string customize = IsEditWindowOpen ? editedCharacterCustomize : plugin.NewCustomizeProfile; + string honorTitle = IsEditWindowOpen ? editedCharacterHonorificTitle : plugin.NewCharacterHonorificTitle; + string honorPref = IsEditWindowOpen ? editedCharacterHonorificPrefix : plugin.NewCharacterHonorificPrefix; + Vector3 honorColor = IsEditWindowOpen ? editedCharacterHonorificColor : plugin.NewCharacterHonorificColor; + Vector3 honorGlow = IsEditWindowOpen ? editedCharacterHonorificGlow : plugin.NewCharacterHonorificGlow; + string moodlePreset = IsEditWindowOpen ? editedCharacterMoodlePreset : plugin.NewCharacterMoodlePreset; + int idlePose = IsEditWindowOpen + ? plugin.Characters[selectedCharacterIndex].IdlePoseIndex + : plugin.NewCharacterIdlePoseIndex; + + var sb = new System.Text.StringBuilder(); + sb.AppendLine($"/penumbra collection individual | {penumbra} | self"); + sb.AppendLine($"/penumbra bulktag disable {penumbra} | gear"); + sb.AppendLine($"/penumbra bulktag disable {penumbra} | hair"); + sb.AppendLine($"/penumbra bulktag enable {penumbra} | {glamourer}"); + sb.AppendLine("/glamour apply no clothes | self"); + sb.AppendLine($"/glamour apply {glamourer} | self"); + + if (plugin.Configuration.EnableAutomations) + { + string automation = IsEditWindowOpen ? editedCharacterAutomation : plugin.NewCharacterAutomation; + if (string.IsNullOrWhiteSpace(automation)) + sb.AppendLine("/glamour automation enable None"); + else + sb.AppendLine($"/glamour automation enable {automation}"); + } + + sb.AppendLine("/customize profile disable "); + if (!string.IsNullOrWhiteSpace(customize)) + sb.AppendLine($"/customize profile enable , {customize}"); + + sb.AppendLine("/honorific force clear"); + if (!string.IsNullOrWhiteSpace(honorTitle)) + { + var colorHex = $"#{(int)(honorColor.X * 255):X2}{(int)(honorColor.Y * 255):X2}{(int)(honorColor.Z * 255):X2}"; + var glowHex = $"#{(int)(honorGlow.X * 255):X2}{(int)(honorGlow.Y * 255):X2}{(int)(honorGlow.Z * 255):X2}"; + sb.AppendLine($"/honorific force set {honorTitle} | {honorPref} | {colorHex} | {glowHex}"); + } + + sb.AppendLine("/moodle remove self preset all"); + if (!string.IsNullOrWhiteSpace(moodlePreset)) + sb.AppendLine($"/moodle apply self preset \"{moodlePreset}\""); + + if (idlePose != 7) + sb.AppendLine($"/sidle {idlePose}"); + + sb.Append("/penumbra redraw self"); + return sb.ToString(); + } + + public void SetSecretMode(bool secretMode) + { + isSecretMode = secretMode; + if (secretMode && !IsEditWindowOpen) + { + plugin.NewCharacterMacros = GenerateSecretMacro(); + } + } + + private void CloseForm() + { + IsEditWindowOpen = false; + plugin.CloseAddCharacterWindow(); + isSecretMode = false; + isAdvancedModeCharacter = false; + ResetFields(); + } + + public void ResetFields() + { + plugin.NewCharacterName = ""; + plugin.NewCharacterColor = new Vector3(1.0f, 1.0f, 1.0f); + plugin.NewPenumbraCollection = ""; + plugin.NewGlamourerDesign = ""; + plugin.NewCharacterAutomation = ""; + plugin.NewCustomizeProfile = ""; + plugin.NewCharacterImagePath = null; + plugin.NewCharacterDesigns.Clear(); + plugin.NewCharacterHonorificTitle = ""; + plugin.NewCharacterHonorificPrefix = "Prefix"; + plugin.NewCharacterHonorificSuffix = "Suffix"; + plugin.NewCharacterHonorificColor = new Vector3(1.0f, 1.0f, 1.0f); + plugin.NewCharacterHonorificGlow = new Vector3(1.0f, 1.0f, 1.0f); + plugin.NewCharacterMoodlePreset = ""; + plugin.NewCharacterIdlePoseIndex = 7; + plugin.NewCharacterIsAdvancedMode = false; + // Reset local temp fields + tempHonorificTitle = ""; + tempHonorificPrefix = "Prefix"; + tempHonorificSuffix = "Suffix"; + tempHonorificColor = new Vector3(1.0f, 1.0f, 1.0f); + tempHonorificGlow = new Vector3(1.0f, 1.0f, 1.0f); + tempMoodlePreset = ""; + + // Reset edit fields + editedCharacterName = ""; + editedCharacterMacros = ""; + editedCharacterImagePath = null; + editedCharacterColor = new Vector3(1.0f, 1.0f, 1.0f); + editedCharacterPenumbra = ""; + editedCharacterGlamourer = ""; + editedCharacterCustomize = ""; + editedCharacterTag = ""; + editedCharacterAutomation = ""; + editedCharacterMoodlePreset = ""; + editedCharacterHonorificTitle = ""; + editedCharacterHonorificPrefix = "Prefix"; + editedCharacterHonorificSuffix = "Suffix"; + editedCharacterHonorificColor = new Vector3(1.0f, 1.0f, 1.0f); + editedCharacterHonorificGlow = new Vector3(1.0f, 1.0f, 1.0f); + + advancedCharacterMacroText = ""; + + // Only regenerate macro if not in advanced mode + if (!isAdvancedModeCharacter) + { + plugin.NewCharacterMacros = GenerateMacro(); + } + } + + private void SaveEditedCharacter() + { + if (selectedCharacterIndex < 0 || selectedCharacterIndex >= plugin.Characters.Count) + return; + + var character = plugin.Characters[selectedCharacterIndex]; + + character.Name = editedCharacterName; + character.Tags = string.IsNullOrWhiteSpace(editedCharacterTag) + ? new List() + : editedCharacterTag.Split(',').Select(f => f.Trim()).ToList(); + character.PenumbraCollection = editedCharacterPenumbra; + character.GlamourerDesign = editedCharacterGlamourer; + character.CustomizeProfile = editedCharacterCustomize; + character.NameplateColor = editedCharacterColor; + character.CharacterAutomation = editedCharacterAutomation; + character.HonorificTitle = editedCharacterHonorificTitle; + character.HonorificPrefix = editedCharacterHonorificPrefix; + character.HonorificSuffix = editedCharacterHonorificSuffix; + character.HonorificColor = editedCharacterHonorificColor; + character.HonorificGlow = editedCharacterHonorificGlow; + character.MoodlePreset = editedCharacterMoodlePreset; + + character.Macros = isAdvancedModeCharacter ? advancedCharacterMacroText : editedCharacterMacros; + + if (!string.IsNullOrEmpty(editedCharacterImagePath)) + { + character.ImagePath = editedCharacterImagePath; + } + + plugin.SaveConfiguration(); + } + + public void OpenEditCharacterWindow(int index) + { + if (index < 0 || index >= plugin.Characters.Count) + return; + + selectedCharacterIndex = index; + var character = plugin.Characters[index]; + + string pluginDirectory = plugin.PluginDirectory; + string defaultImagePath = Path.Combine(pluginDirectory, "Assets", "Default.png"); + + editedCharacterName = character.Name; + editedCharacterPenumbra = character.PenumbraCollection; + editedCharacterGlamourer = character.GlamourerDesign; + editedCharacterCustomize = character.CustomizeProfile ?? ""; + editedCharacterColor = character.NameplateColor; + + editedCharacterMacros = character.Macros; + + editedCharacterImagePath = !string.IsNullOrEmpty(character.ImagePath) ? character.ImagePath : defaultImagePath; + editedCharacterTag = character.Tags != null && character.Tags.Count > 0 + ? string.Join(", ", character.Tags) + : ""; + + editedCharacterHonorificTitle = character.HonorificTitle ?? ""; + editedCharacterHonorificPrefix = character.HonorificPrefix ?? "Prefix"; + editedCharacterHonorificSuffix = character.HonorificSuffix ?? "Suffix"; + editedCharacterHonorificColor = character.HonorificColor; + editedCharacterHonorificGlow = character.HonorificGlow; + editedCharacterMoodlePreset = character.MoodlePreset ?? ""; + + string safeAutomation = character.CharacterAutomation == "None" ? "" : character.CharacterAutomation ?? ""; + editedCharacterAutomation = safeAutomation; + + // Copy to temp fields + tempHonorificTitle = editedCharacterHonorificTitle; + tempHonorificPrefix = editedCharacterHonorificPrefix; + tempHonorificSuffix = editedCharacterHonorificSuffix; + tempHonorificColor = editedCharacterHonorificColor; + tempHonorificGlow = editedCharacterHonorificGlow; + tempMoodlePreset = editedCharacterMoodlePreset; + + if (isAdvancedModeCharacter) + { + advancedCharacterMacroText = character.Macros; + } + // Restore advanced mode state + isAdvancedModeCharacter = character.IsAdvancedMode; + + if (isAdvancedModeCharacter) + { + advancedCharacterMacroText = character.Macros; + } + IsEditWindowOpen = true; + } + } +} diff --git a/CharacterSelectPlugin/Windows/Components/CharacterGrid.cs b/CharacterSelectPlugin/Windows/Components/CharacterGrid.cs new file mode 100644 index 0000000..91c639b --- /dev/null +++ b/CharacterSelectPlugin/Windows/Components/CharacterGrid.cs @@ -0,0 +1,1318 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Numerics; +using ImGuiNET; +using Dalamud.Interface; +using CharacterSelectPlugin.Windows.Styles; +using CharacterSelectPlugin.Effects; + +namespace CharacterSelectPlugin.Windows.Components +{ + public class CharacterGrid : IDisposable + { + private Plugin plugin; + private UIStyles uiStyles; + private Dictionary hoverAnimations = new(); + private bool showSearchBar = false; + private string searchQuery = ""; + private string selectedTag = "All"; + private bool showTagFilter = false; + private Dictionary characterFavoriteEffects = new(); + + // Drag and drop state + private int? draggedCharacterIndex = null; + private bool isDragging = false; + private Vector2 dragStartPos = Vector2.Zero; + private const float DragThreshold = 5f; + public bool ShouldPreventWindowDrag => isDragging; + + // Pagination + private int currentPage = 0; + private int charactersPerPage = 40; + private List<(int characterIndex, Vector2 min, Vector2 max)> cardRects = new(); + private int? currentDropTargetIndex = null; + private bool cardRectsDirty = true; + + // Performance optimizations + private List cachedFilteredCharacters = new(); + private List cachedPagedCharacters = new(); + private string lastSearchQuery = ""; + private string lastSelectedTag = "All"; + private int lastCharacterCount = 0; + private bool filterCacheDirty = true; + + // Cache UI calculations + private float cachedCardWidth = 0f; + private int cachedColumnCount = 0; + private float cachedAvailableWidth = 0f; + private bool layoutCacheDirty = true; + + // Cache expensive string operations + private readonly Dictionary fileExistsCache = new(); + private readonly Dictionary textSizeCache = new(); + + // Frame limiting for animations + private float lastAnimationUpdate = 0f; + private const float AnimationUpdateInterval = 1f / 60f; // 60 FPS max + + // Ghost image state + private Character? draggedCharacter = null; + private Vector2 ghostImageSize = new Vector2(120f, 120f); + private float ghostImageAlpha = 0.8f; + + public Plugin.SortType CurrentSort { get; private set; } + + public CharacterGrid(Plugin plugin, UIStyles uiStyles) + { + this.plugin = plugin; + this.uiStyles = uiStyles; + CurrentSort = (Plugin.SortType)plugin.Configuration.CurrentSortIndex; + } + + public void Dispose() + { + // Clear caches + fileExistsCache.Clear(); + textSizeCache.Clear(); + characterFavoriteEffects.Clear(); + } + + public void Draw() + { + // Calculate responsive scaling + var dpiScale = ImGui.GetIO().DisplayFramebufferScale.X; + var uiScale = plugin.Configuration.UIScaleMultiplier; + var totalScale = GetSafeScale(dpiScale * uiScale); + + ImGuiWindowFlags windowFlags = ImGuiWindowFlags.None; + + // Disable window moving while dragging a character + if (isDragging && draggedCharacterIndex.HasValue) + { + windowFlags |= ImGuiWindowFlags.NoMove; + } + + // Apply the flags to window + DrawToolbar(totalScale); + DrawCharacterGridContent(totalScale); + + // Throttle animation updates + float currentTime = (float)ImGui.GetTime(); + if (currentTime - lastAnimationUpdate >= AnimationUpdateInterval) + { + UpdateEffects(ImGui.GetIO().DeltaTime); + lastAnimationUpdate = currentTime; + } + + DrawEffects(); + DrawPagination(totalScale); + + // Draw the ghost image last so it appears on top of everything + DrawDragGhostImage(totalScale); + } + + private void UpdateEffects(float deltaTime) + { + foreach (var effect in characterFavoriteEffects.Values) + { + effect.Update(deltaTime); + } + } + + private void DrawEffects() + { + foreach (var kvp in characterFavoriteEffects.ToList()) + { + kvp.Value.Draw(); + + if (!kvp.Value.IsActive) + { + characterFavoriteEffects.Remove(kvp.Key); + } + } + } + + private void DrawToolbar(float scale) + { + if (!plugin.IsAddCharacterWindowOpen) + { + float buttonHeight = 25f * scale; + + if (ImGui.Button("Add Character", new Vector2(0, buttonHeight))) + { + var io = ImGui.GetIO(); + bool isSecretMode = io.KeyCtrl && io.KeyShift; + + plugin.OpenAddCharacterWindow(); + + if (isSecretMode) + { + plugin.IsSecretMode = isSecretMode; + } + InvalidateCache(); + } + + plugin.AddCharacterButtonPos = ImGui.GetItemRectMin(); + plugin.AddCharacterButtonSize = ImGui.GetItemRectSize(); + + DrawSearchAndFilters(scale); + } + } + + private void DrawSearchAndFilters(float scale) + { + float tagDropdownWidth = 200f * scale; + float tagIconOffset = 70f * scale; + float tagDropdownOffset = tagDropdownWidth + tagIconOffset + (10f * scale); + float buttonSize = 25f * scale; + + // Tag Filter Toggle + ImGui.SameLine(ImGui.GetWindowWidth() - tagIconOffset - (20f * scale)); + ImGui.PushFont(UiBuilder.IconFont); + if (ImGui.Button("\uf0b0", new Vector2(buttonSize, buttonSize))) + { + showTagFilter = !showTagFilter; + InvalidateCache(); + } + ImGui.PopFont(); + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByPopup)) + { + ImGui.BeginTooltip(); + ImGui.Text("Filter by Tags."); + ImGui.EndTooltip(); + } + + // Tag Filter Dropdown + if (showTagFilter) + { + ImGui.SameLine(ImGui.GetWindowWidth() - tagDropdownOffset - (20f * scale)); + ImGui.SetNextItemWidth(tagDropdownWidth); + if (ImGui.BeginCombo("##TagFilter", selectedTag)) + { + var allTags = plugin.Characters + .SelectMany(c => c.Tags ?? new List()) + .Distinct() + .OrderBy(f => f) + .Prepend("All") + .ToList(); + + foreach (var tag in allTags) + { + bool isSelected = tag == selectedTag; + if (ImGui.Selectable(tag, isSelected)) + { + selectedTag = tag; + InvalidateFilterCache(); + } + + if (isSelected) + ImGui.SetItemDefaultFocus(); + } + ImGui.EndCombo(); + } + } + + // Search Button + ImGui.SameLine(ImGui.GetWindowWidth() - (55f * scale)); + ImGui.PushFont(UiBuilder.IconFont); + if (ImGui.Button("\uf002", new Vector2(buttonSize, buttonSize))) + { + showSearchBar = !showSearchBar; + if (!showSearchBar) + { + searchQuery = ""; + InvalidateFilterCache(); + } + } + ImGui.PopFont(); + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByPopup)) + { + ImGui.BeginTooltip(); + ImGui.Text("Search for a Character."); + ImGui.EndTooltip(); + } + + // Search Input Field + if (showSearchBar) + { + ImGui.SameLine(ImGui.GetWindowWidth() - (265f * scale)); + ImGui.SetNextItemWidth(210f * scale); + if (ImGui.InputTextWithHint("##SearchCharacters", "Search characters...", ref searchQuery, 100)) + InvalidateFilterCache(); + } + } + + private void DrawCharacterGridContent(float scale) + { + var filteredCharacters = GetFilteredCharacters(); + var pagedCharacters = GetPagedCharacters(filteredCharacters); + + float availableWidth = ImGui.GetContentRegionAvail().X; + if (Math.Abs(availableWidth - cachedAvailableWidth) > 1f || layoutCacheDirty) + { + RecalculateLayout(availableWidth, scale); + } + + float cardWidth = cachedCardWidth; + int columnCount = cachedColumnCount; + + float containerMargin = 17f * scale; // Scale the margin + ImGui.SetCursorPos(ImGui.GetCursorPos() + new Vector2(containerMargin, containerMargin)); + + if (columnCount > 1) + { + ImGui.Columns(columnCount, "CharacterGrid", false); + float columnWidth = cardWidth + (plugin.ProfileSpacing * scale) + (24f * scale); // Scale spacing + for (int i = 0; i < columnCount; i++) + { + ImGui.SetColumnWidth(i, columnWidth); + } + } + + bool shouldRebuildRects = cardRectsDirty || isDragging || pagedCharacters.Count != cardRects.Count; + + if (shouldRebuildRects) + { + RebuildCardRects(pagedCharacters, cardWidth, scale); + } + + // Draw character cards + for (int i = 0; i < pagedCharacters.Count; i++) + { + var character = pagedCharacters[i]; + int realCharacterIndex = plugin.Characters.IndexOf(character); + if (realCharacterIndex == -1) continue; + + DrawCharacterCard(character, realCharacterIndex, cardWidth, scale); + + if (columnCount > 1) + ImGui.NextColumn(); + } + + // Reset columns + if (columnCount > 1) + { + ImGui.Columns(1); + } + } + + private void RecalculateLayout(float availableWidth, float scale) + { + float profileSpacing = plugin.ProfileSpacing * scale; + int columnCount = plugin.ProfileColumns; + + if (plugin.IsDesignPanelOpen) + { + columnCount = Math.Max(1, columnCount - 1); + } + + float cardWidth = 250 * plugin.ProfileImageScale * scale; + float borderMargin = 12f * scale; + float totalCardWidth = cardWidth + (borderMargin * 2); + float columnWidth = totalCardWidth + profileSpacing; + + // Ensure column count fits within available space + columnCount = Math.Max(1, Math.Min(columnCount, (int)(availableWidth / columnWidth))); + + // Cache the results + cachedCardWidth = cardWidth; + cachedColumnCount = columnCount; + cachedAvailableWidth = availableWidth; + layoutCacheDirty = false; + } + + private void RebuildCardRects(List pagedCharacters, float cardWidth, float scale) + { + cardRects.Clear(); + for (int i = 0; i < pagedCharacters.Count; i++) + { + var character = pagedCharacters[i]; + int realCharacterIndex = plugin.Characters.IndexOf(character); + if (realCharacterIndex == -1) continue; + + var cardStartPos = ImGui.GetCursorScreenPos(); + float nameplateHeight = 70 * scale; + float imageHeight = cardWidth; + float totalCardHeight = imageHeight + nameplateHeight; + var cardMin = cardStartPos; + var cardMax = cardStartPos + new Vector2(cardWidth, totalCardHeight); + + cardRects.Add((realCharacterIndex, cardMin, cardMax)); + } + cardRectsDirty = false; + } + + private void DrawCharacterCard(Character character, int index, float cardWidth, float scale) + { + cardWidth = Math.Clamp(cardWidth, 64 * scale, 512 * scale); + float nameplateHeight = 70 * scale; + float imageHeight = cardWidth; + float totalCardHeight = imageHeight + nameplateHeight; + float spacing = 12f * scale; + + string pluginDirectory = plugin.PluginDirectory; + string defaultImagePath = Path.Combine(pluginDirectory, "Assets", "Default.png"); + + string finalImagePath = GetCachedImagePath(character.ImagePath, defaultImagePath); + + // Check if this character is the main character + bool isMainCharacter = !string.IsNullOrEmpty(plugin.Configuration.MainCharacterName) && + character.Name == plugin.Configuration.MainCharacterName; + + ImGui.BeginGroup(); + + var cardStartPos = ImGui.GetCursorScreenPos(); + var cardMin = cardStartPos; + var cardMax = cardStartPos + new Vector2(cardWidth, totalCardHeight); + + ImGui.Dummy(new Vector2(cardWidth, totalCardHeight)); + var cardArea = ImGui.GetItemRectMin(); + + ImGui.SetCursorScreenPos(cardArea); + ImGui.InvisibleButton($"##CharCard{index}", new Vector2(cardWidth, imageHeight)); + bool isHovered = ImGui.IsItemHovered(); + + if (ImGui.IsItemClicked(ImGuiMouseButton.Left) && !isDragging) + { + HandleCharacterClick(character, index); + } + + if (ImGui.BeginPopupContextItem($"##ContextMenu_{character.Name}")) + { + DrawContextMenu(character, scale); + ImGui.EndPopup(); + } + + float hoverAmount = UpdateHoverAnimation(index, isHovered); + + Vector3 borderColor = character.NameplateColor; + float borderIntensity = 0.6f + hoverAmount * 0.4f; + + if (draggedCharacterIndex == index) + { + borderIntensity = 1.0f; + } + + var borderMargin = (4f + (hoverAmount * 2f)) * scale; + uiStyles.DrawGlowingBorder( + cardMin - new Vector2(borderMargin, borderMargin), + cardMax + new Vector2(borderMargin, borderMargin), + borderColor, + borderIntensity, + isHovered || draggedCharacterIndex == index + ); + + var drawList = ImGui.GetWindowDrawList(); + uint cardBgColor = ImGui.GetColorU32(new Vector4(0.12f, 0.12f, 0.12f, 0.95f)); + drawList.AddRectFilled(cardMin, cardMax, cardBgColor, 12f * scale); + + var imageArea = cardMin; + var imageAreaSize = new Vector2(cardWidth, imageHeight); + + if (!string.IsNullOrEmpty(finalImagePath)) + { + var texture = Plugin.TextureProvider.GetFromFile(finalImagePath).GetWrapOrDefault(); + + if (texture != null) + { + float originalWidth = texture.Width; + float originalHeight = texture.Height; + float aspectRatio = originalWidth / originalHeight; + + float imageAreaWidth = imageAreaSize.X - (8 * scale); + float imageAreaHeight = imageAreaSize.Y - (8 * scale); + + float displayWidth, displayHeight; + if (aspectRatio > 1) + { + displayWidth = imageAreaWidth; + displayHeight = imageAreaWidth / aspectRatio; + if (displayHeight > imageAreaHeight) + { + displayHeight = imageAreaHeight; + displayWidth = imageAreaHeight * aspectRatio; + } + } + else + { + displayHeight = imageAreaHeight; + displayWidth = imageAreaHeight * aspectRatio; + if (displayWidth > imageAreaWidth) + { + displayWidth = imageAreaWidth; + displayHeight = imageAreaWidth / aspectRatio; + } + } + + float hoverScale = plugin.Configuration.EnableCharacterHoverEffects + ? 1f + (0.05f * hoverAmount) + : 1f; + + float finalWidth = displayWidth * hoverScale; + float finalHeight = displayHeight * hoverScale; + + float paddingX = (imageAreaSize.X - finalWidth) / 2; + float paddingY = (imageAreaSize.Y - finalHeight) / 2; + float liftOffset = -2f * hoverAmount * scale; + + var imagePos = imageArea + new Vector2(paddingX, paddingY + liftOffset); + var imagePosMax = imagePos + new Vector2(finalWidth, finalHeight); + + drawList.AddImageRounded( + texture.ImGuiHandle, + imagePos, + imagePosMax, + new Vector2(0, 0), + new Vector2(1, 1), + ImGui.GetColorU32(new Vector4(1, 1, 1, 1)), + 8f * scale, + ImDrawFlags.RoundCornersTop + ); + + if (isMainCharacter && plugin.Configuration.ShowMainCharacterCrown) + { + DrawMainCharacterCrown(drawList, imagePosMax, imagePos, hoverAmount, scale); + } + } + } + else + { + var textPos = imageArea + imageAreaSize / 2 - new Vector2(30 * scale, 10 * scale); // Scale text position + drawList.AddText(textPos, ImGui.GetColorU32(new Vector4(0.7f, 0.7f, 0.7f, 1f)), "No Image"); + } + + DrawIntegratedNameplate(character, cardMin, cardWidth, imageHeight, nameplateHeight, index, hoverAmount, scale); + + ImGui.EndGroup(); + ImGui.Dummy(new Vector2(0, spacing)); + } + + private string GetCachedImagePath(string? characterImagePath, string defaultImagePath) + { + if (!string.IsNullOrEmpty(characterImagePath)) + { + if (!fileExistsCache.TryGetValue(characterImagePath, out bool exists)) + { + exists = File.Exists(characterImagePath); + fileExistsCache[characterImagePath] = exists; + } + + if (exists) + return characterImagePath; + } + + if (!fileExistsCache.TryGetValue(defaultImagePath, out bool defaultExists)) + { + defaultExists = File.Exists(defaultImagePath); + fileExistsCache[defaultImagePath] = defaultExists; + } + + return defaultExists ? defaultImagePath : ""; + } + + private Vector2 GetCachedTextSize(string text) + { + if (!textSizeCache.TryGetValue(text, out Vector2 size)) + { + size = ImGui.CalcTextSize(text); + textSizeCache[text] = size; + } + return size; + } + + private void DrawMainCharacterCrown(ImDrawListPtr drawList, Vector2 imagePosMax, Vector2 imagePos, float hoverAmount, float scale) + { + float crownBadgeSize = 32f * scale; + var badgePos = new Vector2( + imagePosMax.X - crownBadgeSize - (4 * scale), + imagePos.Y + (4 * scale) + ); + var badgeCenter = badgePos + new Vector2(crownBadgeSize / 2, crownBadgeSize / 2); + + uint badgeBg = ImGui.GetColorU32(new Vector4(0f, 0f, 0f, 0.7f)); + drawList.PathClear(); + drawList.PathArcTo(badgeCenter, crownBadgeSize / 2 + (2 * scale), 0, MathF.PI * 2); + drawList.PathFillConvex(badgeBg); + + uint badgeRing = ImGui.GetColorU32(new Vector4(1f, 0.8f, 0.2f, 0.9f + hoverAmount * 0.1f)); + drawList.PathClear(); + drawList.PathArcTo(badgeCenter, crownBadgeSize / 2, 0, MathF.PI * 2); + drawList.PathStroke(badgeRing, ImDrawFlags.Closed, 3f * scale); + + ImGui.PushFont(UiBuilder.IconFont); + string crownSymbol = "\uf521"; + var crownSize = GetCachedTextSize(crownSymbol); + + var crownPos = new Vector2( + badgeCenter.X - crownSize.X / 2 + (1f * scale), + badgeCenter.Y - crownSize.Y / 2 - (1f * scale) + ); + + uint crownGlow = ImGui.GetColorU32(new Vector4(1f, 0.8f, 0.2f, 0.6f + hoverAmount * 0.4f)); + drawList.AddText(crownPos + new Vector2(1 * scale, 1 * scale), crownGlow, crownSymbol); + + uint crownColor = ImGui.GetColorU32(new Vector4(1f, 0.9f, 0.3f, 1f)); + drawList.AddText(crownPos, crownColor, crownSymbol); + + ImGui.PopFont(); + } + + private void DrawIntegratedNameplate(Character character, Vector2 cardMin, float cardWidth, float imageHeight, float nameplateHeight, int characterIndex, float hoverAmount, float scale) + { + var drawList = ImGui.GetWindowDrawList(); + + var nameplateMin = new Vector2(cardMin.X, cardMin.Y + imageHeight); + var nameplateMax = new Vector2(cardMin.X + cardWidth, cardMin.Y + imageHeight + nameplateHeight); + + uint nameplateColor = ImGui.GetColorU32(new Vector4(0.08f, 0.08f, 0.08f, 0.95f)); + drawList.AddRectFilled(nameplateMin, nameplateMax, nameplateColor, 12f * scale, ImDrawFlags.RoundCornersBottom); + + var accentMin = new Vector2(nameplateMin.X + (6 * scale), nameplateMin.Y + (2 * scale)); + var accentMax = new Vector2(nameplateMax.X - (6 * scale), nameplateMin.Y + (6 * scale)); + uint accentColor = ImGui.GetColorU32(new Vector4(character.NameplateColor.X, character.NameplateColor.Y, character.NameplateColor.Z, 0.9f + hoverAmount * 0.3f)); + drawList.AddRectFilled(accentMin, accentMax, accentColor, 3f * scale); + + float topRowY = nameplateMin.Y + (12 * scale); + + // Favourite Star + string starSymbol = character.IsFavorite ? "★" : "☆"; + var starPos = new Vector2(nameplateMin.X + (8 * scale), topRowY); + var starSize = GetCachedTextSize(starSymbol); + + if (character.IsFavorite) + { + uint starGlow = ImGui.GetColorU32(new Vector4(1f, 0.8f, 0f, 0.5f + hoverAmount * 0.3f)); + drawList.AddText(starPos + new Vector2(1 * scale, 1 * scale), starGlow, starSymbol); + } + + uint starColor = character.IsFavorite + ? ImGui.GetColorU32(new Vector4(1f, 0.9f, 0.2f, 1f)) + : ImGui.GetColorU32(new Vector4(0.5f, 0.5f, 0.5f, 0.7f + hoverAmount * 0.3f)); + drawList.AddText(starPos, starColor, starSymbol); + + var starHitMin = starPos - new Vector2(2 * scale, 2 * scale); + var starHitMax = starPos + starSize + new Vector2(2 * scale, 2 * scale); + if (ImGui.IsMouseHoveringRect(starHitMin, starHitMax)) + { + ImGui.SetTooltip($"{(character.IsFavorite ? "Remove" : "Add")} {character.Name} as a Favourite"); + + if (ImGui.IsMouseClicked(ImGuiMouseButton.Left)) + { + var actualCharacter = plugin.Characters[characterIndex]; + actualCharacter.IsFavorite = !actualCharacter.IsFavorite; + + Vector2 effectPos = starPos + starSize / 2; + if (!characterFavoriteEffects.ContainsKey(characterIndex)) + characterFavoriteEffects[characterIndex] = new FavoriteSparkEffect(); + characterFavoriteEffects[characterIndex].Trigger(effectPos, actualCharacter.IsFavorite); + + plugin.SaveConfiguration(); + SortCharacters(); + } + } + + // Character Name + var textSize = GetCachedTextSize(character.Name); + var nameAreaMin = new Vector2(nameplateMin.X + (35 * scale), topRowY - (4 * scale)); + var nameAreaMax = new Vector2(nameplateMax.X - (35 * scale), topRowY + textSize.Y + (4 * scale)); + var textPos = new Vector2( + nameplateMin.X + (cardWidth - textSize.X) / 2, + topRowY + ); + + bool canDrag = CurrentSort == Plugin.SortType.Manual; + + if (canDrag) + { + HandleCharacterDragAndDrop(characterIndex, nameAreaMin, nameAreaMax, character, scale); + } + + if (draggedCharacterIndex == characterIndex) + { + var highlightColor = ImGui.GetColorU32(new Vector4(character.NameplateColor.X, character.NameplateColor.Y, character.NameplateColor.Z, 0.4f)); + drawList.AddRectFilled(nameAreaMin, nameAreaMax, highlightColor, 4f * scale); + } + + bool hoveringNameArea = ImGui.IsMouseHoveringRect(nameAreaMin, nameAreaMax); + if (canDrag && hoveringNameArea) + { + ImGui.SetMouseCursor(ImGuiMouseCursor.Hand); + ImGui.SetTooltip("Drag to reorder characters\n(Manual sort mode only)"); + } + + drawList.AddText(textPos + new Vector2(1 * scale, 1 * scale), ImGui.GetColorU32(new Vector4(0, 0, 0, 0.8f)), character.Name); + drawList.AddText(textPos, ImGui.GetColorU32(new Vector4(0.95f, 0.95f, 0.95f, 1f)), character.Name); + + // RP Profile Button + ImGui.PushFont(UiBuilder.IconFont); + string icon = "\uf2c2"; + var iconSize = GetCachedTextSize(icon); + var iconPos = new Vector2(nameplateMax.X - iconSize.X - (8 * scale), topRowY); + + if (hoverAmount > 0.1f) + { + uint iconGlow = ImGui.GetColorU32(new Vector4(0.3f, 0.7f, 1f, 0.4f + hoverAmount * 0.4f)); + drawList.AddText(iconPos + new Vector2(1 * scale, 1 * scale), iconGlow, icon); + } + + uint iconColor = ImGui.GetColorU32(new Vector4(0.7f, 0.8f, 1f, 0.8f + hoverAmount * 0.2f)); + drawList.AddText(iconPos, iconColor, icon); + ImGui.PopFont(); + + var iconHitMin = iconPos - new Vector2(2 * scale, 2 * scale); + var iconHitMax = iconPos + iconSize + new Vector2(2 * scale, 2 * scale); + + if (ImGui.IsMouseHoveringRect(iconHitMin, iconHitMax)) + { + ImGui.SetTooltip($"View RolePlay Profile for {character.Name}"); + + if (ImGui.IsMouseClicked(ImGuiMouseButton.Left)) + { + plugin.OpenRPProfileViewWindow(character); + } + } + + if (characterIndex == 0) + { + plugin.RPProfileButtonPos = iconHitMin; + plugin.RPProfileButtonSize = iconHitMax - iconHitMin; + } + + // Buttons!! + float bottomRowY = nameplateMin.Y + (35 * scale); + float btnWidth = (cardWidth - (32 * scale)) / 3; + float btnHeight = 22 * scale; + float btnSpacing = 8 * scale; + + ImGui.SetCursorScreenPos(new Vector2(nameplateMin.X + (8 * scale), bottomRowY)); + + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.15f, 0.15f, 0.15f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.25f, 0.25f, 0.25f, 1.0f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.35f, 0.35f, 0.35f, 1.0f)); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.9f, 0.9f, 0.9f, 1.0f)); + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 4.0f * scale); + ImGui.PushStyleVar(ImGuiStyleVar.ButtonTextAlign, new Vector2(0.5f, 0.5f)); + + var buttonPos = ImGui.GetCursorScreenPos(); + var buttonSize = new Vector2(btnWidth, btnHeight); + + if (ImGui.Button($"Designs##{character.Name}", new Vector2(btnWidth, btnHeight))) + { + int realIndex = plugin.Characters.IndexOf(character); + if (realIndex >= 0) + plugin.OpenDesignPanel(realIndex); + } + + // Store for tutorial + if (plugin.Characters.IndexOf(character) == 0) + { + plugin.FirstCharacterDesignsButtonPos = buttonPos; + plugin.FirstCharacterDesignsButtonSize = buttonSize; + } + + ImGui.SameLine(0, btnSpacing); + + if (ImGui.Button($"Edit##{character.Name}", new Vector2(btnWidth, btnHeight))) + { + int realIndex = plugin.Characters.IndexOf(character); + if (realIndex >= 0) + plugin.OpenEditCharacterWindow(realIndex); + } + + ImGui.SameLine(0, btnSpacing); + + bool isCtrlShiftPressed = ImGui.GetIO().KeyCtrl && ImGui.GetIO().KeyShift; + if (ImGui.Button($"Delete##{character.Name}", new Vector2(btnWidth, btnHeight))) + { + if (isCtrlShiftPressed) + { + plugin.Characters.Remove(character); + plugin.Configuration.Save(); + InvalidateCache(); + } + } + + if (ImGui.IsItemHovered()) + { + ImGui.BeginTooltip(); + ImGui.Text("Hold Ctrl + Shift and click to delete."); + ImGui.EndTooltip(); + } + + ImGui.PopStyleVar(2); + ImGui.PopStyleColor(4); + } + + + private void HandleCharacterDragAndDrop(int characterIndex, Vector2 areaMin, Vector2 areaMax, Character character, float scale) + { + bool hoveringArea = ImGui.IsMouseHoveringRect(areaMin, areaMax); + bool canDrag = CurrentSort == Plugin.SortType.Manual; + + if (canDrag) + { + // Create invisible button + ImGui.SetCursorScreenPos(areaMin); + ImGui.InvisibleButton($"##drag_handle_{characterIndex}", areaMax - areaMin); + + if (ImGui.IsItemActive() && draggedCharacterIndex == null) + { + dragStartPos = ImGui.GetMousePos(); + draggedCharacterIndex = characterIndex; + draggedCharacter = character; + isDragging = false; + } + + if (draggedCharacterIndex == characterIndex && ImGui.IsMouseDown(ImGuiMouseButton.Left)) + { + Vector2 currentPos = ImGui.GetMousePos(); + float distance = Vector2.Distance(dragStartPos, currentPos); + + if (distance > DragThreshold * scale) + { + isDragging = true; + } + } + + // During dragging, find which card the mouse is over + if (isDragging && draggedCharacterIndex != null) + { + Vector2 mousePos = ImGui.GetMousePos(); + if (hoveringArea && characterIndex != draggedCharacterIndex) + { + currentDropTargetIndex = characterIndex; + + var drawList = ImGui.GetWindowDrawList(); + uint dropZoneColor = ImGui.GetColorU32(new Vector4(character.NameplateColor.X, character.NameplateColor.Y, character.NameplateColor.Z, 0.8f)); + drawList.AddRect(areaMin - new Vector2(2 * scale, 2 * scale), areaMax + new Vector2(2 * scale, 2 * scale), dropZoneColor, 8f * scale, ImDrawFlags.None, 3f * scale); + } + else if (currentDropTargetIndex == characterIndex) + { + currentDropTargetIndex = null; + } + } + + // End dragging + if (draggedCharacterIndex == characterIndex && ImGui.IsMouseReleased(ImGuiMouseButton.Left)) + { + if (isDragging && currentDropTargetIndex.HasValue) + { + ReorderCharacters(draggedCharacterIndex.Value, currentDropTargetIndex.Value); + InvalidateCache(); + } + draggedCharacterIndex = null; + draggedCharacter = null; + isDragging = false; + currentDropTargetIndex = null; + } + + // Set cursor when hovering over draggable area + if (ImGui.IsItemHovered()) + { + ImGui.SetMouseCursor(ImGuiMouseCursor.Hand); + ImGui.SetTooltip("Drag to reorder characters\n(Manual sort mode only)"); + } + } + } + private void DrawDragGhostImage(float scale) + { + if (!isDragging || draggedCharacter == null) + return; + + Vector2 mousePos = ImGui.GetMousePos(); + + Vector2 scaledGhostSize = ghostImageSize * scale; + + Vector2 ghostOffset = new Vector2(-scaledGhostSize.X / 2, -scaledGhostSize.Y / 2 - (20 * scale)); + Vector2 ghostPos = mousePos + ghostOffset; + + var drawList = ImGui.GetWindowDrawList(); + + // Draw a semi-transparent background for the ghost, and maybe it won't haunt us + uint ghostBgColor = ImGui.GetColorU32(new Vector4(0.1f, 0.1f, 0.1f, ghostImageAlpha * 0.8f)); + drawList.AddRectFilled( + ghostPos, + ghostPos + scaledGhostSize, + ghostBgColor, + 8f * scale + ); + + // Glowing border using the character's nameplate colour + uint borderColor = ImGui.GetColorU32(new Vector4( + draggedCharacter.NameplateColor.X, + draggedCharacter.NameplateColor.Y, + draggedCharacter.NameplateColor.Z, + ghostImageAlpha + )); + drawList.AddRect( + ghostPos - new Vector2(2 * scale, 2 * scale), + ghostPos + scaledGhostSize + new Vector2(2 * scale, 2 * scale), + borderColor, + 8f * scale, + ImDrawFlags.None, + 2f * scale + ); + + // Draw the character's image + string pluginDirectory = plugin.PluginDirectory; + string defaultImagePath = Path.Combine(pluginDirectory, "Assets", "Default.png"); + string finalImagePath = GetCachedImagePath(draggedCharacter.ImagePath, defaultImagePath); + + if (!string.IsNullOrEmpty(finalImagePath)) + { + var texture = Plugin.TextureProvider.GetFromFile(finalImagePath).GetWrapOrDefault(); + + if (texture != null) + { + float imageMargin = 8f * scale; + Vector2 availableSize = scaledGhostSize - new Vector2(imageMargin * 2, imageMargin + (25 * scale)); + + float originalWidth = texture.Width; + float originalHeight = texture.Height; + float aspectRatio = originalWidth / originalHeight; + + Vector2 imageSize; + if (aspectRatio > 1) // Landscape + { + imageSize.X = availableSize.X; + imageSize.Y = availableSize.X / aspectRatio; + if (imageSize.Y > availableSize.Y) + { + imageSize.Y = availableSize.Y; + imageSize.X = availableSize.Y * aspectRatio; + } + } + else // Portrait or square + { + imageSize.Y = availableSize.Y; + imageSize.X = availableSize.Y * aspectRatio; + if (imageSize.X > availableSize.X) + { + imageSize.X = availableSize.X; + imageSize.Y = availableSize.X / aspectRatio; + } + } + + // Center the image + Vector2 imagePos = ghostPos + new Vector2( + (scaledGhostSize.X - imageSize.X) / 2, + imageMargin + ); + + // Draw image with transparency + uint imageColor = ImGui.GetColorU32(new Vector4(1f, 1f, 1f, ghostImageAlpha)); + drawList.AddImageRounded( + texture.ImGuiHandle, + imagePos, + imagePos + imageSize, + new Vector2(0, 0), + new Vector2(1, 1), + imageColor, + 6f * scale, + ImDrawFlags.RoundCornersTop + ); + } + } + + // Character name + var nameSize = GetCachedTextSize(draggedCharacter.Name); + Vector2 namePos = new Vector2( + ghostPos.X + (scaledGhostSize.X - nameSize.X) / 2, + ghostPos.Y + scaledGhostSize.Y - (20 * scale) + ); + + // Text shadow + uint shadowColor = ImGui.GetColorU32(new Vector4(0f, 0f, 0f, ghostImageAlpha * 0.8f)); + drawList.AddText(namePos + new Vector2(1 * scale, 1 * scale), shadowColor, draggedCharacter.Name); + + // Main text + uint textColor = ImGui.GetColorU32(new Vector4(0.95f, 0.95f, 0.95f, ghostImageAlpha)); + drawList.AddText(namePos, textColor, draggedCharacter.Name); + } + + private void DrawContextMenu(Character character, float scale) + { + if (ImGui.Selectable("Apply to Target")) + { + string macro = Plugin.GenerateTargetMacro(character.Macros); + if (!string.IsNullOrWhiteSpace(macro)) + plugin.ExecuteMacro(macro); + } + + bool isMainCharacter = !string.IsNullOrEmpty(plugin.Configuration.MainCharacterName) && + character.Name == plugin.Configuration.MainCharacterName; + + ImGui.Separator(); + if (isMainCharacter) + { + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(1f, 0.8f, 0.2f, 1f)); + + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf521"); + ImGui.PopFont(); + + ImGui.SameLine(0, 4 * scale); + if (ImGui.Selectable("Remove as Main Character")) + { + plugin.Configuration.MainCharacterName = null; + plugin.Configuration.Save(); + InvalidateCache(); + } + + ImGui.PopStyleColor(); + } + else + { + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(1f, 0.8f, 0.2f, 1f)); + + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf521"); + ImGui.PopFont(); + + ImGui.SameLine(0, 4 * scale); + if (ImGui.Selectable("Set as Main Character")) + { + plugin.Configuration.MainCharacterName = character.Name; + plugin.Configuration.Save(); + InvalidateCache(); + } + + ImGui.PopStyleColor(); + } + + ImGui.Spacing(); + ImGui.PushStyleColor(ImGuiCol.ChildBg, new Vector4(character.NameplateColor, 1.0f)); + ImGui.BeginChild($"##Separator_{character.Name}", new Vector2(ImGui.GetContentRegionAvail().X, 3 * scale), false); + ImGui.EndChild(); + ImGui.PopStyleColor(); + ImGui.Spacing(); + + if (character.Designs.Count > 0) + { + float itemHeight = ImGui.GetTextLineHeightWithSpacing(); + float maxVisible = 10; + float scrollHeight = Math.Min(character.Designs.Count, maxVisible) * itemHeight + (8 * scale); + + if (ImGui.BeginChild($"##DesignScroll_{character.Name}", new Vector2(300 * scale, scrollHeight))) + { + foreach (var design in character.Designs) + { + if (ImGui.Selectable($"Apply Design: {design.Name}")) + { + var macro = Plugin.GenerateTargetMacro( + design.IsAdvancedMode ? design.AdvancedMacro : design.Macro + ); + plugin.ExecuteMacro(macro); + } + } + ImGui.EndChild(); + } + } + } + private void DrawPagination(float scale) + { + var filteredCharacters = GetFilteredCharacters(); + + if (filteredCharacters.Count <= 40) + { + currentPage = 0; + return; + } + + int totalPages = (int)Math.Ceiling((double)filteredCharacters.Count / charactersPerPage); + + if (totalPages <= 1) return; + + ImGui.Spacing(); + + float windowWidth = ImGui.GetWindowWidth(); + float paginationWidth = totalPages * (20.0f * scale); + float startX = (windowWidth - paginationWidth) / 2; + + ImGui.SetCursorPosX(startX); + + Vector2 dotPosition = ImGui.GetCursorScreenPos(); + uiStyles.DrawPaginationDots(currentPage, totalPages, dotPosition, scale); + + for (int i = 0; i < totalPages; i++) + { + Vector2 dotPos = dotPosition + new Vector2(i * (20.0f * scale), 0); + Vector2 dotMin = dotPos - new Vector2(8 * scale, 8 * scale); + Vector2 dotMax = dotPos + new Vector2(8 * scale, 8 * scale); + + if (ImGui.IsMouseHoveringRect(dotMin, dotMax) && ImGui.IsMouseClicked(ImGuiMouseButton.Left)) + { + currentPage = i; + InvalidateCache(); + } + } + } + + private void ReorderCharacters(int fromIndex, int toIndex) + { + if (fromIndex == toIndex || fromIndex < 0 || toIndex < 0 || + fromIndex >= plugin.Characters.Count || toIndex >= plugin.Characters.Count) + return; + + var character = plugin.Characters[fromIndex]; + + plugin.Characters.RemoveAt(fromIndex); + + int insertIndex; + if (fromIndex < toIndex) + { + insertIndex = toIndex - 1; + } + else + { + insertIndex = toIndex; + } + + insertIndex = Math.Clamp(insertIndex, 0, plugin.Characters.Count); + plugin.Characters.Insert(insertIndex, character); + + for (int i = 0; i < plugin.Characters.Count; i++) + { + plugin.Characters[i].SortOrder = i; + } + + plugin.Configuration.CurrentSortIndex = (int)Plugin.SortType.Manual; + plugin.SaveConfiguration(); + + Plugin.Log.Debug($"[DragDrop] Moved character '{character.Name}' from position {fromIndex} to {insertIndex} (target was {toIndex})"); + } + + private void HandleCharacterClick(Character character, int index) + { + if (isDragging || draggedCharacterIndex != null) + return; + + if (plugin.IsDesignPanelOpen) + { + plugin.IsDesignPanelOpen = false; + } + + plugin.ExecuteMacro(character.Macros, character, null); + plugin.SetActiveCharacter(character); + + // Check if we should upload to gallery + if (Plugin.ClientState.LocalPlayer is { } player && player.HomeWorld.IsValid) + { + string localName = player.Name.TextValue; + string worldName = player.HomeWorld.Value.Name.ToString(); + string fullKey = $"{localName}@{worldName}"; + + bool shouldUploadToGallery = ShouldUploadToGallery(character, fullKey); + + if (shouldUploadToGallery) + { + System.Threading.Tasks.Task.Run(() => + { + var profileToSend = new RPProfile + { + Pronouns = character.RPProfile?.Pronouns, + Gender = character.RPProfile?.Gender, + Age = character.RPProfile?.Age, + Race = character.RPProfile?.Race, + Orientation = character.RPProfile?.Orientation, + Relationship = character.RPProfile?.Relationship, + Occupation = character.RPProfile?.Occupation, + Abilities = character.RPProfile?.Abilities, + Bio = character.RPProfile?.Bio, + Tags = character.RPProfile?.Tags, + CustomImagePath = !string.IsNullOrEmpty(character.RPProfile?.CustomImagePath) + ? character.RPProfile.CustomImagePath + : character.ImagePath, + ImageZoom = character.RPProfile?.ImageZoom ?? 1.0f, + ImageOffset = character.RPProfile?.ImageOffset ?? Vector2.Zero, + Sharing = character.RPProfile?.Sharing ?? ProfileSharing.AlwaysShare, + ProfileImageUrl = character.RPProfile?.ProfileImageUrl, + CharacterName = character.Name, + NameplateColor = character.RPProfile?.ProfileColor ?? character.NameplateColor, + BackgroundImage = character.BackgroundImage, + Effects = character.Effects ?? new ProfileEffects(), + GalleryStatus = character.GalleryStatus, + Links = character.RPProfile?.Links, + LastActiveTime = plugin.Configuration.ShowRecentlyActiveStatus ? DateTime.UtcNow : null + }; + + _ = Plugin.UploadProfileAsync(profileToSend, character.LastInGameName ?? character.Name); + }); + Plugin.Log.Info($"[CharacterGrid] ✓ Uploading profile for {character.Name}"); + } + else + { + Plugin.Log.Info($"[CharacterGrid] ⚠ Skipped gallery upload for {character.Name} (not on main character or not public)"); + } + } + plugin.QuickSwitchWindow.UpdateSelectionFromCharacter(character); + } + private bool ShouldUploadToGallery(Character character, string currentPhysicalCharacter) + { + // Is there a main character set? + var userMain = plugin.Configuration.GalleryMainCharacter; + if (string.IsNullOrEmpty(userMain)) + { + Plugin.Log.Debug($"[CharacterGrid-ShouldUpload] No main character set - not uploading {character.Name}"); + return false; + } + + // Are we currently on the main character? + if (currentPhysicalCharacter != userMain) + { + Plugin.Log.Debug($"[CharacterGrid-ShouldUpload] Current character '{currentPhysicalCharacter}' != main '{userMain}' - not uploading {character.Name}"); + return false; + } + + // Is this CS+ character set to public sharing? + var sharing = character.RPProfile?.Sharing ?? ProfileSharing.AlwaysShare; + if (sharing != ProfileSharing.ShowcasePublic) + { + Plugin.Log.Debug($"[CharacterGrid-ShouldUpload] Character '{character.Name}' sharing is '{sharing}' (not public) - not uploading"); + return false; + } + + Plugin.Log.Debug($"[CharacterGrid-ShouldUpload] ✓ All checks passed - will upload {character.Name} as {currentPhysicalCharacter}"); + return true; + } + + private List GetFilteredCharacters() + { + if (filterCacheDirty || + searchQuery != lastSearchQuery || + selectedTag != lastSelectedTag || + plugin.Characters.Count != lastCharacterCount) + { + RecalculateFilteredCharacters(); + } + + return cachedFilteredCharacters; + } + private float GetSafeScale(float baseScale) + { + return Math.Clamp(baseScale, 0.3f, 5.0f); + } + + private void RecalculateFilteredCharacters() + { + var characters = plugin.Characters.AsEnumerable(); + + // Apply tag filter + if (selectedTag != "All") + { + characters = characters.Where(c => c.Tags?.Contains(selectedTag) ?? false); + } + + // Apply search filter + if (!string.IsNullOrWhiteSpace(searchQuery)) + { + characters = characters.Where(c => + c.Name.Contains(searchQuery, StringComparison.OrdinalIgnoreCase)); + } + + cachedFilteredCharacters = characters.ToList(); + + lastSearchQuery = searchQuery; + lastSelectedTag = selectedTag; + lastCharacterCount = plugin.Characters.Count; + filterCacheDirty = false; + } + + private List GetPagedCharacters(List filteredCharacters) + { + int startIndex = currentPage * charactersPerPage; + var pagedResult = filteredCharacters.Skip(startIndex).Take(charactersPerPage).ToList(); + + if (cachedPagedCharacters == null || !cachedPagedCharacters.SequenceEqual(pagedResult)) + { + cachedPagedCharacters = pagedResult; + } + + return cachedPagedCharacters; + } + + private float UpdateHoverAnimation(int characterIndex, bool isHovered) + { + if (!hoverAnimations.ContainsKey(characterIndex)) + hoverAnimations[characterIndex] = 0f; + + float target = isHovered ? 1f : 0f; + float current = hoverAnimations[characterIndex]; + + // Only update if there's a significant change + if (Math.Abs(target - current) > 0.01f) + { + float speed = 8f; + current = current + (target - current) * ImGui.GetIO().DeltaTime * speed; + current = Math.Clamp(current, 0f, 1f); + hoverAnimations[characterIndex] = current; + } + + return current; + } + + public void SortCharacters() + { + if (CurrentSort == Plugin.SortType.Favorites) + { + plugin.Characters.Sort((a, b) => + { + int favCompare = b.IsFavorite.CompareTo(a.IsFavorite); + if (favCompare != 0) return favCompare; + return string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase); + }); + } + else if (CurrentSort == Plugin.SortType.Manual) + { + plugin.Characters.Sort((a, b) => a.SortOrder.CompareTo(b.SortOrder)); + } + else if (CurrentSort == Plugin.SortType.Alphabetical) + { + plugin.Characters.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase)); + } + else if (CurrentSort == Plugin.SortType.Recent) + { + plugin.Characters.Sort((a, b) => b.DateAdded.CompareTo(a.DateAdded)); + } + else if (CurrentSort == Plugin.SortType.Oldest) + { + plugin.Characters.Sort((a, b) => a.DateAdded.CompareTo(b.DateAdded)); + } + + InvalidateCache(); + } + + + public void SetSortType(Plugin.SortType sortType) + { + CurrentSort = sortType; + SortCharacters(); + } + + public void InvalidateCache() + { + cardRectsDirty = true; + layoutCacheDirty = true; + InvalidateFilterCache(); + } + + private void InvalidateFilterCache() + { + filterCacheDirty = true; + } + + // Method to clear file cache when needed + public void ClearFileCache() + { + fileExistsCache.Clear(); + } + + // Method to clear text cache when font changes + public void ClearTextCache() + { + textSizeCache.Clear(); + } + } +} diff --git a/CharacterSelectPlugin/Windows/Components/DesignPanel.cs b/CharacterSelectPlugin/Windows/Components/DesignPanel.cs new file mode 100644 index 0000000..c093229 --- /dev/null +++ b/CharacterSelectPlugin/Windows/Components/DesignPanel.cs @@ -0,0 +1,1962 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Numerics; +using System.Threading; +using System.Windows.Forms; +using ImGuiNET; +using Dalamud.Interface; +using CharacterSelectPlugin.Windows.Styles; +using Dalamud.Interface.Textures.TextureWraps; +using CharacterSelectPlugin.Effects; + +namespace CharacterSelectPlugin.Windows.Components +{ + public class DesignPanel : IDisposable + { + private Plugin plugin; + private UIStyles uiStyles; + + public bool IsOpen { get; private set; } = false; + private int activeCharacterIndex = -1; + private Dictionary designFavoriteEffects = new(); + + // Resizable panel + public float PanelWidth { get; private set; } = 300f; // Default width + private const float MinPanelWidth = 250f; + private const float MaxPanelWidth = 600f; + private bool isResizing = false; + private float resizeHandleWidth = 8f; + + // Design editing state + private bool isEditDesignWindowOpen = false; + private bool isAdvancedModeDesign = false; + private bool isAdvancedModeWindowOpen = false; + private bool isNewDesign = false; + private bool isSecretDesignMode = false; + + // Edit fields + private string editedDesignName = ""; + private string editedDesignMacro = ""; + private string editedGlamourerDesign = ""; + private string editedAutomation = ""; + private string editedCustomizeProfile = ""; + private string editedDesignPreviewPath = ""; + private string advancedDesignMacroText = ""; + private string originalDesignName = ""; + private string? pendingDesignImagePath = null; + + // Design sorting + private enum DesignSortType { Favorites, Alphabetical, Recent, Oldest, Manual } + private DesignSortType currentDesignSort = DesignSortType.Alphabetical; + + // Folder management + private string newFolderName = ""; + private bool isRenamingFolder = false; + private Guid renameFolderId; + private string renameFolderBuf = ""; + private DesignFolder? draggedFolder = null; + private CharacterDesign? draggedDesign = null; + private Vector3? newFolderSelectedColor = null; + + // Import window + private bool isImportWindowOpen = false; + private Character? targetForDesignImport = null; + + public DesignPanel(Plugin plugin, UIStyles uiStyles) + { + this.plugin = plugin; + this.uiStyles = uiStyles; + + // Load saved panel width or use default + PanelWidth = plugin.Configuration.DesignPanelWidth; + } + + public void Dispose() + { + // Save panel width on dispose + plugin.Configuration.DesignPanelWidth = PanelWidth; + plugin.Configuration.Save(); + } + + public void Draw() + { + if (!IsOpen) return; + + // Calculate responsive sizing + var dpiScale = ImGui.GetIO().DisplayFramebufferScale.X; + var uiScale = plugin.Configuration.UIScaleMultiplier; + var totalScale = GetSafeScale(dpiScale * uiScale); + + // Scale the panel dimensions + float scaledPanelWidth = PanelWidth * GetSafeScale(totalScale); + float scaledMinWidth = MinPanelWidth * totalScale; + float scaledMaxWidth = MaxPanelWidth * totalScale; + float scaledHandleWidth = resizeHandleWidth * totalScale; + + DrawDesignPanelContent(totalScale, scaledPanelWidth); + DrawResizeHandle(totalScale, scaledPanelWidth, scaledMinWidth, scaledMaxWidth, scaledHandleWidth); + + if (IsOpen) + { + UpdateEffects(); + } + + DrawImportWindow(totalScale); + DrawAdvancedModeWindow(totalScale); + } + + private void DrawResizeHandle(float totalScale, float scaledPanelWidth, float scaledMinWidth, float scaledMaxWidth, float scaledHandleWidth) + { + // Current window position and size + var windowPos = ImGui.GetWindowPos(); + var windowSize = ImGui.GetWindowSize(); + + // Position handle at the very left edge of the design panel window + var handleMin = new Vector2(windowPos.X, windowPos.Y); + var handleMax = new Vector2(windowPos.X + scaledHandleWidth, windowPos.Y + windowSize.Y); + + // Check if mouse is over the handle area + bool hovered = ImGui.IsMouseHoveringRect(handleMin, handleMax); + + // Capture mouse input when over resize handle to prevent window dragging + if (hovered || isResizing) + { + ImGui.SetMouseCursor(ImGuiMouseCursor.ResizeEW); + + if (hovered && (ImGui.IsMouseClicked(ImGuiMouseButton.Left) || ImGui.IsMouseDown(ImGuiMouseButton.Left))) + { + ImGui.SetItemAllowOverlap(); + + // Create an invisible button over the resize area to capture input + ImGui.SetCursorScreenPos(handleMin); + ImGui.InvisibleButton("##resize_handle", new Vector2(scaledHandleWidth, windowSize.Y)); + + // Check if this invisible button is real... + if (ImGui.IsItemActive() || isResizing) + { + if (ImGui.IsMouseClicked(ImGuiMouseButton.Left)) + { + isResizing = true; + } + } + } + } + + // Handle resizing + if (isResizing) + { + if (ImGui.IsMouseDown(ImGuiMouseButton.Left)) + { + // Current mouse position + float currentMouseX = ImGui.GetMousePos().X; + // Calculate new width based on mouse position relative to the window's right edge + float windowRightEdge = ImGui.GetWindowPos().X + ImGui.GetWindowSize().X; + float newScaledWidth = windowRightEdge - currentMouseX; + // Convert to base units and clamp + float newWidth = newScaledWidth / totalScale; + PanelWidth = Math.Clamp(newWidth, MinPanelWidth, MaxPanelWidth); + // Save the new width immediately for responsiveness + plugin.Configuration.DesignPanelWidth = PanelWidth; + // Force the main window to recalculate layout, consensually + if (plugin.MainWindow != null) + { + plugin.MainWindow.InvalidateLayout(); + } + } + if (ImGui.IsMouseReleased(ImGuiMouseButton.Left)) + { + isResizing = false; + // Save configuration + plugin.Configuration.Save(); + } + } + + // Draw visual resize handle + var drawList = ImGui.GetWindowDrawList(); + uint handleColor = hovered || isResizing + ? ImGui.GetColorU32(new Vector4(0.6f, 0.6f, 0.8f, 0.8f)) + : ImGui.GetColorU32(new Vector4(0.4f, 0.4f, 0.6f, 0.3f)); + + // Draw a subtle line at the left edge, so subtle you might not see it! + drawList.AddLine( + new Vector2(handleMin.X + 2 * totalScale, handleMin.Y + 10 * totalScale), + new Vector2(handleMin.X + 2 * totalScale, handleMax.Y - 10 * totalScale), + handleColor, + 2f * totalScale + ); + + // Draw resize grip dots when hovered, really grip them + if (hovered || isResizing) + { + float dotSize = 2f * totalScale; + float dotSpacing = 6f * totalScale; + var centerX = handleMin.X + scaledHandleWidth / 2; + var centerY = handleMin.Y + windowSize.Y / 2; + for (int i = -2; i <= 2; i++) + { + drawList.AddCircleFilled( + new Vector2(centerX, centerY + i * dotSpacing), + dotSize, + handleColor + ); + } + } + } + private float GetSafeScale(float baseScale) + { + return Math.Clamp(baseScale, 0.3f, 5.0f); + } + + private void UpdateEffects() + { + float deltaTime = ImGui.GetIO().DeltaTime; + foreach (var effect in designFavoriteEffects.Values) + { + effect.Update(deltaTime); + } + + foreach (var kvp in designFavoriteEffects.ToList()) + { + kvp.Value.Draw(); + + if (!kvp.Value.IsActive) + { + designFavoriteEffects.Remove(kvp.Key); + } + } + } + + public void Open(int characterIndex) + { + activeCharacterIndex = characterIndex; + IsOpen = true; + plugin.IsDesignPanelOpen = true; + } + + public void Close() + { + IsOpen = false; + activeCharacterIndex = -1; + plugin.IsDesignPanelOpen = false; + CloseDesignEditor(); + } + + private void DrawDesignPanelContent(float totalScale, float scaledPanelWidth) + { + if (activeCharacterIndex < 0 || activeCharacterIndex >= plugin.Characters.Count) + return; + + var character = plugin.Characters[activeCharacterIndex]; + + ApplyScaledStyles(totalScale); + + try + { + DrawHeader(character, totalScale); + + if (isEditDesignWindowOpen) + { + DrawDesignForm(character, totalScale); + ImGui.Separator(); + } + + DrawSortingControls(character, totalScale); + ImGui.Separator(); + + DrawDesignList(character, totalScale); + } + finally + { + PopScaledStyles(); + } + } + + private void ApplyScaledStyles(float scale) + { + // Style, do you have it? + ImGui.PushStyleColor(ImGuiCol.WindowBg, new Vector4(0.08f, 0.08f, 0.1f, 0.98f)); + ImGui.PushStyleColor(ImGuiCol.ChildBg, new Vector4(0.1f, 0.1f, 0.12f, 0.95f)); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.95f, 0.95f, 0.95f, 1.0f)); + ImGui.PushStyleColor(ImGuiCol.Header, new Vector4(0.16f, 0.16f, 0.2f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.HeaderHovered, new Vector4(0.22f, 0.22f, 0.28f, 1.0f)); + ImGui.PushStyleColor(ImGuiCol.HeaderActive, new Vector4(0.28f, 0.28f, 0.35f, 1.0f)); + + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 5.0f * scale); + ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 8.0f * scale); + ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(8 * scale, 5 * scale)); + ImGui.PushStyleVar(ImGuiStyleVar.FramePadding, new Vector2(6 * scale, 3 * scale)); + } + + private void PopScaledStyles() + { + ImGui.PopStyleVar(4); + ImGui.PopStyleColor(6); + } + + private void DrawHeader(Character character, float scale) + { + float buttonSize = 25f * scale; + float spacing = 5f * scale; + + + ImGui.BeginGroup(); + + // Add and Folder buttons + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.27f, 1.07f, 0.27f, 1.0f)); + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.15f, 0.15f, 0.15f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.25f, 0.25f, 0.25f, 1f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.35f, 0.35f, 0.35f, 1f)); + + if (ImGui.Button("+##AddDesign", new Vector2(buttonSize, buttonSize))) + { + var io = ImGui.GetIO(); + bool ctrlHeld = io.KeyCtrl; + bool shiftHeld = io.KeyShift; + + if (ctrlHeld && shiftHeld) + { + isSecretDesignMode = true; + AddNewDesign(); + editedDesignMacro = GenerateSecretDesignMacro(character); + if (isAdvancedModeDesign) + advancedDesignMacroText = editedDesignMacro; + } + else if (shiftHeld) + { + isSecretDesignMode = false; + isImportWindowOpen = true; + targetForDesignImport = character; + } + else + { + isSecretDesignMode = false; + AddNewDesign(); + editedDesignMacro = GenerateDesignMacro(character); + if (isAdvancedModeDesign) + advancedDesignMacroText = editedDesignMacro; + } + } + + plugin.DesignPanelAddButtonPos = ImGui.GetItemRectMin(); + plugin.DesignPanelAddButtonSize = ImGui.GetItemRectSize(); + + ImGui.PopStyleColor(4); + + if (ImGui.IsItemHovered()) + { + ImGui.BeginTooltip(); + ImGui.Text("Click to add a new design\nHold Shift to import from another character"); + ImGui.EndTooltip(); + } + + ImGui.SameLine(0, spacing); + + // Folder Button + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.9f, 0.7f, 0.3f, 1.0f)); // Yellow + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.15f, 0.15f, 0.15f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.25f, 0.25f, 0.25f, 1f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.35f, 0.35f, 0.35f, 1f)); + + ImGui.PushFont(UiBuilder.IconFont); + if (ImGui.Button("\uf07b##AddFolder", new Vector2(buttonSize, buttonSize))) + ImGui.OpenPopup("CreateFolderPopup"); + ImGui.PopFont(); + + ImGui.PopStyleColor(4); + + DrawFolderCreationPopup(character, scale); + + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Add Folder"); + } + + // Close button + ImGui.SameLine(); + float availableWidth = ImGui.GetContentRegionAvail().X; + ImGui.SetCursorPosX(ImGui.GetCursorPosX() + availableWidth - buttonSize); + + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(1.0f, 0.27f, 0.27f, 1.0f)); + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.15f, 0.15f, 0.15f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.4f, 0.2f, 0.2f, 1f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.5f, 0.3f, 0.3f, 1f)); + + if (ImGui.Button("×##CloseDesignPanel", new Vector2(buttonSize, buttonSize))) + { + Close(); + } + + ImGui.PopStyleColor(4); + + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Close Design Panel"); + } + + ImGui.EndGroup(); + + ImGui.Spacing(); + + // Character name + string name = $"Designs for {character.Name}"; + ImGui.TextUnformatted(name); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip(name); + + ImGui.Spacing(); + } + + private void DrawFolderCreationPopup(Character character, float scale) + { + if (ImGui.BeginPopup("CreateFolderPopup")) + { + ImGui.Text("New Folder Name:"); + ImGui.SetNextItemWidth(200 * scale); + ImGui.InputText("##NewFolder", ref newFolderName, 100); + + ImGui.Spacing(); + ImGui.Text("Folder Color:"); + + // Colour selection + var quickColors = new[] + { + (Vector3?)null, // Auto + new Vector3(0.8f, 0.2f, 0.2f), // Red + new Vector3(0.3f, 0.8f, 0.3f), // Green + new Vector3(0.3f, 0.5f, 0.9f), // Blue + new Vector3(0.7f, 0.3f, 0.9f) // Purple + }; + + float colorButtonSize = 30f * scale; + for (int i = 0; i < quickColors.Length; i++) + { + var color = quickColors[i]; + bool isSelected = (newFolderSelectedColor == null && color == null) || + (newFolderSelectedColor != null && color != null && + Vector3.Distance(newFolderSelectedColor.Value, color.Value) < 0.1f); + + if (i > 0) ImGui.SameLine(); + + Vector4 buttonColor = color.HasValue + ? new Vector4(color.Value.X, color.Value.Y, color.Value.Z, 1.0f) + : new Vector4(0.5f, 0.5f, 0.5f, 1.0f); + + // Style, I think I'm getting it! + ImGui.PushStyleColor(ImGuiCol.Button, buttonColor); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(buttonColor.X * 1.2f, buttonColor.Y * 1.2f, buttonColor.Z * 1.2f, 1.0f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(buttonColor.X * 0.8f, buttonColor.Y * 0.8f, buttonColor.Z * 0.8f, 1.0f)); + + if (isSelected) + { + ImGui.PushStyleColor(ImGuiCol.Border, new Vector4(1, 1, 1, 1)); + ImGui.PushStyleVar(ImGuiStyleVar.FrameBorderSize, 3f * scale); + } + + if (ImGui.Button($"##Color{i}", new Vector2(colorButtonSize, colorButtonSize))) + { + newFolderSelectedColor = color; + } + + if (isSelected) + { + ImGui.PopStyleVar(); + ImGui.PopStyleColor(); + } + + ImGui.PopStyleColor(3); + } + + ImGui.Separator(); + + float buttonWidth = 60f * scale; + if (ImGui.Button("Create", new Vector2(buttonWidth, 0))) + { + var folder = new DesignFolder(newFolderName, Guid.NewGuid()) + { + ParentFolderId = null, + SortOrder = character.DesignFolders.Count, + CustomColor = newFolderSelectedColor + }; + character.DesignFolders.Add(folder); + plugin.SaveConfiguration(); + plugin.RefreshTreeItems(character); + newFolderName = ""; + newFolderSelectedColor = null; + ImGui.CloseCurrentPopup(); + } + ImGui.SameLine(); + if (ImGui.Button("Cancel", new Vector2(buttonWidth, 0))) + { + newFolderName = ""; + newFolderSelectedColor = null; + ImGui.CloseCurrentPopup(); + } + ImGui.EndPopup(); + } + } + + private void DrawDesignForm(Character character, float scale) + { + float formHeight = 320f * scale; + ImGui.BeginChild("EditDesignForm", new Vector2(0, formHeight), true, ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.AlwaysAutoResize); + + bool isNewDesignForm = string.IsNullOrEmpty(editedDesignName); + ImGui.Text(isNewDesignForm ? "Add Design" : "Edit Design"); + + float inputWidth = Math.Max(150f * scale, ImGui.GetContentRegionAvail().X - (50f * scale)); + + // Design Name + ImGui.Text("Design Name*"); + ImGui.SetCursorPosX(10 * scale); + ImGui.SetNextItemWidth(inputWidth); + if (ImGui.InputText("##DesignName", ref editedDesignName, 100)) + { + plugin.EditedDesignName = editedDesignName; + } + plugin.DesignNameFieldPos = ImGui.GetItemRectMin(); + plugin.DesignNameFieldSize = ImGui.GetItemRectSize(); + + ImGui.Separator(); + + DrawGlamourerField(character, inputWidth, scale); + + if (plugin.Configuration.EnableAutomations) + { + DrawAutomationField(inputWidth, scale); + } + + DrawCustomizeField(inputWidth, scale); + + DrawPreviewImageField(scale); + + ImGui.Separator(); + + DrawAdvancedModeToggle(scale); + + ImGui.Separator(); + + DrawFormActionButtons(character, scale); + + ImGui.EndChild(); + } + + private void DrawGlamourerField(Character character, float inputWidth, float scale) + { + ImGui.Text("Glamourer Design*"); + + // Tooltip + ImGui.SameLine(); + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf05a"); + ImGui.PopFont(); + + if (ImGui.IsItemHovered()) + { + ImGui.BeginTooltip(); + ImGui.PushTextWrapPos(300 * scale); + ImGui.TextUnformatted("Enter the name of the Glamourer design to apply to this character.\nMust be entered EXACTLY as it is named in Glamourer!\nNote: You can add additional designs later."); + ImGui.PopTextWrapPos(); + ImGui.EndTooltip(); + } + + ImGui.SetCursorPosX(10 * scale); + ImGui.SetNextItemWidth(inputWidth); + var oldGlam = editedGlamourerDesign; + if (ImGui.InputText("##GlamourerDesign", ref editedGlamourerDesign, 100)) + { + plugin.EditedGlamourerDesign = editedGlamourerDesign; + + if (!isAdvancedModeDesign) + { + editedDesignMacro = isSecretDesignMode + ? GenerateSecretDesignMacro(character) + : GenerateDesignMacro(character); + } + else + { + UpdateAdvancedMacroGlamourerFixed(editedGlamourerDesign); + } + } + plugin.DesignGlamourerFieldPos = ImGui.GetItemRectMin(); + plugin.DesignGlamourerFieldSize = ImGui.GetItemRectSize(); + } + + private void DrawAutomationField(float inputWidth, float scale) + { + ImGui.Text("Glamourer Automation"); + + ImGui.SameLine(); + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf05a"); + ImGui.PopFont(); + + if (ImGui.IsItemHovered()) + { + ImGui.BeginTooltip(); + ImGui.PushTextWrapPos(300 * scale); + ImGui.TextUnformatted("Optional: Enter the name of a Glamourer automation to use with this design.\n⚠️ This must match the name of the automation EXACTLY."); + ImGui.PopTextWrapPos(); + ImGui.EndTooltip(); + } + + ImGui.SetCursorPosX(10 * scale); + ImGui.SetNextItemWidth(inputWidth); + ImGui.InputText("##GlamourerAutomation", ref editedAutomation, 100); + } + + private void DrawCustomizeField(float inputWidth, float scale) + { + ImGui.Text("Customize+ Profile"); + + ImGui.SameLine(); + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf05a"); + ImGui.PopFont(); + + if (ImGui.IsItemHovered()) + { + ImGui.BeginTooltip(); + ImGui.PushTextWrapPos(300 * scale); + ImGui.TextUnformatted("Optional: Enter the name of a Customize+ profile to apply with this design.\nIf left blank, uses the character's profile or disables all profiles."); + ImGui.PopTextWrapPos(); + ImGui.EndTooltip(); + } + + ImGui.SetCursorPosX(10 * scale); + ImGui.SetNextItemWidth(inputWidth); + if (ImGui.InputText("##CustomizePlus", ref editedCustomizeProfile, 100)) + { + // Update macro + if (!isAdvancedModeDesign) + { + editedDesignMacro = isSecretDesignMode + ? GenerateSecretDesignMacro(plugin.Characters[activeCharacterIndex]) + : GenerateDesignMacro(plugin.Characters[activeCharacterIndex]); + } + else + { + UpdateAdvancedMacroCustomize(); + } + } + } + + private void DrawPreviewImageField(float scale) + { + ImGui.Text("Preview Image (Optional)"); + + ImGui.SameLine(); + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf05a"); + ImGui.PopFont(); + if (ImGui.IsItemHovered()) + { + ImGui.BeginTooltip(); + ImGui.PushTextWrapPos(300 * scale); + ImGui.TextUnformatted("Optional: Choose an image to show when hovering over this design.\nThis helps you quickly identify designs at a glance."); + ImGui.PopTextWrapPos(); + ImGui.EndTooltip(); + } + + ImGui.SetCursorPosX(10 * scale); + if (ImGui.Button("Choose Preview Image")) + { + SelectPreviewImage(); + } + + // Apply pending image path + if (pendingDesignImagePath != null) + { + lock (this) + { + editedDesignPreviewPath = pendingDesignImagePath; + pendingDesignImagePath = null; + } + } + + // Show current preview + if (!string.IsNullOrEmpty(editedDesignPreviewPath) && File.Exists(editedDesignPreviewPath)) + { + var texture = Plugin.TextureProvider.GetFromFile(editedDesignPreviewPath).GetWrapOrDefault(); + if (texture != null) + { + float previewSize = 100f * scale; + ImGui.Image(texture.ImGuiHandle, new Vector2(previewSize, previewSize)); + } + } + else if (!string.IsNullOrEmpty(editedDesignPreviewPath)) + { + ImGui.Text("Preview: " + Path.GetFileName(editedDesignPreviewPath)); + } + + ImGui.SameLine(); + if (ImGui.Button("Clear") && !string.IsNullOrEmpty(editedDesignPreviewPath)) + { + editedDesignPreviewPath = ""; + } + } + + private void DrawAdvancedModeToggle(float scale) + { + if (ImGui.Button(isAdvancedModeDesign ? "Exit Advanced Mode" : "Advanced Mode")) + { + isAdvancedModeDesign = !isAdvancedModeDesign; + isAdvancedModeWindowOpen = isAdvancedModeDesign; + + if (isAdvancedModeDesign) + { + advancedDesignMacroText = EnsureProperDesignMacroStructure(); + } + } + + // Tooltip + ImGui.SameLine(); + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf05a"); + ImGui.PopFont(); + if (ImGui.IsItemHovered()) + { + ImGui.BeginTooltip(); + ImGui.PushTextWrapPos(300 * scale); + ImGui.TextUnformatted("⚠️ Do not touch this unless you know what you're doing."); + ImGui.PopTextWrapPos(); + ImGui.EndTooltip(); + } + } + + private void DrawFormActionButtons(Character character, float scale) + { + float buttonWidth = 85 * scale; + float buttonHeight = 20 * scale; + float buttonSpacing = 8 * scale; + float totalButtonWidth = (buttonWidth * 2 + buttonSpacing); + float availableWidth = ImGui.GetContentRegionAvail().X; + float buttonPosX = (availableWidth > totalButtonWidth) ? (availableWidth - totalButtonWidth) / 2f : 0; + + ImGui.SetCursorPosX(buttonPosX); + + bool canSave = !string.IsNullOrWhiteSpace(editedDesignName) && !string.IsNullOrWhiteSpace(editedGlamourerDesign); + + // Save button stylist here, how can i help you today? + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.2f, 0.4f, 0.2f, 0.8f)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.3f, 0.5f, 0.3f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.4f, 0.6f, 0.4f, 1.0f)); + + if (!canSave) + ImGui.BeginDisabled(); + + if (ImGui.Button("Save Design", new Vector2(buttonWidth, buttonHeight))) + { + SaveDesign(character); + CloseDesignEditor(); + } + plugin.SaveDesignButtonPos = ImGui.GetItemRectMin(); + plugin.SaveDesignButtonSize = ImGui.GetItemRectSize(); + + if (!canSave) + ImGui.EndDisabled(); + + ImGui.PopStyleColor(3); + + ImGui.SameLine(); + + // Cancel button styling - #stopcancebutton + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.4f, 0.2f, 0.2f, 0.8f)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.5f, 0.3f, 0.3f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.6f, 0.4f, 0.4f, 1.0f)); + + if (ImGui.Button("Cancel", new Vector2(buttonWidth, buttonHeight))) + { + CloseDesignEditor(); + } + + ImGui.PopStyleColor(3); + } + + private void DrawSortingControls(Character character, float scale) + { + ImGui.Text("Sort Designs By:"); + ImGui.SameLine(); + + float comboWidth = Math.Max(120f * scale, ImGui.GetContentRegionAvail().X - (20f * scale)); + ImGui.SetNextItemWidth(comboWidth); + + if (ImGui.BeginCombo("##DesignSortDropdown", currentDesignSort.ToString())) + { + if (ImGui.Selectable("Favourites", currentDesignSort == DesignSortType.Favorites)) + { + currentDesignSort = DesignSortType.Favorites; + SortDesigns(character); + } + if (ImGui.Selectable("Alphabetical", currentDesignSort == DesignSortType.Alphabetical)) + { + currentDesignSort = DesignSortType.Alphabetical; + SortDesigns(character); + } + if (ImGui.Selectable("Newest", currentDesignSort == DesignSortType.Recent)) + { + currentDesignSort = DesignSortType.Recent; + SortDesigns(character); + } + if (ImGui.Selectable("Oldest", currentDesignSort == DesignSortType.Oldest)) + { + currentDesignSort = DesignSortType.Oldest; + SortDesigns(character); + } + if (ImGui.Selectable("Manual", currentDesignSort == DesignSortType.Manual)) + { + currentDesignSort = DesignSortType.Manual; + } + ImGui.EndCombo(); + } + } + + private void DrawDesignList(Character character, float scale) + { + float remainingHeight = ImGui.GetContentRegionAvail().Y; + + // Minimum height + remainingHeight = Math.Max(remainingHeight, 100f * scale); + + ImGui.BeginChild("DesignListBackground", new Vector2(0, remainingHeight), true, ImGuiWindowFlags.AlwaysVerticalScrollbar); + + // Build unified list of folders and designs + var renderItems = BuildRenderItems(character); + + // Render each item + bool anyRowHovered = false; + bool anyHeaderHovered = false; + + foreach (var entry in renderItems) + { + if (entry.isFolder) + { + var folder = (DesignFolder)entry.item; + bool folderWasHovered = false; + DrawFolderItem(character, folder, ref folderWasHovered, scale); + if (folderWasHovered) anyHeaderHovered = true; + } + else + { + var design = (CharacterDesign)entry.item; + DrawDesignRow(character, design, false, scale); + if (ImGui.IsItemHovered()) anyRowHovered = true; + } + } + + // Handle dropping outside any header + HandleDropToRoot(anyHeaderHovered, anyRowHovered, character); + + ImGui.EndChild(); + } + + private void DrawFolderItem(Character character, DesignFolder folder, ref bool wasHovered, float scale) + { + bool isRenaming = isRenamingFolder && folder.Id == renameFolderId; + bool open = false; + + // Get folder colour + var folderColor = GetFolderColor(character, folder); + + if (isRenaming) + { + ImGui.PushStyleColor(ImGuiCol.FrameBg, new Vector4(0.2f, 0.2f, 0.2f, 1f)); + ImGui.SetNextItemWidth(200 * scale); + if (ImGui.InputText("##InlineRename", ref renameFolderBuf, 128, ImGuiInputTextFlags.EnterReturnsTrue)) + { + folder.Name = renameFolderBuf; + isRenamingFolder = false; + plugin.SaveConfiguration(); + plugin.RefreshTreeItems(character); + } + ImGui.PopStyleColor(); + } + else + { + // Style the folder header with custom colour + ImGui.PushStyleColor(ImGuiCol.Header, folderColor); + ImGui.PushStyleColor(ImGuiCol.HeaderHovered, new Vector4(folderColor.X * 1.2f, folderColor.Y * 1.2f, folderColor.Z * 1.2f, folderColor.W)); + ImGui.PushStyleColor(ImGuiCol.HeaderActive, new Vector4(folderColor.X * 1.4f, folderColor.Y * 1.4f, folderColor.Z * 1.4f, folderColor.W)); + + open = ImGui.CollapsingHeader($"{folder.Name}##F{folder.Id}", ImGuiTreeNodeFlags.SpanFullWidth); + + ImGui.PopStyleColor(3); + + // Drag source + if (ImGui.BeginDragDropSource(ImGuiDragDropFlags.SourceAllowNullID)) + { + draggedFolder = folder; + ImGui.SetDragDropPayload("FOLDER_MOVE", IntPtr.Zero, 0); + ImGui.TextUnformatted($"Moving Folder: {folder.Name}"); + ImGui.EndDragDropSource(); + } + + // Context menu + DrawFolderContextMenu(character, folder, scale); + } + + // Handle hover and drop logic + var hdrMin = ImGui.GetItemRectMin(); + var hdrMax = ImGui.GetItemRectMax(); + bool overHeader = ImGui.IsMouseHoveringRect(hdrMin, hdrMax, true); + wasHovered = overHeader; + + if ((draggedDesign != null || draggedFolder != null) && overHeader) + { + var dl = ImGui.GetWindowDrawList(); + uint col = ImGui.GetColorU32(new Vector4(0.3f, 0.5f, 1f, 1f)); + dl.AddRect(hdrMin, hdrMax, col, 0, ImDrawFlags.None, 2 * scale); + } + + // Drop handling + if (overHeader && ImGui.IsMouseReleased(ImGuiMouseButton.Left)) + { + if (draggedDesign != null) + { + draggedDesign.FolderId = folder.Id; + plugin.SaveConfiguration(); + plugin.RefreshTreeItems(character); + draggedDesign = null; + } + else if (draggedFolder != null && draggedFolder != folder) + { + draggedFolder.ParentFolderId = folder.Id; + plugin.SaveConfiguration(); + plugin.RefreshTreeItems(character); + draggedFolder = null; + } + } + + // Draw folder content + if (open) + { + DrawFolderContents(character, folder, scale); + } + } + + private void DrawFolderContextMenu(Character character, DesignFolder folder, float scale) + { + if (ImGui.IsItemHovered() && ImGui.IsMouseClicked(ImGuiMouseButton.Right)) + ImGui.OpenPopup($"FolderCtx{folder.Id}"); + + if (ImGui.BeginPopup($"FolderCtx{folder.Id}")) + { + if (ImGui.MenuItem("Rename Folder")) + { + renameFolderId = folder.Id; + renameFolderBuf = folder.Name; + isRenamingFolder = true; + ImGui.CloseCurrentPopup(); + } + + ImGui.Separator(); + + // Folder colour menu + if (ImGui.BeginMenu("Folder Colour")) + { + // Auto colour option + if (ImGui.MenuItem("Auto Colour", "", folder.CustomColor == null)) + { + folder.CustomColor = null; + plugin.SaveConfiguration(); + } + + ImGui.Separator(); + + // Preset colours + var presetColors = new[] + { + ("Red", new Vector3(0.8f, 0.2f, 0.2f)), + ("Green", new Vector3(0.3f, 0.8f, 0.3f)), + ("Blue", new Vector3(0.3f, 0.5f, 0.9f)), + ("Yellow", new Vector3(0.9f, 0.8f, 0.2f)), + ("Purple", new Vector3(0.7f, 0.3f, 0.9f)), + ("Orange", new Vector3(1.0f, 0.6f, 0.2f)), + ("Pink", new Vector3(0.9f, 0.4f, 0.7f)), + ("Cyan", new Vector3(0.3f, 0.8f, 0.8f)) + }; + + foreach (var (colorName, color) in presetColors) + { + bool isSelected = folder.CustomColor.HasValue && + Vector3.Distance(folder.CustomColor.Value, color) < 0.1f; + + if (ImGui.MenuItem(colorName, "", isSelected)) + { + folder.CustomColor = color; + plugin.SaveConfiguration(); + } + } + + ImGui.Separator(); + + // Custom colour picker + ImGui.Text("Custom Colour:"); + Vector3 tempColor = folder.CustomColor ?? GetAutoGeneratedColor(character, folder); + + if (ImGui.ColorEdit3("##CustomFolderColour", ref tempColor, ImGuiColorEditFlags.NoInputs)) + { + folder.CustomColor = tempColor; + plugin.SaveConfiguration(); + } + + ImGui.EndMenu(); + } + + ImGui.Separator(); + + if (ImGui.MenuItem("Delete Folder")) + { + DeleteFolder(character, folder); + ImGui.CloseCurrentPopup(); + } + + ImGui.EndPopup(); + } + } + + private void DrawFolderContents(Character character, DesignFolder folder, float scale) + { + float indentAmount = 15f * scale; + + // Child folders + foreach (var child in character.DesignFolders + .Where(f => f.ParentFolderId == folder.Id) + .OrderBy(f => f.SortOrder)) + { + ImGui.Indent(indentAmount); + bool childWasHovered = false; + DrawFolderItem(character, child, ref childWasHovered, scale); + ImGui.Unindent(indentAmount); + } + + foreach (var design in character.Designs + .Where(d => d.FolderId == folder.Id) + .OrderBy(d => d.SortOrder)) + { + ImGui.Indent(indentAmount); + DrawDesignRow(character, design, true, scale); + ImGui.Unindent(indentAmount); + } + + // Visual separation + ImGui.Spacing(); + ImGui.Separator(); + } + + private void DrawDesignRow(Character character, CharacterDesign design, bool isInsideFolder, float scale) + { + ImGui.PushID(design.Name); + + var rowMin = ImGui.GetCursorScreenPos(); + float rowW = ImGui.GetContentRegionAvail().X; + float rowH = 32f * scale; + ImGui.Dummy(new Vector2(rowW, rowH)); + var rowMax = rowMin + new Vector2(rowW, rowH); + + bool hovered = ImGui.IsMouseHoveringRect(rowMin, rowMax, true); + + // Dark row background + if (hovered) + { + var hoverColor = ImGui.GetColorU32(new Vector4(0.25f, 0.25f, 0.25f, 0.8f)); + ImGui.GetWindowDrawList().AddRectFilled(rowMin, rowMax, hoverColor, 4f * scale); + } + + // Draw design row content with compact styling, america's next top model has nothing on me now! + DrawDesignRowContent(character, design, rowMin, rowMax, rowH, hovered, rowW, scale); + + // Handle drag and drop + HandleDesignDragDrop(character, design, rowMin, rowMax, hovered, scale); + + ImGui.PopID(); + ImGui.SetCursorScreenPos(new Vector2(rowMin.X, rowMin.Y + rowH)); + + // Subtle separator + if (!isInsideFolder) + { + var separatorColor = ImGui.GetColorU32(new Vector4(0.3f, 0.3f, 0.3f, 0.5f)); + ImGui.GetWindowDrawList().AddLine( + new Vector2(rowMin.X + (10 * scale), rowMax.Y), + new Vector2(rowMax.X - (10 * scale), rowMax.Y), + separatorColor, 1f * scale + ); + } + } + + private void DrawDesignRowContent(Character character, CharacterDesign design, Vector2 rowMin, Vector2 rowMax, float rowH, bool hovered, float rowW, float scale) + { + float pad = 8f * scale; + float spacing = 4f * scale; + float btnSize = 24f * scale; + float x = rowMin.X + (2f * scale); + + // Drag handle + if (hovered) + { + float handleWidth = 12f * scale; + float handleHeight = rowH * 0.6f; + float yOff = (rowH - handleHeight) / 2; + + ImGui.SetCursorScreenPos(new Vector2(x + pad, rowMin.Y + yOff)); + + var handleColor = new Vector4(character.NameplateColor.X, character.NameplateColor.Y, character.NameplateColor.Z, 0.8f); + + ImGui.PushStyleColor(ImGuiCol.Button, handleColor); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, handleColor); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, handleColor); + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 2f * scale); + + ImGui.Button($"##handle_{design.Name}", new Vector2(handleWidth, handleHeight)); + + ImGui.PopStyleVar(); + ImGui.PopStyleColor(3); + + // Enable drag and drop + if (ImGui.IsItemActive() && ImGui.IsMouseDragging(ImGuiMouseButton.Left, 4f * scale) && + ImGui.BeginDragDropSource(ImGuiDragDropFlags.SourceAllowNullID)) + { + draggedDesign = design; + ImGui.SetDragDropPayload("DESIGN_MOVE", IntPtr.Zero, 0); + + // Ghost image + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(1f, 1f, 1f, 0.8f)); + ImGui.PushStyleColor(ImGuiCol.WindowBg, new Vector4(0.1f, 0.1f, 0.1f, 0.9f)); + ImGui.BeginGroup(); + ImGui.Text("📄"); + ImGui.SameLine(); + ImGui.Text(design.Name); + ImGui.EndGroup(); + ImGui.PopStyleColor(2); + ImGui.EndDragDropSource(); + } + + x += handleWidth + spacing; + } + + // Favourite star + ImGui.SetCursorScreenPos(new Vector2(x, rowMin.Y + (rowH - btnSize) / 2)); + string star = design.IsFavorite ? "★" : "☆"; + + var starColor = design.IsFavorite + ? new Vector4(1f, 0.8f, 0.2f, hovered ? 1f : 0.7f) + : new Vector4(0.5f, 0.5f, 0.5f, hovered ? 0.8f : 0.4f); + + ImGui.PushStyleColor(ImGuiCol.Text, starColor); + if (ImGui.Button($"{star}##{design.Name}", new Vector2(btnSize, btnSize))) + { + bool wasFavorite = design.IsFavorite; + design.IsFavorite = !design.IsFavorite; + + // Trigger particle effect + Vector2 effectPos = ImGui.GetItemRectMin() + ImGui.GetItemRectSize() / 2; + string effectKey = $"{character.Name}_{design.Name}"; + if (!designFavoriteEffects.ContainsKey(effectKey)) + designFavoriteEffects[effectKey] = new FavoriteSparkEffect(); + designFavoriteEffects[effectKey].Trigger(effectPos, design.IsFavorite); + + plugin.SaveConfiguration(); + SortDesigns(character); + } + ImGui.PopStyleColor(); + + if (ImGui.IsItemHovered()) + ImGui.SetTooltip(design.IsFavorite ? "Remove from favourites" : "Add to favourites"); + + x += btnSize + spacing; + + // Design name styling, can't be, won't be, stopped! + float rightZone = hovered ? (3 * btnSize + 2 * spacing + pad) : 0; // Only show buttons on hover + float availW = rowW - (x - rowMin.X) - rightZone - pad; + + ImGui.SetCursorScreenPos(new Vector2(x, rowMin.Y + (rowH - ImGui.GetTextLineHeight()) / 2)); + + var name = design.Name; + if (ImGui.CalcTextSize(name).X > availW) + name = TruncateWithEllipsis(name, availW); + + // Design name + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.9f, 0.9f, 0.9f, 1f)); + ImGui.TextUnformatted(name); + ImGui.PopStyleColor(); + + // Action buttons (only when hovered, compact) + if (hovered) + { + DrawCompactDesignActionButtons(character, design, rowMin, rowW, rowH, btnSize, spacing, pad, scale); + } + } + + private void DrawCompactDesignActionButtons(Character character, CharacterDesign design, Vector2 rowMin, float rowW, float rowH, float btnSize, float spacing, float pad, float scale) + { + // Position buttons + float startX = rowMin.X + rowW - (3 * btnSize + 2 * spacing + pad); + float buttonY = rowMin.Y + (rowH - btnSize) / 2; + + // Dark button styling + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.15f, 0.15f, 0.15f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.25f, 0.25f, 0.25f, 1f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.35f, 0.35f, 0.35f, 1f)); + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 3f * scale); + + // Apply button + ImGui.SetCursorScreenPos(new Vector2(startX, buttonY)); + ImGui.PushFont(UiBuilder.IconFont); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.3f, 0.8f, 0.3f, 1f)); // Green + if (ImGui.Button("\uf00c", new Vector2(btnSize, btnSize))) + plugin.ExecuteMacro(design.Macro, character, design.Name); + ImGui.PopStyleColor(); + ImGui.PopFont(); + + if (ImGui.IsItemHovered()) + { + ImGui.BeginTooltip(); + ImGui.Text("Apply Design"); + + // Preview image in tooltip + if (!string.IsNullOrEmpty(design.PreviewImagePath) && File.Exists(design.PreviewImagePath)) + { + var texture = Plugin.TextureProvider.GetFromFile(design.PreviewImagePath).GetWrapOrDefault(); + if (texture != null) + { + float maxSize = 300f * scale; + var (displayWidth, displayHeight) = CalculateImageDimensions(texture, maxSize); + ImGui.Image(texture.ImGuiHandle, new Vector2(displayWidth, displayHeight)); + } + } + ImGui.EndTooltip(); + } + + // Edit button + ImGui.SetCursorScreenPos(new Vector2(startX + btnSize + spacing, buttonY)); + ImGui.PushFont(UiBuilder.IconFont); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.3f, 0.7f, 1f, 1f)); // Blue + if (ImGui.Button("\uf044", new Vector2(btnSize, btnSize))) + OpenEditDesignWindow(character, design); + ImGui.PopStyleColor(); + ImGui.PopFont(); + + if (ImGui.IsItemHovered()) ImGui.SetTooltip("Edit Design"); + + // Delete button + ImGui.SetCursorScreenPos(new Vector2(startX + 2 * (btnSize + spacing), buttonY)); + ImGui.PushFont(UiBuilder.IconFont); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(1f, 0.4f, 0.4f, 1f)); // Red + var io = ImGui.GetIO(); + if (ImGui.Button("\uf2ed", new Vector2(btnSize, btnSize)) && io.KeyCtrl && io.KeyShift) + { + character.Designs.Remove(design); + plugin.SaveConfiguration(); + } + ImGui.PopStyleColor(); + ImGui.PopFont(); + + if (ImGui.IsItemHovered()) ImGui.SetTooltip("Hold Ctrl+Shift to delete"); + + ImGui.PopStyleVar(); + ImGui.PopStyleColor(3); + } + + private void HandleDesignDragDrop(Character character, CharacterDesign design, Vector2 rowMin, Vector2 rowMax, bool hovered, float scale) + { + // Manual drop target + if (draggedDesign != null && ImGui.IsMouseReleased(ImGuiMouseButton.Left) && + ImGui.IsMouseHoveringRect(rowMin, rowMax, true) && draggedDesign != design) + { + var list = character.Designs; + list.Remove(draggedDesign); + int idx = list.IndexOf(design); + draggedDesign.FolderId = design.FolderId; + list.Insert(idx, draggedDesign); + draggedDesign = null; + plugin.SaveConfiguration(); + plugin.RefreshTreeItems(character); + } + + // Blue outline while dragging over + if (draggedDesign != null && hovered) + { + var dl = ImGui.GetWindowDrawList(); + uint col = ImGui.GetColorU32(new Vector4(0.27f, 0.53f, 0.90f, 1f)); + dl.AddRect(rowMin, rowMax, col, 0, ImDrawFlags.None, 2 * scale); + } + } + + private void HandleDropToRoot(bool anyHeaderHovered, bool anyRowHovered, Character character) + { + if (draggedDesign != null && ImGui.IsMouseReleased(ImGuiMouseButton.Left) && + !anyHeaderHovered && !anyRowHovered) + { + draggedDesign.FolderId = null; + plugin.SaveConfiguration(); + plugin.RefreshTreeItems(character); + draggedDesign = null; + } + + if (draggedFolder != null && ImGui.IsMouseReleased(ImGuiMouseButton.Left) && + !anyHeaderHovered && !anyRowHovered) + { + draggedFolder.ParentFolderId = null; + plugin.SaveConfiguration(); + plugin.RefreshTreeItems(character); + draggedFolder = null; + } + } + + private void DrawImportWindow(float scale) + { + if (!isImportWindowOpen || targetForDesignImport == null) + return; + + var windowSize = new Vector2(400 * scale, 450 * scale); + ImGui.SetNextWindowSize(windowSize, ImGuiCond.FirstUseEver); + + if (ImGui.Begin("Import Designs", ref isImportWindowOpen, ImGuiWindowFlags.NoCollapse)) + { + ApplyScaledStyles(scale); + + ImGui.Text($"Import designs to: {targetForDesignImport.Name}"); + ImGui.Separator(); + + ImGui.BeginChild("ImportScrollArea", new Vector2(0, -40 * scale), false); + + var charactersWithDesigns = plugin.Characters + .Where(c => c != targetForDesignImport && c.Designs.Count > 0) + .OrderBy(c => c.Name) + .ToList(); + + foreach (var character in charactersWithDesigns) + { + if (ImGui.CollapsingHeader($"{character.Name} ({character.Designs.Count} designs)")) + { + float indentAmount = 15f * scale; + ImGui.Indent(indentAmount); + + foreach (var design in character.Designs) + { + float buttonSize = 18f * scale; + + // Green plus symbol + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.2f, 0.8f, 0.2f, 1.0f)); + ImGui.PushFont(UiBuilder.IconFont); + + if (ImGui.Selectable($"\uf067##import_{character.Name}_{design.Name}", false, ImGuiSelectableFlags.None, new Vector2(buttonSize, buttonSize))) + { + var clone = new CharacterDesign( + name: $"{design.Name} (Copy)", + macro: design.Macro, + isAdvancedMode: design.IsAdvancedMode, + advancedMacro: design.AdvancedMacro, + glamourerDesign: design.GlamourerDesign ?? "", + automation: design.Automation ?? "", + customizePlusProfile: design.CustomizePlusProfile ?? "", + previewImagePath: design.PreviewImagePath ?? "" + ); + + targetForDesignImport.Designs.Add(clone); + plugin.SaveConfiguration(); + } + + ImGui.PopFont(); + ImGui.PopStyleColor(); + + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip($"Import '{design.Name}'"); + } + + ImGui.SameLine(); + ImGui.Text(design.Name); + } + + ImGui.Unindent(indentAmount); + } + } + + ImGui.EndChild(); + + ImGui.Separator(); + if (ImGui.Button("Close")) + { + isImportWindowOpen = false; + } + + PopScaledStyles(); + } + ImGui.End(); + } + + private void DrawAdvancedModeWindow(float scale) + { + if (!isAdvancedModeWindowOpen) + return; + + var windowSize = new Vector2(500 * scale, 200 * scale); + ImGui.SetNextWindowSize(windowSize, ImGuiCond.FirstUseEver); + + if (ImGui.Begin("Advanced Macro Editor", ref isAdvancedModeWindowOpen, ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoResize)) + { + ApplyScaledStyles(scale); + + ImGui.Text("Edit Design Macro Manually:"); + + // Dark styling for the text editor + ImGui.PushStyleColor(ImGuiCol.FrameBg, new Vector4(0.08f, 0.08f, 0.08f, 0.95f)); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.9f, 0.9f, 0.9f, 1.0f)); + + ImGui.InputTextMultiline("##AdvancedDesignMacroPopup", ref advancedDesignMacroText, 2000, + new Vector2(-1, -1), ImGuiInputTextFlags.AllowTabInput); + + ImGui.PopStyleColor(2); + PopScaledStyles(); + } + ImGui.End(); + + if (!isAdvancedModeWindowOpen) + isAdvancedModeDesign = false; + } + + // Utility methods + private void SelectPreviewImage() + { + try + { + Thread thread = new Thread(() => + { + try + { + using (OpenFileDialog openFileDialog = new OpenFileDialog()) + { + openFileDialog.Filter = "PNG files (*.png)|*.png"; + openFileDialog.Title = "Select Design Preview Image"; + + if (openFileDialog.ShowDialog() == DialogResult.OK) + { + lock (this) + { + pendingDesignImagePath = openFileDialog.FileName; + } + } + } + } + catch (Exception ex) + { + Plugin.Log.Error($"Error opening file picker: {ex.Message}"); + } + }); + + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + } + catch (Exception ex) + { + Plugin.Log.Error($"Critical file picker error: {ex.Message}"); + } + } + + private (float width, float height) CalculateImageDimensions(IDalamudTextureWrap texture, float maxSize) + { + float originalWidth = texture.Width; + float originalHeight = texture.Height; + float aspectRatio = originalWidth / originalHeight; + + if (aspectRatio > 1) // Landscape + { + return (maxSize, maxSize / aspectRatio); + } + else // Portrait or Square + { + return (maxSize * aspectRatio, maxSize); + } + } + + private void AddNewDesign() + { + isNewDesign = true; + isEditDesignWindowOpen = true; + plugin.IsEditDesignWindowOpen = true; + editedDesignName = ""; + editedGlamourerDesign = ""; + editedDesignMacro = ""; + isAdvancedModeDesign = false; + editedAutomation = ""; + editedCustomizeProfile = ""; + editedDesignPreviewPath = ""; + plugin.EditedDesignName = editedDesignName; + plugin.EditedGlamourerDesign = editedGlamourerDesign; + } + + private void OpenEditDesignWindow(Character character, CharacterDesign design) + { + isNewDesign = false; + isEditDesignWindowOpen = true; + plugin.IsEditDesignWindowOpen = true; + originalDesignName = design.Name; + editedDesignName = design.Name; + editedDesignMacro = design.IsAdvancedMode ? design.AdvancedMacro ?? "" : design.Macro ?? ""; + editedGlamourerDesign = !string.IsNullOrWhiteSpace(design.GlamourerDesign) + ? design.GlamourerDesign + : ExtractGlamourerDesignFromMacro(design.Macro ?? ""); + + editedAutomation = design.Automation ?? ""; + editedCustomizeProfile = design.CustomizePlusProfile ?? ""; + editedDesignPreviewPath = design.PreviewImagePath ?? ""; + isAdvancedModeDesign = design.IsAdvancedMode; + isAdvancedModeWindowOpen = design.IsAdvancedMode; + advancedDesignMacroText = design.AdvancedMacro ?? ""; + } + + private void CloseDesignEditor() + { + isEditDesignWindowOpen = false; + plugin.IsEditDesignWindowOpen = false; + isAdvancedModeWindowOpen = false; + isNewDesign = false; + isSecretDesignMode = false; + ResetEditFields(); + } + + private void ResetEditFields() + { + editedDesignName = ""; + editedDesignMacro = ""; + editedGlamourerDesign = ""; + editedAutomation = ""; + editedCustomizeProfile = ""; + editedDesignPreviewPath = ""; + advancedDesignMacroText = ""; + originalDesignName = ""; + } + + private void SaveDesign(Character character) + { + if (string.IsNullOrWhiteSpace(editedDesignName) || string.IsNullOrWhiteSpace(editedGlamourerDesign)) + return; + + var existingDesign = !isNewDesign + ? character.Designs.FirstOrDefault(d => d.Name == originalDesignName) + : null; + + if (existingDesign != null) + { + // Update existing design + existingDesign.Name = editedDesignName; + bool wasPreviouslyAdvanced = existingDesign.IsAdvancedMode; + bool keepAdvanced = wasPreviouslyAdvanced && !isAdvancedModeDesign; + + existingDesign.Macro = keepAdvanced + ? existingDesign.AdvancedMacro + : (isAdvancedModeDesign ? advancedDesignMacroText : GenerateDesignMacro(character)); + + existingDesign.AdvancedMacro = isAdvancedModeDesign || keepAdvanced + ? advancedDesignMacroText + : ""; + + existingDesign.IsAdvancedMode = isAdvancedModeDesign || keepAdvanced; + existingDesign.Automation = editedAutomation; + existingDesign.GlamourerDesign = editedGlamourerDesign; + existingDesign.CustomizePlusProfile = editedCustomizeProfile; + existingDesign.PreviewImagePath = editedDesignPreviewPath; + } + else + { + // Add new design + character.Designs.Add(new CharacterDesign( + editedDesignName, + isAdvancedModeDesign ? advancedDesignMacroText : GenerateDesignMacro(character), + isAdvancedModeDesign, + isAdvancedModeDesign ? advancedDesignMacroText : "", + editedGlamourerDesign, + editedAutomation, + editedCustomizeProfile, + editedDesignPreviewPath + ) + { + DateAdded = DateTime.UtcNow + }); + } + + plugin.SaveConfiguration(); + } + + private void DeleteFolder(Character character, DesignFolder folder) + { + foreach (var d in character.Designs.Where(d => d.FolderId == folder.Id)) + d.FolderId = null; + + foreach (var sub in character.DesignFolders.Where(f => f.ParentFolderId == folder.Id)) + sub.ParentFolderId = null; + + character.DesignFolders.RemoveAll(f => f.Id == folder.Id); + + plugin.SaveConfiguration(); + plugin.RefreshTreeItems(character); + } + + private void SortDesigns(Character character) + { + if (currentDesignSort == DesignSortType.Manual) + return; + + if (currentDesignSort == DesignSortType.Favorites) + { + character.Designs.Sort((a, b) => + { + int favCompare = b.IsFavorite.CompareTo(a.IsFavorite); + if (favCompare != 0) return favCompare; + return string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase); + }); + } + else if (currentDesignSort == DesignSortType.Alphabetical) + { + character.Designs.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase)); + } + else if (currentDesignSort == DesignSortType.Recent) + { + character.Designs.Sort((a, b) => b.DateAdded.CompareTo(a.DateAdded)); + } + else if (currentDesignSort == DesignSortType.Oldest) + { + character.Designs.Sort((a, b) => a.DateAdded.CompareTo(b.DateAdded)); + } + } + + private Vector4 GetFolderColor(Character character, DesignFolder folder) + { + Vector3 baseColor; + + if (folder.CustomColor.HasValue) + { + baseColor = folder.CustomColor.Value; + } + else + { + baseColor = GetAutoGeneratedColor(character, folder); + } + + return new Vector4(baseColor.X, baseColor.Y, baseColor.Z, 0.6f); + } + + private Vector3 GetAutoGeneratedColor(Character character, DesignFolder folder) + { + return character.NameplateColor; + } + + private List<(string name, bool isFolder, object item, DateTime dateAdded, int manual)> BuildRenderItems(Character character) + { + var renderItems = new List<(string name, bool isFolder, object item, DateTime dateAdded, int manual)>(); + + foreach (var f in character.DesignFolders.Where(f => f.ParentFolderId == null)) + { + renderItems.Add((f.Name, true, f as object, DateTime.MinValue, f.SortOrder)); + } + + foreach (var d in character.Designs.Where(d => d.FolderId == null)) + { + renderItems.Add((d.Name, false, d as object, d.DateAdded, d.SortOrder)); + } + + switch (currentDesignSort) + { + case DesignSortType.Favorites: + renderItems = renderItems + .OrderByDescending(x => x.isFolder ? false : ((CharacterDesign)x.item).IsFavorite) + .ThenBy(x => x.name, StringComparer.OrdinalIgnoreCase) + .ToList(); + break; + case DesignSortType.Alphabetical: + renderItems = renderItems + .OrderBy(x => x.name, StringComparer.OrdinalIgnoreCase) + .ToList(); + break; + case DesignSortType.Recent: + renderItems = renderItems + .OrderByDescending(x => x.dateAdded) + .ToList(); + break; + case DesignSortType.Oldest: + renderItems = renderItems + .OrderBy(x => x.dateAdded) + .ToList(); + break; + case DesignSortType.Manual: + renderItems = renderItems + .OrderBy(x => x.manual) + .ToList(); + break; + } + + return renderItems; + } + + private string GenerateDesignMacro(Character character) + { + if (string.IsNullOrWhiteSpace(editedGlamourerDesign)) + return ""; + + string macro = $"/glamour apply {editedGlamourerDesign} | self"; + + // Conditionally include automation line + if (plugin.Configuration.EnableAutomations) + { + string automationToUse = !string.IsNullOrWhiteSpace(editedAutomation) + ? editedAutomation + : (!string.IsNullOrWhiteSpace(character.CharacterAutomation) + ? character.CharacterAutomation + : "None"); + + macro += $"\n/glamour automation enable {automationToUse}"; + } + + // Always disable Customize+ first + macro += "\n/customize profile disable "; + + // Determine Customize+ profile + string customizeProfileToUse = !string.IsNullOrWhiteSpace(editedCustomizeProfile) + ? editedCustomizeProfile + : !string.IsNullOrWhiteSpace(character.CustomizeProfile) + ? character.CustomizeProfile + : string.Empty; + + // Enable only if needed + if (!string.IsNullOrWhiteSpace(customizeProfileToUse)) + macro += $"\n/customize profile enable , {customizeProfileToUse}"; + + // Redraw line + macro += "\n/penumbra redraw self"; + + return macro; + } + + private string GenerateSecretDesignMacro(Character character) + { + // Which Penumbra collection to target (taken from the character) + var collection = character.PenumbraCollection; + + // What the form is currently set to + var design = editedGlamourerDesign; + var custom = !string.IsNullOrWhiteSpace(editedCustomizeProfile) + ? editedCustomizeProfile + : character.CustomizeProfile; + + var sb = new System.Text.StringBuilder(); + + // Bulk-tag lines + sb.AppendLine($"/penumbra bulktag disable {collection} | gear"); + sb.AppendLine($"/penumbra bulktag disable {collection} | hair"); + sb.AppendLine($"/penumbra bulktag enable {collection} | {design}"); + + // Glamourer "no clothes" + design + sb.AppendLine("/glamour apply no clothes | self"); + sb.AppendLine($"/glamour apply {design} | self"); + + // Automation (if enabled) + if (plugin.Configuration.EnableAutomations) + { + string automationToUse = !string.IsNullOrWhiteSpace(editedAutomation) + ? editedAutomation + : (!string.IsNullOrWhiteSpace(character.CharacterAutomation) + ? character.CharacterAutomation + : "None"); + sb.AppendLine($"/glamour automation enable {automationToUse}"); + } + + // Customize+ + sb.AppendLine("/customize profile disable "); + if (!string.IsNullOrWhiteSpace(custom)) + sb.AppendLine($"/customize profile enable , {custom}"); + + // Final redraw + sb.Append("/penumbra redraw self"); + + return sb.ToString(); + } + + private string EnsureProperDesignMacroStructure() + { + var character = plugin.Characters[activeCharacterIndex]; + string glamourer = !string.IsNullOrWhiteSpace(editedGlamourerDesign) ? editedGlamourerDesign : "[Glamourer Design]"; + + var sb = new System.Text.StringBuilder(); + + if (isSecretDesignMode) + { + string collection = character.PenumbraCollection; + sb.AppendLine($"/penumbra bulktag disable {collection} | gear"); + sb.AppendLine($"/penumbra bulktag disable {collection} | hair"); + sb.AppendLine($"/penumbra bulktag enable {collection} | {glamourer}"); + sb.AppendLine("/glamour apply no clothes | self"); + sb.AppendLine($"/glamour apply {glamourer} | self"); + } + else + { + sb.AppendLine($"/glamour apply {glamourer} | self"); + } + + // Conditionally include automation line + if (plugin.Configuration.EnableAutomations) + { + string automationToUse = !string.IsNullOrWhiteSpace(editedAutomation) + ? editedAutomation + : (!string.IsNullOrWhiteSpace(character.CharacterAutomation) + ? character.CharacterAutomation + : "None"); + sb.AppendLine($"/glamour automation enable {automationToUse}"); + } + + // Always disable Customize+ first + sb.AppendLine("/customize profile disable "); + + // Determine Customize+ profile + string customizeProfileToUse = !string.IsNullOrWhiteSpace(editedCustomizeProfile) + ? editedCustomizeProfile + : !string.IsNullOrWhiteSpace(character.CustomizeProfile) + ? character.CustomizeProfile + : string.Empty; + + // Enable only if needed + if (!string.IsNullOrWhiteSpace(customizeProfileToUse)) + sb.AppendLine($"/customize profile enable , {customizeProfileToUse}"); + + // Redraw line + sb.Append("/penumbra redraw self"); + + return sb.ToString(); + } + + private void UpdateAdvancedMacroGlamourerFixed(string newGlamourer) + { + var lines = advancedDesignMacroText.Split('\n').ToList(); + + // Find and replace the main glamour apply line (not "no clothes") + for (int i = 0; i < lines.Count; i++) + { + var line = lines[i].TrimStart(); + if (line.StartsWith("/glamour apply", StringComparison.OrdinalIgnoreCase) && + !line.Contains("no clothes", StringComparison.OrdinalIgnoreCase)) + { + lines[i] = $"/glamour apply {newGlamourer} | self"; + break; + } + } + + // Update bulktag enable line if it exists (for secret mode) + for (int i = 0; i < lines.Count; i++) + { + var line = lines[i].TrimStart(); + if (line.StartsWith("/penumbra bulktag enable", StringComparison.OrdinalIgnoreCase)) + { + // Extract the collection name and replace the design part + var parts = line.Split('|'); + if (parts.Length >= 2) + { + var collection = parts[0].Replace("/penumbra bulktag enable", "").Trim(); + lines[i] = $"/penumbra bulktag enable {collection} | {newGlamourer}"; + } + break; + } + } + + advancedDesignMacroText = string.Join("\n", lines); + } + + private void UpdateAdvancedMacroCustomize() + { + advancedDesignMacroText = PatchMacroLine( + advancedDesignMacroText, + "/customize profile disable", + "/customize profile disable " + ); + + if (!string.IsNullOrWhiteSpace(editedCustomizeProfile)) + { + advancedDesignMacroText = PatchMacroLine( + advancedDesignMacroText, + "/customize profile enable", + $"/customize profile enable , {editedCustomizeProfile}" + ); + } + else + { + advancedDesignMacroText = string.Join("\n", + advancedDesignMacroText + .Split('\n') + .Where(l => !l.TrimStart().StartsWith("/customize profile enable")) + ); + } + } + + private string PatchMacroLine(string existing, string prefix, string replacement) + { + var lines = existing.Split('\n').ToList(); + var idx = lines.FindIndex(l => l.TrimStart().StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); + + if (idx >= 0) + { + // Replace existing line + lines[idx] = replacement; + } + else + { + int insertPosition = GetProperDesignInsertPosition(lines, prefix); + lines.Insert(insertPosition, replacement); + } + + return string.Join("\n", lines); + } + + private int GetProperDesignInsertPosition(List lines, string prefix) + { + // Order for design macro commands + var order = new[] + { + "/penumbra bulktag disable", + "/penumbra bulktag enable", + "/glamour apply no clothes", + "/glamour apply", + "/glamour automation enable", + "/customize profile disable", + "/customize profile enable", + "/penumbra redraw" + }; + + int targetOrder = Array.FindIndex(order, o => prefix.StartsWith(o, StringComparison.OrdinalIgnoreCase)); + if (targetOrder == -1) return lines.Count; // Unknown command goes at end + + // Find the position where this command should be inserted + for (int i = 0; i < lines.Count; i++) + { + var line = lines[i].TrimStart(); + int lineOrder = Array.FindIndex(order, o => line.StartsWith(o, StringComparison.OrdinalIgnoreCase)); + + if (lineOrder > targetOrder || lineOrder == -1) + { + return i; + } + } + + return lines.Count; + } + + private string ExtractGlamourerDesignFromMacro(string macro) + { + string[] lines = macro.Split('\n'); + foreach (var line in lines) + { + if (line.StartsWith("/glamour apply ", StringComparison.OrdinalIgnoreCase)) + { + return line.Replace("/glamour apply ", "").Replace(" | self", "").Trim(); + } + } + return ""; + } + + private static string TruncateWithEllipsis(string text, float maxWidth) + { + while (ImGui.CalcTextSize(text + "...").X > maxWidth && text.Length > 0) + text = text[..^1]; + return text + "..."; + } + } +} diff --git a/CharacterSelectPlugin/Windows/Components/ReorderWindow.cs b/CharacterSelectPlugin/Windows/Components/ReorderWindow.cs new file mode 100644 index 0000000..fc5ff36 --- /dev/null +++ b/CharacterSelectPlugin/Windows/Components/ReorderWindow.cs @@ -0,0 +1,408 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Numerics; +using System.Runtime.CompilerServices; +using ImGuiNET; +using Dalamud.Interface; +using CharacterSelectPlugin.Windows.Styles; + +namespace CharacterSelectPlugin.Windows.Components +{ + public class ReorderWindow : IDisposable + { + private Plugin plugin; + private UIStyles uiStyles; + + public bool IsOpen { get; private set; } = false; + private List reorderBuffer = new(); + + public ReorderWindow(Plugin plugin, UIStyles uiStyles) + { + this.plugin = plugin; + this.uiStyles = uiStyles; + } + + public void Dispose() + { + } + + public void Draw() + { + if (!IsOpen) + return; + + // Calculate dynamic window size based on DPI and UI scale + var dpiScale = ImGui.GetIO().DisplayFramebufferScale.X; + var uiScale = plugin.Configuration.UIScaleMultiplier; + var totalScale = GetSafeScale(dpiScale * uiScale); + + // Base dimensions + var windowWidth = 500f * totalScale; + var minHeight = 300f * totalScale; + var maxHeight = 800f * totalScale; + + // Calculate dynamic height based on number of characters + float headerHeight = 30f * totalScale; + float buttonHeight = 80f * totalScale; // Bottom buttons + padding + float characterRowHeight = 68f * totalScale; // Each character row (icon + padding) + + // Calculate content height based on character count + float contentHeight = reorderBuffer.Count * characterRowHeight; + + // Total window height = header + content + buttons (with reasonable limits) who'd have thought math would be involved... + var windowHeight = Math.Clamp( + headerHeight + contentHeight + buttonHeight, + minHeight, + maxHeight + ); + + // Center the window on screen + var viewport = ImGui.GetMainViewport(); + var centerPos = new Vector2( + viewport.Pos.X + (viewport.Size.X - windowWidth) * 0.5f, + viewport.Pos.Y + (viewport.Size.Y - windowHeight) * 0.5f + ); + + ImGui.SetNextWindowPos(centerPos, ImGuiCond.FirstUseEver); + ImGui.SetNextWindowSize(new Vector2(windowWidth, windowHeight), ImGuiCond.Always); + + bool isOpenRef = IsOpen; + var windowFlags = ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoResize; + + if (ImGui.Begin("Reorder Characters", ref isOpenRef, windowFlags)) + { + IsOpen = isOpenRef; + + // Apply modern styling that scales with DPI + ApplyScaledStyles(totalScale); + + try + { + DrawReorderContent(totalScale); + } + finally + { + PopScaledStyles(); + } + } + ImGui.End(); + + if (!IsOpen) + { + // Clean up when window is closed + reorderBuffer.Clear(); + } + } + + private void ApplyScaledStyles(float scale) + { + // Style + ImGui.PushStyleColor(ImGuiCol.WindowBg, new Vector4(0.08f, 0.08f, 0.1f, 0.98f)); + ImGui.PushStyleColor(ImGuiCol.ChildBg, new Vector4(0.1f, 0.1f, 0.12f, 0.95f)); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.95f, 0.95f, 0.95f, 1.0f)); + ImGui.PushStyleColor(ImGuiCol.Header, new Vector4(0.16f, 0.16f, 0.2f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.HeaderHovered, new Vector4(0.22f, 0.22f, 0.28f, 1.0f)); + ImGui.PushStyleColor(ImGuiCol.HeaderActive, new Vector4(0.28f, 0.28f, 0.35f, 1.0f)); + + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 5.0f * scale); + ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 8.0f * scale); + ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(8 * scale, 5 * scale)); + ImGui.PushStyleVar(ImGuiStyleVar.FramePadding, new Vector2(6 * scale, 3 * scale)); + } + + private void PopScaledStyles() + { + ImGui.PopStyleVar(4); + ImGui.PopStyleColor(6); + } + + public void Open() + { + IsOpen = true; + reorderBuffer = plugin.Characters.ToList(); + } + + private void DrawReorderContent(float scale) + { + // Instruction text at the top + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.7f, 0.7f, 0.8f, 1f)); + ImGui.Text("Drag character rows to reorder them"); + ImGui.PopStyleColor(); + + ImGui.Separator(); + ImGui.Spacing(); + + // Calculate scroll area height + float buttonAreaHeight = 80f * scale; + float scrollHeight = ImGui.GetContentRegionAvail().Y - buttonAreaHeight; + + // Scrollable character list + ImGui.BeginChild("CharacterReorderScroll", new Vector2(0, scrollHeight), true); + DrawCharacterList(scale); + ImGui.EndChild(); + + // Bottom buttons + DrawActionButtons(scale); + } + + private void DrawCharacterList(float scale) + { + for (int i = 0; i < reorderBuffer.Count; i++) + { + var character = reorderBuffer[i]; + + ImGui.PushID(i); + DrawCharacterRow(character, i, scale); + ImGui.PopID(); + } + } + + private void DrawCharacterRow(Character character, int index, float scale) + { + float iconSize = 48 * scale; + float rowHeight = iconSize + (16 * scale); + + var cursorStart = ImGui.GetCursorScreenPos(); + var rowMin = cursorStart; + var rowMax = cursorStart + new Vector2(ImGui.GetContentRegionAvail().X, rowHeight); + + // Invisible button + ImGui.SetCursorScreenPos(rowMin); + ImGui.InvisibleButton($"##CharRow{index}", new Vector2(ImGui.GetContentRegionAvail().X, rowHeight)); + bool isHovered = ImGui.IsItemHovered(); + HandleDragAndDrop(index, scale); + + // Background based on hover + if (isHovered) + { + var hoverColor = ImGui.GetColorU32(new Vector4(0.3f, 0.3f, 0.3f, 0.5f)); + ImGui.GetWindowDrawList().AddRectFilled(rowMin, rowMax, hoverColor, 6f * scale); + } + + // Subtle border around each row + var borderColor = ImGui.GetColorU32(new Vector4(0.4f, 0.4f, 0.4f, 0.3f)); + ImGui.GetWindowDrawList().AddRect(rowMin, rowMax, borderColor, 6f * scale, ImDrawFlags.None, 1f); + + // Character image + float imageMargin = 8 * scale; + if (!string.IsNullOrEmpty(character.ImagePath) && File.Exists(character.ImagePath)) + { + var texture = Plugin.TextureProvider.GetFromFile(character.ImagePath).GetWrapOrDefault(); + if (texture != null) + { + // Calculate image dimensions + float originalWidth = texture.Width; + float originalHeight = texture.Height; + float aspectRatio = originalWidth / originalHeight; + + float displayWidth = iconSize; + float displayHeight = iconSize; + + if (aspectRatio > 1) // Landscape + { + displayHeight = iconSize / aspectRatio; + } + else if (aspectRatio < 1) // Portrait + { + displayWidth = iconSize * aspectRatio; + } + + // Center the image + float offsetX = (iconSize - displayWidth) / 2; + float offsetY = (iconSize - displayHeight) / 2; + + ImGui.SetCursorScreenPos(cursorStart + new Vector2(imageMargin + offsetX, imageMargin + offsetY)); + ImGui.Image(texture.ImGuiHandle, new Vector2(displayWidth, displayHeight)); + + // Add glowing border around image based on character colour + var imageMin = cursorStart + new Vector2(imageMargin, imageMargin); + var imageMax = imageMin + new Vector2(iconSize, iconSize); + uiStyles.DrawGlowingBorder(imageMin, imageMax, character.NameplateColor, 0.6f, isHovered); + } + } + + // Character name and details + float textStartX = imageMargin + iconSize + (12 * scale); + ImGui.SetCursorScreenPos(cursorStart + new Vector2(textStartX, imageMargin)); + + // Character name with colour + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(character.NameplateColor, 1f)); + ImGui.Text(character.Name); + ImGui.PopStyleColor(); + + // Character details + float lineHeight = ImGui.GetTextLineHeight(); + ImGui.SetCursorScreenPos(cursorStart + new Vector2(textStartX, imageMargin + lineHeight + (4 * scale))); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.7f, 0.7f, 0.7f, 1f)); + + string details = $"Designs: {character.Designs.Count}"; + if (character.Tags != null && character.Tags.Count > 0) + { + details += $" | Tags: {string.Join(", ", character.Tags.Take(2))}{(character.Tags.Count > 2 ? "..." : "")}"; + } + + ImGui.Text(details); + ImGui.PopStyleColor(); + + // Favourite star + if (character.IsFavorite) + { + float rowWidth = ImGui.GetContentRegionAvail().X; + ImGui.SetCursorScreenPos(rowMin + new Vector2(rowWidth - (30 * scale), imageMargin)); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(1f, 0.9f, 0.2f, 1f)); + ImGui.Text("★"); + ImGui.PopStyleColor(); + } + + // Show drag cursor when hovering + if (isHovered) + { + ImGui.SetMouseCursor(ImGuiMouseCursor.Hand); + } + + ImGui.SetCursorScreenPos(new Vector2(rowMin.X, rowMax.Y + (4 * scale))); + } + + private unsafe void HandleDragAndDrop(int index, float scale) + { + // Drag source + if (ImGui.BeginDragDropSource(ImGuiDragDropFlags.SourceAllowNullID)) + { + int dragIndex = index; + ImGui.SetDragDropPayload("CHARACTER_REORDER", new nint(Unsafe.AsPointer(ref dragIndex)), (uint)sizeof(int)); + + // Ghost image for drag...race? + var character = reorderBuffer[index]; + + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(1f, 1f, 1f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.WindowBg, new Vector4(0.1f, 0.1f, 0.1f, 0.95f)); + ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(8 * scale, 6 * scale)); + ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 6f * scale); + + ImGui.BeginGroup(); + + // Mini character preview + if (!string.IsNullOrEmpty(character.ImagePath) && File.Exists(character.ImagePath)) + { + var texture = Plugin.TextureProvider.GetFromFile(character.ImagePath).GetWrapOrDefault(); + if (texture != null) + { + float previewSize = 32 * scale; + ImGui.Image(texture.ImGuiHandle, new Vector2(previewSize, previewSize)); + ImGui.SameLine(); + } + } + + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(character.NameplateColor, 1f)); + ImGui.Text(character.Name); + ImGui.PopStyleColor(); + + ImGui.EndGroup(); + + ImGui.PopStyleVar(2); + ImGui.PopStyleColor(2); + ImGui.EndDragDropSource(); + } + + // Drop target + if (ImGui.BeginDragDropTarget()) + { + var payload = ImGui.AcceptDragDropPayload("CHARACTER_REORDER"); + if (payload.NativePtr != null) + { + int dragIndex = *(int*)payload.Data; + if (dragIndex >= 0 && dragIndex < reorderBuffer.Count && dragIndex != index) + { + var item = reorderBuffer[dragIndex]; + reorderBuffer.RemoveAt(dragIndex); + reorderBuffer.Insert(index, item); + } + } + ImGui.EndDragDropTarget(); + } + + // Visual feedback during drag + if (ImGui.IsItemHovered() && ImGui.GetDragDropPayload().NativePtr != null) + { + var itemMin = ImGui.GetItemRectMin(); + var itemMax = ImGui.GetItemRectMax(); + var color = ImGui.GetColorU32(new Vector4(0.3f, 0.7f, 1f, 0.8f)); + ImGui.GetWindowDrawList().AddRect(itemMin, itemMax, color, 6f * scale, ImDrawFlags.None, 2f); + } + } + + private void DrawActionButtons(float scale) + { + ImGui.Separator(); + + // Show (just the) tips with scaled icon + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.8f, 0.8f, 0.8f, 1.0f)); + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf0c4"); // Link/chain icon + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.Text("Tip: Drag characters by their entire row to reorder them."); + ImGui.PopStyleColor(); + + ImGui.Spacing(); + + float buttonWidth = 120 * scale; + float spacing = 20 * scale; + float buttonHeight = 30 * scale; + float totalWidth = (buttonWidth * 2) + spacing; + float centerX = (ImGui.GetWindowContentRegionMax().X - totalWidth) / 2f; + + ImGui.SetCursorPosX(centerX); + + // Save Order button + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.2f, 0.4f, 0.2f, 0.8f)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.3f, 0.5f, 0.3f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.4f, 0.6f, 0.4f, 1.0f)); + + if (ImGui.Button("Save Order", new Vector2(buttonWidth, buttonHeight))) + { + SaveReorderedCharacters(); + IsOpen = false; + } + + ImGui.PopStyleColor(3); + + ImGui.SameLine(0, spacing); + + // Cancel button + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.4f, 0.2f, 0.2f, 0.8f)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.5f, 0.3f, 0.3f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.6f, 0.4f, 0.4f, 1.0f)); + + if (ImGui.Button("Cancel", new Vector2(buttonWidth, buttonHeight))) + { + IsOpen = false; + } + + ImGui.PopStyleColor(3); + } + + private void SaveReorderedCharacters() + { + // Update sort orders + for (int i = 0; i < reorderBuffer.Count; i++) + { + reorderBuffer[i].SortOrder = i; + } + + plugin.Characters.Clear(); + plugin.Characters.AddRange(reorderBuffer); + + plugin.Configuration.CurrentSortIndex = (int)Plugin.SortType.Manual; + plugin.SaveConfiguration(); + + reorderBuffer.Clear(); + } + private float GetSafeScale(float baseScale) + { + return Math.Clamp(baseScale, 0.3f, 5.0f); // Prevent extreme scaling + } + } +} diff --git a/CharacterSelectPlugin/Windows/Components/SettingsPanel.cs b/CharacterSelectPlugin/Windows/Components/SettingsPanel.cs new file mode 100644 index 0000000..5359da0 --- /dev/null +++ b/CharacterSelectPlugin/Windows/Components/SettingsPanel.cs @@ -0,0 +1,732 @@ +using System; +using System.Linq; +using System.Numerics; +using ImGuiNET; +using Dalamud.Interface; +using CharacterSelectPlugin.Windows.Styles; + +namespace CharacterSelectPlugin.Windows.Components +{ + public class SettingsPanel : IDisposable + { + private Plugin plugin; + private UIStyles uiStyles; + private MainWindow mainWindow; + + // Dynamic sizing + private bool visualSettingsOpen = true; // Default + private bool automationSettingsOpen = false; + private bool behaviorSettingsOpen = false; + private bool mainCharacterSettingsOpen = false; + private bool dialogueSettingsOpen = false; + + public SettingsPanel(Plugin plugin, UIStyles uiStyles, MainWindow mainWindow) + { + this.plugin = plugin; + this.uiStyles = uiStyles; + this.mainWindow = mainWindow; + } + + public void Dispose() + { + } + + public void Draw() + { + if (!plugin.IsSettingsOpen) + return; + + // Calculate dynamic height based on expanded sections + var dpiScale = ImGui.GetIO().DisplayFramebufferScale.X; + var uiScale = plugin.Configuration.UIScaleMultiplier; + var totalScale = GetSafeScale(dpiScale * uiScale); + + var windowWidth = 480f * totalScale; + + // Calculate height based on actual content + float baseHeight = 120f * totalScale; // Header + padding + float sectionHeaderHeight = 30f * totalScale; // Each collapsed section header + float totalContentHeight = 0f; + + // Add heights for expanded sections + if (visualSettingsOpen) + totalContentHeight += 220f * totalScale; + else + totalContentHeight += sectionHeaderHeight; + + if (automationSettingsOpen) + totalContentHeight += 80f * totalScale; // Warning + checkbox + else + totalContentHeight += sectionHeaderHeight; + + if (behaviorSettingsOpen) + totalContentHeight += 150f * totalScale; + else + totalContentHeight += sectionHeaderHeight; + + if (mainCharacterSettingsOpen) + totalContentHeight += 120f * totalScale; + else + totalContentHeight += sectionHeaderHeight; + + if (dialogueSettingsOpen) + totalContentHeight += 200f * totalScale; + else + totalContentHeight += sectionHeaderHeight; + + var windowHeight = Math.Min(baseHeight + totalContentHeight, 700f * totalScale); // Cap at reasonable max + var minHeight = 200f * totalScale; // Minimum height + windowHeight = Math.Max(windowHeight, minHeight); + + // Center window + var viewport = ImGui.GetMainViewport(); + var centerPos = new Vector2( + viewport.Pos.X + (viewport.Size.X - windowWidth) * 0.5f, + viewport.Pos.Y + (viewport.Size.Y - windowHeight) * 0.5f + ); + + ImGui.SetNextWindowPos(centerPos, ImGuiCond.FirstUseEver); + ImGui.SetNextWindowSize(new Vector2(windowWidth, windowHeight), ImGuiCond.Always); + + bool isSettingsOpen = plugin.IsSettingsOpen; + var windowFlags = ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoScrollbar; + + if (ImGui.Begin("Character Select+ Settings", ref isSettingsOpen, windowFlags)) + { + if (!isSettingsOpen) + plugin.IsSettingsOpen = false; + + ApplyFixedStyles(totalScale); + + try + { + DrawFixedSettingsContent(); + } + finally + { + ImGui.PopStyleVar(4); + ImGui.PopStyleColor(6); + } + } + ImGui.End(); + } + + private void ApplyFixedStyles(float totalScale) + { + // Styling + ImGui.PushStyleColor(ImGuiCol.WindowBg, new Vector4(0.08f, 0.08f, 0.1f, 0.98f)); + ImGui.PushStyleColor(ImGuiCol.ChildBg, new Vector4(0.1f, 0.1f, 0.12f, 0.95f)); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.95f, 0.95f, 0.95f, 1.0f)); + ImGui.PushStyleColor(ImGuiCol.Header, new Vector4(0.16f, 0.16f, 0.2f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.HeaderHovered, new Vector4(0.22f, 0.22f, 0.28f, 1.0f)); + ImGui.PushStyleColor(ImGuiCol.HeaderActive, new Vector4(0.28f, 0.28f, 0.35f, 1.0f)); + + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 5.0f * totalScale); + ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 8.0f * totalScale); + ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(8 * totalScale, 5 * totalScale)); + ImGui.PushStyleVar(ImGuiStyleVar.FramePadding, new Vector2(6 * totalScale, 3 * totalScale)); + } + + private void DrawFixedSettingsContent() + { + var contentWidth = ImGui.GetContentRegionAvail().X; + var labelWidth = 140f; + var inputWidth = contentWidth - labelWidth - 20f; + + // Header + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.8f, 0.85f, 0.95f, 1.0f)); + ImGui.Text("Customize your Character Select+ experience"); + ImGui.PopStyleColor(); + ImGui.Separator(); + ImGui.Spacing(); + + // Scrollable content area for all settings + if (ImGui.BeginChild("AllSettings", new Vector2(0, 0), false)) + { + // Visual Settings Section (Red) + visualSettingsOpen = DrawModernCollapsingHeader("Visual Settings", new Vector4(1.0f, 0.3f, 0.3f, 1.0f), visualSettingsOpen); + if (visualSettingsOpen) + { + DrawVisualSettings(labelWidth, inputWidth); + } + + // Glamourer Automations Section (Orange) + automationSettingsOpen = DrawModernCollapsingHeader("Glamourer Automations", new Vector4(1.0f, 0.6f, 0.2f, 1.0f), automationSettingsOpen); + if (automationSettingsOpen) + { + DrawAutomationSettings(); + } + + // Behavior Settings Section (Green) + behaviorSettingsOpen = DrawModernCollapsingHeader("Behavior Settings", new Vector4(0.3f, 0.8f, 0.3f, 1.0f), behaviorSettingsOpen); + if (behaviorSettingsOpen) + { + DrawBehaviorSettings(); + } + + // Main Character Section (Blue) + mainCharacterSettingsOpen = DrawModernCollapsingHeader("Main Character", new Vector4(0.3f, 0.6f, 1.0f, 1.0f), mainCharacterSettingsOpen); + if (mainCharacterSettingsOpen) + { + DrawMainCharacterSettings(labelWidth, inputWidth); + } + + // Roleplay Integration (Purple) + dialogueSettingsOpen = DrawModernCollapsingHeader("Immersive Dialogue", new Vector4(0.7f, 0.4f, 1.0f, 1.0f), dialogueSettingsOpen); + if (dialogueSettingsOpen) + { + DrawDialogueSettings(); + } + } + ImGui.EndChild(); + } + + private bool DrawModernCollapsingHeader(string title, Vector4 titleColor, bool currentState) + { + var flags = currentState ? ImGuiTreeNodeFlags.DefaultOpen : ImGuiTreeNodeFlags.None; + flags |= ImGuiTreeNodeFlags.SpanFullWidth; + + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(1.0f, 1.0f, 1.0f, 1.0f)); // White text + ImGui.PushStyleColor(ImGuiCol.Header, new Vector4(titleColor.X * 0.6f, titleColor.Y * 0.6f, titleColor.Z * 0.6f, 0.7f)); // More vibrant + ImGui.PushStyleColor(ImGuiCol.HeaderHovered, new Vector4(titleColor.X * 0.7f, titleColor.Y * 0.7f, titleColor.Z * 0.7f, 0.8f)); // More vibrant + ImGui.PushStyleColor(ImGuiCol.HeaderActive, new Vector4(titleColor.X * 0.8f, titleColor.Y * 0.8f, titleColor.Z * 0.8f, 0.9f)); // More vibrant + + bool isOpen = ImGui.CollapsingHeader(title, flags); + + ImGui.PopStyleColor(4); + + if (isOpen) + { + ImGui.Spacing(); + } + + return isOpen; + } + + private void DrawVisualSettings(float labelWidth, float inputWidth) + { + // Profile Image Scale + DrawFixedSetting("Profile Image Scale:", labelWidth, inputWidth, () => + { + float tempScale = plugin.ProfileImageScale; + if (ImGui.SliderFloat("##ProfileImageScale", ref tempScale, 0.5f, 2.0f, "%.1f")) + { + plugin.ProfileImageScale = tempScale; + plugin.SaveConfiguration(); + // Force MainWindow layout invalidation + mainWindow.InvalidateLayout(); + Plugin.Log.Debug($"[Settings] Profile Image Scale changed to {tempScale}"); + } + DrawTooltip("Adjusts the size of character profile images in the grid."); + }); + + // Profiles Per Row + DrawFixedSetting("Profiles Per Row:", labelWidth, inputWidth * 0.5f, () => + { + int tempColumns = plugin.ProfileColumns; + if (ImGui.InputInt("##ProfilesPerRow", ref tempColumns, 1, 1)) + { + tempColumns = Math.Clamp(tempColumns, 1, 6); + plugin.ProfileColumns = tempColumns; + plugin.SaveConfiguration(); + // Force MainWindow layout invalidation + mainWindow.InvalidateLayout(); + Plugin.Log.Debug($"[Settings] Profile Columns changed to {tempColumns}"); + } + DrawTooltip("Number of character profiles to display per row."); + }); + + // Profile Spacing + DrawFixedSetting("Profile Spacing:", labelWidth, inputWidth, () => + { + float tempSpacing = plugin.ProfileSpacing; + if (ImGui.SliderFloat("##ProfileSpacing", ref tempSpacing, 0.0f, 50.0f, "%.1f")) + { + plugin.ProfileSpacing = tempSpacing; + plugin.SaveConfiguration(); + // Force MainWindow layout invalidation + mainWindow.InvalidateLayout(); + Plugin.Log.Debug($"[Settings] Profile Spacing changed to {tempSpacing}"); + } + DrawTooltip("Spacing between character profile cards."); + }); + + // UI Scale + DrawFixedSetting("UI Scale:", labelWidth, inputWidth, () => + { + float scaleSetting = plugin.Configuration.UIScaleMultiplier; + if (ImGui.SliderFloat("##UIScale", ref scaleSetting, 0.5f, 2.0f, "%.2fx")) + { + plugin.Configuration.UIScaleMultiplier = scaleSetting; + plugin.Configuration.Save(); + } + DrawTooltip("Scales the entire Character Select+ UI manually.\nUseful for high-DPI monitors (2K / 3K / 4K)."); + }); + + // Sort Characters By + DrawFixedSetting("Sort Characters By:", labelWidth, inputWidth, () => + { + var currentSort = (Plugin.SortType)plugin.Configuration.CurrentSortIndex; + if (ImGui.BeginCombo("##SortDropdown", currentSort.ToString())) + { + if (ImGui.Selectable("Favourites", currentSort == Plugin.SortType.Favorites)) + { + plugin.Configuration.CurrentSortIndex = (int)Plugin.SortType.Favorites; + plugin.Configuration.Save(); + mainWindow.UpdateSortType(); + } + if (ImGui.Selectable("Alphabetical", currentSort == Plugin.SortType.Alphabetical)) + { + plugin.Configuration.CurrentSortIndex = (int)Plugin.SortType.Alphabetical; + plugin.Configuration.Save(); + mainWindow.UpdateSortType(); + } + if (ImGui.Selectable("Most Recent", currentSort == Plugin.SortType.Recent)) + { + plugin.Configuration.CurrentSortIndex = (int)Plugin.SortType.Recent; + plugin.Configuration.Save(); + mainWindow.UpdateSortType(); + } + if (ImGui.Selectable("Oldest", currentSort == Plugin.SortType.Oldest)) + { + plugin.Configuration.CurrentSortIndex = (int)Plugin.SortType.Oldest; + plugin.Configuration.Save(); + mainWindow.UpdateSortType(); + } + if (ImGui.Selectable("Manual", currentSort == Plugin.SortType.Manual)) + { + plugin.Configuration.CurrentSortIndex = (int)Plugin.SortType.Manual; + plugin.Configuration.Save(); + mainWindow.UpdateSortType(); + } + ImGui.EndCombo(); + } + DrawTooltip("Choose how characters are sorted in the main grid."); + }); + + // Character Hover Effects + bool enableHoverEffects = plugin.Configuration.EnableCharacterHoverEffects; + if (ImGui.Checkbox("Character Hover Effects", ref enableHoverEffects)) + { + plugin.Configuration.EnableCharacterHoverEffects = enableHoverEffects; + plugin.SaveConfiguration(); + } + DrawTooltip("Characters grow slightly when hovered over for visual feedback."); + + ImGui.Spacing(); + } + + private void DrawAutomationSettings() + { + // Warning + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(1f, 0.7f, 0.3f, 1f)); + ImGui.TextWrapped("Warning: Requires 'None' automation in Glamourer"); + ImGui.PopStyleColor(); + ImGui.Spacing(); + + bool automationToggle = plugin.Configuration.EnableAutomations; + if (ImGui.Checkbox("Enable Automations", ref automationToggle)) + { + plugin.Configuration.EnableAutomations = automationToggle; + UpdateAutomationSettings(automationToggle); + } + DrawTooltip("Enable support for Glamourer Automations in Characters & Designs.\n\nWhen enabled, you'll be able to assign an Automation to each character & design.\nCharacters & Designs without automations will require a fallback Automation in Glamourer named: \"None\"\nYou also must enter your in-game character name in Glamourer next to \"Any World\" and Set to Character."); + + ImGui.Spacing(); + } + + private void DrawBehaviorSettings() + { + bool enableCompactQuickSwitch = plugin.Configuration.QuickSwitchCompact; + if (ImGui.Checkbox("Compact Quick Switch Bar", ref enableCompactQuickSwitch)) + { + plugin.Configuration.QuickSwitchCompact = enableCompactQuickSwitch; + plugin.Configuration.Save(); + } + DrawTooltip("When enabled, the Quick Switch window will hide its title bar and frame, showing only the dropdowns and apply button."); + + bool enableAutoload = plugin.Configuration.EnableLastUsedCharacterAutoload; + if (ImGui.Checkbox("Auto-Apply Last Used Character on Login", ref enableAutoload)) + { + plugin.Configuration.EnableLastUsedCharacterAutoload = enableAutoload; + plugin.Configuration.Save(); + } + DrawTooltip("Automatically applies the last character you used when logging into the game."); + + bool applyIdle = plugin.Configuration.ApplyIdleOnLogin; + if (ImGui.Checkbox("Apply idle pose on login", ref applyIdle)) + { + plugin.Configuration.ApplyIdleOnLogin = applyIdle; + plugin.Configuration.Save(); + } + DrawTooltip("Automatically applies your idle pose after logging in. Disable if you're seeing pose bugs."); + + bool reapplyDesign = plugin.Configuration.ReapplyDesignOnJobChange; + if (ImGui.Checkbox("Reapply last design on job change", ref reapplyDesign)) + { + plugin.Configuration.ReapplyDesignOnJobChange = reapplyDesign; + plugin.Configuration.Save(); + } + DrawTooltip("If checked, Character Select+ will reapply the last used design when you switch jobs."); + + bool randomFavoritesOnly = plugin.Configuration.RandomSelectionFavoritesOnly; + if (ImGui.Checkbox("Random Selection: Favourites Only", ref randomFavoritesOnly)) + { + plugin.Configuration.RandomSelectionFavoritesOnly = randomFavoritesOnly; + plugin.Configuration.Save(); + } + DrawTooltip("When enabled, random selection will only choose from favourited characters and designs.\nRequires at least one favourited character to work."); + + ImGui.Spacing(); + } + + private void DrawMainCharacterSettings(float labelWidth, float inputWidth) + { + bool enableMainCharacterOnly = plugin.Configuration.EnableMainCharacterOnly; + if (ImGui.Checkbox("Enable Main Character Only Mode", ref enableMainCharacterOnly)) + { + plugin.Configuration.EnableMainCharacterOnly = enableMainCharacterOnly; + plugin.Configuration.Save(); + } + DrawTooltip("When enabled, only your designated main character will auto-apply on login.\nIf no main character is set, the normal auto-apply behavior will be used."); + + bool showCrown = plugin.Configuration.ShowMainCharacterCrown; + if (ImGui.Checkbox("Show Crown Icon on Main Character", ref showCrown)) + { + plugin.Configuration.ShowMainCharacterCrown = showCrown; + plugin.Configuration.Save(); + } + DrawTooltip("When enabled, the main character will display a crown icon in the top corner of their image."); + + DrawFixedSetting("Select Main Character:", labelWidth, inputWidth, () => + { + string currentMainChar = plugin.Configuration.MainCharacterName ?? "None"; + + if (ImGui.BeginCombo("##MainCharacterDropdown", currentMainChar)) + { + if (ImGui.Selectable("None", currentMainChar == "None")) + { + plugin.Configuration.MainCharacterName = null; + plugin.Configuration.Save(); + } + + foreach (var character in plugin.Characters) + { + bool isSelected = character.Name == currentMainChar; + if (ImGui.Selectable(character.Name, isSelected)) + { + plugin.Configuration.MainCharacterName = character.Name; + plugin.Configuration.Save(); + } + + if (isSelected) + ImGui.SetItemDefaultFocus(); + } + ImGui.EndCombo(); + } + DrawTooltip("Select which character should be designated as your main character.\nThe main character will be marked with a crown icon and can be set to auto-apply exclusively on login."); + }); + + // Status display + if (!string.IsNullOrEmpty(plugin.Configuration.MainCharacterName)) + { + var mainCharacter = plugin.Characters.FirstOrDefault(c => c.Name == plugin.Configuration.MainCharacterName); + if (mainCharacter != null) + { + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.7f, 0.9f, 0.7f, 1f)); + ImGui.Text($"Current Main: {mainCharacter.Name}"); + ImGui.PopStyleColor(); + } + else + { + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(1f, 0.6f, 0.6f, 1f)); + ImGui.Text("Main character not found"); + ImGui.PopStyleColor(); + ImGui.SameLine(); + if (ImGui.SmallButton("Clear")) + { + plugin.Configuration.MainCharacterName = null; + plugin.Configuration.Save(); + } + } + } + + ImGui.Spacing(); + } + + private void DrawDialogueSettings() + { + // Warning + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.9f, 0.8f, 0.4f, 1f)); + ImGui.TextWrapped("Uses your CS+ Character's name and pronouns in NPC dialogue"); + ImGui.PopStyleColor(); + + // Requirements + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.7f, 0.7f, 0.8f, 1f)); + ImGui.TextWrapped("Requires: Completed RP Profile (name & pronouns)"); + ImGui.PopStyleColor(); + + // They/Them pronoun chat display warning + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.9f, 0.7f, 0.5f, 1f)); + ImGui.TextWrapped("Note: Users with They/Them pronouns may occasionally see garbled text in chat. Simply switch between chat tabs to refresh the display if this occurs."); + ImGui.PopStyleColor(); + + ImGui.Spacing(); + + bool enableDialogue = plugin.Configuration.EnableDialogueIntegration; + if (ImGui.Checkbox("Enable Immersive Dialogue", ref enableDialogue)) + { + plugin.Configuration.EnableDialogueIntegration = enableDialogue; + + // Reset all dialogue sub-settings when disabled + if (!enableDialogue) + { + plugin.Configuration.EnableLuaHookDialogue = false; + plugin.Configuration.ReplaceNameInDialogue = false; + plugin.Configuration.ReplacePronounsInDialogue = false; + plugin.Configuration.ReplaceGenderedTerms = false; + plugin.Configuration.EnableAdvancedTitleReplacement = false; + plugin.Configuration.EnableSmartGrammarInDialogue = false; + plugin.Configuration.EnableRaceReplacement = false; + plugin.Configuration.ShowDialogueReplacementPreview = false; // Off by default + } + else + { + // Set good defaults when enabling + plugin.Configuration.EnableLuaHookDialogue = true; + plugin.Configuration.ReplaceNameInDialogue = true; + plugin.Configuration.ReplacePronounsInDialogue = true; + plugin.Configuration.ReplaceGenderedTerms = true; + plugin.Configuration.EnableAdvancedTitleReplacement = true; + plugin.Configuration.EnableSmartGrammarInDialogue = true; + plugin.Configuration.EnableRaceReplacement = true; + plugin.Configuration.ShowDialogueReplacementPreview = false; // Keep off by default + } + + plugin.Configuration.Save(); + } + DrawTooltip("Replaces NPC dialogue text to use your CS+ Character's name and pronouns instead of your game character.\nRequires an active CS+ character with RP Profile data."); + + if (plugin.Configuration.EnableDialogueIntegration) + { + ImGui.Indent(); + + // Simplified user-facing options + bool replaceName = plugin.Configuration.ReplaceNameInDialogue; + if (ImGui.Checkbox("Use CS+ Character Name", ref replaceName)) + { + plugin.Configuration.ReplaceNameInDialogue = replaceName; + plugin.Configuration.Save(); + } + DrawTooltip("Replace your real character name with your CS+ character name in dialogue."); + + bool replacePronouns = plugin.Configuration.ReplacePronounsInDialogue; + if (ImGui.Checkbox("Use CS+ Character Pronouns", ref replacePronouns)) + { + plugin.Configuration.ReplacePronounsInDialogue = replacePronouns; + plugin.Configuration.Save(); + } + DrawTooltip("Replace pronouns in dialogue with your character's pronouns from their RP Profile."); + + bool replaceGenderedTerms = plugin.Configuration.ReplaceGenderedTerms; + if (ImGui.Checkbox("Use Gender-Neutral Terms", ref replaceGenderedTerms)) + { + plugin.Configuration.ReplaceGenderedTerms = replaceGenderedTerms; + plugin.Configuration.Save(); + } + DrawTooltip("Replace gendered terms like 'sir/lady', 'man/woman' with appropriate alternatives based on your character's pronouns."); + + //bool replaceRace = plugin.Configuration.EnableRaceReplacement; + //if (ImGui.Checkbox("Use CS+ Character Race", ref replaceRace)) + //{ + // plugin.Configuration.EnableRaceReplacement = replaceRace; + // plugin.Configuration.Save(); + //} + //DrawTooltip("Replace your race with your CS+ character's race from their RP Profile."); + + // They/Them settings section + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.8f, 0.8f, 0.9f, 1.0f)); + ImGui.Text("They/Them Pronoun Settings"); + ImGui.PopStyleColor(); + ImGui.Spacing(); + + // Use proper fixed layout like other settings + var contentWidth = ImGui.GetContentRegionAvail().X; + var labelWidth = 140f; + var inputWidth = contentWidth - labelWidth - 20f; + + DrawFixedSetting("Neutral Title Style:", labelWidth, inputWidth, () => + { + var currentStyle = (int)plugin.Configuration.TheyThemStyle; + string[] styleOptions = { "Friend", "Mx.", "Traveler", "Adventurer", "Custom" }; + + if (ImGui.Combo("##TheyThemStyle", ref currentStyle, styleOptions, styleOptions.Length)) + { + plugin.Configuration.TheyThemStyle = (Configuration.GenderNeutralStyle)currentStyle; + plugin.Configuration.Save(); + } + DrawTooltip("Friend: \"honored sir\" → \"honored friend\"\nMx.: \"honored sir\" → \"honored Mx.\"\nTraveler: \"honored sir\" → \"honored traveler\"\nAdventurer: \"honored sir\" → \"honored adventurer\""); + }); + + if (plugin.Configuration.TheyThemStyle == Configuration.GenderNeutralStyle.Custom) + { + DrawFixedSetting("Custom Title:", labelWidth, inputWidth, () => + { + var customTitle = plugin.Configuration.CustomGenderNeutralTitle; + if (ImGui.InputText("##CustomGenderNeutral", ref customTitle, 50)) + { + plugin.Configuration.CustomGenderNeutralTitle = customTitle; + plugin.Configuration.Save(); + } + DrawTooltip("Enter your preferred gender-neutral title (e.g., \"Warrior\", \"Dhampion\", \"Canadian\")"); + }); + } + + // Preview with proper styling + ImGui.Spacing(); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.7f, 0.7f, 0.7f, 1.0f)); + var characterName = plugin.Characters.FirstOrDefault()?.Name ?? "Warrior of Light"; + ImGui.Text($"Preview: \"Sir {characterName}\" -> \"{plugin.Configuration.GetGenderNeutralFormalTitle()} {characterName}\""); + ImGui.PopStyleColor(); + + ImGui.Unindent(); + } + + ImGui.Spacing(); + } + + private void DrawFixedSetting(string label, float labelWidth, float inputWidth, Action drawControl) + { + ImGui.AlignTextToFramePadding(); + ImGui.Text(label); + ImGui.SameLine(labelWidth); + ImGui.SetNextItemWidth(inputWidth); + drawControl(); + ImGui.Spacing(); + } + + private void UpdateAutomationSettings(bool enableAutomations) + { + bool changed = false; + + // Character-level Automation Handling + foreach (var character in plugin.Characters) + { + if (!enableAutomations) + { + character.CharacterAutomation = string.Empty; + } + else if (string.IsNullOrWhiteSpace(character.CharacterAutomation)) + { + character.CharacterAutomation = "None"; + } + } + + if (!enableAutomations) + { + // Remove automation lines from all macros + foreach (var character in plugin.Characters) + { + foreach (var design in character.Designs) + { + string macro = design.IsAdvancedMode ? design.AdvancedMacro : design.Macro; + if (string.IsNullOrWhiteSpace(macro)) + continue; + + var cleaned = string.Join("\n", macro + .Split('\n') + .Where(line => !line.TrimStart().StartsWith("/glamour automation enable", StringComparison.OrdinalIgnoreCase)) + .Select(line => line.TrimEnd())); + + if (design.IsAdvancedMode && cleaned != design.AdvancedMacro) + { + design.AdvancedMacro = cleaned; + changed = true; + } + else if (!design.IsAdvancedMode && cleaned != design.Macro) + { + design.Macro = cleaned; + changed = true; + } + } + } + + foreach (var character in plugin.Characters) + { + if (string.IsNullOrWhiteSpace(character.Macros)) + continue; + + var cleaned = string.Join("\n", character.Macros + .Split('\n') + .Where(line => !line.TrimStart().StartsWith("/glamour automation enable", StringComparison.OrdinalIgnoreCase)) + .Select(line => line.TrimEnd())); + + if (cleaned != character.Macros) + { + character.Macros = cleaned; + changed = true; + } + } + } + else + { + // Re-add automation lines + foreach (var character in plugin.Characters) + { + foreach (var design in character.Designs) + { + string macro = design.IsAdvancedMode ? design.AdvancedMacro : design.Macro; + if (string.IsNullOrWhiteSpace(macro)) + continue; + + string updated = Plugin.SanitizeDesignMacro(macro, design, character, true); + + if (design.IsAdvancedMode && updated != design.AdvancedMacro) + { + design.AdvancedMacro = updated; + changed = true; + } + else if (!design.IsAdvancedMode && updated != design.Macro) + { + design.Macro = updated; + changed = true; + } + } + } + + foreach (var character in plugin.Characters) + { + string updated = Plugin.SanitizeMacro(character.Macros, character); + if (updated != character.Macros) + { + character.Macros = updated; + changed = true; + } + } + } + + if (changed) + plugin.SaveConfiguration(); + } + private float GetSafeScale(float baseScale) + { + return Math.Clamp(baseScale, 0.3f, 5.0f); // Prevent extreme scaling + } + + private void DrawTooltip(string text) + { + if (ImGui.IsItemHovered()) + { + ImGui.BeginTooltip(); + ImGui.PushTextWrapPos(300f); + ImGui.TextUnformatted(text); + ImGui.PopTextWrapPos(); + ImGui.EndTooltip(); + } + } + } +} diff --git a/CharacterSelectPlugin/Windows/MainWindow.cs b/CharacterSelectPlugin/Windows/MainWindow.cs index ff72294..2363124 100644 --- a/CharacterSelectPlugin/Windows/MainWindow.cs +++ b/CharacterSelectPlugin/Windows/MainWindow.cs @@ -2,184 +2,29 @@ using System; using System.Numerics; using Dalamud.Interface.Windowing; using ImGuiNET; -using System.Windows.Forms; -using System.IO; -using System.Collections.Generic; -using System.Threading; -using System.Linq; +using CharacterSelectPlugin.Windows.Components; +using CharacterSelectPlugin.Windows.Styles; using Dalamud.Interface; -using System.Runtime.CompilerServices; -using Dalamud.Plugin.Services; -using System.Runtime.InteropServices; -using System.Text.RegularExpressions; +using CharacterSelectPlugin.Effects; namespace CharacterSelectPlugin.Windows { public class MainWindow : Window, IDisposable { private Plugin plugin; - private int selectedCharacterIndex = -1; - private string editedCharacterName = ""; - private string editedCharacterMacros = ""; - private string? editedCharacterImagePath = null; - private List editedCharacterDesigns = new(); - private bool isEditCharacterWindowOpen = false; - private int activeDesignCharacterIndex = -1; - private bool isDesignPanelOpen = false; - private string? pendingImagePath = null; // Temporary storage for the selected image path - private Vector3 editedCharacterColor = new Vector3(1.0f, 1.0f, 1.0f); // Default to white - private string editedCharacterPenumbra = ""; - private string editedCharacterGlamourer = ""; - private string editedCharacterCustomize = ""; - private bool isAdvancedModeCharacter = false; // Separate Advanced Mode for Characters - private bool isAdvancedModeDesign = false; // Separate Advanced Mode for Designs - private string advancedCharacterMacroText = ""; // Macro text for Character Advanced Mode - private string advancedDesignMacroText = ""; // Macro text for Design Advanced Mode - private bool isEditDesignWindowOpen = false; - private string editedDesignName = ""; - private string editedDesignMacro = ""; - private string editedGlamourerDesign = ""; - private HashSet knownHonorifics = new HashSet(); - private string originalDesignName = ""; // Stores the original name before editing - private bool isAdvancedModeWindowOpen = false; // Tracks if Advanced Mode window is open - // Honorific Fields - private string tempHonorificTitle = ""; - private string tempHonorificPrefix = "Prefix"; // Default to Prefix - private string tempHonorificSuffix = "Suffix"; // Default to Suffix - private Vector3 tempHonorificColor = new Vector3(1.0f, 1.0f, 1.0f); // Default to White - private Vector3 tempHonorificGlow = new Vector3(1.0f, 1.0f, 1.0f); // Default to White - - // For Editing Characters - private string editedCharacterHonorificTitle = ""; - private string editedCharacterHonorificPrefix = "Prefix"; - private string editedCharacterHonorificSuffix = "Suffix"; - private Vector3 editedCharacterHonorificColor = new Vector3(1.0f, 1.0f, 1.0f); - private Vector3 editedCharacterHonorificGlow = new Vector3(1.0f, 1.0f, 1.0f); - private string editedCharacterAutomation = ""; - private string tempCharacterAutomation = ""; // Temp variable for automation - private CharacterDesign? draggedDesign = null; - private Dictionary folderRenameBuffers = new(); - private bool isNewDesign = false; - private bool isSecretCharacter; - // Set to true when Ctrl+Shift-click “Add Character” - private bool isSecretMode = false; - // Set to true when Ctrl+Shift-click “Add Design” - private bool isSecretDesignMode = false; - - - - - - //MOODLES - public string MoodlePreset { get; set; } = ""; - // Temporary storage for Moodle preset input in Add/Edit Character window - private string tempMoodlePreset = ""; - - // Stores the selected Moodle preset for an edited character - private string editedCharacterMoodlePreset = ""; - - private string editedAutomation = ""; - private string editedCustomizeProfile = ""; - private bool showSearchBar = false; - private string searchQuery = ""; - - // Shared Designs - private bool isImportWindowOpen = false; - private Character? targetForDesignImport = null; - - // Reordering - private bool isReorderWindowOpen = false; - private List reorderBuffer = new(); - // Tags - private string selectedTag = "All"; - private string editedCharacterTag = ""; - private bool showTagFilter = false; - - - // Add Sorting Function - public enum SortType { Manual, Favorites, Alphabetical, Recent, Oldest } - private SortType currentSort; - - private enum DesignSortType { Favorites, Alphabetical, Recent, Oldest, Manual } - private DesignSortType currentDesignSort = DesignSortType.Alphabetical; - private bool isDesignSortWindowOpen = false; - private Character? sortTargetCharacter = null; - private List workingFolders = new(); - private Dictionary workingRenameBuffers = new(); - private string newFolderNameInput = ""; - private bool hasLoadedWorkingFolders = false; - private bool isCreatingFolder = false; - private string newFolderName = ""; - private Guid renameFolderId; - private string renameFolderBuf = ""; - private Guid deleteFolderId; - private bool isRenameFolderPopupOpen = false; - private bool isDeleteFolderPopupOpen = false; - private bool isRenamingFolder = false; - private DesignFolder? draggedFolder = null; - private double rowPressStart; - private CharacterDesign? rowPressDesign; - private const double DragThreshold = 0.5; // seconds to hold before drag kicks in - - public void SortCharacters() - { - if (currentSort == SortType.Favorites) - { - plugin.Characters.Sort((a, b) => - { - int favCompare = b.IsFavorite.CompareTo(a.IsFavorite); // ⭐ Favourites first - if (favCompare != 0) return favCompare; - return string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase); // Alphabetical within favourites - }); - } - if (currentSort == SortType.Manual) - { - plugin.Characters.Sort((a, b) => a.SortOrder.CompareTo(b.SortOrder)); - } - else if (currentSort == SortType.Alphabetical) - { - plugin.Characters.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase)); // Alphabetical - } - else if (currentSort == SortType.Recent) - { - plugin.Characters.Sort((a, b) => b.DateAdded.CompareTo(a.DateAdded)); // Most Recent First - } - else if (currentSort == SortType.Oldest) - { - plugin.Characters.Sort((a, b) => a.DateAdded.CompareTo(b.DateAdded)); // Oldest First - } - } - - private void SortDesigns(Character character) - { - // If the user is in Manual mode, leave the list alone. - if (currentDesignSort == DesignSortType.Manual) - return; - if (currentDesignSort == DesignSortType.Favorites) - { - character.Designs.Sort((a, b) => - { - int favCompare = b.IsFavorite.CompareTo(a.IsFavorite); // Favourites first - if (favCompare != 0) return favCompare; - return string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase); - }); - } - else if (currentDesignSort == DesignSortType.Alphabetical) - { - character.Designs.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase)); - } - else if (currentDesignSort == DesignSortType.Recent) - { - character.Designs.Sort((a, b) => b.DateAdded.CompareTo(a.DateAdded)); - } - else if (currentDesignSort == DesignSortType.Oldest) - { - character.Designs.Sort((a, b) => a.DateAdded.CompareTo(b.DateAdded)); - } - } + private CharacterGrid characterGrid; + private CharacterForm characterForm; + private DesignPanel designPanel; + private SettingsPanel settingsPanel; + private ReorderWindow reorderWindow; + private UIStyles uiStyles; + private FavoriteSparkEffect diceEffect = new(); + public bool IsDesignPanelOpen => designPanel?.IsOpen ?? false; + public bool IsEditCharacterWindowOpen => characterForm?.IsEditWindowOpen ?? false; + public bool IsReorderWindowOpen => reorderWindow?.IsOpen ?? false; public MainWindow(Plugin plugin) - : base("Character Select+", ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse) + : base("Character Select+", ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse | ImGuiWindowFlags.NoDocking) { SizeConstraints = new WindowSizeConstraints { @@ -188,164 +33,170 @@ namespace CharacterSelectPlugin.Windows }; this.plugin = plugin; + this.uiStyles = new UIStyles(plugin); - // Load saved sorting preference - currentSort = (SortType)plugin.Configuration.CurrentSortIndex; - SortCharacters(); // Apply sorting on startup - // Gather all existing honorifics at startup - + this.characterGrid = new CharacterGrid(plugin, uiStyles); + this.characterForm = new CharacterForm(plugin, uiStyles); + this.designPanel = new DesignPanel(plugin, uiStyles); + this.settingsPanel = new SettingsPanel(plugin, uiStyles, this); + this.reorderWindow = new ReorderWindow(plugin, uiStyles); + } + public void InvalidateLayout() + { + characterGrid?.InvalidateCache(); } - - public void Dispose() { } + public void Dispose() + { + characterGrid?.Dispose(); + characterForm?.Dispose(); + designPanel?.Dispose(); + settingsPanel?.Dispose(); + reorderWindow?.Dispose(); + } public override void Draw() { + // Main window position + plugin.MainWindowPos = ImGui.GetWindowPos(); + plugin.MainWindowSize = ImGui.GetWindowSize(); - // Save original scale - ImGui.PushFont(UiBuilder.DefaultFont); - ImGui.SetWindowFontScale(plugin.Configuration.UIScaleMultiplier); + // UI styling + uiStyles.PushMainWindowStyle(); + + try + { + DrawHeader(); + DrawMainContent(); + DrawBottomBar(); + DrawSupportButton(); + + settingsPanel.Draw(); + reorderWindow.Draw(); + } + + finally + { + uiStyles.PopMainWindowStyle(); + } + float deltaTime = ImGui.GetIO().DeltaTime; + diceEffect.Update(deltaTime); + diceEffect.Draw(); + } + + // Modify your DrawHeader() to fix the button text alignment: + private void DrawHeader() + { + // Draw "Choose your character" text ImGui.Text("Choose your character"); + + // Move to the same line and position Discord button at far right + ImGui.SameLine(); + + var dpiScale = ImGui.GetIO().DisplayFramebufferScale.X; + var uiScale = plugin.Configuration.UIScaleMultiplier; + var totalScale = dpiScale * uiScale; + + float buttonWidth = 70 * totalScale; + float buttonHeight = ImGui.GetTextLineHeight() + ImGui.GetStyle().FramePadding.Y * 2; // Match text height + float availableWidth = ImGui.GetContentRegionAvail().X; + + // Position button at far right of the same line + ImGui.SetCursorPosX(ImGui.GetCursorPosX() + availableWidth - buttonWidth); + + // Discord button with proper text alignment + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.35f, 0.39f, 0.96f, 0.8f)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.35f, 0.39f, 0.96f, 1.0f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.25f, 0.29f, 0.86f, 1.0f)); + + if (ImGui.Button("Discord", new Vector2(buttonWidth, buttonHeight))) + { + Dalamud.Utility.Util.OpenLink("https://discord.gg/8JykGErcX4"); + } + + ImGui.PopStyleColor(3); + + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Join our Discord community!"); + } + ImGui.Separator(); + } - if (!plugin.IsAddCharacterWindowOpen && !isEditCharacterWindowOpen) + public void UpdateSortType() + { + characterGrid.SetSortType((Plugin.SortType)plugin.Configuration.CurrentSortIndex); + } + + private void DrawMainContent() + { + // Character form (Add/Edit) + if (plugin.IsAddCharacterWindowOpen || characterForm.IsEditWindowOpen) { - if (ImGui.Button("Add Character")) - { - // Detect Ctrl+Shift for “secret” mode - var io = ImGui.GetIO(); - isSecretMode = io.KeyCtrl && io.KeyShift; - - // Preserve any designs the user has already added - var tempSavedDesigns = new List(plugin.NewCharacterDesigns); - ResetCharacterFields(); - plugin.NewCharacterDesigns = tempSavedDesigns; - - // Preload the appropriate macro - plugin.NewCharacterMacros = isSecretMode - ? GenerateSecretMacro() - : GenerateMacro(); - - // Open the Add Character window - plugin.OpenAddCharacterWindow(); - isEditCharacterWindowOpen = false; - isDesignPanelOpen = false; - isAdvancedModeCharacter = false; - } - - // Tag Toggle + Dropdown (like Search) - float tagDropdownWidth = 200f; - float tagIconOffset = 70f; - float tagDropdownOffset = tagDropdownWidth + tagIconOffset + 10; - - ImGui.SameLine(ImGui.GetWindowWidth() - tagIconOffset); - ImGui.PushFont(UiBuilder.IconFont); - if (ImGui.Button("\uf0b0")) // filter icon - { - showTagFilter = !showTagFilter; - } - ImGui.PopFont(); - if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByPopup)) - { - ImGui.BeginTooltip(); - ImGui.Text("Filter by Tags."); - ImGui.EndTooltip(); - } - - // Tag Filter Dropdown (only shows if toggled) - if (showTagFilter) - { - ImGui.SameLine(ImGui.GetWindowWidth() - tagDropdownOffset); - ImGui.SetNextItemWidth(tagDropdownWidth); - if (ImGui.BeginCombo("##TagFilter", selectedTag)) - { - var allTags = plugin.Characters - .SelectMany(c => c.Tags ?? new List()) - .Distinct() - .OrderBy(f => f) - .Prepend("All") - .ToList(); - - foreach (var tag in allTags) - { - bool isSelected = tag == selectedTag; - if (ImGui.Selectable(tag, isSelected)) - selectedTag = tag; - - if (isSelected) - ImGui.SetItemDefaultFocus(); - } - - ImGui.EndCombo(); - } - } + characterForm.Draw(); } - if (plugin.IsAddCharacterWindowOpen || isEditCharacterWindowOpen) + float characterGridWidth = 0; + if (designPanel.IsOpen) { - float baseLines = 26f; - if (isAdvancedModeWindowOpen) - baseLines += 6f; // give extra space if Advanced Mode is showing + var dpiScale = ImGui.GetIO().DisplayFramebufferScale.X; + var uiScale = plugin.Configuration.UIScaleMultiplier; + var totalScale = dpiScale * uiScale; + float scaledPanelWidth = designPanel.PanelWidth * totalScale; - float maxContentHeight = ImGui.GetTextLineHeightWithSpacing() * baseLines; - float availableHeight = ImGui.GetContentRegionAvail().Y - ImGui.GetFrameHeightWithSpacing() * 2.5f; - - float scrollHeight = Math.Min(maxContentHeight, availableHeight); - - ImGui.BeginChild("CharacterFormScrollable", new Vector2(0, scrollHeight), true, ImGuiWindowFlags.AlwaysVerticalScrollbar); - DrawCharacterForm(); - ImGui.EndChild(); + characterGridWidth = -(scaledPanelWidth + 10); } + // Main area + ImGui.BeginChild("CharacterGrid", new Vector2(characterGridWidth, -30), true); + characterGrid.Draw(); + ImGui.EndChild(); - // Search Button (toggle) - ImGui.SameLine(ImGui.GetWindowWidth() - 35); - ImGui.PushFont(UiBuilder.IconFont); - if (ImGui.Button("\uf002")) // FontAwesome "search" icon - { - showSearchBar = !showSearchBar; - if (!showSearchBar) searchQuery = ""; // Clear when closed - } - ImGui.PopFont(); - if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByPopup)) - { - ImGui.BeginTooltip(); - ImGui.Text("Search for a Character."); - ImGui.EndTooltip(); - } - - // Search Input Field - if (showSearchBar) - { - ImGui.SameLine(ImGui.GetWindowWidth() - 250); // Adjust position - ImGui.SetNextItemWidth(210f); // Width of the input box - ImGui.InputTextWithHint("##SearchCharacters", "Search characters...", ref searchQuery, 100); - } - - ImGui.BeginChild("CharacterGrid", new Vector2(isDesignPanelOpen ? -250 : 0, -30), true); - DrawCharacterGrid(); - ImGui.EndChild(); // Close Character Grid Properly - - if (isDesignPanelOpen) + // Design panel (right side) + if (designPanel.IsOpen) { ImGui.SameLine(); - float characterGridHeight = ImGui.GetItemRectSize().Y; // Get height of the Character Grid - ImGui.SetNextWindowSizeConstraints(new Vector2(250, characterGridHeight), new Vector2(250, characterGridHeight)); - ImGui.BeginChild("DesignPanel", new Vector2(250, characterGridHeight), true); - DrawDesignPanel(); + float characterGridHeight = ImGui.GetItemRectSize().Y; + + var dpiScale = ImGui.GetIO().DisplayFramebufferScale.X; + var uiScale = plugin.Configuration.UIScaleMultiplier; + var totalScale = dpiScale * uiScale; + float scaledPanelWidth = designPanel.PanelWidth * totalScale; + + ImGui.BeginChild("DesignPanel", new Vector2(scaledPanelWidth, characterGridHeight), true); + designPanel.Draw(); ImGui.EndChild(); } + } + public void OpenAddCharacterWindow(bool secretMode = false) + { + characterForm.ResetFields(); + if (secretMode) + { + characterForm.SetSecretMode(true); + } + plugin.IsAddCharacterWindowOpen = true; + } - // Ensure proper bottom-left alignment + public void CloseAddCharacterWindow() + { + plugin.IsAddCharacterWindowOpen = false; + characterForm.SetSecretMode(false); + } + + private void DrawBottomBar() + { ImGui.SetCursorPos(new Vector2(10, ImGui.GetWindowHeight() - 30)); // Settings Button - ImGui.PushFont(UiBuilder.IconFont); - if (ImGui.Button("\uf013")) // Gear icon (Settings) + if (uiStyles.IconButton("\uf013", "Settings")) { plugin.IsSettingsOpen = !plugin.IsSettingsOpen; } - ImGui.PopFont(); + plugin.SettingsButtonPos = ImGui.GetItemRectMin(); + plugin.SettingsButtonSize = ImGui.GetItemRectSize(); + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByPopup)) { ImGui.BeginTooltip(); @@ -360,8 +211,7 @@ namespace CharacterSelectPlugin.Windows // Reorder Button if (ImGui.Button("Reorder Characters")) { - isReorderWindowOpen = true; - reorderBuffer = plugin.Characters.ToList(); + reorderWindow.Open(); } ImGui.SameLine(); @@ -369,8 +219,11 @@ namespace CharacterSelectPlugin.Windows // Quick Switch Button if (ImGui.Button("Quick Switch")) { - plugin.QuickSwitchWindow.IsOpen = !plugin.QuickSwitchWindow.IsOpen; // Toggle Quick Switch Window + plugin.QuickSwitchWindow.IsOpen = !plugin.QuickSwitchWindow.IsOpen; } + plugin.QuickSwitchButtonPos = ImGui.GetItemRectMin(); + plugin.QuickSwitchButtonSize = ImGui.GetItemRectSize(); + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByPopup)) { ImGui.BeginTooltip(); @@ -378,357 +231,83 @@ namespace CharacterSelectPlugin.Windows ImGui.EndTooltip(); } - if (plugin.IsSettingsOpen) + ImGui.SameLine(); + + // Gallery Button + if (ImGui.Button("Gallery")) { - ImGui.SetNextWindowSize(new Vector2(300, 180), ImGuiCond.FirstUseEver); // Adjusted for new setting - - bool isSettingsOpen = plugin.IsSettingsOpen; - if (ImGui.Begin("Settings", ref isSettingsOpen, ImGuiWindowFlags.NoCollapse)) - { - if (!isSettingsOpen) - plugin.IsSettingsOpen = false; - - ImGui.Text("Settings Panel"); - ImGui.Separator(); - - // Profile Image Scale - float tempScale = plugin.ProfileImageScale; - if (ImGui.SliderFloat("Profile Image Scale", ref tempScale, 0.5f, 2.0f, "%.1f")) - { - plugin.ProfileImageScale = tempScale; - plugin.SaveConfiguration(); - } - - // Profile Columns - int tempColumns = plugin.ProfileColumns; - if (ImGui.InputInt("Profiles Per Row", ref tempColumns, 1, 1)) - { - tempColumns = Math.Clamp(tempColumns, 1, 6); - plugin.ProfileColumns = tempColumns; - plugin.SaveConfiguration(); - } - - // Profile Spacing - Match the layout of Profile Image Scale - float tempSpacing = plugin.ProfileSpacing; - - // Slider first - ImGui.SetNextItemWidth(150); - if (ImGui.SliderFloat("##ProfileSpacing", ref tempSpacing, 0.0f, 50.0f, "%.1f")) - { - plugin.ProfileSpacing = tempSpacing; - plugin.SaveConfiguration(); - } - - // Align label to the right of the slider - ImGui.SameLine(); - ImGui.Text("Profile Spacing"); - - // Automation Opt-In Section - ImGui.Separator(); - ImGui.Text("Glamourer Automations"); - - // Tooltip Icon (always next to label) - ImGui.SameLine(); - ImGui.PushFont(UiBuilder.IconFont); - ImGui.TextUnformatted("\uf05a"); - ImGui.PopFont(); - - if (ImGui.IsItemHovered()) - { - ImGui.BeginTooltip(); - ImGui.PushTextWrapPos(300); - ImGui.TextUnformatted("Enable support for Glamourer Automations in Characters & Designs."); - ImGui.Separator(); - ImGui.TextUnformatted("When enabled, you’ll be able to assign an Automation to each character & design."); - ImGui.TextUnformatted("⚠️ Characters & Designs without automations will require a fallback Automation in Glamourer named:"); - ImGui.TextUnformatted("\"None\""); - ImGui.TextUnformatted("You also must enter your in-game character name in Glamourer next to \"Any World\" and Set to Character."); - ImGui.PopTextWrapPos(); - ImGui.EndTooltip(); - } - - bool automationToggle = plugin.Configuration.EnableAutomations; - if (ImGui.Checkbox("Enable Automations", ref automationToggle)) - { - plugin.Configuration.EnableAutomations = automationToggle; - - bool changed = false; - - // Character-level Automation Handling - foreach (var character in plugin.Characters) - { - if (!automationToggle) - { - character.CharacterAutomation = string.Empty; - } - else if (string.IsNullOrWhiteSpace(character.CharacterAutomation)) - { - character.CharacterAutomation = "None"; - } - } - - if (!automationToggle) - { - // Remove automation lines from all macros - - // From Designs - foreach (var character in plugin.Characters) - { - foreach (var design in character.Designs) - { - string macro = design.IsAdvancedMode ? design.AdvancedMacro : design.Macro; - if (string.IsNullOrWhiteSpace(macro)) - continue; - - var cleaned = string.Join("\n", macro - .Split('\n') - .Where(line => !line.TrimStart().StartsWith("/glamour automation enable", StringComparison.OrdinalIgnoreCase)) - .Select(line => line.TrimEnd())); - - if (design.IsAdvancedMode && cleaned != design.AdvancedMacro) - { - design.AdvancedMacro = cleaned; - changed = true; - } - else if (!design.IsAdvancedMode && cleaned != design.Macro) - { - design.Macro = cleaned; - changed = true; - } - } - } - - // From Characters - foreach (var character in plugin.Characters) - { - if (string.IsNullOrWhiteSpace(character.Macros)) - continue; - - var cleaned = string.Join("\n", character.Macros - .Split('\n') - .Where(line => !line.TrimStart().StartsWith("/glamour automation enable", StringComparison.OrdinalIgnoreCase)) - .Select(line => line.TrimEnd())); - - if (cleaned != character.Macros) - { - character.Macros = cleaned; - changed = true; - } - } - } - else - { - // Re-add automation lines using SanitizeDesignMacro and SanitizeMacro - - // Designs - foreach (var character in plugin.Characters) - { - foreach (var design in character.Designs) - { - string macro = design.IsAdvancedMode ? design.AdvancedMacro : design.Macro; - if (string.IsNullOrWhiteSpace(macro)) - continue; - - string updated = Plugin.SanitizeDesignMacro(macro, design, character, true); - - if (design.IsAdvancedMode && updated != design.AdvancedMacro) - { - design.AdvancedMacro = updated; - changed = true; - } - else if (!design.IsAdvancedMode && updated != design.Macro) - { - design.Macro = updated; - changed = true; - } - } - } - - // Characters - foreach (var character in plugin.Characters) - { - string updated = Plugin.SanitizeMacro(character.Macros, character); - if (updated != character.Macros) - { - character.Macros = updated; - changed = true; - } - } - } - - // Save once at end if anything changed - if (changed) - plugin.SaveConfiguration(); - } - bool enableCompactQuickSwitch = plugin.Configuration.QuickSwitchCompact; - if (ImGui.Checkbox("Compact Quick Switch Bar", ref enableCompactQuickSwitch)) - { - plugin.Configuration.QuickSwitchCompact = enableCompactQuickSwitch; - plugin.Configuration.Save(); - } - if (ImGui.IsItemHovered()) - { - ImGui.BeginTooltip(); - ImGui.TextUnformatted("When enabled, the Quick Switch window will hide its title bar and frame, showing only the dropdowns and apply button."); - ImGui.EndTooltip(); - } - bool enableAutoload = plugin.Configuration.EnableLastUsedCharacterAutoload; - if (ImGui.Checkbox("Auto-Apply Last Used Character on Login", ref enableAutoload)) - { - plugin.Configuration.EnableLastUsedCharacterAutoload = enableAutoload; - plugin.Configuration.Save(); - } - if (ImGui.IsItemHovered()) - { - ImGui.BeginTooltip(); - ImGui.TextUnformatted("Automatically applies the last character you used when logging into the game."); - ImGui.EndTooltip(); - } - bool applyIdle = plugin.Configuration.ApplyIdleOnLogin; - if (ImGui.Checkbox("Apply idle pose on login", ref applyIdle)) - { - plugin.Configuration.ApplyIdleOnLogin = applyIdle; - plugin.Configuration.Save(); - } - if (ImGui.IsItemHovered()) - { - ImGui.BeginTooltip(); - ImGui.TextUnformatted("Automatically applies your idle pose after logging in. Disable if you’re seeing pose bugs."); - ImGui.EndTooltip(); - } - bool reapplyDesign = plugin.Configuration.ReapplyDesignOnJobChange; - if (ImGui.Checkbox("Reapply last design on job change", ref reapplyDesign)) - { - plugin.Configuration.ReapplyDesignOnJobChange = reapplyDesign; - plugin.Configuration.Save(); - } - if (ImGui.IsItemHovered()) - { - ImGui.BeginTooltip(); - ImGui.TextUnformatted("If checked, Character Select+ will reapply the last used design when you switch jobs."); - ImGui.EndTooltip(); - } - // Legacy Settings - // bool delay = plugin.Configuration.EnableLoginDelay; - // if (ImGui.Checkbox("Enable delay on login", ref delay)) - // { - // plugin.Configuration.EnableLoginDelay = delay; - // plugin.SaveConfiguration(); - // } - // if (ImGui.IsItemHovered()) - // { - // ImGui.BeginTooltip(); - // ImGui.TextUnformatted("If enabled, Character Select+ will wait after logging in before auto-loading your character profile and applying poses."); - // ImGui.EndTooltip(); - // } - - // bool safeMode = plugin.Configuration.EnableSafeMode; - // if (ImGui.Checkbox("Enable Safe Mode (Disables All Auto Features)", ref safeMode)) - // { - // plugin.Configuration.EnableSafeMode = safeMode; - // plugin.Configuration.Save(); - // } - // if (ImGui.IsItemHovered()) - // { - // ImGui.BeginTooltip(); - // ImGui.TextUnformatted("Temporarily disables all automatic pose, macro, and profile application logic. Useful for debugging crashes."); - // ImGui.EndTooltip(); - // } - - // bool poseAutoSave = plugin.Configuration.EnablePoseAutoSave; - // if (ImGui.Checkbox("Pose auto-save", ref poseAutoSave)) - // { - // plugin.Configuration.EnablePoseAutoSave = poseAutoSave; - // plugin.SaveConfiguration(); - // } - // if (ImGui.IsItemHovered()) - // { - // ImGui.BeginTooltip(); - // ImGui.Text("Tracks pose changes (idle/sit/ground/doze) and auto-saves them."); - // ImGui.Text("If you experience crashes when switching characters, try turning this off."); - // ImGui.EndTooltip(); - // } - float scaleSetting = plugin.Configuration.UIScaleMultiplier; - if (ImGui.SliderFloat("UI Scale", ref scaleSetting, 0.5f, 2.0f, "%.2fx")) - { - plugin.Configuration.UIScaleMultiplier = scaleSetting; - plugin.SaveConfiguration(); - } - if (ImGui.IsItemHovered()) - { - ImGui.BeginTooltip(); - ImGui.Text("Scales the entire Character Select+ UI manually."); - ImGui.Text("Useful for high-DPI monitors (2K / 3K / 4K)."); - ImGui.EndTooltip(); - } - - // Position "Sort By" Dropdown in the Bottom-Right - ImGui.Separator(); - ImGui.Text("Sort By:"); - ImGui.SameLine(); - - if (ImGui.BeginCombo("##SortDropdown", currentSort.ToString())) - { - if (ImGui.Selectable("Favorites", currentSort == SortType.Favorites)) - { - currentSort = SortType.Favorites; - plugin.Configuration.CurrentSortIndex = (int)currentSort; - plugin.Configuration.Save(); - SortCharacters(); - } - if (ImGui.Selectable("Alphabetical", currentSort == SortType.Alphabetical)) - { - currentSort = SortType.Alphabetical; - plugin.Configuration.CurrentSortIndex = (int)currentSort; - plugin.Configuration.Save(); - SortCharacters(); - } - if (ImGui.Selectable("Most Recent", currentSort == SortType.Recent)) - { - currentSort = SortType.Recent; - plugin.Configuration.CurrentSortIndex = (int)currentSort; - plugin.Configuration.Save(); - SortCharacters(); - } - if (ImGui.Selectable("Oldest", currentSort == SortType.Oldest)) - { - currentSort = SortType.Oldest; - plugin.Configuration.CurrentSortIndex = (int)currentSort; - plugin.Configuration.Save(); - SortCharacters(); - } - - ImGui.EndCombo(); - } - if (isAdvancedModeWindowOpen) - { - ImGui.SetNextWindowSize(new Vector2(500, 200), ImGuiCond.FirstUseEver); - if (ImGui.Begin("Advanced Macro Editor", ref isAdvancedModeWindowOpen, ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoResize)) - { - ImGui.Text("Edit Design Macro Manually:"); - ImGui.InputTextMultiline("##AdvancedDesignMacro", ref advancedDesignMacroText, 2000, new Vector2(-1, -1), ImGuiInputTextFlags.AllowTabInput); - - // Auto-save on typing - if (isAdvancedModeDesign) - { - editedDesignMacro = advancedDesignMacroText; - } - } - ImGui.End(); - } - - ImGui.End(); - } + plugin.GalleryWindow.IsOpen = !plugin.GalleryWindow.IsOpen; } - // Get position for bottom-right corner after all layout is done + plugin.GalleryButtonPos = ImGui.GetItemRectMin(); + plugin.GalleryButtonSize = ImGui.GetItemRectSize(); + + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByPopup)) + { + ImGui.BeginTooltip(); + ImGui.Text("Browse the Character Showcase Gallery"); + ImGui.Text("See other players' characters and share your own!"); + ImGui.EndTooltip(); + } + + ImGui.SameLine(); + + if (ImGui.Button("Tutorial")) + { + plugin.TutorialManager.StartTutorial(); + } + ImGui.SameLine(); + + // Patch Notes Button + if (ImGui.Button("Patch Notes")) + { + plugin.PatchNotesWindow.OpenMainMenuOnClose = false; + plugin.PatchNotesWindow.IsOpen = !plugin.PatchNotesWindow.IsOpen; + } + + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByPopup)) + { + ImGui.BeginTooltip(); + ImGui.Text("View what's new in Character Select+"); + ImGui.Text("See the latest features and updates!"); + ImGui.EndTooltip(); + } + + ImGui.SameLine(); + + // Random Button + ImGui.PushFont(UiBuilder.IconFont); + if (ImGui.Button("\uf522##RandomSelect", new Vector2(30, 25))) + { + // Trigger dice effect + Vector2 effectPos = ImGui.GetItemRectMin() + ImGui.GetItemRectSize() / 2; + diceEffect.Trigger(effectPos, true); + + plugin.SelectRandomCharacterAndDesign(); + } + ImGui.PopFont(); + + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByPopup)) + { + ImGui.BeginTooltip(); + ImGui.Text("Select Random Character & Design"); + if (plugin.Configuration.RandomSelectionFavoritesOnly) + ImGui.Text("(Favourites Only)"); + ImGui.EndTooltip(); + } + } + + private void DrawSupportButton() + { + var dpiScale = ImGui.GetIO().DisplayFramebufferScale.X; + var uiScale = plugin.Configuration.UIScaleMultiplier; + var totalScale = dpiScale * uiScale; + Vector2 windowPos = ImGui.GetWindowPos(); Vector2 windowSize = ImGui.GetWindowSize(); - float buttonWidth = 105; - float buttonHeight = 25; - float padding = 05; + float buttonWidth = 105 * totalScale; + float buttonHeight = 25 * totalScale; + float padding = 5 * totalScale; - // Set position to bottom-right, accounting for padding - // Set cursor and button area ImGui.SetCursorScreenPos(new Vector2( windowPos.X + windowSize.X - buttonWidth - padding, windowPos.Y + windowSize.Y - buttonHeight - padding @@ -741,2966 +320,20 @@ namespace CharacterSelectPlugin.Windows } // Icon + text combo - Vector2 textPos = ImGui.GetItemRectMin() + new Vector2(6, 4); // Padding inside button + Vector2 textPos = ImGui.GetItemRectMin() + new Vector2(6 * totalScale, 4 * totalScale); ImGui.GetWindowDrawList().AddText(UiBuilder.IconFont, ImGui.GetFontSize(), textPos, ImGui.GetColorU32(ImGuiCol.Text), "\uf004"); - ImGui.GetWindowDrawList().AddText(textPos + new Vector2(22, 0), ImGui.GetColorU32(ImGuiCol.Text), "Support Dev"); + ImGui.GetWindowDrawList().AddText(textPos + new Vector2(22 * totalScale, 0), ImGui.GetColorU32(ImGuiCol.Text), "Support Dev"); if (ImGui.IsItemHovered()) { ImGui.SetTooltip("Enjoy Character Select+? Consider supporting development!"); } - DrawReorderWindow(); - // Draw Import Designs Window if Open - if (isImportWindowOpen && targetForDesignImport != null) - { - DrawImportDesignWindow(); - } - ImGui.SetWindowFontScale(1f); - ImGui.PopFont(); } - - - // Resets input fields for a new character - private void ResetCharacterFields() - { - plugin.NewCharacterName = ""; - plugin.NewCharacterColor = new Vector3(1.0f, 1.0f, 1.0f); // Reset to white - plugin.NewPenumbraCollection = ""; - plugin.NewGlamourerDesign = ""; - plugin.NewCharacterAutomation = ""; - plugin.NewCustomizeProfile = ""; - plugin.NewCharacterImagePath = null; - plugin.NewCharacterDesigns.Clear(); - plugin.NewCharacterHonorificTitle = ""; - plugin.NewCharacterHonorificPrefix = "Prefix"; - plugin.NewCharacterHonorificSuffix = "Suffix"; - plugin.NewCharacterHonorificColor = new Vector3(1.0f, 1.0f, 1.0f); // Default White - plugin.NewCharacterHonorificGlow = new Vector3(1.0f, 1.0f, 1.0f); // Default White - plugin.NewCharacterMoodlePreset = ""; // RESET Moodle Preset - plugin.NewCharacterIdlePoseIndex = 7; // 7 = None - - tempHonorificTitle = ""; - tempHonorificPrefix = "Prefix"; - tempHonorificSuffix = "Suffix"; - tempHonorificColor = new Vector3(1.0f, 1.0f, 1.0f); - tempHonorificGlow = new Vector3(1.0f, 1.0f, 1.0f); - tempMoodlePreset = ""; // RESET Temporary Moodle Preset - - - // Preserve Advanced Mode Macro when Resetting Fields - if (!isAdvancedModeCharacter) - { - plugin.NewCharacterMacros = GenerateMacro(); // Only reset macro in Normal Mode - } - // Do NOT touch plugin.NewCharacterMacros if Advanced Mode is active - - } - - - private void DrawCharacterForm() - { - float scale = plugin.Configuration.UIScaleMultiplier; - ImGui.PushStyleVar(ImGuiStyleVar.FramePadding, new Vector2(4 * scale, 2 * scale)); - ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(4 * scale, 4 * scale)); - - string tempName = isEditCharacterWindowOpen ? editedCharacterName : plugin.NewCharacterName; - string tempMacros = isEditCharacterWindowOpen ? editedCharacterMacros : plugin.NewCharacterMacros; - string? imagePath = isEditCharacterWindowOpen ? editedCharacterImagePath : plugin.NewCharacterImagePath; - string tempPenumbra = isEditCharacterWindowOpen ? editedCharacterPenumbra : plugin.NewPenumbraCollection; - string tempGlamourer = isEditCharacterWindowOpen ? editedCharacterGlamourer : plugin.NewGlamourerDesign; - string tempCustomize = isEditCharacterWindowOpen ? editedCharacterCustomize : plugin.NewCustomizeProfile; - Vector3 tempColor = isEditCharacterWindowOpen ? editedCharacterColor : plugin.NewCharacterColor; - string tempTag = isEditCharacterWindowOpen ? editedCharacterTag : plugin.NewCharacterTag; - - - - float labelWidth = 130 * scale; // Keep labels aligned - float inputWidth = 250 * scale; // Longer input bars - float inputOffset = 10 * scale; // Moves input fields slightly right - - // Character Name - ImGui.SetCursorPosX(10); - ImGui.Text("Character Name*"); - ImGui.SameLine(labelWidth); - ImGui.SetCursorPosX(labelWidth + inputOffset); - ImGui.SetNextItemWidth(inputWidth); - ImGui.InputText("##CharacterName", ref tempName, 50); - if (isEditCharacterWindowOpen) editedCharacterName = tempName; - else plugin.NewCharacterName = tempName; - - // Tooltip Icon - ImGui.SameLine(); - ImGui.PushFont(UiBuilder.IconFont); - ImGui.Text("\uf05a"); - ImGui.PopFont(); - if (ImGui.IsItemHovered()) { ImGui.SetTooltip("Enter your OC's name or nickname for profile here."); } - - ImGui.Separator(); - - // Tags Input (comma-separated) - ImGui.SetCursorPosX(10); - ImGui.Text("Character Tags"); - ImGui.SameLine(labelWidth); - ImGui.SetCursorPosX(labelWidth + inputOffset); - ImGui.SetNextItemWidth(inputWidth); - ImGui.InputTextWithHint("##Tags", "e.g. Casual, Battle, Beach", ref tempTag, 100); - - // ⬅ Save depending on Add/Edit mode - if (isEditCharacterWindowOpen) - editedCharacterTag = tempTag; - else - plugin.NewCharacterTag = tempTag; - - // Tooltip Icon - ImGui.SameLine(); - ImGui.PushFont(UiBuilder.IconFont); - ImGui.TextUnformatted("\uf05a"); // info icon - ImGui.PopFont(); - - // Tooltip Text - if (ImGui.IsItemHovered()) - { - ImGui.BeginTooltip(); - ImGui.TextUnformatted("You can assign multiple tags by separating them with commas."); - ImGui.TextUnformatted("Examples: Casual, Favorites, Seasonal"); - ImGui.EndTooltip(); - } - - ImGui.Separator(); - - // Nameplate Color - ImGui.SetCursorPosX(10); - ImGui.Text("Nameplate Color"); - ImGui.SameLine(labelWidth); - ImGui.SetCursorPosX(labelWidth + inputOffset); - ImGui.SetNextItemWidth(inputWidth); - ImGui.ColorEdit3("##NameplateColor", ref tempColor); - if (isEditCharacterWindowOpen) editedCharacterColor = tempColor; - else plugin.NewCharacterColor = tempColor; - - // Tooltip Icon - ImGui.SameLine(); - ImGui.PushFont(UiBuilder.IconFont); - ImGui.Text("\uf05a"); - ImGui.PopFont(); - if (ImGui.IsItemHovered()) { ImGui.SetTooltip("Affects your character's nameplate under their profile picture in Character Select+."); } - - ImGui.Separator(); - - // Penumbra Collection - ImGui.SetCursorPosX(10); - ImGui.Text("Penumbra Collection*"); - ImGui.SameLine(labelWidth); - ImGui.SetCursorPosX(labelWidth + inputOffset); - ImGui.SetNextItemWidth(inputWidth); - ImGui.InputText("##PenumbraCollection", ref tempPenumbra, 50); - // Preserve Advanced Mode Edits While Allowing Normal Mode Updates - if (isEditCharacterWindowOpen) - { - // ─── EDIT CHARACTER ─── - if (editedCharacterPenumbra != tempPenumbra) - { - editedCharacterPenumbra = tempPenumbra; - - if (isAdvancedModeCharacter) - { - var col = editedCharacterPenumbra; - - // 1) collection line - advancedCharacterMacroText = PatchMacroLine( - advancedCharacterMacroText, - "/penumbra collection", - $"/penumbra collection individual | {col} | self" - ); - - // 2) all bulk‐disable lines - advancedCharacterMacroText = UpdateCollectionInLines( - advancedCharacterMacroText, - "/penumbra bulktag disable", - col - ); - - // 3) bulk‐enable line (if already present) - advancedCharacterMacroText = UpdateCollectionInLines( - advancedCharacterMacroText, - "/penumbra bulktag enable", - col - ); - } - else - { - // Normal Edit: full regen - editedCharacterMacros = GenerateMacro(); - } - } - } - else - { - // ─── ADD CHARACTER ─── - if (plugin.NewPenumbraCollection != tempPenumbra) - { - plugin.NewPenumbraCollection = tempPenumbra; - var col = plugin.NewPenumbraCollection; - - if (isAdvancedModeCharacter) - { - advancedCharacterMacroText = PatchMacroLine( - advancedCharacterMacroText, - "/penumbra collection", - $"/penumbra collection individual | {col} | self" - ); - - advancedCharacterMacroText = UpdateCollectionInLines( - advancedCharacterMacroText, - "/penumbra bulktag disable", - col - ); - - advancedCharacterMacroText = UpdateCollectionInLines( - advancedCharacterMacroText, - "/penumbra bulktag enable", - col - ); - - plugin.NewCharacterMacros = advancedCharacterMacroText; - } - else - { - // Normal Add: full regen - plugin.NewCharacterMacros = GenerateMacro(); - } - } - } - - // Tooltip Icon - ImGui.SameLine(); - ImGui.PushFont(UiBuilder.IconFont); - ImGui.Text("\uf05a"); - ImGui.PopFont(); - - if (ImGui.IsItemHovered()) - { - ImGui.BeginTooltip(); - ImGui.PushTextWrapPos(300); - ImGui.TextUnformatted("Enter the name of the Penumbra collection to apply to this character."); - ImGui.TextUnformatted("Must be entered EXACTLY as it is named in Penumbra!"); - ImGui.PopTextWrapPos(); - ImGui.EndTooltip(); - } - - ImGui.Separator(); - - - // Glamourer Design - ImGui.SetCursorPosX(10); - ImGui.Text("Glamourer Design*"); - ImGui.SameLine(labelWidth); - ImGui.SetCursorPosX(labelWidth + inputOffset); - ImGui.SetNextItemWidth(inputWidth); - ImGui.InputText("##GlamourerDesign", ref tempGlamourer, 50); - // ── GLAMOURER DESIGN ── - if (isEditCharacterWindowOpen) - { - if (editedCharacterGlamourer != tempGlamourer) - { - // capture the old design so we target exactly that line - var oldGlam = editedCharacterGlamourer; - editedCharacterGlamourer = tempGlamourer; - - if (isAdvancedModeCharacter) - { - var col = editedCharacterPenumbra; - var glam = editedCharacterGlamourer; - - // 1) patch only the exact "/glamour apply OLD | …" line - advancedCharacterMacroText = PatchMacroLine( - advancedCharacterMacroText, - $"/glamour apply {oldGlam} |", - $"/glamour apply {glam} | self" - ); - - // 2) update all bulk‐disable lines’ COLLECTION bit - advancedCharacterMacroText = UpdateCollectionInLines( - advancedCharacterMacroText, - "/penumbra bulktag disable", - col - ); - - // 3) rewrite the one existing enable‐line in place - advancedCharacterMacroText = UpdateBulkTagEnableDesignInMacro( - advancedCharacterMacroText, - col, - glam - ); - } - else - { - // Normal Edit: full regen - editedCharacterMacros = GenerateMacro(); - } - } - } - else - { - if (plugin.NewGlamourerDesign != tempGlamourer) - { - // capture the old draft design - var oldGlam = plugin.NewGlamourerDesign; - plugin.NewGlamourerDesign = tempGlamourer; - - if (isAdvancedModeCharacter) - { - var col = plugin.NewPenumbraCollection; - var glam = plugin.NewGlamourerDesign; - - advancedCharacterMacroText = PatchMacroLine( - advancedCharacterMacroText, - $"/glamour apply {oldGlam} |", - $"/glamour apply {glam} | self" - ); - - advancedCharacterMacroText = UpdateCollectionInLines( - advancedCharacterMacroText, - "/penumbra bulktag disable", - col - ); - - advancedCharacterMacroText = UpdateBulkTagEnableDesignInMacro( - advancedCharacterMacroText, - col, - glam - ); - - plugin.NewCharacterMacros = advancedCharacterMacroText; - } - else - { - // Normal Add: full regen - plugin.NewCharacterMacros = GenerateMacro(); - } - } - } - - - // Tooltip Icon - ImGui.SameLine(); - ImGui.PushFont(UiBuilder.IconFont); - ImGui.Text("\uf05a"); - ImGui.PopFont(); - - if (ImGui.IsItemHovered()) - { - ImGui.BeginTooltip(); - ImGui.PushTextWrapPos(300); - ImGui.TextUnformatted("Enter the name of the Glamourer design to apply to this character."); - ImGui.TextUnformatted("Must be entered EXACTLY as it is named in Glamourer!"); - ImGui.TextUnformatted("Note: You can add additional designs later."); - ImGui.PopTextWrapPos(); - ImGui.EndTooltip(); - } - - ImGui.Separator(); - - // Character Automation (if enabled) - if (plugin.Configuration.EnableAutomations) - { - ImGui.SetCursorPosX(10); - ImGui.Text("Glam. Automation"); - ImGui.SameLine(labelWidth); - ImGui.SetCursorPosX(labelWidth + inputOffset); - ImGui.SetNextItemWidth(inputWidth); - - string tempCharacterAutomation = isEditCharacterWindowOpen ? editedCharacterAutomation : plugin.NewCharacterAutomation; - - if (ImGui.InputText("##Glam.Automation", ref tempCharacterAutomation, 50)) - { - if (isEditCharacterWindowOpen - ? editedCharacterAutomation != tempCharacterAutomation - : plugin.NewCharacterAutomation != tempCharacterAutomation) - { - if (isEditCharacterWindowOpen) - editedCharacterAutomation = tempCharacterAutomation; - else - plugin.NewCharacterAutomation = tempCharacterAutomation; - - if (isAdvancedModeCharacter) - { - var line = string.IsNullOrWhiteSpace(tempCharacterAutomation) - ? "/glamour automation enable None" - : $"/glamour automation enable {tempCharacterAutomation}"; - advancedCharacterMacroText = PatchMacroLine( - advancedCharacterMacroText, - "/glamour automation enable", - line - ); - if (!isEditCharacterWindowOpen) - plugin.NewCharacterMacros = advancedCharacterMacroText; - } - else - { - if (isEditCharacterWindowOpen) - editedCharacterMacros = GenerateMacro(); - else - plugin.NewCharacterMacros = GenerateMacro(); - } - } - } - - // Tooltip - ImGui.SameLine(); - ImGui.PushFont(UiBuilder.IconFont); - ImGui.TextUnformatted("\uf05a"); - ImGui.PopFont(); - if (ImGui.IsItemHovered()) - { - ImGui.BeginTooltip(); - ImGui.PushTextWrapPos(300); - ImGui.TextUnformatted("Enter the name of a Glamourer Automation profile to apply when this character is activated."); - ImGui.TextUnformatted("Design-level automations override this if both are set."); - ImGui.TextUnformatted("Leave blank to default to a fallback profile named 'None'."); - ImGui.TextUnformatted("Steps to make it work:"); - ImGui.TextUnformatted("1. Create an Automation in Glamourer named \"None\""); - ImGui.TextUnformatted("2. Enter your in-game character name next to \"Any World\""); - ImGui.TextUnformatted("3. Set to Character."); - ImGui.PopTextWrapPos(); - ImGui.EndTooltip(); - } - - ImGui.Separator(); - } - - // Customize+ Profile - ImGui.SetCursorPosX(10); - ImGui.Text("Customize+ Profile"); - ImGui.SameLine(labelWidth); - ImGui.SetCursorPosX(labelWidth + inputOffset); - ImGui.SetNextItemWidth(inputWidth); - ImGui.InputText("##CustomizeProfile", ref tempCustomize, 50); - if (isEditCharacterWindowOpen - ? editedCharacterCustomize != tempCustomize - : plugin.NewCustomizeProfile != tempCustomize) - { - if (isEditCharacterWindowOpen) - editedCharacterCustomize = tempCustomize; - else - plugin.NewCustomizeProfile = tempCustomize; - - if (isAdvancedModeCharacter) - { - // always ensure disable-line is correct - advancedCharacterMacroText = PatchMacroLine( - advancedCharacterMacroText, - "/customize profile disable", - "/customize profile disable " - ); - - // enable-line if non-empty, otherwise remove it - if (!string.IsNullOrWhiteSpace(tempCustomize)) - { - advancedCharacterMacroText = PatchMacroLine( - advancedCharacterMacroText, - "/customize profile enable", - $"/customize profile enable , {tempCustomize}" - ); - } - else - { - // strip any existing enable line - advancedCharacterMacroText = string.Join("\n", - advancedCharacterMacroText - .Split('\n') - .Where(l => !l.TrimStart().StartsWith("/customize profile enable")) - ); - } - - if (!isEditCharacterWindowOpen) - plugin.NewCharacterMacros = advancedCharacterMacroText; - } - else - { - if (isEditCharacterWindowOpen) - editedCharacterMacros = GenerateMacro(); - else - plugin.NewCharacterMacros = GenerateMacro(); - } - } - - - // Tooltip Icon - ImGui.SameLine(); - ImGui.PushFont(UiBuilder.IconFont); - ImGui.Text("\uf05a"); - ImGui.PopFont(); - - if (ImGui.IsItemHovered()) - { - ImGui.BeginTooltip(); - ImGui.PushTextWrapPos(300); - ImGui.TextUnformatted("Enter the name of the Customize+ profile to apply to this character."); - ImGui.TextUnformatted("Must be entered EXACTLY as it is named in Customize+!"); - ImGui.PopTextWrapPos(); - ImGui.EndTooltip(); - } - - - - ImGui.Separator(); - - // Honorific Title Section (Proper Alignment) - ImGui.SetCursorPosX(10); - ImGui.Text("Honorific Title"); - ImGui.SameLine(); - - // Move cursor for input alignment - ImGui.SetCursorPosX(labelWidth + inputOffset); - ImGui.SetNextItemWidth(inputWidth); - - // ── HONORIFIC CONTROLS ── - - // 1) Draw widgets & detect any change - bool changed = false; - - // Title - changed |= ImGui.InputText("##HonorificTitle", ref tempHonorificTitle, 50); - - // Placement combo (Prefix / Suffix) - ImGui.SameLine(); - ImGui.SetNextItemWidth(80 * scale); - if (ImGui.BeginCombo("##HonorificPlacement", tempHonorificPrefix)) - { - foreach (var opt in new[] { "Prefix", "Suffix" }) - { - if (ImGui.Selectable(opt, tempHonorificPrefix == opt)) - { - tempHonorificPrefix = opt; - tempHonorificSuffix = opt; - changed = true; - } - if (tempHonorificPrefix == opt) - ImGui.SetItemDefaultFocus(); - } - ImGui.EndCombo(); - } - - // Color picker - ImGui.SameLine(); - ImGui.SetNextItemWidth(40 * scale); - changed |= ImGui.ColorEdit3("##HonorificColor", ref tempHonorificColor, ImGuiColorEditFlags.NoInputs); - - // Glow picker - ImGui.SameLine(); - ImGui.SetNextItemWidth(40 * scale); - changed |= ImGui.ColorEdit3("##HonorificGlow", ref tempHonorificGlow, ImGuiColorEditFlags.NoInputs); - - // 2) If anything changed, update model + macro - if (changed) - { - // sync temp → your data store - if (isEditCharacterWindowOpen) - { - editedCharacterHonorificTitle = tempHonorificTitle; - editedCharacterHonorificPrefix = tempHonorificPrefix; - editedCharacterHonorificSuffix = tempHonorificSuffix; - editedCharacterHonorificColor = tempHonorificColor; - editedCharacterHonorificGlow = tempHonorificGlow; - } - else - { - plugin.NewCharacterHonorificTitle = tempHonorificTitle; - plugin.NewCharacterHonorificPrefix = tempHonorificPrefix; - plugin.NewCharacterHonorificSuffix = tempHonorificSuffix; - plugin.NewCharacterHonorificColor = tempHonorificColor; - plugin.NewCharacterHonorificGlow = tempHonorificGlow; - } - - if (isAdvancedModeCharacter) - { - // build the new set-line - var c = tempHonorificColor; - var g = tempHonorificGlow; - string colorHex = $"#{(int)(c.X * 255):X2}{(int)(c.Y * 255):X2}{(int)(c.Z * 255):X2}"; - string glowHex = $"#{(int)(g.X * 255):X2}{(int)(g.Y * 255):X2}{(int)(g.Z * 255):X2}"; - string setLine = $"/honorific force set {tempHonorificTitle} | {tempHonorificPrefix} | {colorHex} | {glowHex}"; - - // split into lines - var lines = advancedCharacterMacroText.Split('\n').ToList(); - - // look for existing "force set" - var setIdx = lines.FindIndex(l => - l.TrimStart().StartsWith("/honorific force set", StringComparison.OrdinalIgnoreCase)); - - if (setIdx >= 0) - { - // replace it - lines[setIdx] = setLine; - } - else - { - // find "force clear" - var clearIdx = lines.FindIndex(l => - l.TrimStart().StartsWith("/honorific force clear", StringComparison.OrdinalIgnoreCase)); - if (clearIdx >= 0) - lines.Insert(clearIdx + 1, setLine); - else - lines.Add(setLine); - } - - // rejoin - advancedCharacterMacroText = string.Join("\n", lines); - - // if Add‐mode, update the draft as well - if (!isEditCharacterWindowOpen) - plugin.NewCharacterMacros = advancedCharacterMacroText; - } - else - { - // Normal Mode: full live-regen - if (isEditCharacterWindowOpen) - editedCharacterMacros = GenerateMacro(); - else - plugin.NewCharacterMacros = GenerateMacro(); - } - } - - ImGui.SameLine(); - - // Tooltip for Honorific Title - ImGui.PushFont(UiBuilder.IconFont); - ImGui.Text("\uf05a"); - ImGui.PopFont(); - if (ImGui.IsItemHovered()) - { - ImGui.BeginTooltip(); - ImGui.PushTextWrapPos(300); - ImGui.TextUnformatted("This will set a forced title when you switch to this character."); - ImGui.TextUnformatted("The dropdown selects if the title appears above (prefix) or below (suffix) your name in-game."); - ImGui.TextUnformatted("Use the Honorific plug-in’s 'Clear' button if you need to remove it."); - ImGui.PopTextWrapPos(); - ImGui.EndTooltip(); - } - - ImGui.Separator(); - - // Moodle Preset Input - ImGui.SetCursorPosX(10); - ImGui.Text("Moodle Preset"); - ImGui.SameLine(); - - ImGui.SetCursorPosX(labelWidth + inputOffset); - ImGui.SetNextItemWidth(inputWidth); - ImGui.InputText("##MoodlePreset", ref tempMoodlePreset, 50); - - // Update stored preset value - if (isEditCharacterWindowOpen - ? editedCharacterMoodlePreset != tempMoodlePreset - : plugin.NewCharacterMoodlePreset != tempMoodlePreset) - { - if (isEditCharacterWindowOpen) - editedCharacterMoodlePreset = tempMoodlePreset; - else - plugin.NewCharacterMoodlePreset = tempMoodlePreset; - - if (isAdvancedModeCharacter) - { - // remove–all line is static, so no change there - // patch only the apply line - if (!string.IsNullOrWhiteSpace(tempMoodlePreset)) - { - advancedCharacterMacroText = PatchMacroLine( - advancedCharacterMacroText, - "/moodle apply self preset", - $"/moodle apply self preset \"{tempMoodlePreset}\"" - ); - } - else - { - // strip apply line when blank - advancedCharacterMacroText = string.Join("\n", - advancedCharacterMacroText - .Split('\n') - .Where(l => !l.TrimStart().StartsWith("/moodle apply self preset")) - ); - } - if (!isEditCharacterWindowOpen) - plugin.NewCharacterMacros = advancedCharacterMacroText; - } - else - { - if (isEditCharacterWindowOpen) - editedCharacterMacros = GenerateMacro(); - else - plugin.NewCharacterMacros = GenerateMacro(); - } - } - - // Tooltip for Moodle Preset - ImGui.SameLine(); - ImGui.PushFont(UiBuilder.IconFont); - ImGui.Text("\uf05a"); - ImGui.PopFont(); - if (ImGui.IsItemHovered()) - { - ImGui.BeginTooltip(); - ImGui.PushTextWrapPos(300); - ImGui.TextUnformatted("Enter the Moodle preset name exactly as saved in the Moodle plugin."); - ImGui.TextUnformatted("Example: 'HappyFawn' will apply the preset named 'HappyFawn'."); - ImGui.PopTextWrapPos(); - ImGui.EndTooltip(); - } - - ImGui.Separator(); - - // Idle Pose Dropdown (None + 0–6) - ImGui.SetCursorPosX(10); - ImGui.Text("Idle Pose"); - ImGui.SameLine(); - - ImGui.SetCursorPosX(labelWidth + inputOffset); - ImGui.SetNextItemWidth(inputWidth); - - // Poses start from index 0 - string[] poseOptions = { "None", "0", "1", "2", "3", "4", "5", "6" }; - // Get the actual stored pose index (can be 0–6 or 7 for None) - byte storedIndex = isEditCharacterWindowOpen - ? plugin.Characters[selectedCharacterIndex].IdlePoseIndex - : plugin.NewCharacterIdlePoseIndex; - - // Convert to dropdown index: "None" (7) → 0, others shift by +1 - int dropdownIndex = storedIndex == 7 ? 0 : storedIndex + 1; - - if (ImGui.BeginCombo("##IdlePose", poseOptions[dropdownIndex])) - { - for (int i = 0; i < poseOptions.Length; i++) - { - bool selected = i == dropdownIndex; - if (ImGui.Selectable(poseOptions[i], selected)) - { - byte newIndex = (byte)(i == 0 ? 7 : i - 1); - - if (isEditCharacterWindowOpen - ? plugin.Characters[selectedCharacterIndex].IdlePoseIndex != newIndex - : plugin.NewCharacterIdlePoseIndex != newIndex) - { - if (isEditCharacterWindowOpen) - plugin.Characters[selectedCharacterIndex].IdlePoseIndex = newIndex; - else - plugin.NewCharacterIdlePoseIndex = newIndex; - - if (isAdvancedModeCharacter) - { - if (newIndex != 7) - { - // patch /sidle line - advancedCharacterMacroText = PatchMacroLine( - advancedCharacterMacroText, - "/sidle", - $"/sidle {newIndex}" - ); - } - else - { - // remove any existing /sidle - advancedCharacterMacroText = string.Join("\n", - advancedCharacterMacroText - .Split('\n') - .Where(l => !l.TrimStart().StartsWith("/sidle")) - ); - } - if (!isEditCharacterWindowOpen) - plugin.NewCharacterMacros = advancedCharacterMacroText; - } - else - { - if (isEditCharacterWindowOpen) - editedCharacterMacros = GenerateMacro(); - else - plugin.NewCharacterMacros = GenerateMacro(); - } - } - } - if (selected) ImGui.SetItemDefaultFocus(); - } - ImGui.EndCombo(); - } - - - // Tooltip Icon - ImGui.SameLine(); - ImGui.PushFont(UiBuilder.IconFont); - ImGui.TextUnformatted("\uf05a"); - ImGui.PopFont(); - - if (ImGui.IsItemHovered()) - { - ImGui.BeginTooltip(); - ImGui.TextUnformatted("Sets your character's idle pose (0–6)."); - ImGui.TextUnformatted("Choose 'None' if you don’t want Character Select+ to change your idle."); - ImGui.EndTooltip(); - } - - ImGui.Separator(); - - if (isEditCharacterWindowOpen) - editedCharacterMacros = tempMacros; - else -// Ensure Advanced Mode changes are actually applied to new characters -if (isAdvancedModeCharacter) - { - if (!string.IsNullOrWhiteSpace(advancedCharacterMacroText)) - { - plugin.NewCharacterMacros = advancedCharacterMacroText; // Save changes properly - } - } - else - { - // honor secret‐mode when opening “Add Character” - plugin.NewCharacterMacros = isSecretMode - ? GenerateSecretMacro() - : GenerateMacro(); - } - - if (ImGui.Button("Choose Image")) - { - try - { - Thread thread = new Thread(() => - { - try - { - using (OpenFileDialog openFileDialog = new OpenFileDialog()) - { - openFileDialog.Filter = "PNG files (*.png)|*.png"; - openFileDialog.Title = "Select Character Image"; - - if (openFileDialog.ShowDialog() == DialogResult.OK) - { - lock (this) // Prevent race conditions - { - pendingImagePath = openFileDialog.FileName; - } - } - } - } - catch (Exception ex) - { - Plugin.Log.Error($"Error opening file picker: {ex.Message}"); - } - }); - - thread.SetApartmentState(ApartmentState.STA); // Required for OpenFileDialog - thread.Start(); - } - catch (Exception ex) - { - Plugin.Log.Error($"Critical file picker error: {ex.Message}"); - } - } - - // Apply the image path safely on the next frame - if (pendingImagePath != null) - { - lock (this) // Prevent potential race conditions - { - if (isEditCharacterWindowOpen) - editedCharacterImagePath = pendingImagePath; - else - plugin.NewCharacterImagePath = pendingImagePath; - - pendingImagePath = null; // Reset after applying - } - } - - // Get Plugin Directory and Default Image Path - string pluginDirectory = plugin.PluginDirectory; - string defaultImagePath = Path.Combine(pluginDirectory, "Assets", "Default.png"); - - // Assign Default Image if None Selected - // Ensure we get the correct plugin directory - string pluginDir = plugin.PluginDirectory; - string defaultImgPath = Path.Combine(pluginDirectory, "Assets", "Default.png"); - - // Determine which image to display - string finalImagePath = !string.IsNullOrEmpty(imagePath) && File.Exists(imagePath) - ? imagePath - : defaultImagePath; // Always use Default.png if no other image is chosen - - - if (!string.IsNullOrEmpty(finalImagePath) && File.Exists(finalImagePath)) - { - var texture = Plugin.TextureProvider.GetFromFile(finalImagePath).GetWrapOrDefault(); - if (texture != null) - { - float originalWidth = texture.Width; - float originalHeight = texture.Height; - float maxSize = 100f; // Maximum size for preview - - float aspectRatio = originalWidth / originalHeight; - float displayWidth, displayHeight; - - if (aspectRatio > 1) // Landscape (wider than tall) - { - displayWidth = maxSize; - displayHeight = maxSize / aspectRatio; - } - else // Portrait or Square (taller or equal) - { - displayHeight = maxSize; - displayWidth = maxSize * aspectRatio; - } - - ImGui.Image(texture.ImGuiHandle, new Vector2(displayWidth, displayHeight)); - } - else - { - ImGui.Text($"Failed to load image: {Path.GetFileName(finalImagePath)}"); - } - } - else - { - ImGui.Text("No Image Available"); - } - - - List designsToDisplay = isEditCharacterWindowOpen ? editedCharacterDesigns : plugin.NewCharacterDesigns; - - for (int i = 0; i < designsToDisplay.Count; i++) - { - var design = designsToDisplay[i]; - string tempDesignName = design.Name; - string tempDesignMacro = design.Macro; - - ImGui.InputText($"Design Name {i + 1}", ref tempDesignName, 100); - ImGui.Text("Design Macros:"); - ImGui.BeginChild($"DesignMacroChild_{i}", new Vector2(300, 100), true); - float minHeight = 110; - float maxHeight = 300; - float totalHeight = ImGui.GetContentRegionAvail().Y - 55; - float inputHeight = Math.Clamp(totalHeight, minHeight, maxHeight); - - ImGui.BeginChild("AdvancedModeSection", new Vector2(0, inputHeight), true, ImGuiWindowFlags.NoScrollbar); - ImGui.InputTextMultiline("##AdvancedDesignMacro", ref advancedDesignMacroText, 2000, new Vector2(-1, inputHeight - 10), ImGuiInputTextFlags.AllowTabInput); - ImGui.EndChild(); - - ImGui.EndChild(); - - designsToDisplay[i] = new CharacterDesign(tempDesignName, tempDesignMacro); - } - - // Character Advanced Mode Toggle Button - if (ImGui.Button(isAdvancedModeCharacter ? "Exit Advanced Mode" : "Advanced Mode")) - { - isAdvancedModeCharacter = !isAdvancedModeCharacter; - if (isAdvancedModeCharacter) - { - advancedCharacterMacroText = isEditCharacterWindowOpen - ? (!string.IsNullOrWhiteSpace(editedCharacterMacros) ? editedCharacterMacros : GenerateMacro()) - : (!string.IsNullOrWhiteSpace(plugin.NewCharacterMacros) ? plugin.NewCharacterMacros : GenerateMacro()); - } - } - - - // Tooltip Icon (Info about Advanced Mode) - ImGui.SameLine(); - ImGui.SetCursorPosX(ImGui.GetCursorPosX() + 5); // Add slight spacing - - ImGui.PushFont(UiBuilder.IconFont); - ImGui.Text("\uf05a"); - ImGui.PopFont(); - - if (ImGui.IsItemHovered()) - { - ImGui.SetNextWindowPos(ImGui.GetCursorScreenPos() + new Vector2(20, -5)); - - ImGui.BeginTooltip(); - ImGui.PushTextWrapPos(300); - ImGui.TextUnformatted("⚠️ Do not touch this unless you know what you're doing."); - ImGui.PopTextWrapPos(); - ImGui.EndTooltip(); - } - - - - // Show Advanced Mode Editor When Enabled - if (isAdvancedModeCharacter) - { - ImGui.Text("Edit Macro Manually:"); - ImGui.InputTextMultiline("##AdvancedCharacterMacro", ref advancedCharacterMacroText, 2000, new Vector2(500, 150), ImGuiInputTextFlags.AllowTabInput); - } - // Check if required fields are filled - bool canSaveCharacter = !string.IsNullOrWhiteSpace(tempName) && - !string.IsNullOrWhiteSpace(tempPenumbra) && - !string.IsNullOrWhiteSpace(tempGlamourer); - - // Disable the button if any required field is empty - if (!canSaveCharacter) - ImGui.BeginDisabled(); - - if (ImGui.Button(isEditCharacterWindowOpen ? "Save Changes" : "Save Character")) - { - if (isEditCharacterWindowOpen) - { - SaveEditedCharacter(); - } - else - { - // Pass Advanced Macro when Saving a New Character - string finalMacro = isAdvancedModeCharacter ? advancedCharacterMacroText : plugin.NewCharacterMacros; - plugin.SaveNewCharacter(finalMacro); - } - - isEditCharacterWindowOpen = false; - plugin.CloseAddCharacterWindow(); - isSecretMode = false; - } - - - - if (!canSaveCharacter) - ImGui.EndDisabled(); - - ImGui.SameLine(); - - if (ImGui.Button("Cancel")) - { - isEditCharacterWindowOpen = false; - plugin.CloseAddCharacterWindow(); - isSecretMode = false; - } - ImGui.PopStyleVar(2); - - } - - private void DrawCharacterGrid() - { - // Get spacing & column settings - float profileSpacing = plugin.ProfileSpacing; - int columnCount = plugin.ProfileColumns; - - // Adjust column count if Design Panel is open - if (isDesignPanelOpen) - { - columnCount = Math.Max(1, columnCount - 1); - } - - // Calculate dynamic column width - float columnWidth = (250 * plugin.ProfileImageScale) + profileSpacing; - float availableWidth = ImGui.GetContentRegionAvail().X; - - // Ensure column count fits within available space - columnCount = Math.Max(1, Math.Min(columnCount, (int)(availableWidth / columnWidth))); - - // Outer scrollable container (handles both horizontal & vertical scrolling) - ImGui.BeginChild("CharacterGridContainer", new Vector2(0, 0), false, - ImGuiWindowFlags.HorizontalScrollbar | ImGuiWindowFlags.AlwaysVerticalScrollbar); - - // Begin column layout - if (columnCount > 1) - { - ImGui.Columns(columnCount, "CharacterGrid", false); - } - - var visibleCharacters = plugin.Characters - .Where(c => selectedTag == "All" || (c.Tags?.Contains(selectedTag) ?? false)) - .ToList(); - - for (int i = 0; i < visibleCharacters.Count; i++) - { - var character = visibleCharacters[i]; - - // Apply Search Filter - if (!string.IsNullOrWhiteSpace(searchQuery) && - !character.Name.Contains(searchQuery, StringComparison.OrdinalIgnoreCase)) - continue; - - { - var filteredChar = visibleCharacters[i]; - - // Ensure column width is properly set - if (columnCount > 1) - { - int colIndex = i % columnCount; - if (colIndex >= 0 && colIndex < ImGui.GetColumnsCount()) - { - ImGui.SetColumnWidth(colIndex, columnWidth); - } - } - - // Image Scaling - float scale = plugin.ProfileImageScale; - float maxSize = Math.Clamp(250 * scale, 64, 512); // Prevents excessive scaling - float nameplateHeight = 30; - - float displayWidth, displayHeight; - - string pluginDirectory = plugin.PluginDirectory; - string defaultImagePath = Path.Combine(pluginDirectory, "Assets", "Default.png"); - - string finalImagePath = !string.IsNullOrEmpty(character.ImagePath) && File.Exists(character.ImagePath) - ? character.ImagePath - : (File.Exists(defaultImagePath) ? defaultImagePath : ""); - - if (!string.IsNullOrEmpty(finalImagePath) && File.Exists(finalImagePath)) - { - var texture = Plugin.TextureProvider.GetFromFile(finalImagePath).GetWrapOrDefault(); - - if (texture != null) - { - float originalWidth = texture.Width; - float originalHeight = texture.Height; - float aspectRatio = originalWidth / originalHeight; - - if (aspectRatio > 1) // Landscape - { - displayWidth = maxSize; - displayHeight = maxSize / aspectRatio; - } - else // Portrait or Square - { - displayHeight = maxSize; - displayWidth = maxSize * aspectRatio; - } - - float paddingX = (maxSize - displayWidth) / 2; - float paddingY = (maxSize - displayHeight) / 2; - ImGui.SetCursorPosX(ImGui.GetCursorPosX() + paddingX); - ImGui.SetCursorPosY(ImGui.GetCursorPosY() + paddingY); - - ImGui.Image(texture.ImGuiHandle, new Vector2(displayWidth, displayHeight)); - - // Left-click - normal macro execution - if (ImGui.IsItemClicked(ImGuiMouseButton.Left)) - { - if (activeDesignCharacterIndex != -1) - { - activeDesignCharacterIndex = -1; - isDesignPanelOpen = false; - } - plugin.ExecuteMacro(character.Macros, character, null); - plugin.SetActiveCharacter(character); - - var profileToSend = new RPProfile - { - Pronouns = character.RPProfile?.Pronouns, - Gender = character.RPProfile?.Gender, - Age = character.RPProfile?.Age, - Race = character.RPProfile?.Race, - Orientation = character.RPProfile?.Orientation, - Relationship = character.RPProfile?.Relationship, - Occupation = character.RPProfile?.Occupation, - Abilities = character.RPProfile?.Abilities, - Bio = character.RPProfile?.Bio, - Tags = character.RPProfile?.Tags, - CustomImagePath = !string.IsNullOrEmpty(character.RPProfile?.CustomImagePath) - ? character.RPProfile.CustomImagePath - : character.ImagePath, - ImageZoom = character.RPProfile?.ImageZoom ?? 1.0f, - ImageOffset = character.RPProfile?.ImageOffset ?? Vector2.Zero, - Sharing = character.RPProfile?.Sharing ?? ProfileSharing.AlwaysShare, - ProfileImageUrl = character.RPProfile?.ProfileImageUrl, - CharacterName = character.Name, - NameplateColor = character.NameplateColor - }; - - _ = Plugin.UploadProfileAsync(profileToSend, character.LastInGameName ?? character.Name); - } - - // Right-click - apply to - if (ImGui.BeginPopupContextItem($"##ContextMenu_{character.Name}")) - { - if (ImGui.Selectable("Apply to Target")) - { - // Always run the target macro – this replaces self with - string macro = Plugin.GenerateTargetMacro(character.Macros); - - if (!string.IsNullOrWhiteSpace(macro)) - plugin.ExecuteMacro(macro); - } - - - // Coloured line under "Apply to Target" - ImGui.Spacing(); - ImGui.PushStyleColor(ImGuiCol.ChildBg, new Vector4(character.NameplateColor, 1.0f)); - ImGui.BeginChild($"##Separator_{character.Name}", new Vector2(ImGui.GetContentRegionAvail().X, 3), false); - ImGui.EndChild(); - ImGui.PopStyleColor(); - ImGui.Spacing(); - - // Scrollable design list - if (character.Designs.Count > 0) - { - float itemHeight = ImGui.GetTextLineHeightWithSpacing(); - float maxVisible = 10; - float scrollHeight = Math.Min(character.Designs.Count, maxVisible) * itemHeight + 8; - - if (ImGui.BeginChild($"##DesignScroll_{character.Name}", new Vector2(300, scrollHeight))) - { - foreach (var design in character.Designs) - { - if (ImGui.Selectable($"Apply Design: {design.Name}")) - { - var macro = Plugin.GenerateTargetMacro( - design.IsAdvancedMode ? design.AdvancedMacro : design.Macro - ); - plugin.ExecuteMacro(macro); - } - } - ImGui.EndChild(); - } - } - - ImGui.EndPopup(); - } - - - } - } - - // Nameplate Rendering (Keeps consistent alignment) - DrawNameplate(character, maxSize, nameplateHeight); - - // Buttons Section (Proper Spacing) - float buttonWidth = maxSize / 3.1f; - float btnWidth = maxSize / 3.2f; - float btnHeight = 24; - float btnSpacing = 4; - - float btnStartX = ImGui.GetCursorPosX() + (maxSize - (3 * btnWidth + 2 * btnSpacing)) / 2; - ImGui.SetCursorPosX(btnStartX); - - // "Designs" Button - if (ImGui.Button($"Designs##{i}", new Vector2(btnWidth, btnHeight))) - { - if (activeDesignCharacterIndex == i && isDesignPanelOpen) - { - activeDesignCharacterIndex = -1; - isDesignPanelOpen = false; - } - else - { - activeDesignCharacterIndex = i; - isDesignPanelOpen = true; - } - } - - ImGui.SameLine(0, btnSpacing); - if (ImGui.Button($"Edit##{i}", new Vector2(btnWidth, btnHeight))) - { - OpenEditCharacterWindow(i); - isDesignPanelOpen = false; - } - - ImGui.SameLine(0, btnSpacing); - bool isCtrlShiftPressed = ImGui.GetIO().KeyCtrl && ImGui.GetIO().KeyShift; - if (ImGui.Button($"Delete##{i}", new Vector2(btnWidth, btnHeight))) - { - if (isCtrlShiftPressed) - { - plugin.Characters.RemoveAt(i); - plugin.Configuration.Save(); - } - } - - // Tooltip for Delete Button - if (ImGui.IsItemHovered()) - { - ImGui.BeginTooltip(); - ImGui.Text("Hold Ctrl + Shift and click to delete."); - ImGui.EndTooltip(); - } - } - ImGui.NextColumn(); // Move to next column - } - - if (columnCount > 1) - { - ImGui.Columns(1); - } - - ImGui.EndChild(); // Close Outer Scrollable Container - } - - - private void DrawNameplate(Character character, float width, float height) - { - var cursorPos = ImGui.GetCursorScreenPos(); - var drawList = ImGui.GetWindowDrawList(); - - // Nameplate Background - drawList.AddRectFilled( - new Vector2(cursorPos.X, cursorPos.Y), - new Vector2(cursorPos.X + width, cursorPos.Y + height), - ImGui.GetColorU32(new Vector4(0, 0, 0, 0.8f)) // Black background with slight transparency - ); - - // Nameplate Colour Strip - drawList.AddRectFilled( - new Vector2(cursorPos.X, cursorPos.Y + height - 4), - new Vector2(cursorPos.X + width, cursorPos.Y + height), - ImGui.GetColorU32(new Vector4(character.NameplateColor.X, character.NameplateColor.Y, character.NameplateColor.Z, 1.0f)) - ); - - // Character Name - var textSize = ImGui.CalcTextSize(character.Name); - var textPosX = cursorPos.X + (width - textSize.X) / 2; - var textPosY = cursorPos.Y + (height - textSize.Y) / 2; - - drawList.AddText(new Vector2(textPosX, textPosY), ImGui.GetColorU32(ImGuiCol.Text), character.Name); - - // Draw Favorite Star Symbol - string starSymbol = character.IsFavorite ? "★" : "☆"; - var starPos = new Vector2(cursorPos.X + 5, cursorPos.Y + 5); - var starSize = ImGui.CalcTextSize(starSymbol); - drawList.AddText(starPos, ImGui.GetColorU32(ImGuiCol.Text), starSymbol); - - // Hover + Click Region (no layout reservation) - var starEnd = new Vector2(starPos.X + starSize.X + 4, starPos.Y + starSize.Y + 2); - if (ImGui.IsMouseHoveringRect(starPos, starEnd)) - { - ImGui.SetTooltip($"{(character.IsFavorite ? "Remove" : "Add")} {character.Name} as a Favourite"); - - if (ImGui.IsMouseClicked(ImGuiMouseButton.Left)) - { - character.IsFavorite = !character.IsFavorite; - plugin.SaveConfiguration(); - SortCharacters(); - } - } - // RP Profile Button — ID Card Icon - ImGui.PushFont(UiBuilder.IconFont); - ImGui.SetWindowFontScale(0.85f); // Shrink to match star size - - string icon = "\uf2c2"; // ID Card - var iconSize = ImGui.CalcTextSize(icon); - var iconPos = new Vector2(cursorPos.X + width - iconSize.X - 12, cursorPos.Y + 6); - var iconColor = ImGui.GetColorU32(ImGuiCol.Text); - - // Draw the icon at reduced scale - drawList.AddText(iconPos, iconColor, icon); - - // Reset scale and font - ImGui.SetWindowFontScale(1.0f); - ImGui.PopFont(); - - // Clickable area - var iconHitMin = iconPos; - var iconHitMax = iconPos + iconSize + new Vector2(4, 4); - - if (ImGui.IsMouseHoveringRect(iconHitMin, iconHitMax)) - { - ImGui.SetTooltip($"View RolePlay Profile for {character.Name}"); - - if (ImGui.IsMouseClicked(ImGuiMouseButton.Left)) - { - plugin.OpenRPProfileViewWindow(character); - } - } - - ImGui.Dummy(new Vector2(width, height)); // Maintain proper positioning - } - - - // Place GenerateMacro() here: - private string GenerateMacro() - { - string penumbra = isEditCharacterWindowOpen ? editedCharacterPenumbra : plugin.NewPenumbraCollection; - string glamourer = isEditCharacterWindowOpen ? editedCharacterGlamourer : plugin.NewGlamourerDesign; - string customize = isEditCharacterWindowOpen ? editedCharacterCustomize : plugin.NewCustomizeProfile; - string honorificTitle = isEditCharacterWindowOpen ? editedCharacterHonorificTitle : plugin.NewCharacterHonorificTitle; - string honorificPrefix = isEditCharacterWindowOpen ? editedCharacterHonorificPrefix : plugin.NewCharacterHonorificPrefix; - string honorificSuffix = isEditCharacterWindowOpen ? editedCharacterHonorificSuffix : plugin.NewCharacterHonorificSuffix; - Vector3 honorificColor = isEditCharacterWindowOpen ? editedCharacterHonorificColor : plugin.NewCharacterHonorificColor; - Vector3 honorificGlow = isEditCharacterWindowOpen ? editedCharacterHonorificGlow : plugin.NewCharacterHonorificGlow; - - if (string.IsNullOrWhiteSpace(penumbra) || string.IsNullOrWhiteSpace(glamourer)) - return "/penumbra redraw self"; - - string macro = - $"/penumbra collection individual | {penumbra} | self\n" + - $"/glamour apply {glamourer} | self\n"; - - // Character Automation (if enabled) - string automation = isEditCharacterWindowOpen ? editedCharacterAutomation : plugin.NewCharacterAutomation; - - if (plugin.Configuration.EnableAutomations) - { - if (string.IsNullOrWhiteSpace(automation)) - macro += "/glamour automation enable None\n"; - else - macro += $"/glamour automation enable {automation}\n"; - } - - - macro += "/customize profile disable \n"; - - if (!string.IsNullOrWhiteSpace(customize)) - macro += $"/customize profile enable , {customize}\n"; - - // Ensure honorific is always cleared before setting a new one - macro += "/honorific force clear\n"; - - if (!string.IsNullOrWhiteSpace(honorificTitle)) - { - string colorHex = $"#{(int)(honorificColor.X * 255):X2}{(int)(honorificColor.Y * 255):X2}{(int)(honorificColor.Z * 255):X2}"; - string glowHex = $"#{(int)(honorificGlow.X * 255):X2}{(int)(honorificGlow.Y * 255):X2}{(int)(honorificGlow.Z * 255):X2}"; - macro += $"/honorific force set {honorificTitle} | {honorificPrefix} | {colorHex} | {glowHex}\n"; - } - - macro += "/moodle remove self preset all\n"; - - string moodlePreset = isEditCharacterWindowOpen ? editedCharacterMoodlePreset : tempMoodlePreset; - if (!string.IsNullOrWhiteSpace(moodlePreset)) - macro += $"/moodle apply self preset \"{moodlePreset}\"\n"; - - int idlePose = isEditCharacterWindowOpen ? plugin.Characters[selectedCharacterIndex].IdlePoseIndex : plugin.NewCharacterIdlePoseIndex; - if (idlePose != 7) - { - macro += $"/sidle {idlePose}\n"; - } - - macro += "/penumbra redraw self"; - - return macro; - } - // Secret‐mode macro (when Ctrl+Shift+“Add Character”) - private string GenerateSecretMacro() - { - // exactly the same inputs as GenerateMacro() - string penumbra = isEditCharacterWindowOpen ? editedCharacterPenumbra : plugin.NewPenumbraCollection; - string glamourer = isEditCharacterWindowOpen ? editedCharacterGlamourer : plugin.NewGlamourerDesign; - string customize = isEditCharacterWindowOpen ? editedCharacterCustomize : plugin.NewCustomizeProfile; - string honorTitle = isEditCharacterWindowOpen ? editedCharacterHonorificTitle : plugin.NewCharacterHonorificTitle; - string honorPref = isEditCharacterWindowOpen ? editedCharacterHonorificPrefix : plugin.NewCharacterHonorificPrefix; - string honorSuf = isEditCharacterWindowOpen ? editedCharacterHonorificSuffix : plugin.NewCharacterHonorificSuffix; - Vector3 honorColor = isEditCharacterWindowOpen ? editedCharacterHonorificColor : plugin.NewCharacterHonorificColor; - Vector3 honorGlow = isEditCharacterWindowOpen ? editedCharacterHonorificGlow : plugin.NewCharacterHonorificGlow; - string moodlePreset = isEditCharacterWindowOpen ? editedCharacterMoodlePreset : plugin.NewCharacterMoodlePreset; - int idlePose = isEditCharacterWindowOpen - ? plugin.Characters[selectedCharacterIndex].IdlePoseIndex - : plugin.NewCharacterIdlePoseIndex; - - var sb = new System.Text.StringBuilder(); - sb.AppendLine($"/penumbra collection individual | {penumbra} | self"); - sb.AppendLine($"/penumbra bulktag disable {penumbra} | gear"); - sb.AppendLine($"/penumbra bulktag disable {penumbra} | hair"); - sb.AppendLine($"/penumbra bulktag enable {penumbra} | {glamourer}"); - sb.AppendLine("/glamour apply no clothes | self"); - sb.AppendLine($"/glamour apply {glamourer} | self"); - sb.AppendLine("/customize profile disable "); - if (!string.IsNullOrWhiteSpace(customize)) - sb.AppendLine($"/customize profile enable , {customize}"); - sb.AppendLine("/honorific force clear"); - if (!string.IsNullOrWhiteSpace(honorTitle)) - { - var colorHex = $"#{(int)(honorColor.X * 255):X2}{(int)(honorColor.Y * 255):X2}{(int)(honorColor.Z * 255):X2}"; - var glowHex = $"#{(int)(honorGlow.X * 255):X2}{(int)(honorGlow.Y * 255):X2}{(int)(honorGlow.Z * 255):X2}"; - sb.AppendLine($"/honorific force set {honorTitle} | {honorPref} | {colorHex} | {glowHex}"); - } - sb.AppendLine("/moodle remove self preset all"); - if (!string.IsNullOrWhiteSpace(moodlePreset)) - sb.AppendLine($"/moodle apply self preset \"{moodlePreset}\""); - if (idlePose != 7) - sb.AppendLine($"/sidle {idlePose}"); - sb.Append("/penumbra redraw self"); - return sb.ToString(); - } - - - - // Add ExtractGlamourerDesignFromMacro - private string ExtractGlamourerDesignFromMacro(string macro)// Store old honorific before updating - - { - // Find the Glamourer line in the macro - string[] lines = macro.Split('\n'); - foreach (var line in lines) - { - if (line.StartsWith("/glamour apply ", StringComparison.OrdinalIgnoreCase)) - { - return line.Replace("/glamour apply ", "").Replace(" | self", "").Trim(); - } - } - return ""; // Return empty if nothing was found - } - private static string TruncateWithEllipsis(string text, float maxWidth) - { - while (ImGui.CalcTextSize(text + "...").X > maxWidth && text.Length > 0) - text = text[..^1]; - return text + "..."; - } - - - private void DrawDesignPanel() - { - bool anyRowHovered = false; - bool anyHeaderHovered = false; - float scale = plugin.Configuration.UIScaleMultiplier; - var style = ImGui.GetStyle(); - - ImGui.PushStyleVar(ImGuiStyleVar.FramePadding, style.FramePadding * scale); - ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, style.ItemSpacing * scale); - - if (activeDesignCharacterIndex < 0 || activeDesignCharacterIndex >= plugin.Characters.Count) - return; - - var character = plugin.Characters[activeDesignCharacterIndex]; - - // Close Add Design when switching characters - if (selectedCharacterIndex != activeDesignCharacterIndex) - { - isEditDesignWindowOpen = false; - isAdvancedModeWindowOpen = false; - editedDesignName = ""; - editedGlamourerDesign = ""; - editedAutomation = ""; - editedCustomizeProfile = ""; - editedDesignMacro = ""; - advancedDesignMacroText = ""; - selectedCharacterIndex = activeDesignCharacterIndex; // Update tracking - } - - // Header with Add Button - string name = $"Designs for {character.Name}"; - float maxTextWidth = ImGui.GetContentRegionAvail().X - 75f; // space for buttons + buffer - - var clippedName = ImGui.CalcTextSize(name).X > maxTextWidth - ? TruncateWithEllipsis(name, maxTextWidth) - : name; - - ImGui.TextUnformatted(clippedName); - if (ImGui.IsItemHovered()) - ImGui.SetTooltip(name); - - ImGui.SameLine(ImGui.GetContentRegionAvail().X - 60f); - ImGui.SameLine(); - - // Plus Button (Green) - ImGui.PushStyleColor(ImGuiCol.Text, ImGui.GetColorU32(new Vector4(0.27f, 1.07f, 0.27f, 1.0f))); - // read modifier keys once - var io = ImGui.GetIO(); - bool ctrlHeld = io.KeyCtrl; - bool shiftHeld = io.KeyShift; - - if (ImGui.Button("+##AddDesign")) - { - if (ctrlHeld && shiftHeld) - { - // ── SECRET DESIGN MODE ── - isSecretDesignMode = true; - AddNewDesign(); - // preload secret macro - editedDesignMacro = GenerateSecretDesignMacro(plugin.Characters[activeDesignCharacterIndex]); - if (isAdvancedModeDesign) - advancedDesignMacroText = editedDesignMacro; - } - else if (shiftHeld) - { - // ── IMPORT MODE (Shift only) ── - isSecretDesignMode = false; - isImportWindowOpen = true; - targetForDesignImport = plugin.Characters[activeDesignCharacterIndex]; - } - else - { - // ── NORMAL ADD ── - isSecretDesignMode = false; - AddNewDesign(); - // preload normal macro - editedDesignMacro = GenerateDesignMacro(plugin.Characters[activeDesignCharacterIndex]); - if (isAdvancedModeDesign) - advancedDesignMacroText = editedDesignMacro; - } - } - ImGui.PopStyleColor(); - - if (ImGui.IsItemHovered()) - { - ImGui.BeginTooltip(); - ImGui.Text("Click to add a new design\nHold Shift to import from another character"); - ImGui.EndTooltip(); - } - ImGui.SameLine(); - // Folder Button (Yellow) - // Folder icon button (matches + and x in size) - // ─── Inline “Add Folder” button & popup ─── - ImGui.PushFont(UiBuilder.IconFont); - if (ImGui.Button("\uf07b##AddFolder", new Vector2(20, 20))) - ImGui.OpenPopup("CreateFolderPopup"); - ImGui.PopFont(); - - // ONLY here, *before* you BeginChild the list, handle the popup: - if (ImGui.BeginPopup("CreateFolderPopup")) - { - ImGui.Text("New Folder Name:"); - ImGui.SetNextItemWidth(200 * plugin.Configuration.UIScaleMultiplier); - ImGui.InputText("##NewFolder", ref newFolderName, 100); - - ImGui.Separator(); - if (ImGui.Button("Create")) - { - var folder = new DesignFolder(newFolderName, Guid.NewGuid()) - { - ParentFolderId = null, - SortOrder = character.DesignFolders.Count - }; - character.DesignFolders.Add(folder); - plugin.SaveConfiguration(); - plugin.RefreshTreeItems(character); - newFolderName = ""; - ImGui.CloseCurrentPopup(); - } - ImGui.SameLine(); - if (ImGui.Button("Cancel")) - { - newFolderName = ""; - ImGui.CloseCurrentPopup(); - } - ImGui.EndPopup(); - } - - if (ImGui.IsItemHovered()) - { - ImGui.BeginTooltip(); - ImGui.Text("Add Folder"); - ImGui.EndTooltip(); - } - - // Close Button (Red) - ImGui.SameLine(); - ImGui.SetCursorPosX(ImGui.GetWindowContentRegionMax().X - 20); - ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(1.0f, 0.27f, 0.27f, 1.0f)); - if (ImGui.Button("x##CloseDesignPanel")) - { - activeDesignCharacterIndex = -1; - isDesignPanelOpen = false; - isEditDesignWindowOpen = false; - isAdvancedModeWindowOpen = false; // Close pop-up window too - } - ImGui.PopStyleColor(); - if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByPopup)) - { - ImGui.BeginTooltip(); - ImGui.Text("Close Design Panel."); - ImGui.EndTooltip(); - } - - ImGui.Separator(); - - // 1RENDER THE FORM **FIRST** BEFORE THE LIST - if (isEditDesignWindowOpen) - { - ImGui.BeginChild("EditDesignForm", new Vector2(0, 320), true, ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.AlwaysAutoResize); - - bool isNewDesign = string.IsNullOrEmpty(editedDesignName); - ImGui.Text(isNewDesign ? "Add Design" : "Edit Design"); - - float inputWidth = 200; - ImGui.Text("Design Name*"); - ImGui.SetCursorPosX(10); - ImGui.SetNextItemWidth(inputWidth); - ImGui.InputText("##DesignName", ref editedDesignName, 100); - - ImGui.Separator(); - - // Glamourer Design Label - ImGui.Text("Glamourer Design*"); - - // Tooltip Icon - ImGui.SameLine(); - ImGui.PushFont(UiBuilder.IconFont); - ImGui.Text("\uf05a"); // Info icon - ImGui.PopFont(); - - if (ImGui.IsItemHovered()) // Show tooltip on hover - { - ImGui.BeginTooltip(); - ImGui.PushTextWrapPos(300); - ImGui.TextUnformatted("Enter the name of the Glamourer design to apply to this character."); - ImGui.TextUnformatted("Must be entered EXACTLY as it is named in Glamourer!"); - ImGui.TextUnformatted("Note: You can add additional designs later."); - ImGui.PopTextWrapPos(); - ImGui.EndTooltip(); - } - - // Input Field - ImGui.SetCursorPosX(10); - ImGui.SetNextItemWidth(inputWidth); - var oldGlam = editedGlamourerDesign; - if (ImGui.InputText("##GlamourerDesign", ref editedGlamourerDesign, 100)) - { - // Always regenerate the preview from the *same* generator you're about to save with - if (!isAdvancedModeDesign) - { - editedDesignMacro = isSecretDesignMode - ? GenerateSecretDesignMacro(character) - : GenerateDesignMacro(character); - } - else - { - // 1) patch only the exact apply‐line for the old design name - advancedDesignMacroText = PatchMacroLine( - advancedDesignMacroText, - $"/glamour apply {oldGlam} |", - $"/glamour apply {editedGlamourerDesign} | self" - ); - - // 2) now patch the DESIGN bit in your existing bulktag‐enable line - advancedDesignMacroText = UpdateBulkTagEnableDesignInMacro( - advancedDesignMacroText, - character.PenumbraCollection, - editedGlamourerDesign - ); - } - } - - if (plugin.Configuration.EnableAutomations) - { - // Automation Label - ImGui.Text("Glamourer Automation"); - - ImGui.SameLine(); - ImGui.PushFont(UiBuilder.IconFont); - ImGui.Text("\uf05a"); // Info icon - ImGui.PopFont(); - - if (ImGui.IsItemHovered()) - { - ImGui.BeginTooltip(); - ImGui.PushTextWrapPos(300); - ImGui.TextUnformatted("Optional: Enter the name of a Glamourer automation to use with this design."); - ImGui.Separator(); - ImGui.TextUnformatted("⚠️ This must match the name of the automation EXACTLY."); - ImGui.TextUnformatted("Just like with Glamourer Designs, capitalization and spacing matter."); - ImGui.Separator(); - ImGui.TextUnformatted("If you don't want to use an automation here, create one in Glamourer called:"); - ImGui.TextUnformatted("None"); - ImGui.TextUnformatted("and leave it completely empty."); - ImGui.Separator(); - ImGui.TextUnformatted("Why? Character Select+ always includes an automation line."); - ImGui.TextUnformatted("This makes sure your macro is still valid even when no automation is intended."); - ImGui.PopTextWrapPos(); - ImGui.EndTooltip(); - } - - // Automation Input Field - ImGui.SetCursorPosX(10); - ImGui.SetNextItemWidth(inputWidth); - // ── Automation Input Field ── - if (!isAdvancedModeDesign) - { - // Normal Mode: full regenerate - editedDesignMacro = isSecretDesignMode - ? GenerateSecretDesignMacro(character) - : GenerateDesignMacro(character); - } - else - { - // Advanced Mode: patch only the automation line in place - var line = string.IsNullOrWhiteSpace(editedAutomation) - ? "/glamour automation enable None" - : $"/glamour automation enable {editedAutomation}"; - advancedDesignMacroText = PatchMacroLine( - advancedDesignMacroText, - "/glamour automation enable", - line - ); - } - } - // 🔹 Customize+ Label - ImGui.Text("Customize+ Profile"); - - ImGui.SameLine(); - ImGui.PushFont(UiBuilder.IconFont); - ImGui.Text("\uf05a"); // Info icon - ImGui.PopFont(); - - if (ImGui.IsItemHovered()) - { - ImGui.BeginTooltip(); - ImGui.PushTextWrapPos(300); - ImGui.TextUnformatted("Optional: Enter the name of a Customize+ profile to apply with this design."); - ImGui.Separator(); - ImGui.TextUnformatted("If left blank:"); - ImGui.TextUnformatted("• Uses the Customize+ profile set for the character (if any)."); - ImGui.TextUnformatted("• Otherwise disables all Customize+ profiles."); - ImGui.PopTextWrapPos(); - ImGui.EndTooltip(); - } - - // Input Field - ImGui.SetCursorPosX(10); - ImGui.SetNextItemWidth(200); - // ── Customize+ Profile Input Field ── - if (ImGui.InputText("##CustomizePlus", ref editedCustomizeProfile, 100)) - { - if (!isAdvancedModeDesign) - { - // Normal Mode: full regenerate - editedDesignMacro = isSecretDesignMode - ? GenerateSecretDesignMacro(character) - : GenerateDesignMacro(character); - } - else - { - // Advanced Mode: patch only the two customize lines - - // 1) ensure the disable line is correct - advancedDesignMacroText = PatchMacroLine( - advancedDesignMacroText, - "/customize profile disable", - "/customize profile disable " - ); - - // 2) patch or remove the enable line - if (!string.IsNullOrWhiteSpace(editedCustomizeProfile)) - { - advancedDesignMacroText = PatchMacroLine( - advancedDesignMacroText, - "/customize profile enable", - $"/customize profile enable , {editedCustomizeProfile}" - ); - } - else - { - // remove any existing enable line - advancedDesignMacroText = string.Join("\n", - advancedDesignMacroText - .Split('\n') - .Where(l => !l.TrimStart().StartsWith("/customize profile enable")) - ); - } - } - } - - ImGui.Separator(); - // Identify whether we're adding or editing - bool isAddMode = string.IsNullOrWhiteSpace(editedDesignName); - - // Add spacing - ImGui.Dummy(new Vector2(0, 3)); - - // Automation Reminder - if (isAddMode && plugin.Configuration.EnableAutomations) - { - ImGui.SameLine(); - ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(1f, 0.85f, 0.3f, 1f)); - ImGui.PushFont(UiBuilder.IconFont); - - ImGui.PushID("AddAutomationTip"); - ImGui.TextUnformatted("\uf071"); - ImGui.PopID(); - - ImGui.PopFont(); - ImGui.PopStyleColor(); - - if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByPopup)) - { - ImGui.BeginTooltip(); - ImGui.PushTextWrapPos(300); - ImGui.TextUnformatted("Don't forget to create an Automation in Glamourer named \"None\" and enter your in-game character name next to \"Any World\" and Set to Character."); - ImGui.PopTextWrapPos(); - ImGui.EndTooltip(); - } - } - - if (ImGui.Button(isAdvancedModeDesign ? "Exit Advanced Mode" : "Advanced Mode")) - { - isAdvancedModeDesign = !isAdvancedModeDesign; - isAdvancedModeWindowOpen = isAdvancedModeDesign; - - // Always update macro preview with latest edits when toggling ON - if (isAdvancedModeDesign) - { - advancedDesignMacroText = !string.IsNullOrWhiteSpace(editedDesignMacro) - ? editedDesignMacro - : GenerateDesignMacro(plugin.Characters[activeDesignCharacterIndex]); - } - } - - // Tooltip Icon - ImGui.SameLine(); - ImGui.PushFont(UiBuilder.IconFont); - ImGui.Text("\uf05a"); - ImGui.PopFont(); - if (ImGui.IsItemHovered()) - { - ImGui.BeginTooltip(); - ImGui.PushTextWrapPos(300); - ImGui.TextUnformatted("⚠️ Do not touch this unless you know what you're doing."); - ImGui.PopTextWrapPos(); - ImGui.EndTooltip(); - } - - - - ImGui.Separator(); - - // Align Buttons Properly - float buttonWidth = 85; - float buttonHeight = 20; - float buttonSpacing = 8; - float totalButtonWidth = (buttonWidth * 2 + buttonSpacing); - float availableWidth = ImGui.GetContentRegionAvail().X; - float buttonPosX = (availableWidth > totalButtonWidth) - ? (availableWidth - totalButtonWidth) / 2 - : 0; // fallback: align left if not enough space - - ImGui.SetCursorPosX(buttonPosX); - - if (!isAdvancedModeDesign && !isSecretDesignMode) - editedDesignMacro = GenerateDesignMacro(character); - bool canSave = !string.IsNullOrWhiteSpace(editedDesignName) && !string.IsNullOrWhiteSpace(editedGlamourerDesign); - - if (!canSave) - ImGui.BeginDisabled(); - - if (ImGui.Button("Save Design", new Vector2(buttonWidth, buttonHeight))) - { - SaveDesign(plugin.Characters[activeDesignCharacterIndex]); - isSecretDesignMode = false; - isEditDesignWindowOpen = false; - isAdvancedModeWindowOpen = false; // Close pop-up after saving - } - - if (!canSave) - ImGui.EndDisabled(); - - ImGui.SameLine(); - - if (ImGui.Button("Cancel", new Vector2(buttonWidth, buttonHeight))) - { - isSecretDesignMode = false; - isEditDesignWindowOpen = false; - isAdvancedModeWindowOpen = false; - } - - ImGui.EndChild(); // END FORM - } - - ImGui.Separator(); // Visually separate the list - ImGui.Text("Sort Designs By:"); - ImGui.SameLine(); - - if (ImGui.BeginCombo("##DesignSortDropdown", currentDesignSort.ToString())) - { - if (ImGui.Selectable("Favorites", currentDesignSort == DesignSortType.Favorites)) - { - currentDesignSort = DesignSortType.Favorites; - SortDesigns(plugin.Characters[activeDesignCharacterIndex]); - } - if (ImGui.Selectable("Alphabetical", currentDesignSort == DesignSortType.Alphabetical)) - { - currentDesignSort = DesignSortType.Alphabetical; - SortDesigns(plugin.Characters[activeDesignCharacterIndex]); - } - if (ImGui.Selectable("Newest", currentDesignSort == DesignSortType.Recent)) - { - currentDesignSort = DesignSortType.Recent; - SortDesigns(plugin.Characters[activeDesignCharacterIndex]); - } - if (ImGui.Selectable("Oldest", currentDesignSort == DesignSortType.Oldest)) - { - currentDesignSort = DesignSortType.Oldest; - SortDesigns(plugin.Characters[activeDesignCharacterIndex]); - } - if (ImGui.Selectable("Manual", currentDesignSort == DesignSortType.Manual)) - { - currentDesignSort = DesignSortType.Manual; - } - ImGui.EndCombo(); - } - - - ImGui.Separator(); - - - // RENDER THE DESIGN LIST - ImGui.BeginChild( - "DesignListBackground", - new Vector2(0, ImGui.GetContentRegionAvail().Y), - true, - ImGuiWindowFlags.NoScrollbar - ); - - // 1) Build unified top-level list (only roots) - var renderItems = new List<(string name, bool isFolder, object item, DateTime dateAdded, int manual)>(); - - // 1a) Root folders (no parent) - foreach (var f in character.DesignFolders - .Where(f => f.ParentFolderId == null)) - { - renderItems.Add(( - f.Name, - true, - f as object, - DateTime.MinValue, - f.SortOrder - )); - } - - // 1b) Root designs (no folder) - foreach (var d in character.Designs - .Where(d => d.FolderId == null)) - { - renderItems.Add(( - d.Name, - false, - d as object, - d.DateAdded, - d.SortOrder - )); - } - - // 2) Sort according to currentDesignSort - switch (currentDesignSort) - { - case DesignSortType.Favorites: - renderItems = renderItems - .OrderByDescending(x => x.isFolder ? false : ((CharacterDesign)x.item).IsFavorite) - .ThenBy(x => x.name, StringComparer.OrdinalIgnoreCase) - .ToList(); - break; - case DesignSortType.Alphabetical: - renderItems = renderItems - .OrderBy(x => x.name, StringComparer.OrdinalIgnoreCase) - .ToList(); - break; - case DesignSortType.Recent: - renderItems = renderItems - .OrderByDescending(x => x.dateAdded) - .ToList(); - break; - case DesignSortType.Oldest: - renderItems = renderItems - .OrderBy(x => x.dateAdded) - .ToList(); - break; - case DesignSortType.Manual: - renderItems = renderItems - .OrderBy(x => x.manual) - .ToList(); - break; - } - - // 3) Render each entry - foreach (var entry in renderItems) - { - if (entry.isFolder) - { - var folder = (DesignFolder)entry.item; - - // 3a) Inline‐rename? - bool isThisRenaming = isRenamingFolder && folder.Id == renameFolderId; - bool open = false; - - if (isThisRenaming) - { - ImGui.PushStyleColor(ImGuiCol.FrameBg, new Vector4(0.2f, 0.2f, 0.2f, 1f)); - ImGui.SetNextItemWidth(200); - if (ImGui.InputText("##InlineRename", - ref renameFolderBuf, - 128, - ImGuiInputTextFlags.EnterReturnsTrue)) - { - folder.Name = renameFolderBuf; - isRenamingFolder = false; - plugin.SaveConfiguration(); - plugin.RefreshTreeItems(character); - } - ImGui.PopStyleColor(); - } - else - { - // 3b) Single CollapsingHeader for the folder - open = ImGui.CollapsingHeader( - $"{folder.Name}##F{folder.Id}", - ImGuiTreeNodeFlags.SpanFullWidth - ); - - // Drag‐source so you can pick up folders - if (ImGui.BeginDragDropSource(ImGuiDragDropFlags.SourceAllowNullID)) - { - draggedFolder = folder; - ImGui.SetDragDropPayload("FOLDER_MOVE", IntPtr.Zero, 0); - ImGui.TextUnformatted($"Moving Folder: {folder.Name}"); - ImGui.EndDragDropSource(); - } - - // Right‐click context menu - if (ImGui.IsItemHovered() && ImGui.IsMouseClicked(ImGuiMouseButton.Right)) - ImGui.OpenPopup($"FolderCtx{folder.Id}"); - if (ImGui.BeginPopup($"FolderCtx{folder.Id}")) - { - if (ImGui.MenuItem("Rename Folder")) - { - renameFolderId = folder.Id; - renameFolderBuf = folder.Name; - isRenamingFolder = true; - ImGui.CloseCurrentPopup(); - } - if (ImGui.MenuItem("Delete Folder")) - { - // 1) Un-folder any direct designs in THIS folder - foreach (var d in character.Designs.Where(d => d.FolderId == folder.Id)) - d.FolderId = null; - - // 2) Reparent any *subfolders* up to root - foreach (var sub in character.DesignFolders.Where(f => f.ParentFolderId == folder.Id)) - sub.ParentFolderId = null; - - // 3) Finally, remove THIS folder itself - character.DesignFolders.RemoveAll(f2 => f2.Id == folder.Id); - - plugin.SaveConfiguration(); - plugin.RefreshTreeItems(character); - ImGui.CloseCurrentPopup(); - } - ImGui.EndPopup(); - } - } - - // 3c) Hover + drop‐to‐folder logic (shared for designs & folders) - var hdrMin = ImGui.GetItemRectMin(); - var hdrMax = ImGui.GetItemRectMax(); - bool overHeader = ImGui.IsMouseHoveringRect(hdrMin, hdrMax, true); - if ((draggedDesign != null || draggedFolder != null) && overHeader) - { - var dl = ImGui.GetWindowDrawList(); - uint col = ImGui.GetColorU32(new Vector4(0.3f, 0.5f, 1f, 1f)); - dl.AddRect(hdrMin, hdrMax, col, 0, ImDrawFlags.None, 2); - } - if (overHeader) anyHeaderHovered = true; - - // Drop designs into folder - if (draggedDesign != null && overHeader && ImGui.IsMouseReleased(ImGuiMouseButton.Left)) - { - draggedDesign.FolderId = folder.Id; - plugin.SaveConfiguration(); - plugin.RefreshTreeItems(character); - draggedDesign = null; - } - - // Drop folders into folder (nesting) - if (draggedFolder != null - && overHeader - && ImGui.IsMouseReleased(ImGuiMouseButton.Left) - && draggedFolder != folder) - { - draggedFolder.ParentFolderId = folder.Id; - plugin.SaveConfiguration(); - plugin.RefreshTreeItems(character); - draggedFolder = null; - } - - // 3d) If expanded, draw nested folders & designs - if (open) - { - // ── child folders ── - foreach (var child in character.DesignFolders - .Where(f2 => f2.ParentFolderId == folder.Id) - .OrderBy(f2 => f2.SortOrder)) - { - ImGui.Indent(20); - - // Inline-rename check for this child - bool isChildRenaming = isRenamingFolder && child.Id == renameFolderId; - bool childOpen = false; - - if (isChildRenaming) - { - ImGui.PushStyleColor(ImGuiCol.FrameBg, new Vector4(0.2f, 0.2f, 0.2f, 1f)); - ImGui.SetNextItemWidth(200); - if (ImGui.InputText("##InlineRenameChild", - ref renameFolderBuf, - 128, - ImGuiInputTextFlags.EnterReturnsTrue)) - { - child.Name = renameFolderBuf; - isRenamingFolder = false; - plugin.SaveConfiguration(); - plugin.RefreshTreeItems(character); - } - ImGui.PopStyleColor(); - } - else - { - // Child header - childOpen = ImGui.CollapsingHeader( - $"{child.Name}##F{child.Id}", - ImGuiTreeNodeFlags.SpanFullWidth - ); - var childhdrMin = ImGui.GetItemRectMin(); - var childhdrMax = ImGui.GetItemRectMax(); - bool overChildHeader = ImGui.IsMouseHoveringRect(childhdrMin, childhdrMax, true); - if (draggedDesign != null && overChildHeader && ImGui.IsMouseReleased(ImGuiMouseButton.Left)) - { - draggedDesign.FolderId = child.Id; - plugin.SaveConfiguration(); - plugin.RefreshTreeItems(character); - draggedDesign = null; - } - - // Drag source for folders - if (ImGui.BeginDragDropSource(ImGuiDragDropFlags.SourceAllowNullID)) - { - draggedFolder = child; - ImGui.SetDragDropPayload("FOLDER_MOVE", IntPtr.Zero, 0); - ImGui.TextUnformatted($"Moving Folder: {child.Name}"); - ImGui.EndDragDropSource(); - } - - // Right-click context menu - if (ImGui.IsItemHovered() && ImGui.IsMouseClicked(ImGuiMouseButton.Right)) - ImGui.OpenPopup($"FolderCtx{child.Id}"); - if (ImGui.BeginPopup($"FolderCtx{child.Id}")) - { - if (ImGui.MenuItem("Rename Folder")) - { - renameFolderId = child.Id; - renameFolderBuf = child.Name; - isRenamingFolder = true; - ImGui.CloseCurrentPopup(); - } - if (ImGui.MenuItem("Delete Folder")) - { - // 1) Un-folder any direct designs in THIS folder - foreach (var d in character.Designs.Where(d => d.FolderId == folder.Id)) - d.FolderId = null; - - // 2) Reparent any *subfolders* up to root - foreach (var sub in character.DesignFolders.Where(f => f.ParentFolderId == folder.Id)) - sub.ParentFolderId = null; - - // 3) Finally, remove THIS folder itself - character.DesignFolders.RemoveAll(f2 => f2.Id == folder.Id); - - plugin.SaveConfiguration(); - plugin.RefreshTreeItems(character); - ImGui.CloseCurrentPopup(); - } - ImGui.EndPopup(); - } - } - - // Draw this child’s designs if expanded - if (childOpen) - { - foreach (var d2 in character.Designs - .Where(d2 => d2.FolderId == child.Id) - .OrderBy(d2 => d2.SortOrder)) - { - DrawDesignRow(character, d2, true); - if (ImGui.IsItemHovered()) anyRowHovered = true; - } - } - - ImGui.Unindent(); - } - - // ── this folder’s own designs ── - foreach (var d in character.Designs - .Where(d => d.FolderId == folder.Id) - .OrderBy(d => d.SortOrder)) - { - DrawDesignRow(character, d, true); - if (ImGui.IsItemHovered()) anyRowHovered = true; - } - } - } - else - { - // 3e) Standalone design - var design = (CharacterDesign)entry.item; - DrawDesignRow(character, design, false); - if (ImGui.IsItemHovered()) anyRowHovered = true; - } - } - - // 4) Drop outside any header → un‐folder - if (draggedDesign != null - && ImGui.IsMouseReleased(ImGuiMouseButton.Left) - && !anyHeaderHovered - && !anyRowHovered) - { - draggedDesign.FolderId = null; - plugin.SaveConfiguration(); - plugin.RefreshTreeItems(character); - draggedDesign = null; - } - // Drop to root (outside any header) - if (draggedFolder != null - && ImGui.IsMouseReleased(ImGuiMouseButton.Left) - && !anyHeaderHovered - && !anyRowHovered) - { - draggedFolder.ParentFolderId = null; - plugin.SaveConfiguration(); - plugin.RefreshTreeItems(character); - draggedFolder = null; - } - - ImGui.EndChild(); - - - // RENDER THE ADVANCED MODE POP-UP WINDOW - if (isAdvancedModeWindowOpen) - { - ImGui.SetNextWindowSize(new Vector2(500, 200), ImGuiCond.FirstUseEver); - if (ImGui.Begin("Advanced Macro Editor", ref isAdvancedModeWindowOpen, ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoResize)) - { - ImGui.Text("Edit Design Macro Manually:"); - ImGui.InputTextMultiline("##AdvancedDesignMacroPopup", ref advancedDesignMacroText, 2000, new Vector2(-1, -1), ImGuiInputTextFlags.AllowTabInput); - } - ImGui.End(); - if (!isAdvancedModeWindowOpen) - isAdvancedModeDesign = false; - } - ImGui.PopStyleVar(2); - } - private void DrawDesignRow(Character character, CharacterDesign design, bool isInsideFolder) - { - var style = ImGui.GetStyle(); - var io = ImGui.GetIO(); - - // 1) Optional indent for folders - if (isInsideFolder) - { - // 1a) Read ImGui's standard indent spacing - float indentAmt = ImGui.GetStyle().IndentSpacing; - // 1b) Actually apply it - ImGui.Indent(indentAmt); - } - - ImGui.PushID(design.Name); - - // 2) Reserve the row - var rowMin = ImGui.GetCursorScreenPos(); - float rowW = ImGui.GetContentRegionAvail().X; - float rowH = ImGui.GetTextLineHeightWithSpacing() + style.FramePadding.Y * 2; - ImGui.Dummy(new Vector2(rowW, rowH)); - var rowMax = rowMin + new Vector2(rowW, rowH); - - // 3) Detect hover (we’ll need it for handles, stars, name, buttons…) - bool hovered = ImGui.IsMouseHoveringRect(rowMin, rowMax, true); - - // 4) Common metrics - float pad = style.FramePadding.X; - float spacing = style.ItemSpacing.X; - float btnW = rowH; - var btnSz = new Vector2(btnW, btnW); - - // handle is 65% tall - float hs = btnW * 0.65f; - var handleSz = new Vector2(hs, hs); - - // running X - float x = rowMin.X + pad; - - // ── HANDLE ── - if (hovered) - { - // compute a 65%-height, half-as-wide bar - float barHeight = rowH * 0.65f; - float barWidth = barHeight * 0.5f; - var barSize = new Vector2(barWidth, barHeight); - // vertically center it - float yOff = (rowH - barHeight) * 0.5f; - - ImGui.SetCursorScreenPos(new Vector2(x + pad, rowMin.Y + yOff)); - - // use the character’s nameplate colour instead of a fixed blue - var handleColor = new Vector4( - character.NameplateColor.X, - character.NameplateColor.Y, - character.NameplateColor.Z, - 1.0f - ); - - ImGui.PushStyleColor(ImGuiCol.Button, handleColor); - ImGui.PushStyleColor(ImGuiCol.ButtonHovered, handleColor); - ImGui.PushStyleColor(ImGuiCol.ButtonActive, handleColor); - - ImGui.Button($"##handle_{design.Name}", barSize); - ImGui.PopStyleColor(3); - - // only start drag if they click+drag that thin bar - if (ImGui.IsItemActive() - && ImGui.IsMouseDragging(ImGuiMouseButton.Left, 4f) - && ImGui.BeginDragDropSource(ImGuiDragDropFlags.SourceAllowNullID)) - { - draggedDesign = design; - ImGui.SetDragDropPayload("DESIGN_MOVE", IntPtr.Zero, 0); - ImGui.TextUnformatted($"Moving: {design.Name}"); - ImGui.EndDragDropSource(); - } - - // advance X by the _actual_ bar width - x += barWidth + spacing; - } - - // ── STAR ── - if (hovered) - { - ImGui.SetCursorScreenPos(new Vector2(x, rowMin.Y)); - string star = design.IsFavorite ? "★" : "☆"; - if (ImGui.Button(star, btnSz)) - { - design.IsFavorite = !design.IsFavorite; - plugin.SaveConfiguration(); - SortDesigns(character); - } - if (ImGui.IsItemHovered()) - ImGui.SetTooltip(design.IsFavorite ? "Unfavorite" : "Mark as Favorite"); - x += btnW + spacing; - } - - // ── NAME ── - // reserve 3 icons + spacing + padding on the right - float rightZone = 3 * btnW + 2 * spacing + pad; - float availW = rowW - (x - rowMin.X) - rightZone; - ImGui.SetCursorScreenPos(new Vector2(x, rowMin.Y + style.FramePadding.Y)); - var name = design.Name; - if (ImGui.CalcTextSize(name).X > availW) - name = TruncateWithEllipsis(name, availW); - ImGui.TextUnformatted(name); - - // ── MANUAL DROP TARGET ── - // if we’re mid-drag, they just let go, and the mouse is over this row, - // reorder it here without using BeginDragDropTarget: - if (draggedDesign != null - && ImGui.IsMouseReleased(ImGuiMouseButton.Left) - && ImGui.IsMouseHoveringRect(rowMin, rowMax, true) - && draggedDesign != design) - { - var list = character.Designs; - list.Remove(draggedDesign); - int idx = list.IndexOf(design); - draggedDesign.FolderId = design.FolderId; - list.Insert(idx, draggedDesign); - draggedDesign = null; - plugin.SaveConfiguration(); - plugin.RefreshTreeItems(character); - } - - // ── BLUE OUTLINE WHILE DRAGGING OVER ── - if (draggedDesign != null && hovered) - { - var dl = ImGui.GetWindowDrawList(); - uint col = ImGui.GetColorU32(new Vector4(0.27f, 0.53f, 0.90f, 1f)); - dl.AddRect(rowMin, rowMax, col, 0, ImDrawFlags.None, 2); - } - - // ── RIGHT-SIDE ICONS ── - if (hovered) - { - float bx = rowMin.X + rowW - pad - btnW; - ImGui.SetCursorScreenPos(new Vector2(bx - 2 * (btnW + spacing), rowMin.Y)); - - ImGui.PushFont(UiBuilder.IconFont); - if (ImGui.Button("\uf00c", btnSz)) - plugin.ExecuteMacro(design.Macro, character, design.Name); - ImGui.PopFont(); - if (ImGui.IsItemHovered()) ImGui.SetTooltip("Apply Design"); - - ImGui.SameLine(0, spacing); - ImGui.PushFont(UiBuilder.IconFont); - if (ImGui.Button("\uf044", btnSz)) - OpenEditDesignWindow(character, design); - ImGui.PopFont(); - if (ImGui.IsItemHovered()) ImGui.SetTooltip("Edit Design"); - - ImGui.SameLine(0, spacing); - ImGui.PushFont(UiBuilder.IconFont); - if (ImGui.Button("\uf2ed", btnSz) && io.KeyCtrl && io.KeyShift) - { - character.Designs.Remove(design); - plugin.SaveConfiguration(); - } - ImGui.PopFont(); - if (ImGui.IsItemHovered()) ImGui.SetTooltip("Hold Ctrl+Shift to delete"); - } - - // ── CLEANUP ── - ImGui.PopID(); - if (isInsideFolder) ImGui.Unindent(); - ImGui.SetCursorScreenPos(new Vector2(rowMin.X, rowMin.Y + rowH)); - ImGui.Separator(); - } - - - - private void AddNewDesign() - { - isNewDesign = true; // Track that it is a new design - isEditDesignWindowOpen = true; // Ensure the edit design form is opened properly - editedDesignName = ""; // Reset for new design - editedGlamourerDesign = ""; // Reset for new design - editedDesignMacro = ""; // Clear macro for new design - isAdvancedModeDesign = false; // Ensure Advanced Mode starts OFF - editedAutomation = ""; // Reset for new automation - editedCustomizeProfile = ""; // Reset for new Customize+ Profile - } - - private void OpenEditDesignWindow(Character character, CharacterDesign design) - { - isNewDesign = false; - isEditDesignWindowOpen = true; - originalDesignName = design.Name; - editedDesignName = design.Name; - editedDesignMacro = design.IsAdvancedMode ? design.AdvancedMacro ?? "" : design.Macro ?? ""; - editedGlamourerDesign = !string.IsNullOrWhiteSpace(design.GlamourerDesign) - ? design.GlamourerDesign - : ExtractGlamourerDesignFromMacro(design.Macro ?? ""); - - editedAutomation = design.Automation ?? ""; - editedCustomizeProfile = design.CustomizePlusProfile ?? ""; - isAdvancedModeDesign = design.IsAdvancedMode; - isAdvancedModeWindowOpen = design.IsAdvancedMode; - advancedDesignMacroText = design.AdvancedMacro ?? ""; - } - - private void SaveDesign(Character character) - { - if (string.IsNullOrWhiteSpace(editedDesignName) || string.IsNullOrWhiteSpace(editedGlamourerDesign)) - return; - - // If we're editing, try to find the existing design by name - var existingDesign = !isNewDesign - ? character.Designs.FirstOrDefault(d => d.Name == originalDesignName) - : null; - - if (existingDesign != null) - { - // Update existing design - existingDesign.Name = editedDesignName; - - bool wasPreviouslyAdvanced = existingDesign.IsAdvancedMode; - bool keepAdvanced = wasPreviouslyAdvanced && !isAdvancedModeDesign; - - existingDesign.Macro = keepAdvanced - ? existingDesign.AdvancedMacro - : (isAdvancedModeDesign ? advancedDesignMacroText : GenerateDesignMacro(plugin.Characters[activeDesignCharacterIndex])); - - existingDesign.AdvancedMacro = isAdvancedModeDesign || keepAdvanced - ? advancedDesignMacroText - : ""; - - existingDesign.IsAdvancedMode = isAdvancedModeDesign || keepAdvanced; - existingDesign.Automation = editedAutomation; - existingDesign.GlamourerDesign = editedGlamourerDesign; - existingDesign.CustomizePlusProfile = editedCustomizeProfile; - } - else - { - // If no match, add new design - character.Designs.Add(new CharacterDesign( - editedDesignName, - isAdvancedModeDesign ? advancedDesignMacroText : GenerateDesignMacro(plugin.Characters[activeDesignCharacterIndex]), - isAdvancedModeDesign, - isAdvancedModeDesign ? advancedDesignMacroText : "", - editedGlamourerDesign, - editedAutomation, - editedCustomizeProfile - ) - { - DateAdded = DateTime.UtcNow - }); - } - - plugin.SaveConfiguration(); - isEditDesignWindowOpen = false; - isAdvancedModeWindowOpen = false; - isNewDesign = false; - } - - - - private string GenerateDesignMacro(Character character) - { - if (string.IsNullOrWhiteSpace(editedGlamourerDesign)) - return ""; - - string macro = $"/glamour apply {editedGlamourerDesign} | self"; - - // Conditionally include automation line - if (plugin.Configuration.EnableAutomations) - { - string automationToUse = - !string.IsNullOrWhiteSpace(editedAutomation) - ? editedAutomation - : (!string.IsNullOrWhiteSpace(character.CharacterAutomation) - ? character.CharacterAutomation - : "None"); - - macro += $"\n/glamour automation enable {automationToUse}"; - } - - // Always disable Customize+ first - macro += "\n/customize profile disable "; - - // Determine Customize+ profile - string customizeProfileToUse = !string.IsNullOrWhiteSpace(editedCustomizeProfile) - ? editedCustomizeProfile - : !string.IsNullOrWhiteSpace(character.CustomizeProfile) - ? character.CustomizeProfile - : string.Empty; - - // Enable only if needed - if (!string.IsNullOrWhiteSpace(customizeProfileToUse)) - macro += $"\n/customize profile enable , {customizeProfileToUse}"; - - // Redraw line - macro += "\n/penumbra redraw self"; - - return macro; - } - private string GenerateSecretDesignMacro(Character character) - { - // 1) Which Penumbra collection to target (taken from the character) - var collection = character.PenumbraCollection; - - // 2) What the form is currently set to - var design = editedGlamourerDesign; - var custom = !string.IsNullOrWhiteSpace(editedCustomizeProfile) - ? editedCustomizeProfile - : character.CustomizeProfile; - - var sb = new System.Text.StringBuilder(); - - // 3) Bulk-tag lines - sb.AppendLine($"/penumbra bulktag disable {collection} | gear"); - sb.AppendLine($"/penumbra bulktag disable {collection} | hair"); - sb.AppendLine($"/penumbra bulktag enable {collection} | {design}"); - - // 4) Glamourer “no clothes” + design - sb.AppendLine("/glamour apply no clothes | self"); - sb.AppendLine($"/glamour apply {design} | self"); - - // 5) Customize+ - sb.AppendLine("/customize profile disable "); - if (!string.IsNullOrWhiteSpace(custom)) - sb.AppendLine($"/customize profile enable , {custom}"); - - // 6) Final redraw - sb.Append("/penumbra redraw self"); - - return sb.ToString(); - } - - - private void OpenEditCharacterWindow(int index) - { - if (index < 0 || index >= plugin.Characters.Count) - return; - - selectedCharacterIndex = index; - var character = plugin.Characters[index]; - - string pluginDirectory = plugin.PluginDirectory; - string defaultImagePath = Path.Combine(pluginDirectory, "Assets", "Default.png"); - - editedCharacterName = character.Name; - editedCharacterPenumbra = character.PenumbraCollection; - editedCharacterGlamourer = character.GlamourerDesign; - editedCharacterCustomize = character.CustomizeProfile ?? ""; - editedCharacterColor = character.NameplateColor; - editedCharacterMacros = character.Macros; - editedCharacterImagePath = !string.IsNullOrEmpty(character.ImagePath) ? character.ImagePath : defaultImagePath; - editedCharacterTag = character.Tags != null && character.Tags.Count > 0 - ? string.Join(", ", character.Tags) - : ""; - - - // Load Honorific Fields Properly - editedCharacterHonorificTitle = character.HonorificTitle ?? ""; - editedCharacterHonorificPrefix = character.HonorificPrefix ?? "Prefix"; - editedCharacterHonorificSuffix = character.HonorificSuffix ?? "Suffix"; - editedCharacterHonorificColor = character.HonorificColor; - editedCharacterHonorificGlow = character.HonorificGlow; - editedCharacterMoodlePreset = character.MoodlePreset ?? ""; - - // Check if MoodlePreset exists in older profiles - string safeAutomation = character.CharacterAutomation == "None" ? "" : character.CharacterAutomation ?? ""; - - editedCharacterAutomation = safeAutomation; - - character.IdlePoseIndex = plugin.Characters[selectedCharacterIndex].IdlePoseIndex; - - - tempHonorificTitle = editedCharacterHonorificTitle; - tempHonorificPrefix = editedCharacterHonorificPrefix; - tempHonorificSuffix = editedCharacterHonorificSuffix; - tempHonorificColor = editedCharacterHonorificColor; - tempHonorificGlow = editedCharacterHonorificGlow; - tempMoodlePreset = editedCharacterMoodlePreset; - tempCharacterAutomation = safeAutomation; - - - if (isAdvancedModeCharacter) - { - advancedCharacterMacroText = !string.IsNullOrWhiteSpace(character.Macros) - ? character.Macros - : GenerateMacro(); - } - - isEditCharacterWindowOpen = true; - } - - private void SaveEditedCharacter() - { - if (selectedCharacterIndex < 0 || selectedCharacterIndex >= plugin.Characters.Count) - return; - - var character = plugin.Characters[selectedCharacterIndex]; - - character.Name = editedCharacterName; - character.Tags = string.IsNullOrWhiteSpace(editedCharacterTag) - ? new List() - : editedCharacterTag.Split(',').Select(f => f.Trim()).ToList(); - character.PenumbraCollection = editedCharacterPenumbra; - character.GlamourerDesign = editedCharacterGlamourer; - character.CustomizeProfile = editedCharacterCustomize; - character.NameplateColor = editedCharacterColor; - - // Save Honorific Fields - character.HonorificTitle = editedCharacterHonorificTitle; - character.HonorificPrefix = editedCharacterHonorificPrefix; - character.HonorificSuffix = editedCharacterHonorificSuffix; - character.HonorificColor = editedCharacterHonorificColor; - character.HonorificGlow = editedCharacterHonorificGlow; - character.MoodlePreset = editedCharacterMoodlePreset; - - // Save Character Automation - character.CharacterAutomation = editedCharacterAutomation; // Save the edited automation value - - // Ensure MoodlePreset is saved even if previously missing - character.MoodlePreset = string.IsNullOrWhiteSpace(editedCharacterMoodlePreset) ? "" : editedCharacterMoodlePreset; - - - // Ensure Macro Updates Correctly - character.Macros = isAdvancedModeCharacter ? advancedCharacterMacroText : GenerateMacro(); - - if (!string.IsNullOrEmpty(editedCharacterImagePath)) - { - character.ImagePath = editedCharacterImagePath; - } - - plugin.SaveConfiguration(); - isEditCharacterWindowOpen = false; - } - private void DrawImportDesignWindow() - { - if (!isImportWindowOpen || targetForDesignImport == null) - return; - - ImGui.SetNextWindowSize(new Vector2(600, 500), ImGuiCond.FirstUseEver); - if (ImGui.Begin("Import Designs", ref isImportWindowOpen, ImGuiWindowFlags.NoCollapse)) - { - var grouped = plugin.Characters - .Where(c => c != targetForDesignImport && c.Designs.Count > 0) - .OrderBy(c => c.Name) - .ToList(); - - foreach (var character in grouped) - { - float barWidth = 6f; - float barHeight = ImGui.GetTextLineHeight(); - var color = new Vector4(character.NameplateColor, 1.0f); - - // Horizontal layout: bar + header - ImGui.BeginGroup(); - - // Left-coloured bar - ImGui.PushStyleColor(ImGuiCol.ChildBg, color); - ImGui.BeginChild($"##ColorAccent_{character.Name}", new Vector2(barWidth, barHeight), false); - ImGui.EndChild(); - ImGui.PopStyleColor(); - - ImGui.SameLine(); - - // CollapsingHeader without extra flags = starts collapsed - if (ImGui.CollapsingHeader(character.Name)) - { - ImGui.Indent(); - - foreach (var design in character.Designs) - { - ImGui.TextUnformatted($"• {design.Name}"); - ImGui.SameLine(); - - if (ImGui.Button($"+##Import_{character.Name}_{design.Name}")) - { - var clone = new CharacterDesign( - name: design.Name + " (Copy)", - macro: design.Macro, - isAdvancedMode: design.IsAdvancedMode, - advancedMacro: design.AdvancedMacro, - glamourerDesign: design.GlamourerDesign ?? "", - automation: design.Automation ?? "", - customizePlusProfile: design.CustomizePlusProfile ?? "" - ); - - targetForDesignImport.Designs.Add(clone); - plugin.SaveConfiguration(); - } - - if (ImGui.IsItemHovered()) - { - ImGui.BeginTooltip(); - ImGui.Text("Click to import this design"); - ImGui.EndTooltip(); - } - } - ImGui.Unindent(); - ImGui.Spacing(); - } - ImGui.EndGroup(); - ImGui.Spacing(); - } - } - ImGui.End(); - } - private string PatchMacroLine(string existing, string prefix, string replacement) - { - var lines = existing.Split('\n').ToList(); - var idx = lines.FindIndex(l => l.TrimStart().StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); - if (idx >= 0) lines[idx] = replacement; - else lines.Insert(0, replacement); - return string.Join("\n", lines); - } - - // Patches *all* lines matching `prefix` - private string PatchAllMacroLines(string existing, string prefix, string replacement) - { - var lines = existing.Split('\n') - .Select(l => l.TrimStart().StartsWith(prefix, StringComparison.OrdinalIgnoreCase) - ? replacement - : l - ); - return string.Join("\n", lines); - } - private string UpdateCollectionInLines(string existing, string prefix, string newCollection) - { - var lines = existing.Split('\n').Select(line => - { - var trimmed = line.TrimStart(); - if (trimmed.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) - { - // split after prefix - var rest = trimmed.Substring(prefix.Length).TrimStart(); - // rest now starts with e.g. "COLLECTION | gear" - // so remove old collection token - var afterCollection = rest.IndexOf('|') >= 0 - ? rest.Substring(rest.IndexOf('|')) // includes the '|' and everything after - : rest.Substring(rest.IndexOf(' ')); // fallback - // build new line - return $"{prefix} {newCollection} {afterCollection}"; - } - return line; - }); - return string.Join("\n", lines); - } - private string UpdateBulkTagEnableDesignInMacro(string existing, string newCollection, string newDesign) - { - var lines = existing - .Split('\n') - .Select(line => - { - var t = line.TrimStart(); - if (t.StartsWith("/penumbra bulktag enable", StringComparison.OrdinalIgnoreCase)) - return $"/penumbra bulktag enable {newCollection} | {newDesign}"; - return line; - }); - return string.Join("\n", lines); - } - - private unsafe void DrawReorderWindow() - { - if (!isReorderWindowOpen) - return; - - ImGui.SetNextWindowSize(new Vector2(500, 600), ImGuiCond.FirstUseEver); - if (ImGui.Begin("Reorder Characters", ref isReorderWindowOpen, ImGuiWindowFlags.NoCollapse)) - { - ImGui.BeginChild("CharacterReorderScroll", new Vector2(0, -45), true); // scrollable list area - - for (int i = 0; i < reorderBuffer.Count; i++) - { - var character = reorderBuffer[i]; - ImGui.PushID(i); - - float iconSize = 36; - if (!string.IsNullOrEmpty(character.ImagePath) && File.Exists(character.ImagePath)) - { - var texture = Plugin.TextureProvider.GetFromFile(character.ImagePath).GetWrapOrDefault(); - if (texture != null) - { - ImGui.Image(texture.ImGuiHandle, new Vector2(iconSize, iconSize)); - ImGui.SameLine(); - } - } - - ImGui.PushStyleVar(ImGuiStyleVar.FramePadding, new Vector2(6, 6)); - ImGui.SetCursorPosY(ImGui.GetCursorPosY() + 4); // Optional tweak to center text better - ImGui.Selectable(character.Name, false, ImGuiSelectableFlags.AllowDoubleClick); - ImGui.PopStyleVar(); - - - // Drag Source - if (ImGui.BeginDragDropSource()) - { - int dragIndex = i; - ImGui.SetDragDropPayload("CHARACTER_REORDER", new nint(Unsafe.AsPointer(ref dragIndex)), (uint)sizeof(int)); - ImGui.Text($"Moving: {character.Name}"); - ImGui.EndDragDropSource(); - } - - // Drop Target - if (ImGui.BeginDragDropTarget()) - { - var payload = ImGui.AcceptDragDropPayload("CHARACTER_REORDER"); - if (payload.NativePtr != null) - { - int dragIndex = *(int*)payload.Data; - if (dragIndex >= 0 && dragIndex < reorderBuffer.Count && dragIndex != i) - { - var item = reorderBuffer[dragIndex]; - reorderBuffer.RemoveAt(dragIndex); - reorderBuffer.Insert(i, item); - } - } - ImGui.EndDragDropTarget(); - } - - ImGui.PopID(); - } - - ImGui.EndChild(); // end scrollable region - - // Buttons fixed at the bottom - float buttonWidth = 120; - float spacing = 20; - float totalWidth = (buttonWidth * 2) + spacing; - float centerX = (ImGui.GetWindowContentRegionMax().X - totalWidth) / 2f; - - ImGui.SetCursorPosX(centerX); - - if (ImGui.Button("Save Order", new Vector2(buttonWidth, 0))) - { - for (int i = 0; i < reorderBuffer.Count; i++) - reorderBuffer[i].SortOrder = i; - - plugin.Characters.Clear(); - plugin.Characters.AddRange(reorderBuffer); - - currentSort = SortType.Manual; - plugin.Configuration.CurrentSortIndex = (int)currentSort; - plugin.SaveConfiguration(); - SortCharacters(); - isReorderWindowOpen = false; - } - ImGui.End(); - } - } + // Public methods + public void OpenEditCharacterWindow(int index) => characterForm.OpenEditCharacterWindow(index); + public void OpenDesignPanel(int characterIndex) => designPanel.Open(characterIndex); + public void CloseDesignPanel() => designPanel.Close(); + public void SortCharacters() => characterGrid.SortCharacters(); } } diff --git a/CharacterSelectPlugin/Windows/PatchNotesWindow.cs b/CharacterSelectPlugin/Windows/PatchNotesWindow.cs index bc85eb0..dbd0f53 100644 --- a/CharacterSelectPlugin/Windows/PatchNotesWindow.cs +++ b/CharacterSelectPlugin/Windows/PatchNotesWindow.cs @@ -1,116 +1,428 @@ using Dalamud.Interface; using Dalamud.Interface.Windowing; +using Dalamud.Interface.Textures.TextureWraps; using ImGuiNET; +using System; +using System.IO; using System.Numerics; +using System.Collections.Generic; namespace CharacterSelectPlugin.Windows { public class PatchNotesWindow : Window { private readonly Plugin plugin; + private bool hasScrolledToEnd = false; + private bool wasOpen = false; // Track if window was open last frame + public bool OpenMainMenuOnClose = false; - public PatchNotesWindow(Plugin plugin) : base("Character Select+ – What's New?", ImGuiWindowFlags.AlwaysAutoResize) + // Particle system for banner effects + private struct Particle + { + public Vector2 Position; + public Vector2 Velocity; + public float Life; + public float MaxLife; + public float Size; + public Vector4 Color; + } + + private List particles = new List(); + private float particleTimer = 0f; + private Random particleRandom = new Random(); + + public PatchNotesWindow(Plugin plugin) : base("Character Select+ – What's New?", + ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoTitleBar) { this.plugin = plugin; IsOpen = false; + + SizeConstraints = new WindowSizeConstraints + { + MinimumSize = new Vector2(800, 650), + MaximumSize = new Vector2(800, 650) + }; } public override void Draw() { - ImGui.SetNextWindowSizeConstraints(new Vector2(480, 1051), new Vector2(float.MaxValue, float.MaxValue)); - ImGui.PushTextWrapPos(); - - ImGui.TextColored(new Vector4(0.2f, 1.0f, 0.2f, 1.0f), $"★ New in v{Plugin.CurrentPluginVersion}"); - ImGui.Separator(); - ImGui.Spacing(); - - // Latest Patch Notes - if (ImGui.CollapsingHeader("v1.1.0.8 - v1.1.1.2 – April 18 2025", ImGuiTreeNodeFlags.DefaultOpen)) + // Reset scroll tracking if window was just opened + if (IsOpen && !wasOpen) { - // Apply Character on Login - ImGui.PushFont(UiBuilder.IconFont); ImGui.Text("\uf4fc"); ImGui.PopFont(); ImGui.SameLine(); - ImGui.TextColored(new Vector4(0.8f, 0.95f, 1.0f, 1.0f), "Apply Character on Login"); - ImGui.BulletText("New opt-in setting in the plugin options."); - ImGui.BulletText("Character Select+ will remember the last applied character."); - ImGui.BulletText("Next time you log in, it will automatically apply that character."); - ImGui.BulletText("⚠️ May conflict if you are using Glamourer Automations."); - ImGui.Separator(); + hasScrolledToEnd = false; + } + wasOpen = IsOpen; - // Apply Appearance on Job Change - ImGui.PushFont(UiBuilder.IconFont); ImGui.Text("\uf4fc"); ImGui.PopFont(); ImGui.SameLine(); - ImGui.TextColored(new Vector4(0.8f, 0.95f, 1.0f, 1.0f), "Apply Appearance on Job Change"); - ImGui.BulletText("New opt-in setting in the plugin options."); - ImGui.BulletText("Character Select+ will remember the last applied character and/or design."); - ImGui.BulletText("When you switch between jobs, it will automatically apply that character/design."); - ImGui.BulletText("⚠️ WILL 100 percent conflict if you are using Glamourer Automations."); - ImGui.Separator(); + ImGui.SetNextWindowSize(new Vector2(800, 650), ImGuiCond.Always); - // Designs - ImGui.PushFont(UiBuilder.IconFont); ImGui.Text("\uf07b"); ImGui.PopFont(); ImGui.SameLine(); - ImGui.TextColored(new Vector4(0.8f, 0.95f, 1.0f, 1.0f), "Design Panel Rework"); - ImGui.BulletText("Buttons now only appear on hover, keeping the panel clean and focused."); - ImGui.BulletText("Reorder designs by dragging the colored handle‐bar on the left — click and drag to move."); - ImGui.BulletText("Create new folders inline via the folder icon next to the + button, no extra windows needed."); - ImGui.BulletText("Drag-and-drop designs into, out of, and between folders directly within the panel."); - ImGui.BulletText("Right-click folders for inline Rename/Delete context menu, with instant application."); - ImGui.Separator(); + // UI Stylin' + ImGui.PushStyleColor(ImGuiCol.WindowBg, new Vector4(0.06f, 0.06f, 0.06f, 0.98f)); + ImGui.PushStyleColor(ImGuiCol.ChildBg, new Vector4(0.08f, 0.08f, 0.08f, 0.95f)); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.92f, 0.92f, 0.92f, 1.0f)); + ImGui.PushStyleColor(ImGuiCol.Header, new Vector4(0.15f, 0.15f, 0.18f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.HeaderHovered, new Vector4(0.2f, 0.2f, 0.25f, 1.0f)); + ImGui.PushStyleColor(ImGuiCol.HeaderActive, new Vector4(0.25f, 0.25f, 0.3f, 1.0f)); + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 6.0f); + ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 10.0f); + ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(8, 8)); - // Compact Quick Switch - ImGui.PushFont(UiBuilder.IconFont); ImGui.Text("\uf0a0"); ImGui.PopFont(); ImGui.SameLine(); - ImGui.TextColored(new Vector4(0.8f, 0.95f, 1.0f, 1.0f), "Compact Quick Character Switch"); - ImGui.BulletText("Toggleable setting to hide the title bar and window frame for a slim bar."); - ImGui.BulletText("Keeps dropdowns and apply button only, preserving full switch functionality."); - ImGui.Separator(); + try + { + DrawModernHeader(); + DrawPatchNotes(); + DrawBottomButton(); + } + finally + { + ImGui.PopStyleVar(3); + ImGui.PopStyleColor(6); + } + } - // UI Scaling Option - ImGui.PushFont(UiBuilder.IconFont); ImGui.Text("\uf00e"); ImGui.PopFont(); ImGui.SameLine(); // - ImGui.TextColored(new Vector4(0.8f, 0.95f, 1.0f, 1.0f), "UI Scale Setting"); - ImGui.BulletText("You can now adjust the plugin UI scale from the settings menu."); - ImGui.BulletText("Great for users on high-resolution monitors or 4K displays."); - ImGui.BulletText("Let me know if there are any issues using this."); - ImGui.BulletText("⚠️ If your UI is fine as-is, best to leave this be."); - ImGui.Separator(); + private void DrawModernHeader() + { + // Window position + var windowPos = ImGui.GetWindowPos(); + var windowPadding = ImGui.GetStyle().WindowPadding; + + // Header area dimensions - let it start higher + var headerWidth = 800f - (windowPadding.X * 2); + var headerHeight = 180f; + + // Start the header + var headerStart = windowPos + new Vector2(windowPadding.X, windowPadding.Y * 1.1f); + var headerEnd = headerStart + new Vector2(headerWidth, headerHeight); + + var drawList = ImGui.GetWindowDrawList(); + + try + { + string pluginDirectory = Plugin.PluginInterface.AssemblyLocation.DirectoryName ?? ""; + string assetsPath = Path.Combine(pluginDirectory, "Assets"); + string imagePath = Path.Combine(assetsPath, "banner.png"); + + if (File.Exists(imagePath)) + { + var texture = Plugin.TextureProvider.GetFromFile(imagePath).GetWrapOrDefault(); + if (texture != null) + { + // Calculate scaling to fill width and maintain aspect ratio + var imageAspect = (float)texture.Width / texture.Height; + var scaledWidth = headerWidth; + var scaledHeight = scaledWidth / imageAspect; + + // Draw the banner image starting from the header position + var imagePos = headerStart; + drawList.AddImage(texture.ImGuiHandle, imagePos, imagePos + new Vector2(scaledWidth, scaledHeight)); + + // Draw particles + DrawParticleEffects(drawList, headerStart, new Vector2(scaledWidth, scaledHeight)); + } + else + { + DrawGradientBackground(headerStart, headerEnd); + // Add particle effects over the gradient too + DrawParticleEffects(drawList, headerStart, new Vector2(headerWidth, headerHeight)); + } + } + else + { + DrawGradientBackground(headerStart, headerEnd); + // Add particle effects over the gradient + DrawParticleEffects(drawList, headerStart, new Vector2(headerWidth, headerHeight)); + } + } + catch (Exception ex) + { + Plugin.Log.Error($"Failed to load banner image: {ex.Message}"); + DrawGradientBackground(headerStart, headerEnd); + // Add particle effects over the gradient + DrawParticleEffects(drawList, headerStart, new Vector2(headerWidth, headerHeight)); } - // Previous Patch Notes - if (ImGui.CollapsingHeader("v1.1.0.(0-7) – April 09 2025", ImGuiTreeNodeFlags.None)) + ImGui.SetCursorPosY((windowPadding.Y * 0.5f) + headerHeight - 10); + + // Version badge + ImGui.SetCursorPosX(9); + ImGui.PushFont(UiBuilder.IconFont); + ImGui.TextColored(new Vector4(0.4f, 0.9f, 0.4f, 1.0f), "\uf005"); // Green star + ImGui.PopFont(); + ImGui.SameLine(); + + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.4f, 0.9f, 0.4f, 1.0f)); // Green text + ImGui.Text($"New in v{Plugin.CurrentPluginVersion}"); + ImGui.PopStyleColor(); + + ImGui.SameLine(); + ImGui.SetCursorPosX(ImGui.GetCursorPosX() + 10); + ImGui.TextColored(new Vector4(0.75f, 0.75f, 0.85f, 1.0f), "Character Gallery, Visual Overhaul & Interactive Tutorial"); + + ImGui.Separator(); + ImGui.Spacing(); + } + + private void DrawGradientBackground(Vector2 headerStart, Vector2 headerEnd) + { + // Gradient background + var drawList = ImGui.GetWindowDrawList(); + uint gradientTop = ImGui.GetColorU32(new Vector4(0.2f, 0.4f, 0.8f, 0.15f)); + uint gradientBottom = ImGui.GetColorU32(new Vector4(0.1f, 0.1f, 0.2f, 0.05f)); + drawList.AddRectFilledMultiColor(headerStart, headerEnd, gradientTop, gradientTop, gradientBottom, gradientBottom); + + // Add fallback text for gradient version + ImGui.SetCursorPos(new Vector2(20, 15)); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.95f, 0.95f, 0.95f, 1.0f)); + ImGui.Text("Character Select+ – What's New?"); + ImGui.PopStyleColor(); + + ImGui.SetCursorPos(new Vector2(20, 35)); + ImGui.PushFont(UiBuilder.IconFont); + ImGui.TextColored(new Vector4(0.4f, 0.9f, 0.4f, 1.0f), "\uf005"); + ImGui.PopFont(); + ImGui.SameLine(); + + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.4f, 0.9f, 0.4f, 1.0f)); + ImGui.Text($"New in v{Plugin.CurrentPluginVersion}"); + ImGui.PopStyleColor(); + + ImGui.SetCursorPos(new Vector2(20, 55)); + ImGui.TextColored(new Vector4(0.75f, 0.75f, 0.85f, 1.0f), "Character Gallery, Visual Overhaul & Interactive Tutorial"); + } + + private void DrawPatchNotes() + { + // Scrollable content area + ImGui.BeginChild("PatchNotesScroll", new Vector2(0, -70), false, ImGuiWindowFlags.AlwaysVerticalScrollbar); + + // Track scroll position for enabling button + float currentScrollY = ImGui.GetScrollY(); + float maxScrollY = ImGui.GetScrollMaxY(); + + // Check if user has scrolled at least 85% through the content + if (maxScrollY > 0 && currentScrollY >= (maxScrollY * 0.85f)) + { + hasScrolledToEnd = true; + } + + ImGui.PushTextWrapPos(); + + // Latest Patch Notes - v2.0.0.0 (always open for 2.0) + if (DrawModernCollapsingHeader("v2.0.0.0 – Character Gallery & Visual Overhaul", new Vector4(0.4f, 0.9f, 0.4f, 1.0f), true)) + { + Draw120Notes(); + + // Show scroll indicator if haven't scrolled enough + if (!hasScrolledToEnd) + { + ImGui.Spacing(); + ImGui.Spacing(); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.7f, 0.7f, 0.8f, 0.8f)); + ImGui.Text("↓ Scroll down to see all new features before continuing ↓"); + ImGui.PopStyleColor(); + ImGui.Spacing(); + } + } + + // Previous Patch Notes - v1.1 + if (DrawModernCollapsingHeader("v1.1.0.8 - v1.1.1.2 – April 18 2025", new Vector4(0.75f, 0.75f, 0.85f, 1.0f), false)) + { + Draw110Notes(); + } + + // Previous Patch Notes - v1.1.0.0-7 + if (DrawModernCollapsingHeader("v1.1.0.(0-7) – April 09 2025", new Vector4(0.75f, 0.75f, 0.85f, 1.0f), false)) { Draw1100Notes(); } - ImGui.Spacing(); ImGui.Spacing(); - float windowWidth = ImGui.GetWindowSize().X; - float buttonWidth = 90f; - ImGui.SetCursorPosX((windowWidth - buttonWidth) * 0.5f); - - if (ImGui.Button("Got it!", new Vector2(buttonWidth, 0))) - { - plugin.Configuration.LastSeenVersion = Plugin.CurrentPluginVersion; - plugin.Configuration.Save(); - IsOpen = false; - plugin.ToggleMainUI(); - } - ImGui.PopTextWrapPos(); + ImGui.EndChild(); } + + private bool DrawModernCollapsingHeader(string title, Vector4 titleColor, bool defaultOpen) + { + var flags = defaultOpen ? ImGuiTreeNodeFlags.DefaultOpen : ImGuiTreeNodeFlags.None; + + ImGui.PushStyleColor(ImGuiCol.Text, titleColor); + bool isOpen = ImGui.CollapsingHeader(title, flags); + ImGui.PopStyleColor(); + + return isOpen; + } + + private void DrawFeatureSection(string icon, string title, Vector4 accentColor) + { + var drawList = ImGui.GetWindowDrawList(); + var startPos = ImGui.GetCursorScreenPos(); + + // Feature section background + var bgMin = startPos + new Vector2(-10, -5); + var bgMax = startPos + new Vector2(ImGui.GetContentRegionAvail().X + 10, 25); + drawList.AddRectFilled(bgMin, bgMax, ImGui.GetColorU32(new Vector4(0.12f, 0.12f, 0.15f, 0.6f)), 4f); + + drawList.AddRectFilled(bgMin, bgMin + new Vector2(3, bgMax.Y - bgMin.Y), ImGui.GetColorU32(accentColor), 2f); + + ImGui.SetCursorPosX(ImGui.GetCursorPosX() + 1); + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text(icon); + ImGui.PopFont(); + ImGui.SameLine(); + ImGui.TextColored(accentColor, title); + ImGui.Spacing(); + } + + private void Draw120Notes() + { + // Character Gallery (NEW!) + DrawFeatureSection("\uf302", "Character Gallery", new Vector4(0.9f, 0.6f, 0.9f, 1.0f)); + ImGui.BulletText("View and share your CS+ Characters with everyone else!"); + ImGui.BulletText("Opt-in feature - choose your main physical character to represent you"); + ImGui.BulletText("Shows recent activity status with green globe indicators"); + ImGui.BulletText("Like,favourite,add or even block other players' characters"); + ImGui.BulletText("Click any profile to view their full RP Profile with backgrounds & effects"); + ImGui.Spacing(); + + // Revamped RP Profiles + DrawFeatureSection("\uf2c2", "Revamped RP Profiles", new Vector4(0.6f, 0.9f, 1.0f, 1.0f)); + ImGui.BulletText("Complete visual redesign with new layout and styling"); + ImGui.BulletText("80+ FFXIV location backgrounds to choose from"); + ImGui.BulletText("Animated visual effects: butterflies, fireflies, falling leaves, and more"); + ImGui.BulletText("Real-time preview - see changes instantly in the editor"); + ImGui.BulletText("Right-click any player name to view their RP Profile directly"); + ImGui.Spacing(); + + // Immersive Dialogue (NEW!) + DrawFeatureSection("\uf075", "Immersive Dialogue System", new Vector4(0.9f, 0.6f, 0.9f, 1.0f)); + ImGui.BulletText("NPCs now use your CS+ Character's name, pronouns, and desired titles in dialogue!"); + ImGui.BulletText("Integration with he/him, she/her, and they/them pronouns"); + ImGui.BulletText("Granular settings: enable names, pronouns, gendered terms, or race separately"); + ImGui.BulletText("Customizable they/them neutral titles: friend, Mx., traveler, adventurer, or choose your own!"); + ImGui.BulletText("Only affects dialogue referring to your character - NPCs keep their own pronouns"); + ImGui.BulletText("Requires an active CS+ character with RP Profile pronouns set"); + ImGui.BulletText("If you find any instances in which it doesn't seem to be working please report them in the discord!"); + ImGui.Spacing(); + + // Main Window UI Update + DrawFeatureSection("\uf53f", "Main Window Visual Overhaul", new Vector4(0.6f, 0.9f, 1.0f, 1.0f)); + ImGui.BulletText("Complete redesign with compact layout and enhanced visuals"); + ImGui.BulletText("Character cards with integrated nameplates and action buttons"); + ImGui.BulletText("Glowing borders and enhanced hover effects"); + ImGui.BulletText("Optional setting for profiles to grow slightly on hover"); + ImGui.BulletText("Crown indicator for your designated Main Character"); + ImGui.BulletText("Resize Design Panel freely"); + ImGui.BulletText("Drag & Drop character reordering added to Main Window (leftward movement only due to ImGui limitations)"); + ImGui.Spacing(); + + // Tutorial System (NEW!) + DrawFeatureSection("\uf19d", "Interactive Tutorial System", new Vector4(0.9f, 0.6f, 0.9f, 1.0f)); + ImGui.BulletText("Brand new guided tutorial for first-time users"); + ImGui.BulletText("Highlights and points to buttons and fields you need to interact with"); + ImGui.BulletText("Step-by-step guidance through Characters, Designs, and RP Profiles"); + ImGui.BulletText("Can be ended at any time if you prefer to explore on your own"); + ImGui.Spacing(); + + // Design Preview Images (NEW!) + DrawFeatureSection("\uf03e", "Design Preview Images", new Vector4(0.6f, 0.9f, 1.0f, 1.0f)); + ImGui.BulletText("Add custom preview images to your Designs"); + ImGui.BulletText("Preview images by hovering over the Apply (✓) button"); + ImGui.BulletText("Helps you quickly identify Designs at a glance"); + ImGui.Spacing(); + + // Main Game Commands (NEW!) + DrawFeatureSection("\uf120", "Base Game Command Support", new Vector4(0.9f, 0.6f, 0.9f, 1.0f)); + ImGui.BulletText("Add base game commands through Advanced Mode"); + ImGui.BulletText("Example: Add '/gearset change 1' to switch jobs when applying Designs"); + ImGui.BulletText("Perfect combo with 'Reapply Last Design on Job Change' setting"); + ImGui.Spacing(); + + // Random Character + Outfit (NEW!) + DrawFeatureSection("\uf074", "Random Character & Outfit", new Vector4(0.6f, 0.9f, 1.0f, 1.0f)); + ImGui.BulletText("New 'Random' button for spontaneous character switching"); + ImGui.BulletText("Randomly picks from your CS+ Characters and their Designs"); + ImGui.BulletText("Setting to limit random selection to only favourited items"); + ImGui.Spacing(); + + // Main CS+ Character (NEW!) + DrawFeatureSection("\uf521", "Main CS+ Character", new Vector4(0.9f, 0.6f, 0.9f, 1.0f)); + ImGui.BulletText("Designate your main CS+ Character with a crown indicator"); + ImGui.BulletText("Crown display is optional - toggle in settings"); + ImGui.BulletText("'Reapply on Login' can be set to only apply your Main Character"); + ImGui.Spacing(); + + // Quick Character Switch Improvements + DrawFeatureSection("\uf0e7", "Quick Character Switch Updates", new Vector4(0.6f, 0.9f, 1.0f, 1.0f)); + ImGui.BulletText("Now remembers your last used character like Apply on Login"); + ImGui.BulletText("Ready to go when you log in as that character"); + ImGui.BulletText("Will also switch to be on your current CS+ Character if applied through other methods"); + ImGui.Spacing(); + + // Bug Fixes & QoL + DrawFeatureSection("\uf085", "Bug Fixes & Quality of Life", new Vector4(0.8f, 0.8f, 0.9f, 1.0f)); + ImGui.BulletText("Fixed Quick Switch window scroll issues"); + ImGui.BulletText("Disabled window docking to prevent UI conflicts"); + ImGui.BulletText("Added ghost images for drag and drop operations"); + ImGui.BulletText("Automatic character config backup on updates or every 7 days"); + ImGui.BulletText("Various performance improvements and optimizations"); + } + + private void Draw110Notes() + { + // Apply Character on Login + DrawFeatureSection("\uf4fc", "Apply Character on Login", new Vector4(0.6f, 0.9f, 1.0f, 1.0f)); + ImGui.BulletText("New opt-in setting in the plugin options."); + ImGui.BulletText("Character Select+ will remember the last applied character."); + ImGui.BulletText("Next time you log in, it will automatically apply that character."); + ImGui.BulletText("⚠️ May conflict if you are using Glamourer Automations."); + ImGui.Spacing(); + + // Apply Appearance on Job Change + DrawFeatureSection("\uf4fc", "Apply Appearance on Job Change", new Vector4(0.6f, 0.9f, 1.0f, 1.0f)); + ImGui.BulletText("New opt-in setting in the plugin options."); + ImGui.BulletText("Character Select+ will remember the last applied character and/or design."); + ImGui.BulletText("When you switch between jobs, it will automatically apply that character/design."); + ImGui.BulletText("⚠️ WILL 100 percent conflict if you are using Glamourer Automations."); + ImGui.Spacing(); + + // Designs + DrawFeatureSection("\uf07b", "Design Panel Rework", new Vector4(0.6f, 0.9f, 1.0f, 1.0f)); + ImGui.BulletText("Buttons now only appear on hover, keeping the panel clean and focused."); + ImGui.BulletText("Reorder designs by dragging the coloured handle‐bar on the left — click and drag to move."); + ImGui.BulletText("Create new folders inline via the folder icon next to the + button, no extra windows needed."); + ImGui.BulletText("Drag-and-drop designs into, out of, and between folders directly within the panel."); + ImGui.BulletText("Right-click folders for inline Rename/Delete context menu, with instant application."); + ImGui.Spacing(); + + // Compact Quick Switch + DrawFeatureSection("\uf0a0", "Compact Quick Character Switch", new Vector4(0.6f, 0.9f, 1.0f, 1.0f)); + ImGui.BulletText("Toggleable setting to hide the title bar and window frame for a slim bar."); + ImGui.BulletText("Keeps dropdowns and apply button only, preserving full switch functionality."); + ImGui.Spacing(); + + // UI Scaling Option + DrawFeatureSection("\uf00e", "UI Scale Setting", new Vector4(0.6f, 0.9f, 1.0f, 1.0f)); + ImGui.BulletText("You can now adjust the plugin UI scale from the settings menu."); + ImGui.BulletText("Great for users on high-resolution monitors or 4K displays."); + ImGui.BulletText("Let me know if there are any issues using this."); + ImGui.BulletText("⚠️ If your UI is fine as-is, best to leave this be."); + ImGui.Spacing(); + } + private void Draw1100Notes() { // RP Profile Panel - ImGui.PushFont(UiBuilder.IconFont); ImGui.Text("\uf2c2"); ImGui.PopFont(); ImGui.SameLine(); - ImGui.TextColored(new Vector4(0.8f, 0.95f, 1.0f, 1.0f), "RolePlay Profile Panel"); + DrawFeatureSection("\uf2c2", "RolePlay Profile Panel", new Vector4(0.6f, 0.9f, 1.0f, 1.0f)); ImGui.BulletText("Add bios, pronouns, orientation, and more for each character."); ImGui.BulletText("Choose a unique image or reuse the character image."); ImGui.BulletText("Use pan and zoom controls to fine-tune the RP portrait."); ImGui.BulletText("Control visibility: keep private or share with others."); - ImGui.BulletText("Once applied, that character’s RP profile is active."); - ImGui.BulletText("You can view others’ profiles (if shared) and vice versa."); + ImGui.BulletText("Once applied, that character's RP profile is active."); + ImGui.BulletText("You can view others' profiles (if shared) and vice versa."); ImGui.BulletText("Use /viewrp self | /t | First Last@World to view."); ImGui.BulletText("Right-click in the party list, friends list, or chat to access shared RP cards."); - ImGui.Separator(); + ImGui.Spacing(); // Glamourer Automations - ImGui.PushFont(UiBuilder.IconFont); ImGui.Text("\uf5c3"); ImGui.PopFont(); ImGui.SameLine(); - ImGui.TextColored(new Vector4(0.8f, 0.95f, 1.0f, 1.0f), "Glamourer Automations for Characters & Designs"); + DrawFeatureSection("\uf5c3", "Glamourer Automations for Characters & Designs", new Vector4(0.6f, 0.9f, 1.0f, 1.0f)); ImGui.BulletText("Characters & Designs can now trigger specific Glamourer Automation profiles."); ImGui.BulletText("This is *opt-in* — toggle it in plugin settings."); ImGui.BulletText("If no automation is assigned, the design defaults to 'None'."); @@ -119,56 +431,216 @@ namespace CharacterSelectPlugin.Windows ImGui.BulletText("1. Open Glamourer > Automations."); ImGui.BulletText("2. Create an Automation named 'None'."); ImGui.BulletText("3. Add your in-game character name beside 'Any World' then Set to Character."); - ImGui.BulletText("4. That’s it. Don’t touch anything else, you’re done!"); - ImGui.Separator(); + ImGui.BulletText("4. That's it. Don't touch anything else, you're done!"); + ImGui.Spacing(); // Customize+ - ImGui.PushFont(UiBuilder.IconFont); ImGui.Text("\uf234"); ImGui.PopFont(); ImGui.SameLine(); - ImGui.TextColored(new Vector4(0.8f, 0.95f, 1.0f, 1.0f), "Customize+ Profiles for Designs"); + DrawFeatureSection("\uf234", "Customize+ Profiles for Designs", new Vector4(0.6f, 0.9f, 1.0f, 1.0f)); ImGui.BulletText("Each design can now assign its own Customize+ profile."); ImGui.BulletText("This gives you finer control over visual changes per design."); - ImGui.Separator(); + ImGui.Spacing(); // Manual Reordering - ImGui.PushFont(UiBuilder.IconFont); ImGui.Text("\uf0b0"); ImGui.PopFont(); ImGui.SameLine(); - ImGui.TextColored(new Vector4(0.8f, 0.95f, 1.0f, 1.0f), "Manual Character Reordering"); + DrawFeatureSection("\uf0b0", "Manual Character Reordering", new Vector4(0.6f, 0.9f, 1.0f, 1.0f)); ImGui.BulletText("Use the 'Reorder Characters' button at the bottom-left."); ImGui.BulletText("Drag and drop profiles, then press Save to lock it in."); - ImGui.Separator(); + ImGui.Spacing(); // Search - ImGui.PushFont(UiBuilder.IconFont); ImGui.Text("\uf002"); ImGui.PopFont(); ImGui.SameLine(); - ImGui.TextColored(new Vector4(0.8f, 0.95f, 1.0f, 1.0f), "Character Search Bar"); + DrawFeatureSection("\uf002", "Character Search Bar", new Vector4(0.6f, 0.9f, 1.0f, 1.0f)); ImGui.BulletText("Click the magnifying glass to search by name instantly."); - ImGui.Separator(); + ImGui.Spacing(); // Tagging - ImGui.PushFont(UiBuilder.IconFont); ImGui.Text("\uf07b"); ImGui.PopFont(); ImGui.SameLine(); - ImGui.TextColored(new Vector4(0.8f, 0.95f, 1.0f, 1.0f), "Tagging System"); + DrawFeatureSection("\uf07b", "Tagging System", new Vector4(0.6f, 0.9f, 1.0f, 1.0f)); ImGui.BulletText("Add comma-separated 'tags' to organize characters."); ImGui.BulletText("Click the filter icon to filter — characters can appear in multiple tags!"); - ImGui.Separator(); + ImGui.Spacing(); // Apply to Target - ImGui.PushFont(UiBuilder.IconFont); ImGui.Text("\uf140"); ImGui.PopFont(); ImGui.SameLine(); - ImGui.TextColored(new Vector4(0.8f, 0.95f, 1.0f, 1.0f), "Right-click → Apply to Target"); + DrawFeatureSection("\uf140", "Right-click → Apply to Target", new Vector4(0.6f, 0.9f, 1.0f, 1.0f)); ImGui.BulletText("Right-click a character in Character Select+ with a target selected."); ImGui.BulletText("Apply their setup — or even one of their individual designs — to the target."); - ImGui.Separator(); + ImGui.Spacing(); // Copy Designs - ImGui.PushFont(UiBuilder.IconFont); ImGui.Text("\uf0c5"); ImGui.PopFont(); ImGui.SameLine(); - ImGui.TextColored(new Vector4(0.8f, 0.95f, 1.0f, 1.0f), "Copy Designs Between Characters"); + DrawFeatureSection("\uf0c5", "Copy Designs Between Characters", new Vector4(0.6f, 0.9f, 1.0f, 1.0f)); ImGui.BulletText("Hold Shift and click the '+' button in Designs to open the Design Importer."); ImGui.BulletText("Click the + beside a design to copy it. Repeat as needed!"); - ImGui.Separator(); + ImGui.Spacing(); // Other changes - ImGui.PushFont(UiBuilder.IconFont); ImGui.Text("\uf085"); ImGui.PopFont(); ImGui.SameLine(); - ImGui.TextColored(new Vector4(0.65f, 0.65f, 0.9f, 1.0f), "Other Changes"); + DrawFeatureSection("\uf085", "Other Changes", new Vector4(0.8f, 0.8f, 0.9f, 1.0f)); ImGui.BulletText("Older Design macros were automatically upgraded."); ImGui.BulletText("Various UI tweaks, bugfixes, and behind-the-scenes improvements."); } + private void DrawBottomButton() + { + ImGui.Spacing(); + ImGui.Spacing(); + + // Center the button + float windowWidth = ImGui.GetWindowSize().X; + float buttonWidth = 90f; + ImGui.SetCursorPosX((windowWidth - buttonWidth) * 0.5f); + + // Button is only enabled if user has scrolled enough + bool buttonEnabled = hasScrolledToEnd; + + if (!buttonEnabled) + { + ImGui.PushStyleVar(ImGuiStyleVar.Alpha, 0.5f); // Make it look disabled + } + + // Styled button - purple when enabled, gray when disabled + if (buttonEnabled) + { + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.3f, 0.2f, 0.4f, 0.8f)); // Purple base + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.4f, 0.3f, 0.5f, 1f)); // Purple hover + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.25f, 0.15f, 0.35f, 1f)); // Purple active + } + else + { + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.2f, 0.2f, 0.2f, 0.8f)); // Gray base + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.2f, 0.2f, 0.2f, 0.8f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.2f, 0.2f, 0.2f, 0.8f)); + } + + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 6f); + + bool buttonClicked = ImGui.Button("Got it!", new Vector2(buttonWidth, 30)); + + // Show tooltip when disabled + if (!buttonEnabled && ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled)) + { + ImGui.SetTooltip("Read through the new features first! There's a lot!"); + } + + if (buttonClicked && buttonEnabled) + { + plugin.Configuration.LastSeenVersion = Plugin.CurrentPluginVersion; + plugin.Configuration.Save(); + IsOpen = false; + if (OpenMainMenuOnClose) + { + plugin.ToggleMainUI(); + } + OpenMainMenuOnClose = false; + } + + ImGui.PopStyleVar(!buttonEnabled ? 2 : 1); + ImGui.PopStyleColor(3); + } + + // Optional debug method - temporarily call this in Draw() to see scroll values + private void DrawDebugInfo() + { + ImGui.Spacing(); + ImGui.Text($"Scroll Debug Info:"); + + // Get the scroll values from the child window + if (ImGui.BeginChild("PatchNotesScroll", Vector2.Zero, false)) + { + float currentScrollY = ImGui.GetScrollY(); + float maxScrollY = ImGui.GetScrollMaxY(); + ImGui.EndChild(); + + ImGui.Text($"Current: {currentScrollY:F1}, Max: {maxScrollY:F1}"); + ImGui.Text($"Progress: {(maxScrollY > 0 ? (currentScrollY / maxScrollY * 100) : 0):F1}%"); + ImGui.Text($"hasScrolledToEnd: {hasScrolledToEnd}"); + ImGui.Text($"85% threshold: {maxScrollY * 0.85f:F1}"); + } + } + + private void DrawParticleEffects(ImDrawListPtr drawList, Vector2 bannerStart, Vector2 bannerSize) + { + float deltaTime = ImGui.GetIO().DeltaTime; + particleTimer += deltaTime; + + // Spawn new particles more frequently and across the entire banner + if (particleTimer > 0.15f && particles.Count < 40) // More particles, spawn faster + { + SpawnParticle(bannerStart, bannerSize); + particleTimer = 0f; + } + + // Update and draw existing particles + for (int i = particles.Count - 1; i >= 0; i--) + { + var particle = particles[i]; + + // Update particle + particle.Position += particle.Velocity * deltaTime; + particle.Life -= deltaTime; + + // Remove dead particles or ones that left the banner area + if (particle.Life <= 0 || + particle.Position.X > bannerStart.X + bannerSize.X + 50 || + particle.Position.Y < bannerStart.Y - 50 || + particle.Position.Y > bannerStart.Y + bannerSize.Y + 50) + { + particles.RemoveAt(i); + continue; + } + + // Calculate alpha based on life remaining + float alpha = Math.Min(1f, particle.Life / particle.MaxLife); + var color = new Vector4(particle.Color.X, particle.Color.Y, particle.Color.Z, particle.Color.W * alpha); + + // Draw particle as a small circle + drawList.AddCircleFilled( + particle.Position, + particle.Size, + ImGui.GetColorU32(color) + ); + + // Subtle glow effect + if (alpha > 0.3f) + { + var glowColor = new Vector4(color.X, color.Y, color.Z, color.W * 0.15f); + drawList.AddCircleFilled( + particle.Position, + particle.Size * 2.5f, + ImGui.GetColorU32(glowColor) + ); + } + + particles[i] = particle; + } + } + + private void SpawnParticle(Vector2 bannerStart, Vector2 bannerSize) + { + var particle = new Particle + { + // Spawn randomly across the ENTIRE banner area + Position = new Vector2( + bannerStart.X + (float)particleRandom.NextDouble() * bannerSize.X, + bannerStart.Y + (float)particleRandom.NextDouble() * bannerSize.Y + ), + + Velocity = new Vector2( + -10f + (float)particleRandom.NextDouble() * 20f, + -15f + (float)particleRandom.NextDouble() * -10f + ), + + MaxLife = 6f + (float)particleRandom.NextDouble() * 4f, + Size = 1.5f + (float)particleRandom.NextDouble() * 2.5f, + + // More visible colors - brighter and more opaque + Color = particleRandom.Next(5) switch + { + 0 => new Vector4(0.7f, 0.9f, 1.0f, 0.8f), // Bright blue + 1 => new Vector4(1.0f, 0.7f, 1.0f, 0.7f), // Bright purple + 2 => new Vector4(1.0f, 1.0f, 1.0f, 0.6f), // Bright white + 3 => new Vector4(0.8f, 1.0f, 1.0f, 0.7f), // Bright cyan + _ => new Vector4(0.9f, 0.8f, 1.0f, 0.6f) // Light lavender + } + }; + + particle.Life = particle.MaxLife; + particles.Add(particle); + } } } diff --git a/CharacterSelectPlugin/Windows/RPProfileViewWindow.cs b/CharacterSelectPlugin/Windows/RPProfileViewWindow.cs index 46d394f..afbc30a 100644 --- a/CharacterSelectPlugin/Windows/RPProfileViewWindow.cs +++ b/CharacterSelectPlugin/Windows/RPProfileViewWindow.cs @@ -1,11 +1,13 @@ using Dalamud.Interface.Windowing; +using Dalamud.Interface.Textures.TextureWraps; using ImGuiNET; -using ImGuiScene; using System; using System.IO; using System.Linq; using System.Numerics; using System.Threading.Tasks; +using System.Collections.Generic; +using Dalamud.Interface; namespace CharacterSelectPlugin.Windows { @@ -18,38 +20,131 @@ namespace CharacterSelectPlugin.Windows private bool imageDownloadStarted = false; private bool imageDownloadComplete = false; private string? downloadedImagePath = null; - private TextureWrap? cachedTexture; + private IDalamudTextureWrap? cachedTexture; private string? cachedTexturePath; private bool bringToFront = false; private bool firstOpen = true; + private float windowWidth = 420f; + private float bioScrollY = 0f; + private string? imagePreviewUrl = null; + private bool showImagePreview = false; + + // Animation variables + private float animationTime = 0f; + private float[] circuitPacketPositions = new float[6]; + private float[] circuitPacketSpeeds = new float[6]; + private float[] circuitNodeGlowPhases = new float[12]; + private float[] particlePositions = new float[20]; + private float[] particleVelocitiesX = new float[20]; + private float[] particleVelocitiesY = new float[20]; + private float[] particleLifetimes = new float[20]; + private float[] gothicWispPositions = new float[8]; + private float[] gothicWispPhases = new float[8]; + private bool stylesPushed = false; + public RPProfile? CurrentProfile => showingExternal ? externalProfile : character?.RPProfile; public RPProfileViewWindow(Plugin plugin) - : base("RP Profile", ImGuiWindowFlags.AlwaysAutoResize) + : base("RPProfileWindow", + ImGuiWindowFlags.NoDecoration | + ImGuiWindowFlags.NoScrollbar | + ImGuiWindowFlags.NoCollapse) { this.plugin = plugin; IsOpen = false; + + var dpiScale = ImGui.GetIO().DisplayFramebufferScale.X; + var uiScale = plugin.Configuration.UIScaleMultiplier; + var totalScale = GetSafeScale(dpiScale * uiScale); + + SizeConstraints = new WindowSizeConstraints + { + MinimumSize = new Vector2(420 * totalScale, 550 * totalScale), + MaximumSize = new Vector2(9999, 9999) + }; + + InitializeAnimationData(); } + + private void InitializeAnimationData() + { + var random = new Random(); + + // Circuit board animation + for (int i = 0; i < circuitPacketPositions.Length; i++) + { + circuitPacketPositions[i] = random.NextSingle() * 400f; + circuitPacketSpeeds[i] = 20f + random.NextSingle() * 40f; + } + for (int i = 0; i < circuitNodeGlowPhases.Length; i++) + { + circuitNodeGlowPhases[i] = random.NextSingle() * 6.28f; + } + + // Nature particles + for (int i = 0; i < particlePositions.Length; i++) + { + particlePositions[i] = random.NextSingle() * 400f; + particleVelocitiesX[i] = (random.NextSingle() - 0.5f) * 20f; + particleVelocitiesY[i] = random.NextSingle() * 30f + 10f; + particleLifetimes[i] = random.NextSingle() * 5f; + } + + // Wisps + for (int i = 0; i < gothicWispPositions.Length; i++) + { + gothicWispPositions[i] = random.NextSingle() * 400f; + gothicWispPhases[i] = random.NextSingle() * 6.28f; + } + } + public void SetCharacter(Character character) { this.character = character; showingExternal = false; bringToFront = true; + ResetImageCache(); + } - // Clear old cached texture so image reloads properly + public void SetExternalProfile(RPProfile profile) + { + externalProfile = profile; + showingExternal = true; + ResetImageCache(); + bringToFront = true; + } + + private void ResetImageCache() + { + imageDownloadStarted = false; + imageDownloadComplete = false; + downloadedImagePath = null; cachedTexture?.Dispose(); cachedTexture = null; cachedTexturePath = null; } + public override void PreDraw() { + stylesPushed = false; + + if (IsOpen && CurrentProfile != null) + { + ImGui.PushStyleColor(ImGuiCol.WindowBg, new Vector4(0, 0, 0, 0)); + ImGui.PushStyleColor(ImGuiCol.Border, new Vector4(0, 0, 0, 0)); + ImGui.PushStyleVar(ImGuiStyleVar.WindowBorderSize, 0f); + ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(0, 0)); + stylesPushed = true; + } + if (IsOpen && bringToFront) { ImGui.SetNextWindowFocus(); if (firstOpen) { - ImGui.SetNextWindowPos(ImGui.GetMainViewport().GetCenter(), ImGuiCond.Appearing, new Vector2(0.5f, 0.5f)); + var center = ImGui.GetMainViewport().GetCenter(); + ImGui.SetNextWindowPos(center, ImGuiCond.Appearing, new Vector2(0.5f, 0.5f)); firstOpen = false; } @@ -59,305 +154,2504 @@ namespace CharacterSelectPlugin.Windows public override void Draw() { - RPProfile? rp = null; - Vector3 nameplateColor; - string displayName; - // Case: Viewing an external profile - if (showingExternal && externalProfile != null) + var dpiScale = ImGui.GetIO().DisplayFramebufferScale.X; + var uiScale = plugin.Configuration.UIScaleMultiplier; + var totalScale = GetSafeScale(dpiScale * uiScale); + + var rp = CurrentProfile; + if (rp == null) { - rp = externalProfile; - nameplateColor = (Vector3)externalProfile.NameplateColor; - displayName = externalProfile.CharacterName ?? "External Profile"; + if (stylesPushed) + { + ImGui.PopStyleVar(2); + ImGui.PopStyleColor(2); + stylesPushed = false; + } + return; } - // Case: Viewing a local character's profile - else if (character != null && character.RPProfile != null) + + var currentSize = ImGui.GetWindowSize(); + + if (ImGui.IsWindowHovered() && ImGui.IsMouseDragging(ImGuiMouseButton.Left)) { - rp = character.RPProfile; - nameplateColor = character.NameplateColor; - displayName = character.Name; + var mousePos = ImGui.GetMousePos(); + var windowPos = ImGui.GetWindowPos(); + var windowSize = ImGui.GetWindowSize(); + + var resizeAreaMin = windowPos + windowSize - new Vector2(20 * totalScale, 20 * totalScale); + var resizeAreaMax = windowPos + windowSize; + + if (mousePos.X >= resizeAreaMin.X && mousePos.Y >= resizeAreaMin.Y && + mousePos.X <= resizeAreaMax.X && mousePos.Y <= resizeAreaMax.Y) + { + var baseAspect = 420f / 580f; + var newWidth = currentSize.X; + var newHeight = currentSize.Y; + + if (newWidth / newHeight > baseAspect) + { + newWidth = newHeight * baseAspect; + } + else + { + newHeight = newWidth / baseAspect; + } + + ImGui.SetWindowSize(new Vector2(newWidth, newHeight)); + } + } + + if (!ImGui.Begin("RPProfileWindow", + ImGuiWindowFlags.NoDecoration | + ImGuiWindowFlags.NoCollapse | + ImGuiWindowFlags.NoScrollbar)) + { + ImGui.End(); + if (stylesPushed) + { + ImGui.PopStyleVar(2); + ImGui.PopStyleColor(2); + stylesPushed = false; + } + return; + } + + plugin.RPProfileViewWindowPos = ImGui.GetWindowPos(); + plugin.RPProfileViewWindowSize = ImGui.GetWindowSize(); + DrawProfileContent(rp, totalScale); + DrawImagePreview(totalScale); + ImGui.End(); + + if (stylesPushed) + { + ImGui.PopStyleVar(2); + ImGui.PopStyleColor(2); + stylesPushed = false; + } + } + private void DrawImagePreview(float scale) + { + if (!showImagePreview || string.IsNullOrEmpty(imagePreviewUrl)) + return; + + var viewport = ImGui.GetMainViewport(); + ImGui.SetNextWindowPos(viewport.Pos); + ImGui.SetNextWindowSize(viewport.Size); + + ImGui.PushStyleColor(ImGuiCol.WindowBg, new Vector4(0, 0, 0, 0.9f)); + ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, Vector2.Zero); + ImGui.PushStyleVar(ImGuiStyleVar.WindowBorderSize, 0); + + if (ImGui.Begin("ImagePreview", ref showImagePreview, + ImGuiWindowFlags.NoDecoration | ImGuiWindowFlags.NoMove | ImGuiWindowFlags.NoResize)) + { + IDalamudTextureWrap? texture = null; + + if (File.Exists(imagePreviewUrl!)) + { + texture = Plugin.TextureProvider.GetFromFile(imagePreviewUrl!).GetWrapOrDefault(); + } + + if (texture != null) + { + var windowSize = ImGui.GetWindowSize(); + var imageSize = new Vector2(texture.Width, texture.Height); + + float scaleX = windowSize.X * 0.9f / imageSize.X; + float scaleY = windowSize.Y * 0.9f / imageSize.Y; + float imageScale = Math.Min(scaleX, scaleY); + + var displaySize = imageSize * imageScale; + var startPos = (windowSize - displaySize) * 0.5f; + + ImGui.SetCursorPos(startPos); + ImGui.Image(texture.ImGuiHandle, displaySize); + } + else + { + // Show loading or error message + var windowSize = ImGui.GetWindowSize(); + var textSize = ImGui.CalcTextSize("Loading image..."); + var textPos = (windowSize - textSize) * 0.5f; + ImGui.SetCursorPos(textPos); + ImGui.Text("Loading image..."); + } + + // Click anywhere to close + if (ImGui.IsWindowHovered() && ImGui.IsMouseClicked(ImGuiMouseButton.Left)) + showImagePreview = false; + } + ImGui.End(); + + ImGui.PopStyleVar(2); + ImGui.PopStyleColor(); + } + private string? GetCurrentImagePath() + { + var rp = CurrentProfile; + if (rp == null) return null; + + // For external profiles, check if we have a downloaded image + if (showingExternal && !string.IsNullOrEmpty(rp.ProfileImageUrl)) + { + if (imageDownloadComplete && File.Exists(downloadedImagePath)) + return downloadedImagePath; + else + return null; // Still downloading or failed + } + + // For local profiles, prefer custom image path + if (!string.IsNullOrEmpty(rp.CustomImagePath) && File.Exists(rp.CustomImagePath)) + { + return rp.CustomImagePath; + } + + // Fall back to character image path + if (!showingExternal && character?.ImagePath is { Length: > 0 } ip && File.Exists(ip)) + { + return ip; + } + + // Don't preview the default image + return null; + } + + + private void DrawProfileContent(RPProfile rp, float scale) + { + var dl = ImGui.GetWindowDrawList(); + var wndPos = ImGui.GetWindowPos(); + var wndSize = ImGui.GetWindowSize(); + + var contentStartY = 65f * scale; + var bioStartY = Math.Max(280f * scale, wndSize.Y * 0.48f); + var animationStartY = Math.Max(bioStartY + (120f * scale), wndSize.Y * 0.85f); + + bool hasCustomBackground = !string.IsNullOrEmpty(rp.BackgroundImage); + + if (hasCustomBackground) + { + DrawCustomBackground(dl, wndPos, wndSize, rp.BackgroundImage!, scale); } else { - ImGui.Text("No profile available."); - return; + var theme = rp.AnimationTheme ?? ProfileAnimationTheme.CircuitBoard; + + if (theme == ProfileAnimationTheme.Nature) + { + DrawFullWindowForestBackground(dl, wndPos, wndSize); + } + else if (theme == ProfileAnimationTheme.DarkGothic) + { + DrawFullWindowGothicBackground(dl, wndPos, wndSize); + } + else if (theme == ProfileAnimationTheme.MagicalParticles) + { + DrawFullWindowMagicalBackground(dl, wndPos, wndSize); + } + else + { + var animationHeight = wndSize.Y - animationStartY - (10f * scale); + DrawNeonGlow(dl, wndPos, wndSize, scale); + DrawMainBackground(dl, wndPos, wndSize, scale); + DrawAnimationTheme(dl, wndPos, wndSize, animationStartY, animationHeight, theme, scale); + DrawAnimationFadeIn(dl, wndPos, wndSize, bioStartY, animationStartY, scale); + } } - ImGui.PushTextWrapPos(); - // Top Bar + DrawEnhancedBorders(dl, wndPos, wndSize, scale); + DrawEnhancedHeader(scale); + ImGui.SetCursorPos(new Vector2(20 * scale, contentStartY)); + + bool needsContentBackground = hasCustomBackground; + if (!hasCustomBackground) + { + var theme = rp.AnimationTheme ?? ProfileAnimationTheme.CircuitBoard; + needsContentBackground = (theme == ProfileAnimationTheme.Nature || + theme == ProfileAnimationTheme.DarkGothic || + theme == ProfileAnimationTheme.MagicalParticles); + } + + if (needsContentBackground) + { + var headerHeight = 45f * scale; + var borderThickness = 2f * scale; + var contentBg = new Vector4(0.02f, 0.05f, 0.1f, 0.85f); + + dl.AddRectFilled( + wndPos + new Vector2(borderThickness, headerHeight + (1 * scale)), + wndPos + new Vector2(wndSize.X - borderThickness, bioStartY + (120f * scale)), + ImGui.ColorConvertFloat4ToU32(contentBg), + 0f + ); + } + + var contentHeight = bioStartY + (120f * scale) - contentStartY; + ImGui.BeginChild("##Content", new Vector2(wndSize.X - (40 * scale), contentHeight), false, ImGuiWindowFlags.NoScrollbar); + + ImGui.BeginGroup(); + DrawPortraitSection(rp, scale); + DrawTagsSection(rp, scale); + ImGui.EndGroup(); + + ImGui.SameLine(); + ImGui.SetCursorPosX(ImGui.GetCursorPosX() + (15f * scale)); + + ImGui.BeginGroup(); + DrawNameSection(rp, scale); + ImGui.Spacing(); + DrawExtendedFieldsAligned(rp, scale); + ImGui.EndGroup(); + + ImGui.Spacing(); + ImGui.Spacing(); + + DrawResponsiveBioSection(rp, wndSize, scale); + + ImGui.EndChild(); + + if (hasCustomBackground) + { + DrawModularEffects(dl, wndPos, wndSize, rp, scale); + } + else + { + var theme = rp.AnimationTheme ?? ProfileAnimationTheme.CircuitBoard; + + if (theme == ProfileAnimationTheme.Nature) + { + DrawAnimatedPNGLeaves(dl, wndPos, wndSize.X, wndSize.Y, scale); + DrawAnimatedFireflies(dl, wndPos, wndSize.X, wndSize.Y, scale); + } + + // Dark Gothic effects + if (theme == ProfileAnimationTheme.DarkGothic) + { + var deltaTime = ImGui.GetIO().DeltaTime; + animationTime += deltaTime; + + DrawPulsingWindows(dl, wndPos, wndSize.X, wndSize.Y, ResolveNameplateColor(), scale); + DrawDriftingSmoke(dl, wndPos, wndSize.X, wndSize.Y, ResolveNameplateColor(), scale); + DrawAnimatedPixelBats(dl, wndPos, wndSize.X, wndSize.Y, scale); + DrawAnimatedFireSprites(dl, wndPos, wndSize.X, wndSize.Y, scale); + DrawGothicParticles(dl, wndPos, wndSize.X, wndSize.Y, ResolveNameplateColor(), scale); + } + if (theme == ProfileAnimationTheme.MagicalParticles) + { + DrawMagicalSparkles(dl, wndPos, wndSize.X, wndSize.Y, scale); + DrawMagicalDustMotes(dl, wndPos, wndSize.X, wndSize.Y, scale); + DrawEnhancedLanternGlow(dl, wndPos, wndSize.X, wndSize.Y, scale); + DrawAnimatedMagicalButterflies(dl, wndPos, wndSize.X, wndSize.Y, scale); + } + } + DrawFloatingActionButton(wndPos, wndSize, animationStartY, scale); + DrawResizeHandle(wndPos, wndSize, scale); + } + + private void DrawResponsiveBioSection(RPProfile rp, Vector2 windowSize, float scale) + { + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.9f, 0.9f, 0.7f, 1f)); + ImGui.Text("Biography:"); + ImGui.PopStyleColor(); + + var bioText = rp.Bio ?? "No biography available."; + + var remainingHeight = ImGui.GetContentRegionAvail().Y - (20f * scale); + var baseHeight = 120f * scale; // Minimum bio height + var scaledHeight = Math.Max(baseHeight, remainingHeight * 0.8f); + + ImGui.PushStyleColor(ImGuiCol.ChildBg, new Vector4(0.05f, 0.05f, 0.1f, 0.6f)); + ImGui.PushStyleColor(ImGuiCol.Border, new Vector4(0.3f, 0.3f, 0.4f, 0.5f)); + + ImGui.BeginChild("##RPBio", new Vector2(-1, scaledHeight), true, ImGuiWindowFlags.AlwaysVerticalScrollbar); + + ImGui.SetCursorPos(new Vector2(8f * scale, 8f * scale)); + + var availableWidth = ImGui.GetContentRegionAvail().X - (16f * scale); + + ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(4 * scale, 6 * scale)); + + ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + availableWidth); + ImGui.TextWrapped(bioText); + ImGui.PopTextWrapPos(); + + ImGui.PopStyleVar(); + ImGui.EndChild(); + + ImGui.PopStyleColor(2); + } + private void OpenUrl(string url) + { + try + { + if (!url.StartsWith("http://") && !url.StartsWith("https://")) + { + url = "https://" + url; + } + + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = url, + UseShellExecute = true + }); + } + catch (Exception ex) + { + Plugin.Log.Error($"Failed to open URL: {ex.Message}"); + } + } + + private void DrawResizeHandle(Vector2 wndPos, Vector2 wndSize, float scale) + { + var dl = ImGui.GetWindowDrawList(); var color = ResolveNameplateColor(); - var topColor = new Vector4(color.X, color.Y, color.Z, 1f); - var drawList = ImGui.GetWindowDrawList(); - var topLeft = ImGui.GetCursorScreenPos(); - drawList.AddRectFilled(topLeft, topLeft + new Vector2(600, 6), ImGui.ColorConvertFloat4ToU32(topColor)); - ImGui.Dummy(new Vector2(1, 10)); // spacer + var handleSize = 20f * scale; + var handlePos = wndPos + wndSize - new Vector2(handleSize, handleSize); - ImGui.BeginChild("ProfileCard", new Vector2(600, 210), false); + ImGui.SetCursorScreenPos(handlePos); + + // Invisible button to capture mouse input + ImGui.InvisibleButton("##ResizeHandle", new Vector2(handleSize, handleSize)); + + // Change cursor when hovering + if (ImGui.IsItemHovered()) + { + ImGui.SetMouseCursor(ImGuiMouseCursor.ResizeNWSE); + } + + // Handle dragging + if (ImGui.IsItemActive() && ImGui.IsMouseDragging(ImGuiMouseButton.Left)) + { + var mouseDelta = ImGui.GetIO().MouseDelta; + var currentSize = ImGui.GetWindowSize(); + var newSize = currentSize + mouseDelta; + + // Maintain aspect ratio (420:580 = 0.724) more math!!! + var aspectRatio = 420f / 580f; + + if (Math.Abs(mouseDelta.X) > Math.Abs(mouseDelta.Y)) + { + newSize.Y = newSize.X / aspectRatio; + } + else + { + newSize.X = newSize.Y * aspectRatio; + } + + newSize = Vector2.Max(newSize, new Vector2(420 * scale, 580 * scale)); + + ImGui.SetWindowSize(newSize); + } + + var lineColor = new Vector4(color.X, color.Y, color.Z, 0.6f); + uint lineU32 = ImGui.ColorConvertFloat4ToU32(lineColor); + + var visualPos = handlePos + new Vector2(5f * scale, 5f * scale); + for (int i = 0; i < 3; i++) + { + var offset = i * (3f * scale); + dl.AddLine( + visualPos + new Vector2(offset, 10f * scale), + visualPos + new Vector2(10f * scale, offset), + lineU32, 1.5f * scale + ); + } + } + + // Custom background drawing + private void DrawCustomBackground(ImDrawListPtr dl, Vector2 wndPos, Vector2 wndSize, string backgroundImage, float scale) + { + try + { + string pluginDirectory = Plugin.PluginInterface.AssemblyLocation.DirectoryName ?? ""; + string assetsPath = Path.Combine(pluginDirectory, "Assets", "Backgrounds"); + + string imagePath = Path.Combine(assetsPath, backgroundImage); + + if (!File.Exists(imagePath) && !backgroundImage.EndsWith(".jpg")) + { + imagePath = Path.Combine(assetsPath, $"{backgroundImage}.jpg"); + } + + if (!File.Exists(imagePath)) + { + if (Directory.Exists(assetsPath)) + { + var files = Directory.GetFiles(assetsPath); + } + else + { + Plugin.Log.Info($"Directory does not exist: {assetsPath}"); + } + DrawNeonGlow(dl, wndPos, wndSize, scale); + DrawMainBackground(dl, wndPos, wndSize, scale); + return; + } + + var texture = Plugin.TextureProvider.GetFromFile(imagePath).GetWrapOrDefault(); + if (texture != null) + { + Vector2 imageSize = new Vector2(texture.Width, texture.Height); + float scaleX = wndSize.X / imageSize.X; + float scaleY = wndSize.Y / imageSize.Y; + float imageScale = Math.Max(scaleX, scaleY) * 1.1f; + + Vector2 scaledSize = imageSize * imageScale; + Vector2 offset = (wndSize - scaledSize) * 0.5f; + + dl.PushClipRect(wndPos, wndPos + wndSize, true); + dl.AddImage(texture.ImGuiHandle, + wndPos + offset, + wndPos + offset + scaledSize, + Vector2.Zero, + Vector2.One, + ImGui.ColorConvertFloat4ToU32(new Vector4(1f, 1f, 1f, 1.0f)) + ); + dl.PopClipRect(); + } + else + { + DrawNeonGlow(dl, wndPos, wndSize, scale); + DrawMainBackground(dl, wndPos, wndSize, scale); + } + } + catch (Exception ex) + { + Plugin.Log.Error($"Error in DrawCustomBackground: {ex.Message}"); + DrawNeonGlow(dl, wndPos, wndSize, scale); + DrawMainBackground(dl, wndPos, wndSize, scale); + } + } + + private void DrawModularEffects(ImDrawListPtr dl, Vector2 wndPos, Vector2 wndSize, RPProfile rp, float scale) + { + // Update animation time once + var deltaTime = ImGui.GetIO().DeltaTime; + animationTime += deltaTime; + + if (rp.Effects?.Fireflies == true) + DrawAnimatedFireflies(dl, wndPos, wndSize.X, wndSize.Y, scale); + + if (rp.Effects?.FallingLeaves == true) + DrawAnimatedPNGLeaves(dl, wndPos, wndSize.X, wndSize.Y, scale); + + if (rp.Effects?.Butterflies == true) + DrawAnimatedMagicalButterflies(dl, wndPos, wndSize.X, wndSize.Y, scale); + + if (rp.Effects?.Bats == true) + DrawAnimatedPixelBats(dl, wndPos, wndSize.X, wndSize.Y, scale); + + if (rp.Effects?.Fire == true) + DrawAnimatedFireSprites(dl, wndPos, wndSize.X, wndSize.Y, scale); + + if (rp.Effects?.Smoke == true) + DrawDriftingSmoke(dl, wndPos, wndSize.X, wndSize.Y, ResolveNameplateColor(), scale); + + if (rp.Effects?.CircuitBoard == true) + { + var animationStartY = (350f + 15f) * scale; + var animationHeight = wndSize.Y - animationStartY - (10f * scale); + var animationArea = wndPos + new Vector2(10 * scale, animationStartY); + var animationSize = new Vector2(wndSize.X - (20f * scale), animationHeight); + DrawCircuitBoardAnimation(dl, animationArea, animationSize.X, animationSize.Y, scale); + } + } + + private void DrawAnimationTheme(ImDrawListPtr dl, Vector2 wndPos, Vector2 wndSize, float startY, float height, ProfileAnimationTheme theme, float scale) + { + var animationArea = wndPos + new Vector2(10 * scale, startY); + var animationSize = new Vector2(wndSize.X - (20f * scale), height); + + switch (theme) + { + case ProfileAnimationTheme.CircuitBoard: + DrawCircuitBoardAnimation(dl, animationArea, animationSize.X, animationSize.Y, scale); + break; + case ProfileAnimationTheme.Minimalist: + DrawMinimalistAnimation(dl, animationArea, animationSize.X, animationSize.Y, scale); + break; + case ProfileAnimationTheme.MagicalParticles: + DrawMagicalParticlesAnimation(dl, animationArea, animationSize.X, animationSize.Y, scale); + break; + } + } + + private void DrawExtendedFieldsAligned(RPProfile rp, float scale) + { + var labels = new[] { "Race:", "Gender:", "Age:", "Occupation:", "Orientation:", "Relationship:" }; + float maxLabelWidth = 0f; + foreach (var label in labels) + { + var width = ImGui.CalcTextSize(label).X; + if (width > maxLabelWidth) maxLabelWidth = width; + } + maxLabelWidth += (8f * scale); + + DrawAlignedInfoField("Race", rp.Race, maxLabelWidth, scale); + DrawAlignedInfoField("Gender", rp.Gender, maxLabelWidth, scale); + DrawAlignedInfoField("Age", rp.Age, maxLabelWidth, scale); + DrawAlignedInfoField("Occupation", rp.Occupation, maxLabelWidth, scale); + DrawAlignedInfoField("Orientation", rp.Orientation, maxLabelWidth, scale); + DrawAlignedInfoField("Relationship", rp.Relationship, maxLabelWidth, scale); + + if (!string.IsNullOrWhiteSpace(rp.Abilities)) + { + ImGui.Spacing(); + DrawAlignedInfoField("Abilities", rp.Abilities, maxLabelWidth, scale); + } + } + + private void DrawAlignedInfoField(string label, string? value, float labelWidth, float scale) + { + if (string.IsNullOrWhiteSpace(value)) return; - // Left (Image + Tags) ImGui.BeginGroup(); - string fallback = Path.Combine(plugin.PluginDirectory, "Assets", "Default.png"); - string? imagePath = null; + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.9f, 0.9f, 0.7f, 1f)); + ImGui.Text($"{label}:"); + ImGui.PopStyleColor(); - // If viewing external profile and has ProfileImageUrl - if (showingExternal && !string.IsNullOrEmpty(rp.ProfileImageUrl)) + ImGui.SameLine(); + ImGui.SetCursorPosX(ImGui.GetCursorPosX() + (labelWidth - ImGui.CalcTextSize($"{label}:").X)); + + var availableWidth = ImGui.GetContentRegionAvail().X; + var textWidth = ImGui.CalcTextSize(value).X; + + if (textWidth > availableWidth) { - if (!imageDownloadStarted) + var truncatedText = value; + while (ImGui.CalcTextSize(truncatedText + "...").X > availableWidth && truncatedText.Length > 0) { - imageDownloadStarted = true; - Task.Run(() => - { - try - { - using var client = new System.Net.Http.HttpClient(); - var data = client.GetByteArrayAsync(rp.ProfileImageUrl).GetAwaiter().GetResult(); - - var hash = Convert.ToBase64String(System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes(rp.ProfileImageUrl))) - .Replace("/", "_").Replace("+", "-"); - string fileName = $"RPImage_{hash}.png"; - string path = Path.Combine(Plugin.PluginInterface.GetPluginConfigDirectory(), fileName); - - // Save once only if not exists - File.WriteAllBytes(path, data); - Plugin.Log.Debug($"[RPProfileView] Downloaded image to: {path}"); - - - downloadedImagePath = path; - imageDownloadComplete = true; - - // Force window to update and focus - bringToFront = true; - - } - catch (Exception ex) - { - Plugin.Log.Error($"[RPProfileViewWindow] Failed to download profile image: {ex.Message}"); - imageDownloadComplete = true; - } - }); + truncatedText = truncatedText.Substring(0, truncatedText.Length - 1); } + truncatedText += "..."; - - if (imageDownloadComplete && File.Exists(downloadedImagePath)) + ImGui.Text(truncatedText); + if (ImGui.IsItemHovered()) { - imagePath = downloadedImagePath; + ImGui.SetTooltip(value); } } - // Local fallback options + else + { + ImGui.Text(value); + } + + ImGui.EndGroup(); + } + + private void DrawCircuitBoardAnimation(ImDrawListPtr dl, Vector2 startPos, float width, float height, float scale) + { + var color = ResolveNameplateColor(); + var deltaTime = ImGui.GetIO().DeltaTime; + animationTime += deltaTime; + + for (int i = 0; i < circuitPacketPositions.Length; i++) + { + circuitPacketPositions[i] += circuitPacketSpeeds[i] * deltaTime; + if (circuitPacketPositions[i] > width + (20f * scale)) + { + circuitPacketPositions[i] = -(20f * scale); + } + } + + // Draw circuit grid + var gridColor = new Vector4(color.X, color.Y, color.Z, 0.08f); + uint gridU32 = ImGui.ColorConvertFloat4ToU32(gridColor); + + for (float y = 0; y <= height; y += (8f * scale)) + { + dl.AddLine(startPos + new Vector2(0, y), startPos + new Vector2(width, y), gridU32, 0.5f * scale); + } + for (float x = 0; x <= width; x += (12f * scale)) + { + dl.AddLine(startPos + new Vector2(x, 0), startPos + new Vector2(x, height), gridU32, 0.5f * scale); + } + + // Draw pulsing connection lines + var connectionCount = Math.Max(6, (int)(height / (25f * scale))); + for (int i = 0; i < connectionCount; i++) + { + var y = (height / (connectionCount + 1)) * (i + 1); + var pulse = 0.2f + 0.5f * (float)Math.Sin(animationTime * 1.8f + i * 0.8f); + var lineColor = new Vector4(color.X, color.Y, color.Z, pulse); + uint lineU32 = ImGui.ColorConvertFloat4ToU32(lineColor); + + dl.AddLine(startPos + new Vector2(0, y), startPos + new Vector2(width, y), lineU32, 1.5f * scale); + + // Connection branches + for (float x = 25f * scale; x < width - (25f * scale); x += (40f * scale)) + { + var branchHeight = 6f * scale; + var branchColor = new Vector4(color.X, color.Y, color.Z, pulse * 0.6f); + uint branchU32 = ImGui.ColorConvertFloat4ToU32(branchColor); + + dl.AddLine(startPos + new Vector2(x, y - branchHeight), startPos + new Vector2(x, y + branchHeight), branchU32, 1f * scale); + dl.AddLine(startPos + new Vector2(x - (6f * scale), y - branchHeight), startPos + new Vector2(x + (6f * scale), y - branchHeight), branchU32, 1f * scale); + dl.AddLine(startPos + new Vector2(x - (6f * scale), y + branchHeight), startPos + new Vector2(x + (6f * scale), y + branchHeight), branchU32, 1f * scale); + } + } + + // Draw glowing nodes + var nodesPerRow = 8; + var rows = Math.Max(3, (int)(height / (30f * scale))); + for (int row = 0; row < rows; row++) + { + for (int col = 0; col < nodesPerRow; col++) + { + var nodeX = (width / (nodesPerRow + 1)) * (col + 1); + var nodeY = (height / (rows + 1)) * (row + 1); + var nodePos = startPos + new Vector2(nodeX, nodeY); + + var nodeIndex = row * nodesPerRow + col; + var phaseOffset = nodeIndex < circuitNodeGlowPhases.Length ? circuitNodeGlowPhases[nodeIndex % circuitNodeGlowPhases.Length] : 0f; + var glow = 0.3f + 0.7f * (float)Math.Sin(animationTime * 1.3f + phaseOffset); + + var glowColor = new Vector4(color.X, color.Y, color.Z, glow * 0.3f); + dl.AddCircleFilled(nodePos, 5f * scale, ImGui.ColorConvertFloat4ToU32(glowColor)); + + var coreColor = new Vector4(color.X, color.Y, color.Z, glow); + dl.AddCircleFilled(nodePos, 2.5f * scale, ImGui.ColorConvertFloat4ToU32(coreColor)); + } + } + + var pathCount = Math.Max(3, (int)(height / (40f * scale))); + for (int i = 0; i < circuitPacketPositions.Length; i++) + { + var pathIndex = i % pathCount; + var packetY = (height / (pathCount + 1)) * (pathIndex + 1); + var packetX = circuitPacketPositions[i]; + + if (packetX >= 0 && packetX <= width) + { + var packetPos = startPos + new Vector2(packetX, packetY); + + for (int t = 0; t < 8; t++) + { + var trailX = packetX - t * (4f * scale); + if (trailX >= 0) + { + var trailAlpha = (1f - t / 8f) * 0.7f; + var trailColor = new Vector4(color.X, color.Y, color.Z, trailAlpha); + var trailPos = startPos + new Vector2(trailX, packetY); + dl.AddCircleFilled(trailPos, (2f - t * 0.15f) * scale, ImGui.ColorConvertFloat4ToU32(trailColor)); + } + } + var packetColor = new Vector4(color.X * 1.3f, color.Y * 1.3f, color.Z * 1.3f, 0.9f); + dl.AddCircleFilled(packetPos, 2.5f * scale, ImGui.ColorConvertFloat4ToU32(packetColor)); + } + } + } + + private void DrawMinimalistAnimation(ImDrawListPtr dl, Vector2 startPos, float width, float height, float scale) + { + var color = ResolveNameplateColor(); + var deltaTime = ImGui.GetIO().DeltaTime; + animationTime += deltaTime; + + var breathPulse = 0.3f + 0.4f * (float)Math.Sin(animationTime * 0.8f); + var glowColor = new Vector4(color.X, color.Y, color.Z, breathPulse); + + var barHeight = 6f * scale; + var barY = startPos.Y + height - barHeight - (10f * scale); + + // Glow effect + dl.AddRectFilled( + new Vector2(startPos.X, barY - (2f * scale)), + new Vector2(startPos.X + width, barY + barHeight + (2f * scale)), + ImGui.ColorConvertFloat4ToU32(new Vector4(color.X, color.Y, color.Z, breathPulse * 0.3f)) + ); + + dl.AddRectFilled( + new Vector2(startPos.X, barY), + new Vector2(startPos.X + width, barY + barHeight), + ImGui.ColorConvertFloat4ToU32(glowColor) + ); + + for (int i = 0; i < 3; i++) + { + var shapeX = startPos.X + (width / 4f) * (i + 1); + var shapeY = startPos.Y + height * 0.6f; + var shapePulse = 0.1f + 0.3f * (float)Math.Sin(animationTime * 0.6f + i * 2f); + var shapeColor = new Vector4(color.X, color.Y, color.Z, shapePulse); + + dl.AddCircleFilled(new Vector2(shapeX, shapeY), 8f * scale, ImGui.ColorConvertFloat4ToU32(shapeColor)); + } + } + + private void DrawMagicalParticlesAnimation(ImDrawListPtr dl, Vector2 startPos, float width, float height, float scale) + { + } + + private void DrawFullWindowForestBackground(ImDrawListPtr dl, Vector2 wndPos, Vector2 wndSize) + { + try + { + string pluginDirectory = Plugin.PluginInterface.AssemblyLocation.DirectoryName ?? ""; + string assetsPath = Path.Combine(pluginDirectory, "Assets"); + string imagePath = Path.Combine(assetsPath, "forest_background.png"); + + if (!File.Exists(imagePath)) + { + Vector4 topColor = new Vector4(0.1f, 0.1f, 0.2f, 1f); + Vector4 bottomColor = new Vector4(0.05f, 0.15f, 0.05f, 1f); + + for (int y = 0; y < wndSize.Y; y += 2) + { + float t = y / wndSize.Y; + Vector4 color = Vector4.Lerp(topColor, bottomColor, t); + uint colorU32 = ImGui.ColorConvertFloat4ToU32(color); + + dl.AddRectFilled( + wndPos + new Vector2(0, y), + wndPos + new Vector2(wndSize.X, y + 2), + colorU32 + ); + } + return; + } + + var texture = Plugin.TextureProvider.GetFromFile(imagePath).GetWrapOrDefault(); + if (texture != null) + { + Vector2 imageSize = new Vector2(texture.Width, texture.Height); + float scaleX = wndSize.X / imageSize.X; + float scaleY = wndSize.Y / imageSize.Y; + float scale = Math.Max(scaleX, scaleY) * 1.1f; + + Vector2 scaledSize = imageSize * scale; + Vector2 offset = (wndSize - scaledSize) * 0.5f; + float backgroundAlpha = 1.0f; + dl.AddImage( + texture.ImGuiHandle, + wndPos + offset, + wndPos + offset + scaledSize, + Vector2.Zero, + Vector2.One, + ImGui.ColorConvertFloat4ToU32(new Vector4(1f, 1f, 1f, backgroundAlpha)) + ); + } + } + catch (Exception) + { + dl.AddRectFilled(wndPos, wndPos + wndSize, ImGui.ColorConvertFloat4ToU32(new Vector4(0.05f, 0.1f, 0.15f, 1f))); + } + } + + private void DrawFullWindowGothicBackground(ImDrawListPtr dl, Vector2 wndPos, Vector2 wndSize) + { + try + { + string pluginDirectory = Plugin.PluginInterface.AssemblyLocation.DirectoryName ?? ""; + string assetsPath = Path.Combine(pluginDirectory, "Assets"); + string imagePath = Path.Combine(assetsPath, "gothic_background.png"); + + if (!File.Exists(imagePath)) + { + Vector4 topColor = new Vector4(0.05f, 0.08f, 0.15f, 1f); + Vector4 bottomColor = new Vector4(0.02f, 0.02f, 0.05f, 1f); + + for (int y = 0; y < wndSize.Y; y += 2) + { + float t = y / wndSize.Y; + Vector4 color = Vector4.Lerp(topColor, bottomColor, t); + uint colorU32 = ImGui.ColorConvertFloat4ToU32(color); + + dl.AddRectFilled( + wndPos + new Vector2(0, y), + wndPos + new Vector2(wndSize.X, y + 2), + colorU32 + ); + } + return; + } + + var texture = Plugin.TextureProvider.GetFromFile(imagePath).GetWrapOrDefault(); + if (texture != null) + { + Vector2 imageSize = new Vector2(texture.Width, texture.Height); + float scaleX = wndSize.X / imageSize.X; + float scaleY = wndSize.Y / imageSize.Y; + float scale = Math.Max(scaleX, scaleY) * 1.2f; + + Vector2 scaledSize = imageSize * scale; + Vector2 offset = new Vector2((wndSize.X - scaledSize.X) * 0.5f, (wndSize.Y - scaledSize.Y) * 0.2f); + + dl.AddImage( + texture.ImGuiHandle, + wndPos + offset, + wndPos + offset + scaledSize, + Vector2.Zero, + Vector2.One, + ImGui.ColorConvertFloat4ToU32(new Vector4(1f, 1f, 1f, 1.0f)) + ); + } + } + catch (Exception) + { + dl.AddRectFilled(wndPos, wndPos + wndSize, ImGui.ColorConvertFloat4ToU32(new Vector4(0.05f, 0.08f, 0.15f, 1f))); + } + } + + private void DrawFullWindowMagicalBackground(ImDrawListPtr dl, Vector2 wndPos, Vector2 wndSize) + { + try + { + string pluginDirectory = Plugin.PluginInterface.AssemblyLocation.DirectoryName ?? ""; + string assetsPath = Path.Combine(pluginDirectory, "Assets"); + string imagePath = Path.Combine(assetsPath, "magical_background.png"); + + if (!File.Exists(imagePath)) + { + Vector4 topColor = new Vector4(0.05f, 0.15f, 0.25f, 1f); // Deep magical blue + Vector4 bottomColor = new Vector4(0.15f, 0.08f, 0.05f, 1f); // Warm brown + + for (int y = 0; y < wndSize.Y; y += 2) + { + float t = y / wndSize.Y; + Vector4 color = Vector4.Lerp(topColor, bottomColor, t); + uint colorU32 = ImGui.ColorConvertFloat4ToU32(color); + + dl.AddRectFilled( + wndPos + new Vector2(0, y), + wndPos + new Vector2(wndSize.X, y + 2), + colorU32 + ); + } + return; + } + + var texture = Plugin.TextureProvider.GetFromFile(imagePath).GetWrapOrDefault(); + if (texture != null) + { + Vector2 imageSize = new Vector2(texture.Width, texture.Height); + + float scaleX = wndSize.X / imageSize.X; + float scaleY = wndSize.Y / imageSize.Y; + float scale = Math.Max(scaleX, scaleY) * 1.1f; + + Vector2 scaledSize = imageSize * scale; + Vector2 offset = (wndSize - scaledSize) * 0.5f; + + dl.PushClipRect(wndPos, wndPos + wndSize, true); + + dl.AddImage( + texture.ImGuiHandle, + wndPos + offset, + wndPos + offset + scaledSize, + Vector2.Zero, + Vector2.One, + ImGui.ColorConvertFloat4ToU32(new Vector4(1f, 1f, 1f, 1.0f)) + ); + + dl.PopClipRect(); + } + } + catch (Exception) + { + dl.AddRectFilled(wndPos, wndPos + wndSize, ImGui.ColorConvertFloat4ToU32(new Vector4(0.08f, 0.12f, 0.18f, 1f))); + } + } + + private void DrawAnimatedFireflies(ImDrawListPtr dl, Vector2 startPos, float width, float height, float scale) + { + var deltaTime = ImGui.GetIO().DeltaTime; + animationTime += deltaTime; + + var rp = CurrentProfile; + Vector4[] particleColors = GetParticleColors(rp); + + for (int i = 0; i < 10; i++) + { + float fireflyTime = animationTime * (0.4f + i * 0.04f) + i * 1.5f; + + float baseX = width * (0.1f + (i * 0.08f)); + float baseY = height * (0.2f + (i % 5) * 0.15f); + float primaryX = (float)Math.Sin(fireflyTime * 0.3f) * (35f * scale); + float primaryY = (float)Math.Cos(fireflyTime * 0.25f) * (20f * scale); + float secondaryX = (float)Math.Sin(fireflyTime * 1.1f + i * 0.4f) * (10f * scale); + float secondaryY = (float)Math.Cos(fireflyTime * 1.3f + i * 0.3f) * (6f * scale); + float microX = (float)Math.Sin(fireflyTime * 3.2f + i * 1.1f) * (2f * scale); + float microY = (float)Math.Cos(fireflyTime * 3.8f + i * 0.7f) * (1.5f * scale); + + Vector2 fireflyPos = startPos + new Vector2( + baseX + primaryX + secondaryX + microX, + baseY + primaryY + secondaryY + microY + ); + + Vector4 fireflyColor = particleColors[i % particleColors.Length]; + Vector4 fireflyGlow = fireflyColor * 0.45f; + + float primaryPulse = (float)Math.Sin(fireflyTime * 2.5f + i * 0.6f) * 0.35f; + float secondaryPulse = (float)Math.Sin(fireflyTime * 4.2f + i * 0.3f) * 0.15f; + float magicalShimmer = (float)Math.Sin(fireflyTime * 6.8f + i * 0.8f) * 0.1f; + + float pulse = Math.Clamp(0.5f + primaryPulse + secondaryPulse + magicalShimmer, 0.25f, 1f); + Vector4 currentFirefly = fireflyColor * pulse; + Vector4 currentGlow = fireflyGlow * (pulse * 0.8f); + + Vector2 pixelPos = new Vector2((float)Math.Floor(fireflyPos.X), (float)Math.Floor(fireflyPos.Y)); + + for (int glowRing = 4; glowRing >= 1; glowRing--) + { + float glowAlpha = currentGlow.W * (1f - (glowRing - 1) * 0.22f); + Vector4 ringGlow = new Vector4(currentGlow.X, currentGlow.Y, currentGlow.Z, glowAlpha); + float ringSize = glowRing * 1.8f * scale; + dl.AddRectFilled( + pixelPos + new Vector2(-ringSize, -ringSize), + pixelPos + new Vector2(ringSize + (2 * scale), ringSize + (2 * scale)), + ImGui.ColorConvertFloat4ToU32(ringGlow) + ); + } + + dl.AddRectFilled(pixelPos, pixelPos + new Vector2(2 * scale, 2 * scale), ImGui.ColorConvertFloat4ToU32(currentFirefly)); + + float flashChance = (float)Math.Sin(fireflyTime * 3.5f + i * 1.2f); + if (flashChance > 0.7f) + { + float flashIntensity = (flashChance - 0.7f) / 0.3f; + Vector4 flashColor = fireflyColor * (1f + flashIntensity * 0.6f); + dl.AddRectFilled(pixelPos, pixelPos + new Vector2(3 * scale, 3 * scale), ImGui.ColorConvertFloat4ToU32(flashColor)); + } + } + } + + private Vector4[] GetParticleColors(RPProfile rp) + { + if (rp?.Effects == null) + { + var nameplateColor = ResolveNameplateColor(); + return new Vector4[] { + new Vector4(nameplateColor.X, nameplateColor.Y, nameplateColor.Z, 0.9f), + new Vector4(nameplateColor.X * 1.2f, nameplateColor.Y * 1.2f, nameplateColor.Z * 1.2f, 0.9f), + new Vector4(nameplateColor.X * 0.8f, nameplateColor.Y * 0.8f, nameplateColor.Z * 0.8f, 0.9f), + new Vector4(nameplateColor.X * 0.9f, nameplateColor.Y * 0.9f, nameplateColor.Z * 0.9f, 0.9f) + }; + } + + switch (rp.Effects.ColorScheme) + { + case ParticleColorScheme.Warm: + return new Vector4[] { + new Vector4(1f, 0.8f, 0.4f, 0.9f), // Warm yellow + new Vector4(1f, 0.6f, 0.2f, 0.9f), // Orange + new Vector4(1f, 0.9f, 0.5f, 0.9f), // Light yellow + new Vector4(0.9f, 0.7f, 0.3f, 0.9f) // Gold + }; + + case ParticleColorScheme.Cool: + return new Vector4[] { + new Vector4(0.4f, 0.8f, 1f, 0.9f), // Light blue + new Vector4(0.3f, 0.7f, 0.9f, 0.9f), // Blue + new Vector4(0.5f, 0.9f, 1f, 0.9f), // Cyan + new Vector4(0.6f, 0.8f, 0.9f, 0.9f) // Light blue-gray + }; + + case ParticleColorScheme.Forest: + return new Vector4[] { + new Vector4(0.4f, 0.8f, 0.3f, 0.9f), // Green + new Vector4(0.6f, 0.9f, 0.4f, 0.9f), // Light green + new Vector4(0.3f, 0.7f, 0.2f, 0.9f), // Dark green + new Vector4(0.5f, 0.8f, 0.6f, 0.9f) // Mint green + }; + + case ParticleColorScheme.Magical: + return new Vector4[] { + new Vector4(0.8f, 0.4f, 1f, 0.9f), // Purple + new Vector4(0.6f, 0.8f, 1f, 0.9f), // Light purple-blue + new Vector4(0.9f, 0.5f, 0.9f, 0.9f), // Pink + new Vector4(0.7f, 0.6f, 1f, 0.9f) // Lavender + }; + + case ParticleColorScheme.Winter: + return new Vector4[] { + new Vector4(1f, 1f, 1f, 0.9f), // White + new Vector4(0.9f, 0.9f, 1f, 0.9f), // Light blue-white + new Vector4(0.8f, 0.9f, 1f, 0.9f), // Ice blue + new Vector4(0.95f, 0.95f, 0.95f, 0.9f) // Silver + }; + + case ParticleColorScheme.Custom: + var customColor = rp.Effects.CustomParticleColor; + return new Vector4[] { + new Vector4(customColor.X, customColor.Y, customColor.Z, 0.9f), + new Vector4(customColor.X * 0.8f, customColor.Y * 0.8f, customColor.Z * 0.8f, 0.9f), + new Vector4(customColor.X * 1.2f, customColor.Y * 1.2f, customColor.Z * 1.2f, 0.9f), + new Vector4(customColor.X * 0.9f, customColor.Y * 0.9f, customColor.Z * 0.9f, 0.9f) + }; + + case ParticleColorScheme.Auto: + default: + var nameplateColor = ResolveNameplateColor(); + return new Vector4[] { + new Vector4(nameplateColor.X, nameplateColor.Y, nameplateColor.Z, 0.9f), + new Vector4(nameplateColor.X * 1.2f, nameplateColor.Y * 1.2f, nameplateColor.Z * 1.2f, 0.9f), + new Vector4(nameplateColor.X * 0.8f, nameplateColor.Y * 0.8f, nameplateColor.Z * 0.8f, 0.9f), + new Vector4(nameplateColor.X * 0.9f, nameplateColor.Y * 0.9f, nameplateColor.Z * 0.9f, 0.9f) + }; + } + } + + private void DrawAnimatedPNGLeaves(ImDrawListPtr dl, Vector2 startPos, float width, float height, float scale) + { + var deltaTime = ImGui.GetIO().DeltaTime; + animationTime += deltaTime; + + try + { + string pluginDirectory = Plugin.PluginInterface.AssemblyLocation.DirectoryName ?? ""; + string assetsPath = Path.Combine(pluginDirectory, "Assets"); + + var rp = CurrentProfile; + string[] leafFiles; + + switch (rp?.Effects?.ColorScheme) + { + case ParticleColorScheme.Forest: + leafFiles = new string[] { "pixel_leaf_green.png" }; + break; + case ParticleColorScheme.Cool: + leafFiles = new string[] { "pixel_leaf_blue.png" }; + break; + case ParticleColorScheme.Magical: + leafFiles = new string[] { "pixel_leaf_purple.png" }; + break; + case ParticleColorScheme.Winter: + leafFiles = new string[] { "pixel_leaf_white.png" }; + break; + case ParticleColorScheme.Warm: + leafFiles = new string[] { "pixel_leaf_orange.png" }; + break; + case ParticleColorScheme.Custom: + leafFiles = new string[] { "pixel_leaf.png" }; + break; + case ParticleColorScheme.Auto: + default: + leafFiles = new string[] + { + "pixel_leaf.png", // Original/default + "pixel_leaf_green.png", // Forest green + "pixel_leaf_teal.png", // Magical teal + "pixel_leaf_blue.png", // Mystical blue + "pixel_leaf_dark.png" // Shadow/dark green + }; + break; + } + + var leafTextures = new List(); + foreach (var leafFile in leafFiles) + { + string leafPath = Path.Combine(assetsPath, leafFile); + if (File.Exists(leafPath)) + { + var texture = Plugin.TextureProvider.GetFromFile(leafPath).GetWrapOrDefault(); + if (texture != null) + leafTextures.Add(texture); + } + } + + if (leafTextures.Count == 0) + { + string fallbackPath = Path.Combine(assetsPath, "pixel_leaf.png"); + if (File.Exists(fallbackPath)) + { + var texture = Plugin.TextureProvider.GetFromFile(fallbackPath).GetWrapOrDefault(); + if (texture != null) + leafTextures.Add(texture); + } + } + + if (leafTextures.Count == 0) return; + + int maxLeaves = 10; + for (int i = 0; i < maxLeaves; i++) + { + float leafSeed = i * 7.3f; + + float speedVariation = 0.6f + (float)Math.Sin(leafSeed) * 0.4f; + float baseSpeed = (12f + (i % 4) * 5f) * scale; + float leafTime = animationTime * speedVariation * (baseSpeed / (20f * scale)) + leafSeed; + + float swayFreq = 0.4f + (i % 5) * 0.2f; + float swayAmount = (8f + (i % 3) * 12f) * scale; + float spiralEffect = (float)Math.Sin(leafTime * 1.2f + leafSeed) * (5f * scale); + + float fallDistance = height + (100f * scale); + float leafY = -(50f * scale) + (leafTime * (20f * scale)) % (fallDistance); + + Vector2 leafPos = startPos + new Vector2( + width * (0.05f + (i * 11.7f) % 90f / 100f) + + (float)Math.Sin(leafTime * swayFreq) * swayAmount + spiralEffect, + leafY + ); + float sizeVariation = 0.7f + (i % 4) * 0.15f; + float baseScale = (0.08f + (i % 3) * 0.02f) * 0.6f * sizeVariation * scale; + + var selectedTexture = leafTextures[i % leafTextures.Count]; + Vector2 leafSize = new Vector2(selectedTexture.Width * baseScale, selectedTexture.Height * baseScale); + Vector4 colorTint = new Vector4( + + 0.9f + (float)Math.Sin(leafTime * 0.1f + leafSeed) * 0.1f, + 0.95f + (float)Math.Cos(leafTime * 0.08f + leafSeed) * 0.05f, + 0.9f + (float)Math.Sin(leafTime * 0.12f + leafSeed) * 0.1f, + 0.8f + ); + + if (leafPos.Y >= startPos.Y - (60f * scale) && leafPos.Y <= startPos.Y + height + (60f * scale)) + { + // Tiny shadow + Vector2 shadowOffset = new Vector2(0.3f * scale, 0.3f * scale); + Vector4 shadowColor = new Vector4(0f, 0f, 0f, 0.1f); + + // Tiny leaf + dl.AddImage( + selectedTexture.ImGuiHandle, + leafPos, + leafPos + leafSize, + Vector2.Zero, + Vector2.One, + ImGui.ColorConvertFloat4ToU32(colorTint) + ); + + // Very subtle shadow, don't look it's shy! + dl.AddImage( + selectedTexture.ImGuiHandle, + leafPos + shadowOffset, + leafPos + leafSize + shadowOffset, + Vector2.Zero, + Vector2.One, + ImGui.ColorConvertFloat4ToU32(shadowColor) + ); + } + } + } + catch (Exception ex) + { + Plugin.Log.Error($"Error loading leaf PNG: {ex.Message}"); + } + } + + // Animated pixel bats + private void DrawAnimatedPixelBats(ImDrawListPtr dl, Vector2 startPos, float width, float height, float scale) + { + try + { + string pluginDirectory = Plugin.PluginInterface.AssemblyLocation.DirectoryName ?? ""; + string assetsPath = Path.Combine(pluginDirectory, "Assets"); + + string batNormalPath = Path.Combine(assetsPath, "pixel_bat_normal.png"); + string batWingsUpPath = Path.Combine(assetsPath, "pixel_bat_wings_up.png"); + + var batNormalTexture = File.Exists(batNormalPath) ? + Plugin.TextureProvider.GetFromFile(batNormalPath).GetWrapOrDefault() : null; + var batWingsUpTexture = File.Exists(batWingsUpPath) ? + Plugin.TextureProvider.GetFromFile(batWingsUpPath).GetWrapOrDefault() : null; + + if (batNormalTexture == null && batWingsUpTexture == null) return; + + var fallbackTexture = batNormalTexture ?? batWingsUpTexture; + + for (int i = 0; i < 3; i++) + { + float batSeed = i * 3.7f; + float batTime = animationTime * (0.15f + (i % 3) * 0.05f) + batSeed; + + float flapSpeed = 1.5f + (i % 3) * 0.5f; + float flapTime = batTime * flapSpeed; + bool wingsUp = (flapTime % 1f) < 0.3f; + + var currentTexture = (wingsUp && batWingsUpTexture != null) ? batWingsUpTexture : + (batNormalTexture ?? fallbackTexture); + + if (currentTexture == null) continue; + + Vector2 batPos = GetBatPosition(startPos, width, height, i, batTime, batSeed, scale); + + float batScale = (0.1f + (i % 3) * 0.03f) * scale; + Vector2 batSize = new Vector2(currentTexture.Width * batScale, currentTexture.Height * batScale); + + float bobbing = (float)Math.Sin(batTime * 2f + batSeed) * (1.5f * scale); + batPos.Y += bobbing; + + Vector4 batTint = GetBatTint(i, batTime); + + if (batPos.X >= startPos.X - batSize.X && batPos.X <= startPos.X + width + batSize.X && + batPos.Y >= startPos.Y - batSize.Y && batPos.Y <= startPos.Y + height + batSize.Y) + { + Vector2 shadowOffset = new Vector2(1f * scale, 1f * scale); + Vector4 shadowColor = new Vector4(0f, 0f, 0f, 0.3f); + dl.AddImage( + currentTexture.ImGuiHandle, + batPos + shadowOffset, + batPos + batSize + shadowOffset, + Vector2.Zero, + Vector2.One, + ImGui.ColorConvertFloat4ToU32(shadowColor) + ); + + dl.AddImage( + currentTexture.ImGuiHandle, + batPos, + batPos + batSize, + Vector2.Zero, + Vector2.One, + ImGui.ColorConvertFloat4ToU32(batTint) + ); + } + } + } + catch (Exception ex) + { + Plugin.Log.Error($"Error loading bat sprites: {ex.Message}"); + } + } + + private Vector2 GetBatPosition(Vector2 startPos, float width, float height, int batIndex, float batTime, float batSeed, float scale) + { + switch (batIndex) + { + case 0: + { + float centerX = width * 0.12f; + float centerY = height * 0.25f; + + return startPos + new Vector2( + centerX + (float)Math.Sin(batTime * 0.3f + batSeed) * (8f * scale), + centerY + (float)Math.Cos(batTime * 0.25f + batSeed) * (5f * scale) + ); + } + + case 1: + { + float centerX = width * 0.18f; + float centerY = height * 0.75f; + + return startPos + new Vector2( + centerX + (float)Math.Sin(batTime * 0.2f + batSeed) * (10f * scale), + centerY + (float)Math.Sin(batTime * 0.4f + batSeed) * (6f * scale) + ); + } + + default: + { + float centerX = width * 0.75f; + float centerY = height * 0.3f; + + return startPos + new Vector2( + centerX + (float)Math.Sin(batTime * 0.15f + batSeed) * (12f * scale), + centerY + (float)Math.Cos(batTime * 0.12f + batSeed) * (4f * scale) + ); + } + } + } + + private Vector4 GetBatTint(int batIndex, float batTime) + { + Vector4[] batColors = new Vector4[] + { + new Vector4(0.9f, 0.9f, 0.9f, 0.95f), + new Vector4(0.7f, 0.7f, 0.8f, 0.9f), + new Vector4(0.8f, 0.7f, 0.7f, 0.9f), + new Vector4(0.6f, 0.6f, 0.7f, 0.85f), + }; + + Vector4 baseColor = batColors[batIndex % batColors.Length]; + float brightness = 0.9f + (float)Math.Sin(batTime * 0.5f + batIndex) * 0.1f; + + return new Vector4( + baseColor.X * brightness, + baseColor.Y * brightness, + baseColor.Z * brightness, + baseColor.W + ); + } + private void DrawAnimatedFireSprites(ImDrawListPtr dl, Vector2 startPos, float width, float height, float scale) + { + try + { + string pluginDirectory = Plugin.PluginInterface.AssemblyLocation.DirectoryName ?? ""; + string assetsPath = Path.Combine(pluginDirectory, "Assets"); + + var fireTextures = new List(); + for (int i = 1; i <= 7; i++) + { + string firePath = Path.Combine(assetsPath, $"fire_{i}.png"); + if (File.Exists(firePath)) + { + var texture = Plugin.TextureProvider.GetFromFile(firePath).GetWrapOrDefault(); + if (texture != null) + { + fireTextures.Add(texture); + } + } + } + + if (fireTextures.Count == 0) return; + + float fireTime = animationTime * 1.5f; + + int cycleNum = (int)(fireTime * 0.3f); + Random rng = new Random(cycleNum * 777); + + float randomValue = (float)rng.NextDouble(); + int maxFrame; + + if (randomValue < 0.4f) + { + maxFrame = rng.Next(2, 4); + } + else if (randomValue < 0.7f) + { + maxFrame = 4; + } + else if (randomValue < 0.85f) + { + maxFrame = 5; + } + else if (randomValue < 0.95f) + { + maxFrame = 6; + } + else + { + maxFrame = 7; + } + + float cycleProgress = (fireTime * 0.4f) % 1f; + float totalSteps = (maxFrame - 1) * 2f; + float currentStep = cycleProgress * totalSteps; + + int frameIndex; + if (currentStep <= (maxFrame - 1)) + { + frameIndex = 1 + (int)currentStep; + } + else + { + float downStep = currentStep - (maxFrame - 1); + frameIndex = maxFrame - (int)downStep; + } + + frameIndex = Math.Max(1, Math.Min(maxFrame, frameIndex)); + int textureIndex = frameIndex - 1; + + var fireTexture = fireTextures[textureIndex]; + + float fireX = startPos.X + (width - (fireTexture.Width * scale)) * 0.5f; + float fireY = startPos.Y + height - (fireTexture.Height * scale) - (5f * scale); + + Vector2 firePosition = new Vector2(fireX, fireY); + Vector2 spriteSize = new Vector2(fireTexture.Width * scale, fireTexture.Height * scale); + + Vector2 finalPosition = firePosition; + + // Purple/red colour mix + Vector4 fireColor = new Vector4(1.3f, 0.6f, 1.2f, 1f); + float brightness = 0.9f + (float)Math.Sin(fireTime * 2f) * 0.1f; + fireColor = fireColor * brightness; + fireColor.W = 1f; + + dl.AddImage( + fireTexture.ImGuiHandle, + finalPosition, + finalPosition + spriteSize, + Vector2.Zero, + Vector2.One, + ImGui.ColorConvertFloat4ToU32(fireColor) + ); + } + catch (Exception ex) + { + Plugin.Log.Error($"Error in fire animation: {ex.Message}"); + } + } + private void DrawPulsingWindows(ImDrawListPtr dl, Vector2 startPos, float width, float height, Vector3 color, float scale) + { + Vector2[] windowPositions = new Vector2[] + { + new Vector2(width * 0.45f, height * 0.35f), + new Vector2(width * 0.52f, height * 0.45f), + new Vector2(width * 0.38f, height * 0.55f), + new Vector2(width * 0.48f, height * 0.65f), + new Vector2(width * 0.42f, height * 0.75f), + new Vector2(width * 0.35f, height * 0.4f), + new Vector2(width * 0.55f, height * 0.5f), + new Vector2(width * 0.46f, height * 0.85f), + }; + + for (int i = 0; i < windowPositions.Length; i++) + { + Vector2 windowPos = startPos + windowPositions[i]; + float windowTime = animationTime * (0.15f + i * 0.05f) + i * 4f; + + float pulse = (i % 3) switch + { + 0 => 0.3f + (float)Math.Sin(windowTime * 0.2f) * 0.2f, + 1 => 0.25f + (float)Math.Sin(windowTime * 0.3f) * 0.25f, + _ => 0.4f + (float)Math.Sin(windowTime * 0.15f) * 0.15f + }; + + Vector4 windowColor = (i % 4) switch + { + 0 => new Vector4(1f, 0.4f, 0.1f, pulse * 0.7f), + 1 => new Vector4(1f, 0.6f, 0.2f, pulse * 0.7f), + 2 => new Vector4(1f, 0.3f, 0.1f, pulse * 0.7f), + _ => new Vector4(1f, 0.7f, 0.3f, pulse * 0.7f) + }; + float glowSize = (5f + pulse * 3f) * scale; + dl.AddCircleFilled(windowPos, glowSize, ImGui.ColorConvertFloat4ToU32(windowColor * 0.4f)); + + dl.AddCircleFilled(windowPos, (2f + pulse * 1f) * scale, ImGui.ColorConvertFloat4ToU32(windowColor)); + + float randomFlare = (float)Math.Sin(windowTime * 0.4f + i * 3.1f) * (float)Math.Cos(windowTime * 0.2f + i * 1.7f); + if (randomFlare > 0.9f) + { + Vector4 flareColor = windowColor * 1.3f; + dl.AddCircleFilled(windowPos, glowSize * 1.2f, ImGui.ColorConvertFloat4ToU32(flareColor * 0.2f)); + } + } + } + private void DrawDriftingSmoke(ImDrawListPtr dl, Vector2 startPos, float width, float height, Vector3 color, float scale) + { + for (int i = 0; i < 5; i++) + { + float smokeTime = animationTime * (0.05f + i * 0.01f) + i * 4f; + + float smokeX = startPos.X + (width * -0.3f + (smokeTime * (12f * scale)) % (width * 1.6f)); + float smokeY = startPos.Y + height * (0.45f + (i % 3) * 0.15f) + + (float)Math.Sin(smokeTime * 0.3f + i) * (12f * scale); + + Vector2 smokePos = new Vector2(smokeX, smokeY); + + Vector4 smokeColor = new Vector4( + 0.2f + color.X * 0.1f, + 0.15f + color.Y * 0.05f, + 0.25f + color.Z * 0.15f, + 0.25f + (float)Math.Sin(smokeTime * 0.6f) * 0.1f + ); + + for (int layer = 0; layer < 3; layer++) + { + float layerOffset = layer * (6f * scale); + float layerSize = ((20f + layer * 12f) * (0.9f + (float)Math.Sin(smokeTime * 0.4f + layer) * 0.3f)) * scale; + float layerAlpha = smokeColor.W * (1f - layer * 0.25f); + + Vector4 layerColor = new Vector4(smokeColor.X, smokeColor.Y, smokeColor.Z, layerAlpha); + + dl.AddCircleFilled( + smokePos + new Vector2(layerOffset, layer * (2f * scale)), + layerSize, + ImGui.ColorConvertFloat4ToU32(layerColor) + ); + } + } + } + private void DrawGothicParticles(ImDrawListPtr dl, Vector2 startPos, float width, float height, Vector3 color, float scale) + { + for (int i = 0; i < 15; i++) + { + float particleTime = animationTime * (0.15f + i * 0.03f) + i * 1.2f; + bool isEmber = i % 6 == 0; + + Vector2 particlePos = startPos + new Vector2( + (width / 15f) * i + (float)Math.Sin(particleTime * 0.8f) * (30f * scale), + height * (0.1f + (particleTime * 0.12f) % 0.9f) + (float)Math.Cos(particleTime * 0.6f) * (20f * scale) + ); + + if (isEmber) + { + float glow = 0.6f + (float)Math.Sin(particleTime * 3f) * 0.4f; + Vector4 emberColor = new Vector4(1f, 0.5f, 0.1f, glow * 0.7f); + + dl.AddCircleFilled(particlePos, 4f * scale, ImGui.ColorConvertFloat4ToU32(emberColor * 0.4f)); + dl.AddCircleFilled(particlePos, 1.5f * scale, ImGui.ColorConvertFloat4ToU32(emberColor)); + } + else + { + Vector4 ashColor = new Vector4(0.3f, 0.3f, 0.4f, 0.5f); + float size = (0.8f + (float)Math.Sin(particleTime * 2f) * 0.4f) * scale; + dl.AddCircleFilled(particlePos, size, ImGui.ColorConvertFloat4ToU32(ashColor)); + } + } + } + private void DrawMagicalSparkles(ImDrawListPtr dl, Vector2 startPos, float width, float height, float scale) + { + var deltaTime = ImGui.GetIO().DeltaTime; + animationTime += deltaTime; + for (int i = 0; i < 12; i++) + { + float sparkleTime = animationTime * (0.3f + i * 0.05f) + i * 1.8f; + + float baseX = width * (0.08f + (i * 0.07f)); + float baseY = height * (0.15f + (i % 6) * 0.12f); + float primaryX = (float)Math.Sin(sparkleTime * 0.25f) * (40f * scale); + float primaryY = (float)Math.Cos(sparkleTime * 0.2f) * (25f * scale); + + float secondaryX = (float)Math.Sin(sparkleTime * 0.9f + i * 0.5f) * (12f * scale); + float secondaryY = (float)Math.Cos(sparkleTime * 1.1f + i * 0.4f) * (8f * scale); + + float microX = (float)Math.Sin(sparkleTime * 2.8f + i * 1.3f) * (3f * scale); + float microY = (float)Math.Cos(sparkleTime * 3.2f + i * 0.9f) * (2f * scale); + + Vector2 sparklePos = startPos + new Vector2( + baseX + primaryX + secondaryX + microX, + baseY + primaryY + secondaryY + microY + ); + + Vector4 sparkleColor = (i % 4) switch + { + 0 => new Vector4(1f, 0.8f, 0.4f, 0.9f), + 1 => new Vector4(0.9f, 0.7f, 0.3f, 0.9f), + 2 => new Vector4(0.4f, 0.8f, 0.9f, 0.9f), + _ => new Vector4(0.8f, 0.9f, 1f, 0.9f) + }; + + Vector4 sparkleGlow = sparkleColor * 0.5f; + float primaryPulse = (float)Math.Sin(sparkleTime * 2.2f + i * 0.7f) * 0.4f; + float secondaryPulse = (float)Math.Sin(sparkleTime * 3.8f + i * 0.4f) * 0.2f; + float magicalShimmer = (float)Math.Sin(sparkleTime * 6.5f + i * 1.1f) * 0.15f; + + float pulse = 0.45f + primaryPulse + secondaryPulse + magicalShimmer; + pulse = Math.Clamp(pulse, 0.2f, 1f); + + Vector4 currentSparkle = sparkleColor * pulse; + Vector4 currentGlow = sparkleGlow * (pulse * 0.9f); + + Vector2 pixelPos = new Vector2((float)Math.Floor(sparklePos.X), (float)Math.Floor(sparklePos.Y)); + + for (int glowRing = 5; glowRing >= 1; glowRing--) + { + float glowAlpha = currentGlow.W * (1f - (glowRing - 1) * 0.18f); + Vector4 ringGlow = new Vector4(currentGlow.X, currentGlow.Y, currentGlow.Z, glowAlpha); + + float ringSize = glowRing * (2.2f * scale); + dl.AddRectFilled( + pixelPos + new Vector2(-ringSize, -ringSize), + pixelPos + new Vector2(ringSize + (2 * scale), ringSize + (2 * scale)), + ImGui.ColorConvertFloat4ToU32(ringGlow) + ); + } + + dl.AddRectFilled(pixelPos, pixelPos + new Vector2(3 * scale, 3 * scale), ImGui.ColorConvertFloat4ToU32(currentSparkle)); + + float flashChance = (float)Math.Sin(sparkleTime * 3.2f + i * 1.4f); + if (flashChance > 0.75f) + { + float flashIntensity = (flashChance - 0.75f) / 0.25f; + Vector4 flashColor = sparkleColor * (1f + flashIntensity * 0.5f); + + if (flashIntensity > 0.7f) + { + flashColor = (i % 2 == 0) + ? new Vector4(0.9f, 0.6f, 1f, flashColor.W) // Magical purple + : new Vector4(0.6f, 1f, 0.8f, flashColor.W); // Mint green + } + + dl.AddRectFilled(pixelPos, pixelPos + new Vector2(4 * scale, 4 * scale), ImGui.ColorConvertFloat4ToU32(flashColor)); + } + } + } + + // Floating magical dust motes (like dust in sunbeams, but magical) + private void DrawMagicalDustMotes(ImDrawListPtr dl, Vector2 startPos, float width, float height, float scale) + { + for (int i = 0; i < 20; i++) + { + float dustTime = animationTime * (0.1f + i * 0.02f) + i * 2.5f; + + Vector2 dustPos = startPos + new Vector2( + width * (0.05f + (i * 13.7f) % 90f / 100f) + (float)Math.Sin(dustTime * 0.3f) * (15f * scale), + height * (0.1f + (dustTime * 0.08f) % 0.8f) + (float)Math.Cos(dustTime * 0.25f) * (8f * scale) + ); + + bool isMagical = i % 5 == 0; + + if (isMagical) + { + Vector4 magicalColor = new Vector4(0.8f, 0.7f, 0.3f, 0.6f); + float twinkle = 0.4f + (float)Math.Sin(dustTime * 4f) * 0.3f; + magicalColor.W *= twinkle; + + dl.AddCircleFilled(dustPos, 1.5f * scale, ImGui.ColorConvertFloat4ToU32(magicalColor)); + } + else + { + Vector4 dustColor = new Vector4(0.4f, 0.35f, 0.3f, 0.3f); + dl.AddCircleFilled(dustPos, 0.8f * scale, ImGui.ColorConvertFloat4ToU32(dustColor)); + } + } + } + private void DrawEnhancedLanternGlow(ImDrawListPtr dl, Vector2 startPos, float width, float height, float scale) + { + Vector2[] lanternPositions = new Vector2[] + { + new Vector2(width * 0.15f, height * 0.3f), + new Vector2(width * 0.25f, height * 0.25f), + new Vector2(width * 0.35f, height * 0.35f), + new Vector2(width * 0.45f, height * 0.2f), + new Vector2(width * 0.55f, height * 0.3f), + new Vector2(width * 0.65f, height * 0.25f), + new Vector2(width * 0.75f, height * 0.35f), + new Vector2(width * 0.85f, height * 0.4f), + }; + + for (int i = 0; i < lanternPositions.Length; i++) + { + Vector2 lanternPos = startPos + lanternPositions[i]; + float lanternTime = animationTime * (0.4f + i * 0.1f) + i * 3f; + + float flicker = 0.6f + (float)Math.Sin(lanternTime * 1.5f) * 0.2f + + (float)Math.Sin(lanternTime * 3.2f) * 0.1f; + + Vector4 lanternGlow = new Vector4(1f, 0.7f, 0.3f, flicker * 0.4f); + + float glowSize = (12f + flicker * 6f) * scale; + dl.AddCircleFilled(lanternPos, glowSize, ImGui.ColorConvertFloat4ToU32(lanternGlow * 0.3f)); + dl.AddCircleFilled(lanternPos, glowSize * 0.7f, ImGui.ColorConvertFloat4ToU32(lanternGlow * 0.5f)); + + dl.AddCircleFilled(lanternPos, 3f * scale, ImGui.ColorConvertFloat4ToU32(lanternGlow * 1.2f)); + } + } + + // Animated magical butterflies with 14-frame wing flapping, almost lifelike! + private void DrawAnimatedMagicalButterflies(ImDrawListPtr dl, Vector2 startPos, float width, float height, float scale) + { + try + { + string pluginDirectory = Plugin.PluginInterface.AssemblyLocation.DirectoryName ?? ""; + string assetsPath = Path.Combine(pluginDirectory, "Assets"); + + var butterflyTextures = new List(); + for (int i = 1; i <= 14; i++) + { + string butterflyPath = Path.Combine(assetsPath, $"butterfly_{i}.png"); + if (File.Exists(butterflyPath)) + { + var texture = Plugin.TextureProvider.GetFromFile(butterflyPath).GetWrapOrDefault(); + if (texture != null) + { + butterflyTextures.Add(texture); + } + } + } + + if (butterflyTextures.Count == 0) return; + + for (int butterflyIndex = 0; butterflyIndex < 5; butterflyIndex++) + { + float butterflySeed = butterflyIndex * 5.2f; + float butterflyTime = animationTime * (0.6f + butterflyIndex * 0.1f) + butterflySeed; + + float cycleProgress = (butterflyTime * 0.6f) % 1f; + float totalSteps = 13f * 2f; + float currentStep = cycleProgress * totalSteps; + + int frameIndex; + if (currentStep <= 13f) + { + frameIndex = 1 + (int)currentStep; + } + else + { + float downStep = currentStep - 13f; + frameIndex = 14 - (int)downStep; + } + frameIndex = Math.Max(1, Math.Min(14, frameIndex)); + int textureIndex = frameIndex - 1; + + var butterflyTexture = butterflyTextures[Math.Min(textureIndex, butterflyTextures.Count - 1)]; + + Vector2 butterflyBasePos = GetButterflyPosition(startPos, width, height, butterflyIndex, butterflyTime, butterflySeed, scale); + + float butterflyScale = (0.15f + (butterflyIndex % 2) * 0.05f) * scale; + Vector2 butterflySize = new Vector2(butterflyTexture.Width * butterflyScale, butterflyTexture.Height * butterflyScale); + + float bobbing = (float)Math.Sin(butterflyTime * 1.5f + butterflySeed) * (4f * scale); + Vector2 finalPos = butterflyBasePos + new Vector2(0, bobbing); + + bool flipHorizontal = butterflyIndex % 2 == 1; + Vector2 uvStart = flipHorizontal ? new Vector2(1, 0) : Vector2.Zero; + Vector2 uvEnd = flipHorizontal ? new Vector2(0, 1) : Vector2.One; + + Vector4 butterflyTint = GetButterflyTint(butterflyIndex, butterflyTime); + + if (finalPos.X >= startPos.X - butterflySize.X && finalPos.X <= startPos.X + width + butterflySize.X && + finalPos.Y >= startPos.Y - butterflySize.Y && finalPos.Y <= startPos.Y + height + butterflySize.Y) + { + Vector2 shadowOffset = new Vector2(2f * scale, 2f * scale); + Vector4 shadowColor = new Vector4(0f, 0f, 0f, 0.2f); + dl.AddImage( + butterflyTexture.ImGuiHandle, + finalPos + shadowOffset, + finalPos + butterflySize + shadowOffset, + uvStart, + uvEnd, + ImGui.ColorConvertFloat4ToU32(shadowColor) + ); + + dl.AddImage( + butterflyTexture.ImGuiHandle, + finalPos, + finalPos + butterflySize, + uvStart, + uvEnd, + ImGui.ColorConvertFloat4ToU32(butterflyTint) + ); + } + } + } + catch (Exception ex) + { + Plugin.Log.Error($"Error in butterfly animation: {ex.Message}"); + } + } + + private Vector2 GetButterflyPosition(Vector2 startPos, float width, float height, int butterflyIndex, float butterflyTime, float butterflySeed, float scale) + { + switch (butterflyIndex) + { + case 0: + { + float centerX = width * 0.25f; + float centerY = height * 0.3f; + + return startPos + new Vector2( + centerX + (float)Math.Sin(butterflyTime * 0.3f) * (35f * scale), + centerY + (float)Math.Sin(butterflyTime * 0.6f) * (20f * scale) + ); + } + + case 1: + { + float centerX = width * 0.5f; + float centerY = height * 0.45f; + + return startPos + new Vector2( + centerX + (float)Math.Cos(butterflyTime * 0.25f + butterflySeed) * (40f * scale), + centerY + (float)Math.Sin(butterflyTime * 0.25f + butterflySeed) * (25f * scale) + ); + } + + case 2: + { + float centerX = width * 0.75f; + float centerY = height * 0.35f; + + return startPos + new Vector2( + centerX + (float)Math.Sin(butterflyTime * 0.2f + butterflySeed) * (30f * scale) + + (float)Math.Sin(butterflyTime * 0.8f) * (10f * scale), + centerY + (float)Math.Cos(butterflyTime * 0.15f + butterflySeed) * (15f * scale) + ); + } + + case 3: + { + float centerX = width * 0.2f; + float centerY = height * 0.75f; + + return startPos + new Vector2( + centerX + (float)Math.Sin(butterflyTime * 0.35f + butterflySeed) * (25f * scale), + centerY + (float)Math.Cos(butterflyTime * 0.4f + butterflySeed) * (12f * scale) + ); + } + + default: + { + float centerX = width * 0.8f; + float centerY = height * 0.7f; + + return startPos + new Vector2( + centerX + (float)Math.Cos(butterflyTime * 0.28f + butterflySeed) * (30f * scale), + centerY + (float)Math.Sin(butterflyTime * 0.22f + butterflySeed) * (18f * scale) + + (float)Math.Sin(butterflyTime * 0.6f) * (6f * scale) + ); + } + } + } + + private Vector4 GetButterflyTint(int butterflyIndex, float butterflyTime) + { + Vector4[] butterflyColors = new Vector4[] + { + new Vector4(0.3f, 0.6f, 1f, 0.95f), // Bright blue + new Vector4(0.5f, 0.8f, 1f, 0.9f), // Light blue/cyan + new Vector4(0.2f, 0.4f, 0.9f, 0.95f), // Deep blue + new Vector4(0.6f, 0.9f, 1f, 0.9f), // Pale blue + }; + + Vector4 baseColor = butterflyColors[butterflyIndex % butterflyColors.Length]; + + float shimmer = 0.95f + (float)Math.Sin(butterflyTime * 2f + butterflyIndex) * 0.05f; + + return new Vector4( + baseColor.X * shimmer, + baseColor.Y * shimmer, + baseColor.Z * shimmer, + baseColor.W + ); + } + + private void DrawNeonGlow(ImDrawListPtr dl, Vector2 wndPos, Vector2 wndSize, float scale) + { + var color = ResolveNameplateColor(); + var glowColor1 = new Vector4(color.X, color.Y, color.Z, 0.5f); + var glowColor2 = new Vector4(color.X, color.Y, color.Z, 0.3f); + var glowColor3 = new Vector4(color.X, color.Y, color.Z, 0.15f); + var glowColor4 = new Vector4(color.X, color.Y, color.Z, 0.08f); + + uint glow1 = ImGui.ColorConvertFloat4ToU32(glowColor1); + uint glow2 = ImGui.ColorConvertFloat4ToU32(glowColor2); + uint glow3 = ImGui.ColorConvertFloat4ToU32(glowColor3); + uint glow4 = ImGui.ColorConvertFloat4ToU32(glowColor4); + + var max = wndPos + wndSize; + + dl.AddRect(wndPos - new Vector2(12 * scale, 12 * scale), max + new Vector2(12 * scale, 12 * scale), glow4, 0f, ImDrawFlags.None, 12f * scale); + dl.AddRect(wndPos - new Vector2(8 * scale, 8 * scale), max + new Vector2(8 * scale, 8 * scale), glow3, 0f, ImDrawFlags.None, 8f * scale); + dl.AddRect(wndPos - new Vector2(5 * scale, 5 * scale), max + new Vector2(5 * scale, 5 * scale), glow2, 0f, ImDrawFlags.None, 5f * scale); + dl.AddRect(wndPos - new Vector2(2 * scale, 2 * scale), max + new Vector2(2 * scale, 2 * scale), glow1, 0f, ImDrawFlags.None, 3f * scale); + + var edgeColor = new Vector4(color.X, color.Y, color.Z, 0.8f); + dl.AddRect(wndPos, max, ImGui.ColorConvertFloat4ToU32(edgeColor), 0f, ImDrawFlags.None, 1f * scale); + } + + private void DrawMainBackground(ImDrawListPtr dl, Vector2 wndPos, Vector2 wndSize, float scale) + { + var color = ResolveNameplateColor(); + var bgColor1 = new Vector4(0.03f, 0.03f, 0.12f, 1f); + var bgColor2 = new Vector4(color.X * 0.12f, color.Y * 0.06f, color.Z * 0.22f, 1f); + var bgColor3 = new Vector4(0.02f, 0.02f, 0.08f, 1f); + + uint bg1 = ImGui.ColorConvertFloat4ToU32(bgColor1); + uint bg2 = ImGui.ColorConvertFloat4ToU32(bgColor2); + uint bg3 = ImGui.ColorConvertFloat4ToU32(bgColor3); + + // Gradient background + dl.AddRectFilledMultiColor(wndPos, wndPos + new Vector2(wndSize.X, wndSize.Y * 0.6f), bg1, bg1, bg2, bg2); + dl.AddRectFilledMultiColor(wndPos + new Vector2(0, wndSize.Y * 0.6f), wndPos + wndSize, bg2, bg2, bg3, bg3); + } + + private void DrawAnimationFadeIn(ImDrawListPtr dl, Vector2 wndPos, Vector2 wndSize, float bioEndY, float animationStartY, float scale) + { + var rp = CurrentProfile; + var theme = rp?.AnimationTheme ?? ProfileAnimationTheme.CircuitBoard; + + if (theme == ProfileAnimationTheme.Nature) + return; + + var color = ResolveNameplateColor(); + var fadeHeight = 25f * scale; + var fadeStart = animationStartY; + var fadeEnd = animationStartY + fadeHeight; + + var solidColor = new Vector4(color.X * 0.12f, color.Y * 0.06f, color.Z * 0.22f, 1f); + var transparentColor = new Vector4(color.X * 0.12f, color.Y * 0.06f, color.Z * 0.22f, 0f); + + uint solidU32 = ImGui.ColorConvertFloat4ToU32(solidColor); + uint transparentU32 = ImGui.ColorConvertFloat4ToU32(transparentColor); + + dl.AddRectFilledMultiColor( + wndPos + new Vector2(3 * scale, fadeStart), + wndPos + new Vector2(wndSize.X - (3 * scale), fadeEnd), + solidU32, solidU32, + transparentU32, transparentU32 + ); + } + + private void DrawEnhancedBorders(ImDrawListPtr dl, Vector2 wndPos, Vector2 wndSize, float scale) + { + var color = ResolveNameplateColor(); + var borderColor = new Vector4(color.X, color.Y, color.Z, 0.6f); + var borderGlow = new Vector4(color.X, color.Y, color.Z, 0.3f); + + dl.AddRect(wndPos + new Vector2(2 * scale, 2 * scale), wndPos + wndSize - new Vector2(2 * scale, 2 * scale), + ImGui.ColorConvertFloat4ToU32(borderGlow), 0f, ImDrawFlags.None, 3f * scale); + + dl.AddRect(wndPos + new Vector2(1 * scale, 1 * scale), wndPos + wndSize - new Vector2(1 * scale, 1 * scale), + ImGui.ColorConvertFloat4ToU32(borderColor), 0f, ImDrawFlags.None, 1f * scale); + } + + private void DrawEnhancedHeader(float scale) + { + var dl = ImGui.GetWindowDrawList(); + var wndPos = ImGui.GetWindowPos(); + var wndSize = ImGui.GetWindowSize(); + var color = ResolveNameplateColor(); + + var headerHeight = 45f * scale; + var headerColor1 = new Vector4(color.X * 0.4f, color.Y * 0.3f, color.Z * 0.5f, 0.9f); + var headerColor2 = new Vector4(color.X * 0.15f, color.Y * 0.08f, color.Z * 0.25f, 0.9f); + + uint hc1 = ImGui.ColorConvertFloat4ToU32(headerColor1); + uint hc2 = ImGui.ColorConvertFloat4ToU32(headerColor2); + + dl.AddRectFilledMultiColor(wndPos, wndPos + new Vector2(wndSize.X, headerHeight), hc1, hc1, hc2, hc2); + + var topLineColor = new Vector4(color.X, color.Y, color.Z, 0.8f); + var topGlowColor = new Vector4(color.X, color.Y, color.Z, 0.4f); + + dl.AddLine(wndPos + new Vector2(0, 1 * scale), wndPos + new Vector2(wndSize.X, 1 * scale), + ImGui.ColorConvertFloat4ToU32(topGlowColor), 4f * scale); + dl.AddLine(wndPos, wndPos + new Vector2(wndSize.X, 0), + ImGui.ColorConvertFloat4ToU32(topLineColor), 2f * scale); + + var lineColor = new Vector4(color.X, color.Y, color.Z, 0.8f); + var glowLineColor = new Vector4(color.X, color.Y, color.Z, 0.4f); + + dl.AddLine(wndPos + new Vector2(0, headerHeight + (1 * scale)), wndPos + new Vector2(wndSize.X, headerHeight + (1 * scale)), + ImGui.ColorConvertFloat4ToU32(glowLineColor), 4f * scale); + dl.AddLine(wndPos + new Vector2(0, headerHeight), wndPos + new Vector2(wndSize.X, headerHeight), + ImGui.ColorConvertFloat4ToU32(lineColor), 2f * scale); + + DrawEnhancedCloseButton(scale); + + ImGui.SetCursorPos(new Vector2(20 * scale, 15 * scale)); + var headerColor = new Vector4(1f, 0.95f, 0.85f, 1f); + var headerGlow = new Vector4(color.X, color.Y, color.Z, 0.6f); + + var textPos = ImGui.GetCursorScreenPos(); + dl.AddText(textPos - new Vector2(1 * scale, 1 * scale), ImGui.ColorConvertFloat4ToU32(headerGlow), "ROLEPLAY PROFILE"); + + ImGui.PushStyleColor(ImGuiCol.Text, headerColor); + ImGui.Text("ROLEPLAY PROFILE"); + ImGui.PopStyleColor(); + } + + private void DrawEnhancedCloseButton(float scale) + { + var wndSize = ImGui.GetWindowSize(); + var buttonSize = 20f * scale; + var margin = 12f * scale; + + ImGui.SetCursorPos(new Vector2(wndSize.X - buttonSize - margin, 12f * scale)); + + var buttonPos = ImGui.GetCursorScreenPos(); + var dl = ImGui.GetWindowDrawList(); + var glowColor = new Vector4(1f, 0.3f, 0.3f, 0.4f); + + dl.AddRectFilled(buttonPos - new Vector2(3 * scale, 3 * scale), buttonPos + new Vector2(buttonSize + (3 * scale), buttonSize + (3 * scale)), + ImGui.ColorConvertFloat4ToU32(glowColor), 0f); + + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.9f, 0.1f, 0.1f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(1f, 0.2f, 0.2f, 1f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.8f, 0.05f, 0.05f, 1f)); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(1f, 1f, 1f, 1f)); + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 0f); + + if (ImGui.Button("X", new Vector2(buttonSize, buttonSize))) + { + IsOpen = false; + } + + ImGui.PopStyleVar(); + ImGui.PopStyleColor(4); + } + + private void DrawNameSection(RPProfile rp, float scale) + { + var dl = ImGui.GetWindowDrawList(); + var namePos = ImGui.GetCursorScreenPos(); + var color = ResolveNameplateColor(); + + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(1f, 0.95f, 0.8f, 1f)); + ImGui.Text(rp.CharacterName ?? "Unknown"); + ImGui.PopStyleColor(); + + var nameSize = ImGui.CalcTextSize(rp.CharacterName ?? "Unknown"); + var glowColor = new Vector4(color.X, color.Y, color.Z, 0.3f); + dl.AddText(namePos - new Vector2(1 * scale, 1 * scale), ImGui.ColorConvertFloat4ToU32(glowColor), rp.CharacterName ?? "Unknown"); + + if (!string.IsNullOrEmpty(rp.Pronouns)) + { + ImGui.SameLine(); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.85f, 0.85f, 0.85f, 0.9f)); + ImGui.Text($"({rp.Pronouns})"); + ImGui.PopStyleColor(); + + if (!string.IsNullOrEmpty(rp.Links)) + { + ImGui.SameLine(); + + var iconPos = ImGui.GetCursorScreenPos(); + ImGui.SetCursorScreenPos(iconPos + new Vector2(0, 2 * scale)); + var iconSize = new Vector2(12 * scale, 12 * scale); + var iconMin = iconPos - new Vector2(2 * scale, 2 * scale); + var iconMax = iconPos + iconSize + new Vector2(2 * scale, 2 * scale); + + bool isHovering = ImGui.IsMouseHoveringRect(iconMin, iconMax); + + ImGui.PushFont(UiBuilder.IconFont); + ImGui.SetWindowFontScale(0.8f); + + if (isHovering) + { + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(1f, 1f, 1f, 1f)); + ImGui.SetMouseCursor(ImGuiMouseCursor.Hand); + } + else + { + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.7f, 0.8f, 1f, 0.9f)); + } + + ImGui.Text("\uf0c1"); + + ImGui.PopStyleColor(); + ImGui.SetWindowFontScale(1.0f); + ImGui.PopFont(); + + if (isHovering && ImGui.IsMouseClicked(ImGuiMouseButton.Left)) + { + if (!string.IsNullOrEmpty(rp.Links)) + { + OpenUrl(rp.Links.Trim()); + } + } + + if (isHovering) + { + ImGui.PushStyleColor(ImGuiCol.PopupBg, new Vector4(0.06f, 0.06f, 0.06f, 0.98f)); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.92f, 0.92f, 0.92f, 1.0f)); + ImGui.PushStyleColor(ImGuiCol.Border, new Vector4(0.25f, 0.25f, 0.35f, 0.6f)); + + ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(12 * scale, 10 * scale)); + ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(8 * scale, 6 * scale)); + ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 6.0f * scale); + + ImGui.BeginTooltip(); + + float minTooltipWidth = 320 * scale; + ImGui.Dummy(new Vector2(minTooltipWidth, 0)); + + // Link icon + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.7f, 0.8f, 1f, 1.0f)); // Link blue + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf0c1"); + ImGui.PopFont(); + ImGui.PopStyleColor(); + ImGui.SameLine(0, 8 * scale); + ImGui.Text("External Link"); + + ImGui.Dummy(new Vector2(0, 4 * scale)); + + // URL display + ImGui.Separator(); + ImGui.Dummy(new Vector2(0, 2 * scale)); + + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.8f, 0.8f, 0.8f, 1.0f)); + ImGui.Text("URL:"); + ImGui.SameLine(0, 4 * scale); + + string displayUrl = rp.Links.Length > 80 ? rp.Links.Substring(0, 77) + "..." : rp.Links; + ImGui.Text(displayUrl); + ImGui.PopStyleColor(); + + ImGui.Dummy(new Vector2(0, 4 * scale)); + + // Warning section + ImGui.Separator(); + ImGui.Dummy(new Vector2(0, 2 * scale)); + + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(1.0f, 0.8f, 0.4f, 1.0f)); // Warning yellow + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf071"); // Warning icon + ImGui.PopFont(); + ImGui.PopStyleColor(); + ImGui.SameLine(0, 8 * scale); + + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(1.0f, 0.8f, 0.4f, 1.0f)); + ImGui.Text("Caution: External Link"); + ImGui.PopStyleColor(); + + // Warning text + ImGui.Dummy(new Vector2(0, 2 * scale)); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.7f, 0.7f, 0.7f, 1.0f)); + ImGui.Text("Only click links from trusted sources."); + ImGui.Text("Links will open in your default browser."); + ImGui.PopStyleColor(); + + ImGui.Dummy(new Vector2(0, 2 * scale)); + + ImGui.EndTooltip(); + + ImGui.PopStyleVar(3); + ImGui.PopStyleColor(3); + } + } + } + } + + private void DrawPortraitSection(RPProfile rp, float scale) + { + ImGui.BeginGroup(); + + var texture = GetProfileTexture(rp); + if (texture != null) + { + DrawPortraitImage(texture, rp, scale); + } + else + { + ImGui.Dummy(new Vector2(140 * scale, 140 * scale)); + var dl = ImGui.GetWindowDrawList(); + var pos = ImGui.GetItemRectMin(); + var max = ImGui.GetItemRectMax(); + + var color = ResolveNameplateColor(); + var frameColor = new Vector4(color.X, color.Y, color.Z, 0.3f); + var glowColor = new Vector4(color.X, color.Y, color.Z, 0.1f); + + dl.AddRectFilled(pos - new Vector2(6 * scale, 6 * scale), max + new Vector2(6 * scale, 6 * scale), ImGui.ColorConvertFloat4ToU32(glowColor), 8f * scale); + dl.AddRectFilled(pos - new Vector2(3 * scale, 3 * scale), max + new Vector2(3 * scale, 3 * scale), ImGui.ColorConvertFloat4ToU32(frameColor), 6f * scale); + + dl.AddText(pos + new Vector2(45 * scale, 65 * scale), ImGui.ColorConvertFloat4ToU32(new Vector4(0.8f, 0.8f, 0.8f, 1f)), "Loading..."); + } + + ImGui.EndGroup(); + } + + private void DrawTagsSection(RPProfile rp, float scale) + { + // Tags display - tag icon with tooltip on hover + if (!string.IsNullOrWhiteSpace(rp.Tags)) + { + ImGui.Spacing(); + var tags = rp.Tags.Split(',').Select(t => t.Trim()).Where(t => !string.IsNullOrEmpty(t)).ToArray(); + if (tags.Length > 0) + { + var color = ResolveNameplateColor(); + + ImGui.PushFont(UiBuilder.IconFont); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(color.X * 0.9f, color.Y * 0.9f, color.Z * 0.9f, 0.8f)); + ImGui.Text(FontAwesomeIcon.Tag.ToIconString()); + ImGui.PopStyleColor(); + ImGui.PopFont(); + + if (ImGui.IsItemHovered()) + { + ImGui.PushStyleColor(ImGuiCol.PopupBg, new Vector4(0.06f, 0.06f, 0.06f, 0.98f)); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.92f, 0.92f, 0.92f, 1.0f)); + ImGui.PushStyleColor(ImGuiCol.Border, new Vector4(0.25f, 0.25f, 0.35f, 0.6f)); + + ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(10 * scale, 8 * scale)); + ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(6 * scale, 4 * scale)); + ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 6.0f * scale); + + ImGui.BeginTooltip(); + + float minTooltipWidth = 220 * scale; + ImGui.Dummy(new Vector2(minTooltipWidth, 0)); + + // Tag header with icon + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.8f, 0.6f, 1.0f, 1.0f)); + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text(FontAwesomeIcon.Tag.ToIconString()); + ImGui.PopFont(); + ImGui.PopStyleColor(); + ImGui.SameLine(0, 6 * scale); + ImGui.Text("Tags"); + + ImGui.Dummy(new Vector2(0, 3 * scale)); + ImGui.Separator(); + ImGui.Dummy(new Vector2(0, 2 * scale)); + + string tagText = string.Join(", ", tags); + + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(color.X * 0.9f, color.Y * 0.9f, color.Z * 0.9f, 0.9f)); + + if (ImGui.CalcTextSize(tagText).X > minTooltipWidth - (20 * scale)) + { + ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + minTooltipWidth - (20 * scale)); + ImGui.TextWrapped(tagText); + ImGui.PopTextWrapPos(); + } + else + { + ImGui.Text(tagText); + } + + ImGui.PopStyleColor(); + + ImGui.Dummy(new Vector2(0, 2 * scale)); + + ImGui.EndTooltip(); + + ImGui.PopStyleVar(3); + ImGui.PopStyleColor(3); + } + } + } + } + + private void DrawPortraitImage(IDalamudTextureWrap texture, RPProfile rp, float scale) + { + float portraitSize = 140f * scale; + float zoom = Math.Clamp(rp.ImageZoom, 0.5f, 3.0f); + Vector2 offset = rp.ImageOffset * scale; + + float texAspect = (float)texture.Width / texture.Height; + float drawWidth, drawHeight; + + if (texAspect >= 1f) + { + drawHeight = portraitSize * zoom; + drawWidth = drawHeight * texAspect; + } + else + { + drawWidth = portraitSize * zoom; + drawHeight = drawWidth / texAspect; + } + + Vector2 drawSize = new(drawWidth, drawHeight); + Vector2 cursor = ImGui.GetCursorScreenPos(); + var dl = ImGui.GetWindowDrawList(); + + var color = ResolveNameplateColor(); + var frameColor = new Vector4(color.X, color.Y, color.Z, 0.9f); + var glowColor = new Vector4(color.X, color.Y, color.Z, 0.4f); + + dl.AddRectFilled( + cursor - new Vector2(6 * scale, 6 * scale), + cursor + new Vector2(portraitSize + (6 * scale), portraitSize + (6 * scale)), + ImGui.ColorConvertFloat4ToU32(glowColor), + 8f * scale + ); + + dl.AddRectFilled( + cursor - new Vector2(3 * scale, 3 * scale), + cursor + new Vector2(portraitSize + (3 * scale), portraitSize + (3 * scale)), + ImGui.ColorConvertFloat4ToU32(frameColor), + 6f * scale + ); + + ImGui.BeginChild("ImageView", new Vector2(portraitSize), false, + ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse); + + ImGui.SetCursorScreenPos(cursor + offset); + ImGui.Image(texture.ImGuiHandle, drawSize); + if (ImGui.IsItemClicked(ImGuiMouseButton.Left) || ImGui.IsItemClicked(ImGuiMouseButton.Right)) + { + string? imagePath = GetCurrentImagePath(); + if (!string.IsNullOrEmpty(imagePath)) + { + imagePreviewUrl = imagePath; + showImagePreview = true; + } + } + if (ImGui.IsItemHovered()) + { + string? imagePath = GetCurrentImagePath(); + if (!string.IsNullOrEmpty(imagePath)) + { + ImGui.PushStyleColor(ImGuiCol.PopupBg, new Vector4(0.06f, 0.06f, 0.06f, 0.98f)); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.92f, 0.92f, 0.92f, 1.0f)); + ImGui.PushStyleColor(ImGuiCol.Border, new Vector4(0.25f, 0.25f, 0.35f, 0.6f)); + + ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(12 * scale, 10 * scale)); + ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 6.0f * scale); + + ImGui.BeginTooltip(); + + // Image icon + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.7f, 0.8f, 1.0f, 1.0f)); + ImGui.PushFont(UiBuilder.IconFont); + ImGui.Text("\uf03e"); // Image icon + ImGui.PopFont(); + ImGui.PopStyleColor(); + ImGui.SameLine(0, 8 * scale); + ImGui.Text("Image Preview"); + + ImGui.Dummy(new Vector2(0, 2 * scale)); + ImGui.Separator(); + ImGui.Dummy(new Vector2(0, 2 * scale)); + + ImGui.Text("Click to view full image"); + + ImGui.EndTooltip(); + + ImGui.PopStyleVar(2); + ImGui.PopStyleColor(3); + } + } + ImGui.EndChild(); + } + + private void DrawBioSection(RPProfile rp, float scale) + { + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.9f, 0.9f, 0.7f, 1f)); + ImGui.Text("Biography:"); + ImGui.PopStyleColor(); + + var bioText = rp.Bio ?? "No biography available."; + var textSize = ImGui.CalcTextSize(bioText, ImGui.GetContentRegionAvail().X - (20f * scale)); + var bioHeight = Math.Min(Math.Max(textSize.Y + (20f * scale), 80f * scale), 150f * scale); + + ImGui.PushStyleColor(ImGuiCol.ChildBg, new Vector4(0.05f, 0.05f, 0.1f, 0.6f)); + ImGui.PushStyleColor(ImGuiCol.Border, new Vector4(0.3f, 0.3f, 0.4f, 0.5f)); + + ImGui.BeginChild("##RPBio", new Vector2(0, bioHeight), true, ImGuiWindowFlags.AlwaysVerticalScrollbar); + + ImGui.SetCursorPos(new Vector2(8f * scale, 8f * scale)); + + var availableWidth = ImGui.GetContentRegionAvail().X - (16f * scale); + + ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(4 * scale, 6 * scale)); + + ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + availableWidth); + ImGui.TextWrapped(bioText); + ImGui.PopTextWrapPos(); + + ImGui.PopStyleVar(); + ImGui.EndChild(); + + ImGui.PopStyleColor(2); + } + + private void DrawFloatingActionButton(Vector2 wndPos, Vector2 wndSize, float animationStartY, float scale) + { + if (showingExternal || character == null) return; + + var buttonWidth = 120f * scale; + var buttonHeight = 28f * scale; + var buttonX = (wndSize.X - buttonWidth) * 0.5f; + var buttonY = animationStartY + (30f * scale); + + ImGui.SetCursorPos(new Vector2(buttonX, buttonY)); + + var color = ResolveNameplateColor(); + var dl = ImGui.GetWindowDrawList(); + var buttonPos = ImGui.GetCursorScreenPos(); + + var bgPadding = 8f * scale; + var bgColor = new Vector4(0.02f, 0.02f, 0.08f, 0.9f); + dl.AddRectFilled( + buttonPos - new Vector2(bgPadding, bgPadding), + buttonPos + new Vector2(buttonWidth + bgPadding, buttonHeight + bgPadding), + ImGui.ColorConvertFloat4ToU32(bgColor), 6f * scale); + + var glowColor = new Vector4(color.X, color.Y, color.Z, 0.3f); + dl.AddRectFilled(buttonPos - new Vector2(3 * scale, 3 * scale), buttonPos + new Vector2(buttonWidth + (3 * scale), buttonHeight + (3 * scale)), + ImGui.ColorConvertFloat4ToU32(glowColor), 4f * scale); + + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(color.X * 0.5f, color.Y * 0.5f, color.Z * 0.5f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(color.X * 0.8f, color.Y * 0.8f, color.Z * 0.8f, 1f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(color.X, color.Y, color.Z, 1f)); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(1f, 1f, 1f, 1f)); + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 4f * scale); + + if (ImGui.Button("Edit Profile", new Vector2(buttonWidth, buttonHeight))) + { + plugin.RPProfileEditor.SetCharacter(character); + plugin.RPProfileEditor.IsOpen = true; + } + // Capture Edit Profile button position for tutorial + plugin.EditProfileButtonPos = ImGui.GetItemRectMin(); + plugin.EditProfileButtonSize = ImGui.GetItemRectSize(); + ImGui.PopStyleVar(); + ImGui.PopStyleColor(4); + } + + private IDalamudTextureWrap? GetProfileTexture(RPProfile rp) + { + string? imagePath = null; + + if (showingExternal && !string.IsNullOrEmpty(rp.ProfileImageUrl)) + { + HandleExternalImageDownload(rp.ProfileImageUrl); + if (imageDownloadComplete && File.Exists(downloadedImagePath)) + imagePath = downloadedImagePath; + else + return null; + } else if (!string.IsNullOrEmpty(rp.CustomImagePath) && File.Exists(rp.CustomImagePath)) { imagePath = rp.CustomImagePath; } - else if (!showingExternal && character?.ImagePath is { Length: > 0 } && File.Exists(character.ImagePath)) + else if (!showingExternal && character?.ImagePath is { Length: > 0 } ip && File.Exists(ip)) { - imagePath = character.ImagePath; + imagePath = ip; } - // Final fallback - string finalImagePath = !string.IsNullOrEmpty(imagePath) && File.Exists(imagePath) ? imagePath : fallback; - - - var texture = Plugin.TextureProvider.GetFromFile(finalImagePath).GetWrapOrDefault(); - if (texture != null) + if (string.IsNullOrEmpty(imagePath) && !showingExternal) { - float previewSize = 150f; - float zoom = Math.Clamp(rp.ImageZoom, 0.5f, 5.0f); - Vector2 offset = rp.ImageOffset; - - float texAspect = (float)texture.Width / texture.Height; - float drawWidth, drawHeight; - - if (texAspect >= 1f) - { - drawHeight = previewSize * zoom; - drawWidth = drawHeight * texAspect; - } - else - { - drawWidth = previewSize * zoom; - drawHeight = drawWidth / texAspect; - } - - Vector2 drawSize = new(drawWidth, drawHeight); - Vector2 cursor = ImGui.GetCursorScreenPos(); - - // Outer border frame - drawList.AddRectFilled(cursor - new Vector2(2, 2), cursor + new Vector2(previewSize + 2), ImGui.ColorConvertFloat4ToU32(topColor), 4); - - // Crop area - ImGui.BeginChild("ImageView", new Vector2(previewSize), false, ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse); - ImGui.SetCursorScreenPos(cursor + offset); - ImGui.Image(texture.ImGuiHandle, drawSize); - ImGui.EndChild(); + string fallback = Path.Combine(plugin.PluginDirectory, "Assets", "Default.png"); + imagePath = fallback; } - else - { - ImGui.Dummy(new Vector2(150, 150)); - } - - if (!string.IsNullOrWhiteSpace(rp.Tags)) - { - ImGui.Spacing(); - var tagColor = new Vector4((color.X + 1f) / 2f, (color.Y + 1f) / 2f, (color.Z + 1f) / 2f, 0.65f); - var tags = rp.Tags.Split(',').Select(t => t.Trim()).Where(t => !string.IsNullOrEmpty(t)); - foreach (var tag in tags) - { - ImGui.PushStyleColor(ImGuiCol.Button, tagColor); - ImGui.Button(tag); - ImGui.PopStyleColor(); - ImGui.SameLine(); - } - ImGui.NewLine(); - } - - ImGui.EndGroup(); - - // Name + Fields - ImGui.SameLine(); - ImGui.SetCursorPosX(180); // ensure it never touches image - - ImGui.BeginGroup(); - // Header: Name – Pronouns Roleplay Profile - ImGui.TextColored(new Vector4(1f, 0.75f, 0.4f, 1f), displayName); - if (!string.IsNullOrWhiteSpace(rp.Pronouns)) - { - ImGui.SameLine(); - ImGui.Text($"– {rp.Pronouns}"); - } - ImGui.SameLine(); - ImGui.TextDisabled("Roleplay Profile"); - - ImGui.Spacing(); - - // New field layout - float colSplit = 200f; - DrawFieldRow("▪ Gender", rp.Gender, "▪ Age", rp.Age, colSplit); - DrawFieldRow("▪ Race", rp.Race, "▪ Orientation", rp.Orientation, colSplit); - DrawFieldRow("▪ Relationship", rp.Relationship, "▪ Occupation", rp.Occupation, colSplit); - if (!string.IsNullOrWhiteSpace(rp.Abilities)) - { - ImGui.Spacing(); - ImGui.Text("Abilities:"); - - var abilities = rp.Abilities.Split(',') - .Select(a => a.Trim()) - .Where(a => !string.IsNullOrEmpty(a)); - - ImGui.PushTextWrapPos(); - ImGui.Text(string.Join(" • ", abilities)); - ImGui.PopTextWrapPos(); - } - - ImGui.EndGroup(); - ImGui.EndChild(); - - // Divider Bar Below Card - // Diamond Divider - var diamond = "◆"; - var diamondWidth = ImGui.CalcTextSize(diamond).X; - - float barPadding = 6f; - float totalWidth = 600f; - float barLength = (totalWidth - diamondWidth - (barPadding * 2)) / 2f; - float dividerY = ImGui.GetCursorScreenPos().Y; - float dividerX = ImGui.GetCursorScreenPos().X; - - drawList.AddLine( - new Vector2(dividerX, dividerY), - new Vector2(dividerX + barLength, dividerY), - ImGui.ColorConvertFloat4ToU32(topColor), 1f - ); - - ImGui.SetCursorScreenPos(new Vector2(dividerX + barLength + barPadding, dividerY - ImGui.GetTextLineHeight() / 2)); - ImGui.TextColored(topColor, diamond); - - drawList.AddLine( - new Vector2(dividerX + barLength + barPadding + diamondWidth + barPadding, dividerY), - new Vector2(dividerX + totalWidth, dividerY), - ImGui.ColorConvertFloat4ToU32(topColor), 1f - ); - - ImGui.Dummy(new Vector2(1, 8)); - - - // Bio Section - ImGui.Text("Bio:"); - ImGui.PushStyleColor(ImGuiCol.ChildBg, new Vector4(0.15f, 0.15f, 0.15f, 0.7f)); - ImGui.BeginChild("BioScroll", new Vector2(600, 120), true); - ImGui.TextWrapped(string.IsNullOrWhiteSpace(rp.Bio) ? "—" : rp.Bio); - ImGui.EndChild(); - ImGui.PopStyleColor(); - - // Centered Edit Button - ImGui.Spacing(); - ImGui.SetCursorPosX((ImGui.GetWindowWidth() - 120) * 0.5f); - - if (!showingExternal && character != null && ImGui.Button("Edit Profile", new Vector2(120, 0))) - { - plugin.RPProfileEditor.SetCharacter(character); - plugin.RPProfileEditor.IsOpen = true; - IsOpen = false; - } - - - ImGui.PopTextWrapPos(); - if (!IsOpen) - { - showingExternal = false; - externalProfile = null; - } + return !string.IsNullOrEmpty(imagePath) && File.Exists(imagePath) + ? Plugin.TextureProvider.GetFromFile(imagePath).GetWrapOrDefault() + : null; } - private void DrawFieldRow(string label1, string? val1, string label2, string? val2, float columnSplit) + private void HandleExternalImageDownload(string imageUrl) { - float rowStartX = ImGui.GetCursorPosX(); - var labelColor = new Vector4(1f, 1f, 0.85f, 1f); + if (imageDownloadStarted) return; - // First column - ImGui.SetCursorPosX(rowStartX); - ImGui.TextColored(labelColor, label1 + ":"); - ImGui.SameLine(); - ImGui.Text(string.IsNullOrWhiteSpace(val1) ? "—" : val1); + imageDownloadStarted = true; + Task.Run(() => + { + try + { + using var client = new System.Net.Http.HttpClient(); + client.Timeout = TimeSpan.FromSeconds(10); + var data = client.GetByteArrayAsync(imageUrl).GetAwaiter().GetResult(); - // Second column - ImGui.SameLine(); - ImGui.SetCursorPosX(rowStartX + columnSplit); - ImGui.TextColored(labelColor, label2 + ":"); - ImGui.SameLine(); - ImGui.Text(string.IsNullOrWhiteSpace(val2) ? "—" : val2); + var hash = Convert.ToBase64String( + System.Security.Cryptography.MD5.HashData( + System.Text.Encoding.UTF8.GetBytes(imageUrl) + ) + ).Replace("/", "_").Replace("+", "-"); + + string fileName = $"RPImage_{hash}.png"; + string path = Path.Combine( + Plugin.PluginInterface.GetPluginConfigDirectory(), + fileName + ); + + File.WriteAllBytes(path, data); + downloadedImagePath = path; + imageDownloadComplete = true; + bringToFront = true; + } + catch (Exception ex) + { + Plugin.Log.Error($"[RPProfileViewWindow] Failed to download profile image: {ex.Message}"); + imageDownloadComplete = true; + } + }); } - public void SetExternalProfile(RPProfile profile) - { - externalProfile = profile; - showingExternal = true; - imageDownloadStarted = false; - imageDownloadComplete = false; - downloadedImagePath = null; - - cachedTexture?.Dispose(); - cachedTexture = null; - cachedTexturePath = null; - - bringToFront = true; // Bring the window to front - } private Vector3 ResolveNameplateColor() { - Vector3 fallback = new(0.3f, 0.6f, 1.0f); // soft blue + Vector3 fallback = new(0.4f, 0.7f, 1.0f); if (showingExternal && externalProfile != null) { - var c = (Vector3)externalProfile.NameplateColor; - // If the colour is effectively black or unset, fallback - if (c.X < 0.01f && c.Y < 0.01f && c.Z < 0.01f) - return fallback; + if (externalProfile.ProfileColor.HasValue) + { + var c = (Vector3)externalProfile.ProfileColor.Value; + if (c.X > 0.01f || c.Y > 0.01f || c.Z > 0.01f) + return c; + } - return c; + var nc = (Vector3)externalProfile.NameplateColor; + if (nc.X < 0.01f && nc.Y < 0.01f && nc.Z < 0.01f) + return fallback; + return nc; + } + if (character?.RPProfile?.ProfileColor.HasValue == true) + { + var c = (Vector3)character.RPProfile.ProfileColor.Value; + if (c.X > 0.01f || c.Y > 0.01f || c.Z > 0.01f) + return c; } return character?.NameplateColor ?? fallback; } + private float GetSafeScale(float baseScale) + { + return Math.Clamp(baseScale, 0.3f, 5.0f); // Prevent extreme scaling + } } } diff --git a/CharacterSelectPlugin/Windows/RPProfileWindow.cs b/CharacterSelectPlugin/Windows/RPProfileWindow.cs index 8ec3e5b..912be7e 100644 --- a/CharacterSelectPlugin/Windows/RPProfileWindow.cs +++ b/CharacterSelectPlugin/Windows/RPProfileWindow.cs @@ -5,6 +5,8 @@ using System; using System.Numerics; using System.Threading; using System.Windows.Forms; +using System.Collections.Generic; +using System.Linq; namespace CharacterSelectPlugin.Windows { @@ -24,291 +26,910 @@ namespace CharacterSelectPlugin.Windows private string bio = ""; private string tags = ""; private string race = ""; + private Vector3? originalProfileColor; + private string links = ""; - public RPProfileWindow(Plugin plugin) : base("Roleplay Profile", ImGuiWindowFlags.AlwaysAutoResize) + private string originalPronouns = ""; + private string originalGender = ""; + private string originalAge = ""; + private string originalOrientation = ""; + private string originalRelationship = ""; + private string originalOccupation = ""; + private string originalAbilities = ""; + private string originalBio = ""; + private string originalTags = ""; + private string originalRace = ""; + private string? originalBackgroundImage = null; + private ProfileEffects originalEffects = new(); + private float originalImageZoom = 1.0f; + private Vector2 originalImageOffset = Vector2.Zero; + private string? originalCustomImagePath = null; + private string originalLinks = ""; + + private List availableBackgrounds = new(); + private string[] backgroundDisplayNames = Array.Empty(); + private int selectedBackgroundIndex = 0; + + public RPProfileWindow(Plugin plugin) : base("Roleplay Profile", ImGuiWindowFlags.None) { this.plugin = plugin; IsOpen = false; + LoadAvailableBackgrounds(); + + var dpiScale = ImGui.GetIO().DisplayFramebufferScale.X; + var uiScale = plugin.Configuration.UIScaleMultiplier; + var totalScale = GetSafeScale(dpiScale * uiScale); + + SizeConstraints = new WindowSizeConstraints + { + MinimumSize = new Vector2(700 * totalScale, 500 * totalScale), + MaximumSize = new Vector2(900 * totalScale, 700 * totalScale) + }; + } + + private void LoadAvailableBackgrounds() + { + try + { + string pluginDirectory = Plugin.PluginInterface.AssemblyLocation.DirectoryName ?? ""; + string assetsPath = Path.Combine(pluginDirectory, "Assets", "Backgrounds"); + + availableBackgrounds.Clear(); + availableBackgrounds.Add(""); + + if (Directory.Exists(assetsPath)) + { + for (int i = 1; i <= 80; i++) + { + string numberedFile = $"{i}.jpg"; + string fullPath = Path.Combine(assetsPath, numberedFile); + + if (File.Exists(fullPath)) + { + availableBackgrounds.Add(numberedFile); + } + } + } + + backgroundDisplayNames = availableBackgrounds.Select(bg => + string.IsNullOrEmpty(bg) ? "None (Procedural Theme)" : GetCustomBackgroundName(bg) + ).ToArray(); + } + catch (Exception ex) + { + Plugin.Log.Error($"Error loading backgrounds: {ex.Message}"); + availableBackgrounds = new List { "" }; + backgroundDisplayNames = new string[] { "None (Procedural Theme)" }; + } + } + + private string GetCustomBackgroundName(string filename) + { + string numberStr = Path.GetFileNameWithoutExtension(filename); + + return numberStr switch + { + "1" => "Gloomy Occult - @KateFF14", + "2" => "East Shroud - @Rishido_Cuore", + "3" => "Coerthas - @_a222xiv", + "4" => "Tuliyollal - @anoSviX", + "5" => "Puppets' Bunker - @Ar_FF14_", + "6" => "Crystal Tower - @Cielbn_FF14", + "7" => "The Dead Ends Third Zone - @FF14__Ciel", + "8" => "Radz-at-Han Market - @futsukatsuki", + "9" => "Yak T'el - @gm_am03", + "10" => "Delubrum Reginae - @h_x_ff14", + "11" => "Aglaia - @haku_XIV", + "12" => "The Aitiascope - @hoka_mit", + "13" => "The Dead Ends First Zone - @i_id_tea", + "14" => "the Lunar Subterrane - @igaigako", + "15" => "The Black Shroud Bridge - @Kyrie_FF14", + "16" => "Little Solace - @Kyrie_FF14", + "17" => "Tuliyollal Beach - @Kyrie_FF14", + "18" => "The Qitana Ravel - @Kyrie_FF14", + "19" => "The Black Shroud Bridge 2 - @Kyrie_FF14", + "20" => "The Grand Cosmos - @lemi_ff14", + "21" => "Crystal Tower - @lemi_ff14", + "22" => "Sinus Ardorum - @lemi_ff14", + "23" => "Alexandria - @len_xiv", + "24" => "Crystal Tower 2 - @len_xiv", + "25" => "Eulmore - @len_xiv", + "26" => "Ishgard - @Lina_ff14_", + "27" => "Ishgard 2 - @Lina_ff14_", + "28" => "Amourot - @Lina_ff14_", + "29" => "Alexandria 2 - @Lina_ff14_", + "30" => "Limsa Lominsa - @m_salyu63436", + "31" => "Castrum Fluminis - @mario_ops", + "32" => "Mamook - @mario_ops", + "33" => "Mamook 2 - @mario_ops", + "34" => "The Fringes - @MorgenRei", + "35" => "Ul'dah - @opapi_ff14", + "36" => "Solution Nine - @ratototo1209", + "37" => "Kozama'uka - @reirxiv", + "38" => "Occult Crescent - @reirxiv", + "39" => "Occult Crescent 2 - @reirxiv", + "40" => "Somewhere in La Noscea - @reirxiv", + "41" => "Worlar's Echo - @Rishido_Cuore", + "42" => "Kozama'uka 2 - @Rishido_Cuore", + "43" => "Mor Dhona - @Rishido_Cuore", + "44" => "Old Gridania - @Rishido_Cuore", + "45" => "Soution Nine 2 - @Rishido_Cuore", + "46" => "The Black Shroud - @Rishido_Cuore", + "47" => "Kozama'uka 3 - @Rishido_Cuore", + "48" => "The Great Gubal Library - @sheri_shi_", + "49" => "Xelphatol - @sheri_shi_", + "50" => "The Qitana Ravel 2 - @ST_261", + "51" => "Living Memory - @anoSviX", + "52" => "A Cave - @Ar_FF14_", + "53" => "Uh..Nier Raid Place? - @BabeUnico", + "54" => "Il Mheg 1 - @FF14__Ciel", + "55" => "Garden - @FFAru7714", + "56" => "The Aetherial Slough - @hoka_mit", + "57" => "Garlemald - @igaigako", + "58" => "Night Flowers - @KyoBlack_xiv", + "59" => "Solution Nine 3 - @KyoBlack_xiv", + "60" => "The Black Shroud 2 - @Kyrie_FF14", + "61" => "Gears! - @Kyrie_FF14", + "62" => "Crystarium - @len_xiv", + "63" => "Sil'dihn Subterrane - @len_xiv", + "64" => "Thaleia - @len_xiv", + "65" => "Il Mheg 2 - @len_xiv", + "66" => "Ishgard 3 - @Lina_ff14_", + "67" => "Il Mheg 3 - @Lina_ff14_", + "68" => "Urqopacha - @Lina_ff14_", + "69" => "Mount Rokkon - @Lina_ff14_", + "70" => "Great Gubal Library - @Lina_ff14_", + "71" => "Clouds - @M_Cieux_FF14", + "72" => "Forest Clearing - @natsuchrome", + "73" => "Occult Crescent 3 - @NtatuA", + "74" => "Crypt in a Cave - @opheli_msb10", + "75" => "Mamook 3 - @Rishido_Cuore", + "76" => "The Azim Steppe - @Rishido_Cuore", + "77" => "Il Mheg 4 - @sakusaku121625", + "78" => "Living Memory 2 - @sheri_shi_", + "79" => "Puppet's Bunker - @YoshiFahrenheit", + "80" => "Sil'dihn Subterrane 2 - @YoshiFahrenheit", + + _ => $"Background {numberStr}" + }; } public void SetCharacter(Character character) { this.character = character; + plugin.IsRPProfileEditorOpen = true; profile = character.RPProfile ??= new RPProfile(); var rp = character.RPProfile ??= new RPProfile(); + + // Store current values pronouns = rp.Pronouns ?? ""; + race = rp.Race ?? ""; gender = rp.Gender ?? ""; age = rp.Age ?? ""; - race = rp.Race ?? ""; + occupation = rp.Occupation ?? ""; orientation = rp.Orientation ?? ""; relationship = rp.Relationship ?? ""; - occupation = rp.Occupation ?? ""; abilities = rp.Abilities ?? ""; - bio = rp.Bio ?? ""; tags = rp.Tags ?? ""; + bio = rp.Bio ?? ""; + links = rp.Links ?? ""; + + // Store original values for cancel functionality + originalPronouns = pronouns; + originalRace = race; + originalGender = gender; + originalAge = age; + originalOccupation = occupation; + originalOrientation = orientation; + originalRelationship = relationship; + originalAbilities = abilities; + originalTags = tags; + originalBio = bio; + originalTags = tags; + originalBackgroundImage = rp.BackgroundImage; + originalEffects = new ProfileEffects + { + CircuitBoard = rp.Effects.CircuitBoard, + Fireflies = rp.Effects.Fireflies, + FallingLeaves = rp.Effects.FallingLeaves, + Butterflies = rp.Effects.Butterflies, + Bats = rp.Effects.Bats, + Fire = rp.Effects.Fire, + Smoke = rp.Effects.Smoke, + ColorScheme = rp.Effects.ColorScheme, + CustomParticleColor = rp.Effects.CustomParticleColor + }; + originalImageZoom = rp.ImageZoom; + originalImageOffset = rp.ImageOffset; + originalCustomImagePath = rp.CustomImagePath; + originalLinks = links; + + originalProfileColor = rp.ProfileColor; + + if (rp.ProfileColor == null) + { + } + + // Set background selection + selectedBackgroundIndex = 0; + if (!string.IsNullOrEmpty(rp.BackgroundImage)) + { + int index = availableBackgrounds.IndexOf(rp.BackgroundImage); + if (index > 0) selectedBackgroundIndex = index; + } } public override void Draw() { + var dpiScale = ImGui.GetIO().DisplayFramebufferScale.X; + var uiScale = plugin.Configuration.UIScaleMultiplier; + var totalScale = GetSafeScale(dpiScale * uiScale); + if (character == null) { ImGui.Text("No character selected."); return; } - var rp = character.RPProfile ??= new RPProfile(); - // Image Override Section (Top of Window) - ImGui.Text("Profile Image:"); + // Dark stylin' on 'em + ImGui.PushStyleColor(ImGuiCol.WindowBg, new Vector4(0.06f, 0.06f, 0.06f, 0.98f)); + ImGui.PushStyleColor(ImGuiCol.ChildBg, new Vector4(0.08f, 0.08f, 0.08f, 0.95f)); + ImGui.PushStyleColor(ImGuiCol.PopupBg, new Vector4(0.06f, 0.06f, 0.06f, 0.98f)); + ImGui.PushStyleColor(ImGuiCol.ScrollbarBg, new Vector4(0.04f, 0.04f, 0.04f, 0.8f)); + ImGui.PushStyleColor(ImGuiCol.ScrollbarGrab, new Vector4(0.2f, 0.2f, 0.2f, 0.8f)); + ImGui.PushStyleColor(ImGuiCol.ScrollbarGrabHovered, new Vector4(0.3f, 0.3f, 0.3f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.ScrollbarGrabActive, new Vector4(0.4f, 0.4f, 0.4f, 1.0f)); + ImGui.PushStyleColor(ImGuiCol.Separator, new Vector4(0.25f, 0.25f, 0.25f, 0.6f)); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.92f, 0.92f, 0.92f, 1.0f)); + ImGui.PushStyleColor(ImGuiCol.TextDisabled, new Vector4(0.5f, 0.5f, 0.5f, 0.8f)); - // Choose Image Button - if (ImGui.Button("Choose Image")) + ImGui.PushStyleVar(ImGuiStyleVar.FramePadding, new Vector2(8 * totalScale, 4 * totalScale)); + ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(8 * totalScale, 6 * totalScale)); + ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 10.0f * totalScale); + ImGui.PushStyleVar(ImGuiStyleVar.ChildRounding, 8.0f * totalScale); + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 6.0f * totalScale); + ImGui.PushStyleVar(ImGuiStyleVar.ScrollbarRounding, 8.0f * totalScale); + + try { - try - { - Thread thread = new Thread(() => - { - try - { - using (OpenFileDialog openFileDialog = new OpenFileDialog()) - { - openFileDialog.Filter = "PNG files (*.png)|*.png"; - openFileDialog.Title = "Select Profile Image"; + ImGui.SetNextWindowSize(new Vector2(750 * totalScale, 620 * totalScale), ImGuiCond.FirstUseEver); + plugin.RPProfileEditorWindowPos = ImGui.GetWindowPos(); + plugin.RPProfileEditorWindowSize = ImGui.GetWindowSize(); + var rp = character.RPProfile ??= new RPProfile(); - if (openFileDialog.ShowDialog() == DialogResult.OK) + var contentHeight = ImGui.GetContentRegionAvail().Y - (80 * totalScale); + var availableWidth = ImGui.GetContentRegionAvail().X; + var leftColumnWidth = availableWidth * 0.40f; + var rightColumnWidth = availableWidth * 0.58f; + + // Image and appearance + ImGui.BeginChild("##LeftColumn", new Vector2(leftColumnWidth, contentHeight), true, ImGuiWindowFlags.AlwaysVerticalScrollbar); + + ImGui.TextColored(new Vector4(0.7f, 0.9f, 1f, 1f), "Profile Image"); + ImGui.Separator(); + + if (ImGui.Button("Choose Image", new Vector2(120 * totalScale, 0))) + { + try + { + Thread thread = new Thread(() => + { + try + { + using (OpenFileDialog openFileDialog = new OpenFileDialog()) { - lock (this) + openFileDialog.Filter = "PNG files (*.png)|*.png"; + openFileDialog.Title = "Select Profile Image"; + + if (openFileDialog.ShowDialog() == DialogResult.OK) { - rp.CustomImagePath = openFileDialog.FileName; + lock (this) + { + rp.CustomImagePath = openFileDialog.FileName; + } } } } - } - catch (Exception ex) - { - Plugin.Log.Error($"Error opening file picker: {ex.Message}"); - } - }); + catch (Exception ex) + { + Plugin.Log.Error($"Error opening file picker: {ex.Message}"); + } + }); - thread.SetApartmentState(ApartmentState.STA); - thread.Start(); - } - catch (Exception ex) - { - Plugin.Log.Error($"Critical file picker error: {ex.Message}"); - } - } - - ImGui.SameLine(); - if (!string.IsNullOrEmpty(rp.CustomImagePath)) - { - if (ImGui.Button("Clear")) - rp.CustomImagePath = ""; - } - - // Fixed-Frame Image Preview with Zoom & Pan - string pluginDir = plugin.PluginDirectory; - string fallback = Path.Combine(pluginDir, "Assets", "Default.png"); - string finalImagePath = !string.IsNullOrEmpty(rp.CustomImagePath) && File.Exists(rp.CustomImagePath) - ? rp.CustomImagePath - : (!string.IsNullOrEmpty(character.ImagePath) && File.Exists(character.ImagePath) - ? character.ImagePath - : fallback); - - if (File.Exists(finalImagePath)) - { - var texture = Plugin.TextureProvider.GetFromFile(finalImagePath).GetWrapOrDefault(); - if (texture != null) - { - float frameSize = 150f; - float zoom = Math.Clamp(rp.ImageZoom, 0.5f, 5.0f); - Vector2 offset = rp.ImageOffset; - - float imgAspect = (float)texture.Width / texture.Height; - float drawWidth, drawHeight; - - if (imgAspect >= 1f) + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + } + catch (Exception ex) { - drawHeight = frameSize * zoom; - drawWidth = drawHeight * imgAspect; + Plugin.Log.Error($"Critical file picker error: {ex.Message}"); + } + } + ImGui.SameLine(); + if (!string.IsNullOrEmpty(rp.CustomImagePath) && ImGui.Button("Clear", new Vector2(60 * totalScale, 0))) + rp.CustomImagePath = ""; + + ImGui.Spacing(); + + // Image preview + string pluginDir = plugin.PluginDirectory; + string fallback = Path.Combine(pluginDir, "Assets", "Default.png"); + string finalImagePath = !string.IsNullOrEmpty(rp.CustomImagePath) && File.Exists(rp.CustomImagePath) + ? rp.CustomImagePath + : (!string.IsNullOrEmpty(character.ImagePath) && File.Exists(character.ImagePath) + ? character.ImagePath + : fallback); + + if (File.Exists(finalImagePath)) + { + var texture = Plugin.TextureProvider.GetFromFile(finalImagePath).GetWrapOrDefault(); + if (texture != null) + { + float frameSize = 140f * totalScale; + float zoom = Math.Clamp(rp.ImageZoom, 0.5f, 5.0f); + Vector2 offset = rp.ImageOffset * totalScale; + + float imgAspect = (float)texture.Width / texture.Height; + float drawWidth, drawHeight; + + if (imgAspect >= 1f) + { + drawHeight = frameSize * zoom; + drawWidth = drawHeight * imgAspect; + } + else + { + drawWidth = frameSize * zoom; + drawHeight = drawWidth / imgAspect; + } + + Vector2 drawSize = new(drawWidth, drawHeight); + Vector2 drawPos = ImGui.GetCursorScreenPos() + offset; + + // Background border + var cursor = ImGui.GetCursorScreenPos(); + var drawList = ImGui.GetWindowDrawList(); + drawList.AddRectFilled(cursor - new Vector2(2 * totalScale, 2 * totalScale), cursor + new Vector2(frameSize + (2 * totalScale)), ImGui.ColorConvertFloat4ToU32(new Vector4(0.4f, 0.4f, 0.4f, 1f)), 4 * totalScale); + + // Crop region + ImGui.BeginChild("ImageCropFrame", new Vector2(frameSize), false, ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse); + ImGui.SetCursorScreenPos(drawPos); + ImGui.Image(texture.ImGuiHandle, drawSize); + ImGui.EndChild(); } else { - drawWidth = frameSize * zoom; - drawHeight = drawWidth / imgAspect; + ImGui.Text($"⚠ Failed to load image"); } - - Vector2 drawSize = new(drawWidth, drawHeight); - Vector2 drawPos = ImGui.GetCursorScreenPos() + offset; - - // Background border - var cursor = ImGui.GetCursorScreenPos(); - var drawList = ImGui.GetWindowDrawList(); - drawList.AddRectFilled(cursor - new Vector2(2, 2), cursor + new Vector2(frameSize + 2), ImGui.ColorConvertFloat4ToU32(new Vector4(0.4f, 0.4f, 0.4f, 1f)), 4); - - // Crop region - ImGui.BeginChild("ImageCropFrame", new Vector2(frameSize), false, ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse); - ImGui.SetCursorScreenPos(drawPos); - ImGui.Image(texture.ImGuiHandle, drawSize); - ImGui.EndChild(); } else { - ImGui.Text($"⚠ Failed to load: {Path.GetFileName(finalImagePath)}"); + ImGui.TextDisabled("No Image Available"); } - } - else - { - ImGui.TextDisabled("No Image Available"); - } - // Image Offset Sliders - ImGui.Spacing(); - ImGui.Text("Image Position Offset:"); - ImGui.SameLine(); - ImGui.TextDisabled("ⓘ"); - if (ImGui.IsItemHovered()) - ImGui.SetTooltip("Use these sliders to reposition your image inside the fixed frame."); + ImGui.Spacing(); - Vector2 newOffset = rp.ImageOffset; - ImGui.PushItemWidth(200); - ImGui.SliderFloat("X##offset", ref newOffset.X, -300f, 300f, "%.0f"); - ImGui.SliderFloat("Y##offset", ref newOffset.Y, -300f, 300f, "%.0f"); - ImGui.PopItemWidth(); - rp.ImageOffset = newOffset; + // Image controls + ImGui.Text("Image Position:"); + Vector2 newOffset = rp.ImageOffset; + ImGui.PushItemWidth(160 * totalScale); + ImGui.SliderFloat("X Offset", ref newOffset.X, -300f, 300f, "%.0f"); + ImGui.SliderFloat("Y Offset", ref newOffset.Y, -300f, 300f, "%.0f"); + float newZoom = rp.ImageZoom; + ImGui.SliderFloat("Zoom Level", ref newZoom, 0.5f, 5.0f, "%.1fx"); + ImGui.PopItemWidth(); + rp.ImageOffset = newOffset; + rp.ImageZoom = newZoom; - // Zoom - ImGui.Spacing(); - ImGui.Text("Zoom:"); - ImGui.SameLine(); - ImGui.TextDisabled("ⓘ"); - if (ImGui.IsItemHovered()) - ImGui.SetTooltip("Zoom in or out on your image."); + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); - float newZoom = rp.ImageZoom; - ImGui.PushItemWidth(200); - ImGui.SliderFloat("##zoom", ref newZoom, 0.5f, 5.0f, "%.1fx"); - ImGui.PopItemWidth(); - rp.ImageZoom = newZoom; + ImGui.TextColored(new Vector4(0.7f, 0.9f, 1f, 1f), "Profile Appearance"); + ImGui.Separator(); + ImGui.Text("Profile Colour:"); + ImGui.SameLine(); + ImGui.TextDisabled("ⓘ"); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Choose your profile's accent colour. Used for borders, glows, and nameplate display."); - ImGui.Spacing(); + Vector3 profileColor = rp.ProfileColor ?? character.NameplateColor; - ImGui.PushTextWrapPos(); + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.15f, 0.15f, 0.15f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.25f, 0.25f, 0.25f, 1.0f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.35f, 0.35f, 0.35f, 1.0f)); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.9f, 0.9f, 0.9f, 1.0f)); + ImGui.PushStyleColor(ImGuiCol.FrameBg, new Vector4(0.12f, 0.12f, 0.12f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.FrameBgHovered, new Vector4(0.18f, 0.18f, 0.18f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.FrameBgActive, new Vector4(0.22f, 0.22f, 0.22f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.CheckMark, new Vector4(character.NameplateColor.X, character.NameplateColor.Y, character.NameplateColor.Z, 1.0f)); + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 4.0f * totalScale); - ImGui.TextColored(new Vector4(1f, 0.75f, 0.4f, 1f), $"{character.Name} – Roleplay Profile"); - ImGui.Separator(); - - DrawEditableField("Pronouns", ref pronouns); - DrawEditableField("Gender", ref gender); - DrawEditableField("Age", ref age); - DrawEditableField("Race", ref race); - DrawEditableField("Sexual Orientation", ref orientation); - DrawEditableField("Relationship", ref relationship); - DrawEditableField("Occupation", ref occupation); - ImGui.Spacing(); - - // Abilities (as tag-like input) - ImGui.Text("Abilities:"); - ImGui.SameLine(); - ImGui.TextDisabled("ⓘ"); - if (ImGui.IsItemHovered()) - ImGui.SetTooltip("Separate abilities using commas: e.g. 'alchemy, bardic magic, swordplay'"); - - ImGui.InputTextMultiline("##abilities", ref abilities, 1000, new Vector2(-1, 40)); - - ImGui.Spacing(); - ImGui.Text("Bio:"); - ImGui.InputTextMultiline("##bio", ref bio, 1000, new Vector2(-1, 100)); - - ImGui.Spacing(); - ImGui.Text("RP Tags:"); - ImGui.SameLine(); - ImGui.TextDisabled("ⓘ"); - if (ImGui.IsItemHovered()) - ImGui.SetTooltip("Separate tags using commas: e.g. 'casual, paragraph, lore-heavy'"); - - ImGui.InputTextMultiline("##tags", ref tags, 1000, new Vector2(-1, 40)); - - ImGui.Text("Profile Sharing:"); - ImGui.SameLine(); - - var sharing = profile.Sharing; - string GetSharingDisplayName(ProfileSharing option) => option switch - { - ProfileSharing.AlwaysShare => "Always Share", - ProfileSharing.NeverShare => "Never Share", - _ => option.ToString(), - }; - - if (ImGui.BeginCombo("##SharingSetting", GetSharingDisplayName(sharing))) - { - foreach (ProfileSharing option in Enum.GetValues(typeof(ProfileSharing))) + bool useNameplateColor = rp.ProfileColor == null; + if (ImGui.Checkbox("Use Nameplate Colour", ref useNameplateColor)) { - bool isSelected = (sharing == option); - if (ImGui.Selectable(GetSharingDisplayName(option), isSelected)) - profile.Sharing = option; - if (isSelected) - ImGui.SetItemDefaultFocus(); + if (useNameplateColor) + { + rp.ProfileColor = null; // Use nameplate colour + } + else + { + rp.ProfileColor = character.NameplateColor; + } } - ImGui.EndCombo(); - } - ImGui.Spacing(); - if (ImGui.Button("Save")) - { - // Save all edits into profile - profile.Pronouns = pronouns; - profile.Gender = gender; - profile.Age = age; - profile.Race = race; - profile.Orientation = orientation; - profile.Relationship = relationship; - profile.Occupation = occupation; - profile.Abilities = abilities; - profile.Bio = bio; - profile.Tags = tags; - profile.ImageZoom = rp.ImageZoom; - profile.ImageOffset = rp.ImageOffset; + ImGui.PopStyleVar(1); + ImGui.PopStyleColor(8); - // Save reference back to character and config - character.RPProfile = profile; - plugin.SaveConfiguration(); - // Upload profile just like ApplyProfile() does - if (!string.IsNullOrWhiteSpace(character.LastInGameName)) + // Color picker (ONLY show if custom colour is selected) + if (!useNameplateColor) { - character.RPProfile.CharacterName = character.Name; - character.RPProfile.NameplateColor = character.NameplateColor; + ImGui.Spacing(); - _ = Plugin.UploadProfileAsync(character.RPProfile, character.LastInGameName); + ImGui.PushStyleColor(ImGuiCol.FrameBg, new Vector4(0.12f, 0.12f, 0.12f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.FrameBgHovered, new Vector4(0.18f, 0.18f, 0.18f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.FrameBgActive, new Vector4(0.22f, 0.22f, 0.22f, 0.9f)); + + if (ImGui.ColorPicker3("##ProfileColourPicker", ref profileColor, ImGuiColorEditFlags.NoSidePreview | ImGuiColorEditFlags.NoInputs | ImGuiColorEditFlags.NoAlpha)) + { + rp.ProfileColor = profileColor; + } + + ImGui.PopStyleColor(3); } + ImGui.Spacing(); - IsOpen = false; + // Background section + ImGui.Text("Background:"); + ImGui.SameLine(); + ImGui.TextDisabled("ⓘ"); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Choose a background image from various FFXIV locations, or use procedural themes"); - // Auto-open view window with new profile - plugin.RPProfileViewer.SetCharacter(character); - plugin.RPProfileViewer.IsOpen = true; + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.15f, 0.15f, 0.15f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.25f, 0.25f, 0.25f, 1.0f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.35f, 0.35f, 0.35f, 1.0f)); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.9f, 0.9f, 0.9f, 1.0f)); + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 4.0f * totalScale); + ImGui.PushStyleVar(ImGuiStyleVar.ButtonTextAlign, new Vector2(0.5f, 0.5f)); + + // Previous/Next buttons + if (ImGui.Button("◀", new Vector2(25 * totalScale, 0))) + { + selectedBackgroundIndex = selectedBackgroundIndex > 0 ? selectedBackgroundIndex - 1 : backgroundDisplayNames.Length - 1; + rp.BackgroundImage = selectedBackgroundIndex > 0 ? availableBackgrounds[selectedBackgroundIndex] : null; + } + ImGui.SameLine(); + + ImGui.PopStyleVar(2); + ImGui.PopStyleColor(4); + + // Apply + ImGui.PushStyleColor(ImGuiCol.FrameBg, new Vector4(0.12f, 0.12f, 0.12f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.FrameBgHovered, new Vector4(0.18f, 0.18f, 0.18f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.FrameBgActive, new Vector4(0.22f, 0.22f, 0.22f, 0.9f)); + + ImGui.PushItemWidth(ImGui.GetContentRegionAvail().X - (30 * totalScale)); // Leave space for next button - scaled + if (ImGui.Combo("##Background", ref selectedBackgroundIndex, backgroundDisplayNames, backgroundDisplayNames.Length)) + { + rp.BackgroundImage = selectedBackgroundIndex > 0 ? availableBackgrounds[selectedBackgroundIndex] : null; + } + plugin.RPBackgroundDropdownPos = ImGui.GetItemRectMin(); + plugin.RPBackgroundDropdownSize = ImGui.GetItemRectSize(); + ImGui.PopItemWidth(); + + ImGui.PopStyleColor(3); + + ImGui.SameLine(); + + // Apply button styling + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.15f, 0.15f, 0.15f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.25f, 0.25f, 0.25f, 1.0f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.35f, 0.35f, 0.35f, 1.0f)); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.9f, 0.9f, 0.9f, 1.0f)); + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 4.0f * totalScale); + ImGui.PushStyleVar(ImGuiStyleVar.ButtonTextAlign, new Vector2(0.5f, 0.5f)); + + if (ImGui.Button("▶", new Vector2(25 * totalScale, 0))) + { + selectedBackgroundIndex = (selectedBackgroundIndex + 1) % backgroundDisplayNames.Length; + rp.BackgroundImage = selectedBackgroundIndex > 0 ? availableBackgrounds[selectedBackgroundIndex] : null; + } + + ImGui.PopStyleVar(2); + ImGui.PopStyleColor(4); + + ImGui.Spacing(); + + // Effects + ImGui.Text("Visual Effects:"); + ImGui.SameLine(); + ImGui.TextDisabled("ⓘ"); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Choose which animated effects to layer over your background. Mix and match as desired!"); + + bool circuitBoard = rp.Effects.CircuitBoard; + bool fireflies = rp.Effects.Fireflies; + bool leaves = rp.Effects.FallingLeaves; + bool butterflies = rp.Effects.Butterflies; + bool bats = rp.Effects.Bats; + bool fire = rp.Effects.Fire; + bool smoke = rp.Effects.Smoke; + + // Checkbox styling + ImGui.PushStyleColor(ImGuiCol.FrameBg, new Vector4(0.12f, 0.12f, 0.12f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.FrameBgHovered, new Vector4(0.18f, 0.18f, 0.18f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.FrameBgActive, new Vector4(0.22f, 0.22f, 0.22f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.CheckMark, new Vector4(character.NameplateColor.X, character.NameplateColor.Y, character.NameplateColor.Z, 1.0f)); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.9f, 0.9f, 0.9f, 1.0f)); + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 4.0f * totalScale); + + // Effects + var effectsStartPos = ImGui.GetCursorScreenPos(); + plugin.RPVisualEffectsPos = effectsStartPos; + + ImGui.Checkbox("Circuit Board", ref circuitBoard); + ImGui.Checkbox("Fireflies/Sparkles", ref fireflies); + ImGui.Checkbox("Falling Leaves", ref leaves); + ImGui.Checkbox("Butterflies", ref butterflies); + ImGui.Checkbox("Flying Bats", ref bats); + ImGui.Checkbox("Fire Effects", ref fire); + ImGui.Checkbox("Smoke/Mist", ref smoke); + + ImGui.PopStyleVar(1); + ImGui.PopStyleColor(5); + + var effectsEndPos = ImGui.GetCursorScreenPos(); + plugin.RPVisualEffectsSize = new Vector2(ImGui.GetContentRegionAvail().X, effectsEndPos.Y - effectsStartPos.Y); + + // Save + rp.Effects.CircuitBoard = circuitBoard; + rp.Effects.Fireflies = fireflies; + rp.Effects.FallingLeaves = leaves; + rp.Effects.Butterflies = butterflies; + rp.Effects.Bats = bats; + rp.Effects.Fire = fire; + rp.Effects.Smoke = smoke; + + ImGui.Spacing(); + + // Colour scheme with styling + ImGui.Text("Particle Colours:"); + ImGui.SameLine(); + ImGui.TextDisabled("ⓘ"); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Choose a colour scheme for particles to match your chosen background"); + + var colorScheme = rp.Effects.ColorScheme; + + ImGui.PushStyleColor(ImGuiCol.FrameBg, new Vector4(0.12f, 0.12f, 0.12f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.FrameBgHovered, new Vector4(0.18f, 0.18f, 0.18f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.FrameBgActive, new Vector4(0.22f, 0.22f, 0.22f, 0.9f)); + + ImGui.PushItemWidth(-1); + if (ImGui.BeginCombo("##ColorScheme", GetColorSchemeDisplayName(colorScheme))) + { + foreach (ParticleColorScheme scheme in Enum.GetValues(typeof(ParticleColorScheme))) + { + bool isSelected = (colorScheme == scheme); + if (ImGui.Selectable(GetColorSchemeDisplayName(scheme), isSelected)) + rp.Effects.ColorScheme = scheme; + if (isSelected) + ImGui.SetItemDefaultFocus(); + } + ImGui.EndCombo(); + } + ImGui.PopItemWidth(); + + ImGui.PopStyleColor(3); + + if (rp.Effects.ColorScheme == ParticleColorScheme.Custom) + { + ImGui.Spacing(); + Vector3 customColor = rp.Effects.CustomParticleColor; + + ImGui.PushStyleColor(ImGuiCol.FrameBg, new Vector4(0.12f, 0.12f, 0.12f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.FrameBgHovered, new Vector4(0.18f, 0.18f, 0.18f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.FrameBgActive, new Vector4(0.22f, 0.22f, 0.22f, 0.9f)); + + if (ImGui.ColorEdit3("Custom Colour", ref customColor)) + { + rp.Effects.CustomParticleColor = customColor; + } + + ImGui.PopStyleColor(3); + } + + ImGui.EndChild(); + + // Profile info + ImGui.SameLine(); + ImGui.BeginChild("##RightColumn", new Vector2(rightColumnWidth, contentHeight), true, ImGuiWindowFlags.AlwaysVerticalScrollbar); + + ImGui.TextColored(new Vector4(1f, 0.75f, 0.4f, 1f), $"{character.Name} – Profile Info"); + ImGui.Separator(); + + // Keep stylin on 'em + ImGui.PushStyleColor(ImGuiCol.FrameBg, new Vector4(0.16f, 0.16f, 0.16f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.FrameBgHovered, new Vector4(0.22f, 0.22f, 0.22f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.FrameBgActive, new Vector4(0.28f, 0.28f, 0.28f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.9f, 0.9f, 0.9f, 1.0f)); + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 4.0f * totalScale); + ImGui.PushStyleVar(ImGuiStyleVar.FramePadding, new Vector2(6 * totalScale, 4 * totalScale)); + + // Profile fields + ImGui.Text("Pronouns:"); + ImGui.SetNextItemWidth(-1); + ImGui.InputText("##editPronouns", ref pronouns, 100); + plugin.RPPronounsFieldPos = ImGui.GetItemRectMin(); + plugin.RPPronounsFieldSize = ImGui.GetItemRectSize(); + + ImGui.Text("Race:"); + ImGui.SetNextItemWidth(-1); + ImGui.InputText("##editRace", ref race, 100); + + ImGui.Text("Gender:"); + ImGui.SetNextItemWidth(-1); + ImGui.InputText("##editGender", ref gender, 100); + + ImGui.Text("Age:"); + ImGui.SetNextItemWidth(-1); + ImGui.InputText("##editAge", ref age, 100); + + ImGui.Text("Occupation:"); + ImGui.SetNextItemWidth(-1); + ImGui.InputText("##editOccupation", ref occupation, 100); + + ImGui.Text("Orientation:"); + ImGui.SetNextItemWidth(-1); + ImGui.InputText("##editOrientation", ref orientation, 100); + + ImGui.Text("Relationship:"); + ImGui.SetNextItemWidth(-1); + ImGui.InputText("##editRelationship", ref relationship, 100); + + ImGui.Text("Abilities:"); + ImGui.SameLine(); + ImGui.TextDisabled("ⓘ"); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Separate abilities using commas: e.g. 'alchemy, bardic magic, swordplay'"); + ImGui.InputTextMultiline("##abilities", ref abilities, 1000, new Vector2(-1, 35 * totalScale), ImGuiInputTextFlags.CtrlEnterForNewLine); + + ImGui.Text("Tags:"); + ImGui.SameLine(); + ImGui.TextDisabled("ⓘ"); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Separate tags using commas: e.g. 'casual, paragraph, lore-heavy'"); + ImGui.InputTextMultiline("##tags", ref tags, 1000, new Vector2(-1, 35 * totalScale)); + + ImGui.Spacing(); + ImGui.Text("Bio:"); + ImGui.InputTextMultiline("##bio", ref bio, 1000, new Vector2(-1, 90 * totalScale), ImGuiInputTextFlags.CtrlEnterForNewLine); + plugin.RPBioFieldPos = ImGui.GetItemRectMin(); + plugin.RPBioFieldSize = ImGui.GetItemRectSize(); + + ImGui.Text("Links:"); + ImGui.SameLine(); + ImGui.TextDisabled("ⓘ"); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Add a social media link, website, etc."); + ImGui.SetNextItemWidth(-1); + ImGui.InputText("##editLinks", ref links, 1000); + + ImGui.PopStyleVar(2); + ImGui.PopStyleColor(4); + + ImGui.EndChild(); + + ImGui.Separator(); + ImGui.Spacing(); + + // Profile sharing + ImGui.Text("Profile Sharing:"); + var sharing = profile.Sharing; + + ImGui.PushStyleColor(ImGuiCol.FrameBg, new Vector4(0.16f, 0.16f, 0.16f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.FrameBgHovered, new Vector4(0.22f, 0.22f, 0.22f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.FrameBgActive, new Vector4(0.28f, 0.28f, 0.28f, 0.9f)); + + ImGui.PushItemWidth(200 * totalScale); // Fixed width for sharing dropdown + if (ImGui.BeginCombo("##SharingSetting", GetSharingDisplayName(sharing))) + { + foreach (ProfileSharing option in Enum.GetValues(typeof(ProfileSharing))) + { + bool isSelected = (sharing == option); + if (ImGui.Selectable(GetSharingDisplayName(option), isSelected)) + profile.Sharing = option; + if (isSelected) + ImGui.SetItemDefaultFocus(); + } + ImGui.EndCombo(); + plugin.RPSharingDropdownPos = ImGui.GetItemRectMin(); + plugin.RPSharingDropdownSize = ImGui.GetItemRectSize(); + } + ImGui.PopItemWidth(); + + ImGui.PopStyleColor(3); + + ImGui.SameLine(); + + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.2f, 0.2f, 0.2f, 0.8f)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.3f, 0.3f, 0.3f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.4f, 0.4f, 0.4f, 1.0f)); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.9f, 0.9f, 0.9f, 1.0f)); + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 6.0f * totalScale); + ImGui.PushStyleVar(ImGuiStyleVar.ButtonTextAlign, new Vector2(0.5f, 0.5f)); + + float buttonWidth = 80f * totalScale; + float totalButtonWidth = buttonWidth * 2 + (20f * totalScale); + float availableSpace = ImGui.GetContentRegionAvail().X; + float startPos = availableSpace - totalButtonWidth; + + ImGui.SetCursorPosX(ImGui.GetCursorPosX() + startPos); + if (ImGui.Button("Save", new Vector2(buttonWidth, 35 * totalScale))) + { + plugin.SaveRPProfileButtonPos = ImGui.GetItemRectMin(); + plugin.SaveRPProfileButtonSize = ImGui.GetItemRectSize(); + + profile.Pronouns = pronouns; + profile.Race = race; + profile.Gender = gender; + profile.Age = age; + profile.Occupation = occupation; + profile.Orientation = orientation; + profile.Relationship = relationship; + profile.Abilities = abilities; + profile.Tags = tags; + profile.Bio = bio; + profile.Links = links; + + profile.BackgroundImage = rp.BackgroundImage; + profile.Effects = rp.Effects; + profile.ImageZoom = rp.ImageZoom; + profile.ImageOffset = rp.ImageOffset; + profile.CustomImagePath = rp.CustomImagePath; + + character.BackgroundImage = rp.BackgroundImage; + character.Effects = rp.Effects; + profile.ProfileColor = rp.ProfileColor; + character.RPProfile = profile; + + profile.GalleryStatus = character.GalleryStatus; + profile.AnimationTheme = character.RPProfile?.AnimationTheme ?? ProfileAnimationTheme.CircuitBoard; + profile.LastActiveTime = plugin.Configuration.ShowRecentlyActiveStatus ? DateTime.UtcNow : null; + + string imagePathToUse = !string.IsNullOrEmpty(profile.CustomImagePath) + ? profile.CustomImagePath + : character.ImagePath; + profile.CustomImagePath = imagePathToUse; + + profile.CharacterName = character.Name; + profile.NameplateColor = character.NameplateColor; + + plugin.SaveConfiguration(); + + if (!string.IsNullOrWhiteSpace(character.LastInGameName)) + { + // Check if we should upload to gallery based on main character setting + if (Plugin.ClientState.LocalPlayer is { } player && player.HomeWorld.IsValid) + { + string localName = player.Name.TextValue; + string worldName = player.HomeWorld.Value.Name.ToString(); + string fullKey = $"{localName}@{worldName}"; + + // Use the same ShouldUploadToGallery logic + var userMain = plugin.Configuration.GalleryMainCharacter; + bool shouldUpload = !string.IsNullOrEmpty(userMain) && + fullKey == userMain && + profile.Sharing == ProfileSharing.ShowcasePublic; + + if (shouldUpload) + { + _ = Plugin.UploadProfileAsync(profile, character.LastInGameName); + plugin.GalleryWindow.RefreshLikeStatesAfterProfileUpdate(character.Name); + Plugin.Log.Info($"[RPProfile] ✅ Uploaded profile for {character.Name} from RP editor"); + } + else + { + Plugin.Log.Info($"[RPProfile] ⚠ Skipped gallery upload from RP editor - main character check failed"); + } + } + } + + IsOpen = false; + plugin.IsRPProfileEditorOpen = false; + + // Auto-open view window with new profile + plugin.RPProfileViewer.SetCharacter(character); + plugin.RPProfileViewer.IsOpen = true; + } + else + { + // Capture position for Tutorial + plugin.SaveRPProfileButtonPos = ImGui.GetItemRectMin(); + plugin.SaveRPProfileButtonSize = ImGui.GetItemRectSize(); + } + + ImGui.SameLine(); + if (ImGui.Button("Cancel", new Vector2(buttonWidth, 35 * totalScale))) + { + rp.Pronouns = originalPronouns; + rp.Gender = originalGender; + rp.Age = originalAge; + rp.Race = originalRace; + rp.Orientation = originalOrientation; + rp.Relationship = originalRelationship; + rp.Occupation = originalOccupation; + rp.Abilities = originalAbilities; + rp.Bio = originalBio; + profile.Tags = originalTags; + rp.BackgroundImage = originalBackgroundImage; + rp.Effects = originalEffects; + rp.ImageZoom = originalImageZoom; + rp.ImageOffset = originalImageOffset; + rp.CustomImagePath = originalCustomImagePath; + rp.Links = originalLinks; + + rp.ProfileColor = originalProfileColor; + + IsOpen = false; + plugin.IsRPProfileEditorOpen = false; + } + + ImGui.PopStyleVar(2); + ImGui.PopStyleColor(4); } - - ImGui.SameLine(); - if (ImGui.Button("Cancel")) + finally { - IsOpen = false; - // Immediately open the RP profile viewer - plugin.RPProfileViewer.SetCharacter(character); - plugin.RPProfileViewer.IsOpen = true; + ImGui.PopStyleVar(6); + ImGui.PopStyleColor(10); } - - ImGui.PopTextWrapPos(); } - private void DrawEditableField(string label, ref string value) + private void DrawCompactField(string label1, ref string value1, string label2, ref string value2, float scale) { - ImGui.Text(label + ":"); - ImGui.SameLine(130); // Adjust for better alignment - ImGui.SetNextItemWidth(200); - ImGui.InputText("##" + label, ref value, 100); + if (!string.IsNullOrEmpty(label1)) + { + ImGui.Text($"{label1}:"); + ImGui.SameLine(100 * scale); + ImGui.SetNextItemWidth(140 * scale); + ImGui.InputText($"##edit{label1}", ref value1, 100); + } + + if (!string.IsNullOrEmpty(label2)) + { + ImGui.SameLine(260 * scale); + ImGui.Text($"{label2}:"); + ImGui.SameLine(340 * scale); + ImGui.SetNextItemWidth(140 * scale); + ImGui.InputText($"##edit{label2}", ref value2, 100); + } + } + + private string GetColorSchemeDisplayName(ParticleColorScheme scheme) => scheme switch + { + ParticleColorScheme.Auto => "Auto (Match Background)", + ParticleColorScheme.Warm => "Warm (Orange/Gold)", + ParticleColorScheme.Cool => "Cool (Blue/Teal)", + ParticleColorScheme.Forest => "Forest (Green)", + ParticleColorScheme.Magical => "Magical (Purple)", + ParticleColorScheme.Winter => "Winter (White/Silver)", + ParticleColorScheme.Custom => "Custom Colour", + _ => scheme.ToString() + }; + + private string GetSharingDisplayName(ProfileSharing option) => option switch + { + ProfileSharing.NeverShare => "Private", + ProfileSharing.AlwaysShare => "Direct Sharing", + ProfileSharing.ShowcasePublic => "Public", + _ => option.ToString(), + }; + private float GetSafeScale(float baseScale) + { + return Math.Clamp(baseScale, 0.3f, 5.0f); // Prevent extreme scaling } } } diff --git a/CharacterSelectPlugin/Windows/Styles/ColorSchemes.cs b/CharacterSelectPlugin/Windows/Styles/ColorSchemes.cs new file mode 100644 index 0000000..ed0d9a5 --- /dev/null +++ b/CharacterSelectPlugin/Windows/Styles/ColorSchemes.cs @@ -0,0 +1,50 @@ +using System.Numerics; + +namespace CharacterSelectPlugin.Windows.Styles +{ + public static class ColorSchemes + { + public static class Dark + { + // Updated to match the new matte black theme + public static readonly Vector4 WindowBackground = new(0.06f, 0.06f, 0.06f, 0.98f); + public static readonly Vector4 ChildBackground = new(0.08f, 0.08f, 0.08f, 0.95f); + public static readonly Vector4 PopupBackground = new(0.06f, 0.06f, 0.06f, 0.98f); + public static readonly Vector4 FrameBackground = new(0.12f, 0.12f, 0.12f, 0.9f); + public static readonly Vector4 FrameBackgroundHovered = new(0.18f, 0.18f, 0.18f, 0.9f); + public static readonly Vector4 FrameBackgroundActive = new(0.22f, 0.22f, 0.22f, 0.9f); + + public static readonly Vector4 ButtonNormal = new(0.15f, 0.15f, 0.15f, 0.9f); + public static readonly Vector4 ButtonHovered = new(0.25f, 0.25f, 0.25f, 1.0f); + public static readonly Vector4 ButtonActive = new(0.35f, 0.35f, 0.35f, 1.0f); + + public static readonly Vector4 TextPrimary = new(0.92f, 0.92f, 0.92f, 1.0f); + public static readonly Vector4 TextSecondary = new(0.7f, 0.7f, 0.7f, 1.0f); + public static readonly Vector4 TextMuted = new(0.5f, 0.5f, 0.5f, 0.8f); + + public static readonly Vector4 AccentBlue = new(0.3f, 0.7f, 1.0f, 1.0f); + public static readonly Vector4 AccentGreen = new(0.27f, 1.07f, 0.27f, 1.0f); + public static readonly Vector4 AccentRed = new(1.0f, 0.27f, 0.27f, 1.0f); + public static readonly Vector4 AccentYellow = new(1.0f, 0.85f, 0.3f, 1.0f); + + public static readonly Vector4 NameplateBackground = new(0, 0, 0, 0.9f); + public static readonly Vector4 CardBackground = new(0.1f, 0.1f, 0.1f, 0.95f); + } + + public static class Gradients + { + public static readonly Vector4 NameplateTop = new(0, 0, 0, 0.9f); + public static readonly Vector4 NameplateBottom = new(0, 0, 0, 0.7f); + + public static readonly Vector4 CardTop = new(0.18f, 0.18f, 0.18f, 0.9f); + public static readonly Vector4 CardBottom = new(0.12f, 0.12f, 0.12f, 0.9f); + } + + public static class Glow + { + public static readonly Vector4 Subtle = new(1.0f, 1.0f, 1.0f, 0.3f); + public static readonly Vector4 Medium = new(1.0f, 1.0f, 1.0f, 0.6f); + public static readonly Vector4 Strong = new(1.0f, 1.0f, 1.0f, 0.9f); + } + } +} diff --git a/CharacterSelectPlugin/Windows/Styles/UIStyles.cs b/CharacterSelectPlugin/Windows/Styles/UIStyles.cs new file mode 100644 index 0000000..d489f44 --- /dev/null +++ b/CharacterSelectPlugin/Windows/Styles/UIStyles.cs @@ -0,0 +1,268 @@ +using System; +using System.Numerics; +using ImGuiNET; +using Dalamud.Interface; + +namespace CharacterSelectPlugin.Windows.Styles +{ + public class UIStyles + { + private Plugin plugin; + private int styleStackCount = 0; + private int colorStackCount = 0; + + public UIStyles(Plugin plugin) + { + this.plugin = plugin; + } + + public void PushMainWindowStyle() + { + float scale = plugin.Configuration.UIScaleMultiplier; + + // Matte black styling + ImGui.PushStyleColor(ImGuiCol.WindowBg, new Vector4(0.06f, 0.06f, 0.06f, 0.98f)); + ImGui.PushStyleColor(ImGuiCol.ChildBg, new Vector4(0.08f, 0.08f, 0.08f, 0.95f)); + ImGui.PushStyleColor(ImGuiCol.PopupBg, new Vector4(0.06f, 0.06f, 0.06f, 0.98f)); + ImGui.PushStyleColor(ImGuiCol.FrameBg, new Vector4(0.12f, 0.12f, 0.12f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.FrameBgHovered, new Vector4(0.18f, 0.18f, 0.18f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.FrameBgActive, new Vector4(0.22f, 0.22f, 0.22f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.TitleBg, new Vector4(0.04f, 0.04f, 0.04f, 1.0f)); + ImGui.PushStyleColor(ImGuiCol.TitleBgActive, new Vector4(0.06f, 0.06f, 0.06f, 1.0f)); + ImGui.PushStyleColor(ImGuiCol.MenuBarBg, new Vector4(0.06f, 0.06f, 0.06f, 0.98f)); + ImGui.PushStyleColor(ImGuiCol.ScrollbarBg, new Vector4(0.04f, 0.04f, 0.04f, 0.8f)); + ImGui.PushStyleColor(ImGuiCol.ScrollbarGrab, new Vector4(0.2f, 0.2f, 0.2f, 0.8f)); + ImGui.PushStyleColor(ImGuiCol.ScrollbarGrabHovered, new Vector4(0.3f, 0.3f, 0.3f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.ScrollbarGrabActive, new Vector4(0.4f, 0.4f, 0.4f, 1.0f)); + ImGui.PushStyleColor(ImGuiCol.Separator, new Vector4(0.25f, 0.25f, 0.25f, 0.6f)); + ImGui.PushStyleColor(ImGuiCol.SeparatorHovered, new Vector4(0.35f, 0.35f, 0.35f, 0.8f)); + ImGui.PushStyleColor(ImGuiCol.SeparatorActive, new Vector4(0.45f, 0.45f, 0.45f, 1.0f)); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.92f, 0.92f, 0.92f, 1.0f)); + ImGui.PushStyleColor(ImGuiCol.TextDisabled, new Vector4(0.5f, 0.5f, 0.5f, 0.8f)); + + // Styling variables for polish + ImGui.PushStyleVar(ImGuiStyleVar.FramePadding, new Vector2(8 * scale, 4 * scale)); + ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(8 * scale, 6 * scale)); + ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 10.0f * scale); // Scale window rounding + ImGui.PushStyleVar(ImGuiStyleVar.ChildRounding, 8.0f * scale); // Scale child rounding + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 6.0f * scale); // Scale frame rounding + ImGui.PushStyleVar(ImGuiStyleVar.ScrollbarRounding, 8.0f * scale); // Scale scrollbar rounding + ImGui.PushStyleVar(ImGuiStyleVar.GrabRounding, 6.0f * scale); // Scale grab rounding + ImGui.PushStyleVar(ImGuiStyleVar.WindowBorderSize, 1.0f * scale); // Scale borders + ImGui.PushStyleVar(ImGuiStyleVar.ChildBorderSize, 0.5f * scale); + ImGui.PushStyleVar(ImGuiStyleVar.FrameBorderSize, 0.5f * scale); + + colorStackCount += 18; + styleStackCount += 10; + + ImGui.SetWindowFontScale(scale); + } + + public void PopMainWindowStyle() + { + ImGui.SetWindowFontScale(1f); + ImGui.PopStyleVar(styleStackCount); + ImGui.PopStyleColor(colorStackCount); + styleStackCount = 0; + colorStackCount = 0; + } + + public void PushCharacterCardStyle(Vector3 glowColor, bool isHovered = false, float scale = 1.0f) + { + // Dark card background with subtle transparency + ImGui.PushStyleColor(ImGuiCol.ChildBg, new Vector4(0.15f, 0.15f, 0.15f, 0.9f)); + + // Rounded corners + ImGui.PushStyleVar(ImGuiStyleVar.ChildRounding, 12.0f * scale); + ImGui.PushStyleVar(ImGuiStyleVar.ChildBorderSize, (isHovered ? 2.0f : 1.0f) * scale); + + colorStackCount++; + styleStackCount += 2; + } + + public void PopCharacterCardStyle() + { + ImGui.PopStyleVar(2); + ImGui.PopStyleColor(1); + styleStackCount -= 2; + colorStackCount--; + } + + public void DrawGlowingBorder(Vector2 min, Vector2 max, Vector3 color, float intensity = 1.0f, bool isHovered = false, float scale = 1.0f) + { + var drawList = ImGui.GetWindowDrawList(); + + // Convert colour to ImGui format + var glowColor = new Vector4(color.X, color.Y, color.Z, intensity); + uint glowColorU32 = ImGui.GetColorU32(glowColor); + + // Draw multiple borders for glow effect - scale thickness and radius + float thickness = (isHovered ? 2.0f : 1.5f) * scale; + float cornerRadius = 12.0f * scale; + + // Outer glow + for (int i = 0; i < 5; i++) + { + float alpha = (0.4f - i * 0.08f) * intensity; + if (alpha <= 0) break; + + uint outerColor = ImGui.GetColorU32(new Vector4(color.X, color.Y, color.Z, alpha)); + float offset = (i + 1) * 1.5f * scale; + + drawList.AddRect( + min - new Vector2(offset, offset), + max + new Vector2(offset, offset), + outerColor, + cornerRadius + offset, + ImDrawFlags.RoundCornersAll, + 1.0f * scale + ); + } + + // Inner bright border + if (isHovered) + { + uint brightColor = ImGui.GetColorU32(new Vector4(color.X, color.Y, color.Z, intensity * 0.8f)); + drawList.AddRect( + min + new Vector2(1 * scale, 1 * scale), + max - new Vector2(1 * scale, 1 * scale), + brightColor, + cornerRadius - (1 * scale), + ImDrawFlags.RoundCornersAll, + 1.0f * scale + ); + } + + // Main border + drawList.AddRect(min, max, glowColorU32, cornerRadius, ImDrawFlags.RoundCornersAll, thickness); + } + + public void PushDarkButtonStyle(float scale = 1.0f) + { + // Dark button styling + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.2f, 0.2f, 0.2f, 0.8f)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.3f, 0.3f, 0.3f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.4f, 0.4f, 0.4f, 1.0f)); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(0.9f, 0.9f, 0.9f, 1.0f)); + + ImGui.PushStyleVar(ImGuiStyleVar.ButtonTextAlign, new Vector2(0.5f, 0.5f)); + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 6.0f * scale); // Scale button rounding + + colorStackCount += 4; + styleStackCount += 2; + } + + public void PopDarkButtonStyle() + { + ImGui.PopStyleVar(2); + ImGui.PopStyleColor(4); + styleStackCount -= 2; + colorStackCount -= 4; + } + + public bool IconButton(string icon, string tooltip, Vector2? size = null, float scale = 1.0f) + { + ImGui.PushFont(UiBuilder.IconFont); + + // Scale the button size if provided + Vector2 buttonSize = size ?? Vector2.Zero; + if (size.HasValue) + { + buttonSize = new Vector2(size.Value.X * scale, size.Value.Y * scale); + } + + bool result = ImGui.Button(icon, buttonSize); + ImGui.PopFont(); + + if (ImGui.IsItemHovered() && !string.IsNullOrEmpty(tooltip)) + { + ImGui.SetTooltip(tooltip); + } + + return result; + } + + public void DrawGradientBackground(Vector2 min, Vector2 max, Vector4 topColor, Vector4 bottomColor) + { + var drawList = ImGui.GetWindowDrawList(); + + uint topColorU32 = ImGui.GetColorU32(topColor); + uint bottomColorU32 = ImGui.GetColorU32(bottomColor); + + drawList.AddRectFilledMultiColor( + min, max, + topColorU32, topColorU32, + bottomColorU32, bottomColorU32 + ); + } + + public void PushNameplateStyle(float scale = 1.0f) + { + // Nameplate styling with transparency + ImGui.PushStyleColor(ImGuiCol.ChildBg, new Vector4(0, 0, 0, 0.85f)); + ImGui.PushStyleVar(ImGuiStyleVar.ChildRounding, 0.0f); // Nameplates typically don't have rounding + ImGui.PushStyleVar(ImGuiStyleVar.ChildBorderSize, 0.0f); + + colorStackCount++; + styleStackCount += 2; + } + + public void PopNameplateStyle() + { + ImGui.PopStyleVar(2); + ImGui.PopStyleColor(1); + styleStackCount -= 2; + colorStackCount--; + } + + public void DrawPaginationDots(int currentPage, int totalPages, Vector2 position, float scale = 1.0f) + { + if (totalPages <= 1) return; + + var drawList = ImGui.GetWindowDrawList(); + float dotSize = 8.0f * scale; + float spacing = 16.0f * scale; + + for (int i = 0; i < totalPages; i++) + { + Vector2 dotPos = position + new Vector2(i * spacing, 0); + uint color = i == currentPage + ? ImGui.GetColorU32(new Vector4(1.0f, 1.0f, 1.0f, 1.0f)) + : ImGui.GetColorU32(new Vector4(0.5f, 0.5f, 0.5f, 0.7f)); + + drawList.AddCircleFilled(dotPos, dotSize / 2, color); + + // Glow effect for active dot + if (i == currentPage) + { + drawList.AddCircle(dotPos, dotSize / 2 + (2 * scale), + ImGui.GetColorU32(new Vector4(1.0f, 1.0f, 1.0f, 0.5f)), 0, 1.0f * scale); + } + } + } + + public void PushFormStyle() + { + float scale = plugin.Configuration.UIScaleMultiplier; + + // Form-specific styling + ImGui.PushStyleColor(ImGuiCol.FrameBg, new Vector4(0.16f, 0.16f, 0.16f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.FrameBgHovered, new Vector4(0.22f, 0.22f, 0.22f, 0.9f)); + ImGui.PushStyleColor(ImGuiCol.FrameBgActive, new Vector4(0.28f, 0.28f, 0.28f, 0.9f)); + + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 4.0f); + ImGui.PushStyleVar(ImGuiStyleVar.FramePadding, new Vector2(6 * scale, 4 * scale)); + + colorStackCount += 3; + styleStackCount += 2; + } + + public void PopFormStyle() + { + ImGui.PopStyleVar(2); + ImGui.PopStyleColor(3); + styleStackCount -= 2; + colorStackCount -= 3; + } + } +} diff --git a/CharacterSelectPlugin/Windows/Utils/AnimationHelper.cs b/CharacterSelectPlugin/Windows/Utils/AnimationHelper.cs new file mode 100644 index 0000000..482474a --- /dev/null +++ b/CharacterSelectPlugin/Windows/Utils/AnimationHelper.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using ImGuiNET; + +namespace CharacterSelectPlugin.Windows.Utils +{ + public static class AnimationHelper + { + private static Dictionary AnimationStates = new(); + private static Dictionary AnimationTargets = new(); + + // Smooth interpolation between current and target values + public static float SmoothStep(string key, float target, float speed = 8f) + { + if (!AnimationStates.ContainsKey(key)) + AnimationStates[key] = 0f; + + if (!AnimationTargets.ContainsKey(key)) + AnimationTargets[key] = target; + + // Only update if target changed + if (Math.Abs(AnimationTargets[key] - target) > 0.001f) + AnimationTargets[key] = target; + + float current = AnimationStates[key]; + float deltaTime = ImGui.GetIO().DeltaTime; + + // Smooth interpolation + current = current + (AnimationTargets[key] - current) * deltaTime * speed; + + // Clamp to avoid overshooting + current = Math.Clamp(current, 0f, 1f); + + AnimationStates[key] = current; + return current; + } + + // Ease-in-out animation curve + public static float EaseInOut(float t) + { + return t * t * (3f - 2f * t); + } + + // Ease-out animation curve (starts fast, ends slow) + public static float EaseOut(float t) + { + return 1f - (1f - t) * (1f - t); + } + + // Ease-in animation curve (starts slow, ends fast) + public static float EaseIn(float t) + { + return t * t; + } + + // Bounce animation effect + public static float Bounce(float t) + { + if (t < 0.5f) + return 2f * t * t; + else + return 1f - 2f * (1f - t) * (1f - t); + } + + // Pulse animation (0 to 1 and back) + public static float Pulse(float frequency = 2f) + { + return (float)(Math.Sin(ImGui.GetTime() * frequency) + 1f) / 2f; + } + + // Clear animation state for a specific key + public static void ClearAnimation(string key) + { + AnimationStates.Remove(key); + AnimationTargets.Remove(key); + } + + // Clear all animation states + public static void ClearAllAnimations() + { + AnimationStates.Clear(); + AnimationTargets.Clear(); + } + + // Get current animation value without updating + public static float GetAnimationValue(string key) + { + return AnimationStates.TryGetValue(key, out float value) ? value : 0f; + } + + // Set animation value directly + public static void SetAnimationValue(string key, float value) + { + AnimationStates[key] = Math.Clamp(value, 0f, 1f); + } + + // Animate a float value with custom easing + public static float AnimateFloat(string key, float target, float speed = 8f, Func? easing = null) + { + float t = SmoothStep(key, target, speed); + return easing != null ? easing(t) : t; + } + + // Create a hover animation for UI elements + public static float HoverAnimation(string key, bool isHovered, float speed = 10f) + { + return SmoothStep(key, isHovered ? 1f : 0f, speed); + } + + // Create a loading spinner animation + public static float SpinnerAnimation(float speed = 4f) + { + return (float)(ImGui.GetTime() * speed) % (2f * (float)Math.PI); + } + } +} diff --git a/CharacterSelectPlugin/Windows/Utils/LayoutHelper.cs b/CharacterSelectPlugin/Windows/Utils/LayoutHelper.cs new file mode 100644 index 0000000..4a74940 --- /dev/null +++ b/CharacterSelectPlugin/Windows/Utils/LayoutHelper.cs @@ -0,0 +1,218 @@ +using System; +using System.Numerics; +using ImGuiNET; + +namespace CharacterSelectPlugin.Windows.Utils +{ + public static class LayoutHelper + { + // Calculate responsive column count based on available width + public static int CalculateColumnCount(float itemWidth, float spacing, float availableWidth, int maxColumns = 6) + { + if (availableWidth <= 0) return 1; + + float totalItemWidth = itemWidth + spacing; + int columns = (int)Math.Floor(availableWidth / totalItemWidth); + + return Math.Clamp(columns, 1, maxColumns); + } + + // Center content horizontally within available space + public static void CenterHorizontally(float contentWidth) + { + float availableWidth = ImGui.GetContentRegionAvail().X; + float offset = Math.Max(0, (availableWidth - contentWidth) / 2); + ImGui.SetCursorPosX(ImGui.GetCursorPosX() + offset); + } + + // Center content vertically within available space + public static void CenterVertically(float contentHeight) + { + float availableHeight = ImGui.GetContentRegionAvail().Y; + float offset = Math.Max(0, (availableHeight - contentHeight) / 2); + ImGui.SetCursorPosY(ImGui.GetCursorPosY() + offset); + } + + // Center content both horizontally and vertically + public static void CenterContent(Vector2 contentSize) + { + CenterHorizontally(contentSize.X); + CenterVertically(contentSize.Y); + } + + // Create a flexible row layout with evenly spaced items + public static void FlexRow(float[] itemWidths, float spacing = 8f) + { + if (itemWidths.Length == 0) return; + + float totalItemWidth = 0; + foreach (float width in itemWidths) + totalItemWidth += width; + + float availableWidth = ImGui.GetContentRegionAvail().X; + float totalSpacing = (itemWidths.Length - 1) * spacing; + float extraSpace = Math.Max(0, availableWidth - totalItemWidth - totalSpacing); + float extraSpacePerItem = extraSpace / itemWidths.Length; + + for (int i = 0; i < itemWidths.Length; i++) + { + if (i > 0) + { + ImGui.SameLine(0, spacing); + } + + float finalWidth = itemWidths[i] + extraSpacePerItem; + ImGui.SetNextItemWidth(finalWidth); + } + } + + // Create a grid layout with automatic wrapping + public static bool BeginGrid(string id, float itemWidth, float itemHeight, float spacing = 8f) + { + int columns = CalculateColumnCount(itemWidth, spacing, ImGui.GetContentRegionAvail().X); + + if (columns > 1) + { + ImGui.Columns(columns, id, false); + + // Set column widths + for (int i = 0; i < columns; i++) + { + ImGui.SetColumnWidth(i, itemWidth + spacing); + } + + return true; + } + + return false; + } + + // End grid layout + public static void EndGrid() + { + ImGui.Columns(1); + } + + // Calculate image dimensions while maintaining aspect ratio + public static Vector2 CalculateImageSize(Vector2 originalSize, float maxWidth, float maxHeight) + { + if (originalSize.X <= 0 || originalSize.Y <= 0) + return new Vector2(maxWidth, maxHeight); + + float aspectRatio = originalSize.X / originalSize.Y; + + if (aspectRatio > 1) // Landscape + { + float width = Math.Min(maxWidth, originalSize.X); + float height = width / aspectRatio; + + if (height > maxHeight) + { + height = maxHeight; + width = height * aspectRatio; + } + + return new Vector2(width, height); + } + else // Portrait or square + { + float height = Math.Min(maxHeight, originalSize.Y); + float width = height * aspectRatio; + + if (width > maxWidth) + { + width = maxWidth; + height = width / aspectRatio; + } + + return new Vector2(width, height); + } + } + + // Create a responsive button layout + public static void ResponsiveButtons(string[] buttonLabels, float minButtonWidth = 80f, float spacing = 8f) + { + if (buttonLabels.Length == 0) return; + + float availableWidth = ImGui.GetContentRegionAvail().X; + float totalSpacing = (buttonLabels.Length - 1) * spacing; + float buttonWidth = Math.Max(minButtonWidth, (availableWidth - totalSpacing) / buttonLabels.Length); + + for (int i = 0; i < buttonLabels.Length; i++) + { + if (i > 0) + ImGui.SameLine(0, spacing); + + ImGui.Button(buttonLabels[i], new Vector2(buttonWidth, 0)); + } + } + + // Create a tooltip with proper positioning + public static void Tooltip(string text, float maxWidth = 300f) + { + if (ImGui.IsItemHovered()) + { + ImGui.BeginTooltip(); + ImGui.PushTextWrapPos(maxWidth); + ImGui.TextUnformatted(text); + ImGui.PopTextWrapPos(); + ImGui.EndTooltip(); + } + } + + // Create a separator with custom styling + public static void StyledSeparator(Vector4 color, float thickness = 1f) + { + var drawList = ImGui.GetWindowDrawList(); + var pos = ImGui.GetCursorScreenPos(); + var width = ImGui.GetContentRegionAvail().X; + + drawList.AddLine( + pos, + pos + new Vector2(width, 0), + ImGui.GetColorU32(color), + thickness + ); + + ImGui.Dummy(new Vector2(0, thickness + 4f)); + } + + // Create a loading indicator + public static void LoadingSpinner(float size = 20f, float thickness = 3f) + { + var drawList = ImGui.GetWindowDrawList(); + var pos = ImGui.GetCursorScreenPos(); + var center = pos + new Vector2(size / 2, size / 2); + + float angle = (float)(ImGui.GetTime() * 4f) % (2f * (float)Math.PI); + uint color = ImGui.GetColorU32(new Vector4(1f, 1f, 1f, 0.8f)); + + // Draw spinning arc + drawList.PathArcTo(center, size / 2 - thickness / 2, angle, angle + (float)Math.PI * 1.5f, 16); + drawList.PathStroke(color, ImDrawFlags.None, thickness); + + ImGui.Dummy(new Vector2(size, size)); + } + + // Get screen position for centering a popup + public static Vector2 GetCenterScreenPos(Vector2 popupSize) + { + var viewport = ImGui.GetMainViewport(); + return viewport.Pos + (viewport.Size - popupSize) / 2f; + } + + // Clamp text to fit within a specific width + public static string ClampText(string text, float maxWidth, string ellipsis = "...") + { + if (ImGui.CalcTextSize(text).X <= maxWidth) + return text; + + while (text.Length > 0 && ImGui.CalcTextSize(text + ellipsis).X > maxWidth) + { + text = text[..^1]; + } + + return text + ellipsis; + } + } +} diff --git a/CharacterSelectPlugin/Windows/Utils/ParticleEffects.cs b/CharacterSelectPlugin/Windows/Utils/ParticleEffects.cs new file mode 100644 index 0000000..7f7b3a8 --- /dev/null +++ b/CharacterSelectPlugin/Windows/Utils/ParticleEffects.cs @@ -0,0 +1,255 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using ImGuiNET; + +namespace CharacterSelectPlugin.Effects +{ + public class Particle + { + public Vector2 Position { get; set; } + public Vector2 Velocity { get; set; } + public Vector4 Color { get; set; } + public float Life { get; set; } + public float MaxLife { get; set; } + public float Size { get; set; } + + public bool IsAlive => Life > 0; + + public void Update(float deltaTime) + { + Life -= deltaTime; + Position += Velocity * deltaTime; + + // Fade out over time + float alpha = Life / MaxLife; + Color = new Vector4(Color.X, Color.Y, Color.Z, alpha); + + // Shrink over time + Size *= 0.99f; + } + } + + public class FavoriteSparkEffect + { + private List particles = new(); + private float duration = 0.8f; + private float elapsed = 0; + private bool isActive = false; + private Vector2 origin; + + public bool IsActive => isActive && elapsed < duration; + + public void Trigger(Vector2 position, bool isFavorited) + { + particles.Clear(); + origin = position; + elapsed = 0; + isActive = true; + + var random = new Random(); + int particleCount = isFavorited ? 12 : 8; // Favouriting + + Vector4 baseColor = isFavorited + ? new Vector4(1f, 0.8f, 0.2f, 1f) // Gold for favourited + : new Vector4(0.6f, 0.6f, 0.6f, 1f); // Gray for unfavourited + + for (int i = 0; i < particleCount; i++) + { + float angle = (float)(random.NextDouble() * Math.PI * 2); + float speed = 50f + (float)(random.NextDouble() * 100f); + float life = 0.4f + (float)(random.NextDouble() * 0.4f); + + var particle = new Particle + { + Position = position + new Vector2( + (float)(random.NextDouble() * 10 - 5), + (float)(random.NextDouble() * 10 - 5) + ), + Velocity = new Vector2( + (float)Math.Cos(angle) * speed, + (float)Math.Sin(angle) * speed + ), + Color = baseColor + new Vector4( + (float)(random.NextDouble() * 0.2f - 0.1f), + (float)(random.NextDouble() * 0.2f - 0.1f), + (float)(random.NextDouble() * 0.2f - 0.1f), + 0 + ), + Life = life, + MaxLife = life, + Size = 2f + (float)(random.NextDouble() * 2f) + }; + + particles.Add(particle); + } + } + + public void Update(float deltaTime) + { + if (!isActive) return; + + elapsed += deltaTime; + + for (int i = particles.Count - 1; i >= 0; i--) + { + particles[i].Update(deltaTime); + if (!particles[i].IsAlive) + { + particles.RemoveAt(i); + } + } + + if (elapsed >= duration) + { + isActive = false; + particles.Clear(); + } + } + + public void Draw() + { + if (!IsActive) return; + + var drawList = ImGui.GetWindowDrawList(); + + foreach (var particle in particles) + { + if (particle.IsAlive) + { + uint color = ImGui.GetColorU32(particle.Color); + + // Draw particles + drawList.AddCircleFilled( + particle.Position, + particle.Size, + color, + 6 + ); + + // Subtle glow effect + if (particle.Color.X > 0.8f) // Gold particles + { + var glowColor = new Vector4(particle.Color.X, particle.Color.Y, particle.Color.Z, particle.Color.W * 0.3f); + drawList.AddCircleFilled( + particle.Position, + particle.Size * 1.5f, + ImGui.GetColorU32(glowColor), + 8 + ); + } + } + } + } + } + public class LikeSparkEffect + { + private List particles = new(); + private float duration = 0.8f; + private float elapsed = 0; + private bool isActive = false; + private Vector2 origin; + + public bool IsActive => isActive && elapsed < duration; + + public void Trigger(Vector2 position, bool isFavorited) + { + particles.Clear(); + origin = position; + elapsed = 0; + isActive = true; + + var random = new Random(); + int particleCount = isFavorited ? 12 : 8; // Particles when favouriting + + Vector4 baseColor = isFavorited + ? new Vector4(1f, 0.2f, 0.4f, 1f) // Red for liking + : new Vector4(0.6f, 0.6f, 0.6f, 1f); // Gray for unfavourited + + for (int i = 0; i < particleCount; i++) + { + float angle = (float)(random.NextDouble() * Math.PI * 2); + float speed = 50f + (float)(random.NextDouble() * 100f); + float life = 0.4f + (float)(random.NextDouble() * 0.4f); + + var particle = new Particle + { + Position = position + new Vector2( + (float)(random.NextDouble() * 10 - 5), + (float)(random.NextDouble() * 10 - 5) + ), + Velocity = new Vector2( + (float)Math.Cos(angle) * speed, + (float)Math.Sin(angle) * speed + ), + Color = baseColor + new Vector4( + (float)(random.NextDouble() * 0.2f - 0.1f), + (float)(random.NextDouble() * 0.2f - 0.1f), + (float)(random.NextDouble() * 0.2f - 0.1f), + 0 + ), + Life = life, + MaxLife = life, + Size = 2f + (float)(random.NextDouble() * 2f) + }; + + particles.Add(particle); + } + } + + public void Update(float deltaTime) + { + if (!isActive) return; + + elapsed += deltaTime; + + for (int i = particles.Count - 1; i >= 0; i--) + { + particles[i].Update(deltaTime); + if (!particles[i].IsAlive) + { + particles.RemoveAt(i); + } + } + + if (elapsed >= duration) + { + isActive = false; + particles.Clear(); + } + } + + public void Draw() + { + if (!IsActive) return; + + var drawList = ImGui.GetWindowDrawList(); + + foreach (var particle in particles) + { + if (particle.IsAlive) + { + uint color = ImGui.GetColorU32(particle.Color); + + drawList.AddCircleFilled( + particle.Position, + particle.Size, + color, + 6 + ); + + if (particle.Color.X > 0.7f && particle.Color.X > particle.Color.Y && particle.Color.X > particle.Color.Z) + { + var glowColor = new Vector4(particle.Color.X, particle.Color.Y, particle.Color.Z, particle.Color.W * 0.3f); + drawList.AddCircleFilled( + particle.Position, + particle.Size * 1.5f, + ImGui.GetColorU32(glowColor), + 8 + ); + } + } + } + } + } +} diff --git a/CharacterSelectPlugin/repo.json b/CharacterSelectPlugin/repo.json index 52723e5..76ecd8e 100644 --- a/CharacterSelectPlugin/repo.json +++ b/CharacterSelectPlugin/repo.json @@ -8,16 +8,16 @@ "Select" ], "InternalName": "CharacterSelectPlugin", - "AssemblyVersion": "1.1.1.3", - "TestingAssemblyVersion": "1.1.1.3", + "AssemblyVersion": "2.0.0.3", + "TestingAssemblyVersion": "2.0.0.3", "RepoUrl": "https://github.com/IcarusXIV/Character-Select-", "ApplicableVersion": "any", "DalamudApiLevel": 12, "IsHide": "False", "IsTestingExclusive": "False", - "DownloadLinkInstall": "https://github.com/IcarusXIV/Character-Select-/releases/download/1.1.1.3/CharacterSelectPlugin.zip", - "DownloadLinkTesting": "https://github.com/IcarusXIV/Character-Select-/releases/download/1.1.1.3/CharacterSelectPlugin.zip", - "DownloadLinkUpdate": "https://github.com/IcarusXIV/Character-Select-/releases/download/1.1.1.3/CharacterSelectPlugin.zip", + "DownloadLinkInstall": "https://github.com/IcarusXIV/Character-Select-/releases/download/2.0.0.3/CharacterSelectPlugin.zip", + "DownloadLinkTesting": "https://github.com/IcarusXIV/Character-Select-/releases/download/2.0.0.3/CharacterSelectPlugin.zip", + "DownloadLinkUpdate": "https://github.com/IcarusXIV/Character-Select-/releases/download/2.0.0.3/CharacterSelectPlugin.zip", "IconUrl": "https://raw.githubusercontent.com/IcarusXIV/Character-Select-/master/CharacterSelectPlugin/Assets/Icon.png" } ]