Skip to content

Commit 42fa6ff

Browse files
committed
Merge branch 'dev' of https://github.com/XeldarAlz/FFXIV-Aetherphone into dev
2 parents 2f84616 + ea29b60 commit 42fa6ff

22 files changed

Lines changed: 465 additions & 14 deletions

docs/architecture.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,7 @@ One line per subfolder of `src/Aetherphone/Core/`. Root-level files not listed h
298298
| Report | Central report popup service |
299299
| Sharing | `ShareService` and share item types |
300300
| Shell | The shell layer covered above |
301-
| Shortcuts | Shortcuts app data: entries, macros, share codes, the runner, plugin command catalog |
301+
| Shortcuts | Shortcuts app data: entries, macros, share codes, the runner, plugin command catalog, the custom icon library |
302302
| Social | Shared social app models: feeds, reactions, mentions, tagging |
303303
| Songs | Song search, audio streaming, playlists |
304304
| Telephony | `CallHub` voice calls and call audio |

src/Aetherphone.Tests/ShortcutTests.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,16 @@ public void ADecodedCode_IsANewShortcutRatherThanTheOriginal()
187187
Assert.NotEqual(source.Id, decoded.Id);
188188
}
189189

190+
[Fact]
191+
public void ACustomImageIcon_NeverLeavesTheDeviceInTheCode()
192+
{
193+
var source = new ShortcutEntry { Name = "Ride", IconImage = "abc123" };
194+
source.Steps.Add(new ShortcutStep { Kind = ShortcutStepKind.Command, Text = "/mount" });
195+
196+
Assert.True(ShortcutCode.TryDecode(ShortcutCode.Encode(source), out var decoded, out _));
197+
Assert.Equal(string.Empty, decoded.IconImage);
198+
}
199+
190200
[Theory]
191201
[InlineData("")]
192202
[InlineData(" ")]
@@ -486,6 +496,14 @@ public void Copy_MintsANewIdentity()
486496
Assert.NotEqual(source.Id, source.Copy().Id);
487497
}
488498

499+
[Fact]
500+
public void Copy_CarriesTheCustomIconId()
501+
{
502+
var source = new ShortcutEntry { Name = "Ride", IconImage = "abc123" };
503+
504+
Assert.Equal("abc123", source.Copy().IconImage);
505+
}
506+
489507
[Fact]
490508
public void CopyFrom_ReplacesStepsRatherThanAppending()
491509
{
@@ -503,6 +521,17 @@ public void CopyFrom_ReplacesStepsRatherThanAppending()
503521
Assert.Single(target.Steps);
504522
Assert.Equal("/three", target.Steps[0].Text);
505523
}
524+
525+
[Fact]
526+
public void CopyFrom_ReplacesTheCustomIconId()
527+
{
528+
var target = new ShortcutEntry { Name = "Old", IconImage = "old-icon" };
529+
var source = new ShortcutEntry { Name = "New", IconImage = "new-icon" };
530+
531+
target.CopyFrom(source);
532+
533+
Assert.Equal("new-icon", target.IconImage);
534+
}
506535
}
507536

508537
public sealed class ShortcutHoldTests
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
using Aetherphone.Core;
2+
using Aetherphone.Core.Apps;
3+
using Aetherphone.Core.Localization;
4+
using Aetherphone.Core.Shortcuts;
5+
using Aetherphone.Core.Wallpapers;
6+
using Aetherphone.Windows.Components;
7+
8+
namespace Aetherphone.Apps.Shortcuts;
9+
10+
internal sealed partial class ShortcutsApp
11+
{
12+
private volatile bool iconSaving;
13+
private volatile bool iconFailed;
14+
private byte[]? pendingIconBytes;
15+
private ShortcutEntry? iconBakeDraft;
16+
17+
private void OpenCustomIconPicker()
18+
{
19+
iconFailed = false;
20+
iconPicker.Open();
21+
router.Push(ShortcutsScreen.CustomIcon);
22+
}
23+
24+
private void DrawCustomIconPicker(Rect content)
25+
{
26+
if (draft is null)
27+
{
28+
router.Reset();
29+
return;
30+
}
31+
32+
if (iconFailed)
33+
{
34+
iconFailed = false;
35+
Warn(Loc.T(L.Shortcuts.CustomIconFailed));
36+
}
37+
38+
var context = new PhoneContext(content, theme, navigation);
39+
var labels = new ImagePickCropLabels(Loc.T(L.Shortcuts.CustomIconTitle), Loc.T(L.Common.ImportFromPc),
40+
Loc.T(L.Common.NoPhotos), Loc.T(L.Shortcuts.CustomIconMoveAndScale), Loc.T(L.Shortcuts.CustomIconUse),
41+
Loc.T(L.Shortcuts.CustomIconSaving), Loc.T(L.Shortcuts.CustomIconGestureHint));
42+
var result = iconPicker.Draw(content, context, labels, ui.Accent, iconSaving);
43+
if (result == ImagePickCropEvent.Cancelled)
44+
{
45+
router.Pop();
46+
return;
47+
}
48+
49+
if (result == ImagePickCropEvent.Committed && !iconSaving && iconPicker.SourcePath.Length > 0)
50+
{
51+
BakeCustomIcon(iconPicker.SourcePath, iconPicker.Crop);
52+
}
53+
}
54+
55+
private void BakeCustomIcon(string sourcePath, WallpaperCrop crop)
56+
{
57+
iconSaving = true;
58+
iconBakeDraft = draft;
59+
_ = Task.Run(() =>
60+
{
61+
try
62+
{
63+
var bytes = ShortcutIconLibrary.Bake(sourcePath, crop);
64+
Interlocked.Exchange(ref pendingIconBytes, bytes);
65+
}
66+
catch (Exception exception)
67+
{
68+
AepLog.Warning(exception, "[Shortcuts] failed to bake a custom icon");
69+
iconSaving = false;
70+
iconFailed = true;
71+
iconBakeDraft = null;
72+
}
73+
});
74+
}
75+
76+
private void ConsumeBakedIcon()
77+
{
78+
var bytes = Interlocked.Exchange(ref pendingIconBytes, null);
79+
if (bytes is null)
80+
{
81+
return;
82+
}
83+
84+
iconSaving = false;
85+
var target = iconBakeDraft;
86+
iconBakeDraft = null;
87+
if (target is null || !ReferenceEquals(target, draft))
88+
{
89+
return;
90+
}
91+
92+
var previousUnsaved = UnsavedIconOf(target);
93+
store.SetCustomIcon(target, bytes);
94+
if (previousUnsaved.Length > 0)
95+
{
96+
store.ReleaseIcon(previousUnsaved);
97+
}
98+
99+
if (router.Current == ShortcutsScreen.CustomIcon)
100+
{
101+
router.Pop();
102+
}
103+
}
104+
105+
private void DiscardUnsavedIcon()
106+
{
107+
if (draft is null)
108+
{
109+
return;
110+
}
111+
112+
var unsaved = UnsavedIconOf(draft);
113+
if (unsaved.Length > 0)
114+
{
115+
store.ReleaseIcon(unsaved);
116+
}
117+
}
118+
119+
private string UnsavedIconOf(ShortcutEntry entry)
120+
{
121+
if (entry.IconImage.Length == 0)
122+
{
123+
return string.Empty;
124+
}
125+
126+
var persisted = draftId == Guid.Empty ? null : store.Find(draftId);
127+
return persisted is not null && persisted.IconImage == entry.IconImage ? string.Empty : entry.IconImage;
128+
}
129+
}

src/Aetherphone/Apps/Shortcuts/ShortcutsApp.Editor.cs

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ private void StartNewShortcut()
3939
return;
4040
}
4141

42+
DiscardUnsavedIcon();
4243
draft = new ShortcutEntry { Tint = HexColor.ToDigits(ShortcutPalette.Wheel[0]) };
4344
draft.Steps.Add(new ShortcutStep { Kind = ShortcutStepKind.Command });
4445
draftId = Guid.Empty;
@@ -48,6 +49,7 @@ private void StartNewShortcut()
4849

4950
private void StartEditShortcut(ShortcutEntry entry)
5051
{
52+
DiscardUnsavedIcon();
5153
draft = entry.Copy();
5254
draftId = entry.Id;
5355
draftPinned = store.IsPinned(entry.Id);
@@ -429,9 +431,14 @@ private void CommitDraft()
429431
}
430432
else
431433
{
434+
var previousIcon = target.IconImage;
432435
target.CopyFrom(draft);
433436
store.Save();
434437
store.SetPinned(target.Id, draftPinned);
438+
if (previousIcon.Length > 0 && previousIcon != target.IconImage)
439+
{
440+
store.ReleaseIcon(previousIcon);
441+
}
435442
}
436443

437444
draft = null;
@@ -446,10 +453,22 @@ private void DuplicateDraft()
446453
return;
447454
}
448455

456+
var previousUnsavedIcon = UnsavedIconOf(draft);
457+
449458
var copy = draft.Copy();
450459
copy.Name = CopyName(draft.Name);
451460
PruneEmptySteps(copy);
461+
if (copy.IconImage.Length > 0)
462+
{
463+
copy.IconImage = store.DuplicateIcon(copy.IconImage) ?? string.Empty;
464+
}
465+
452466
store.Add(copy);
467+
if (previousUnsavedIcon.Length > 0)
468+
{
469+
store.ReleaseIcon(previousUnsavedIcon);
470+
}
471+
453472
draft = copy.Copy();
454473
draftId = copy.Id;
455474
draftPinned = false;
@@ -499,6 +518,7 @@ private void AskDeleteDraft()
499518
private void DeleteShortcut(Guid id)
500519
{
501520
store.Remove(id);
521+
DiscardUnsavedIcon();
502522
draft = null;
503523
draftId = Guid.Empty;
504524
router.Reset();
@@ -607,7 +627,8 @@ private void DrawGlyphSection(ShortcutEntry entry, float scale)
607627
{
608628
ui.SectionLabel(Loc.T(L.Shortcuts.Symbol), TextStyles.FootnoteEmphasized, 6f);
609629
var usesPluginIcon = entry.IconPlugin.Length > 0;
610-
DrawIconPluginRow(entry, usesPluginIcon, scale);
630+
var usesCustomIcon = entry.IconImage.Length > 0;
631+
DrawIconSourceRows(entry, usesPluginIcon, usesCustomIcon, scale);
611632

612633
var origin = ImGui.GetCursorScreenPos();
613634
var width = ImGui.GetContentRegionAvail().X;
@@ -644,29 +665,44 @@ private void DrawGlyphSection(ShortcutEntry entry, float scale)
644665

645666
if (UiInteract.HoverClick(min, max))
646667
{
668+
var unsavedIcon = UnsavedIconOf(entry);
669+
if (unsavedIcon.Length > 0)
670+
{
671+
store.ReleaseIcon(unsavedIcon);
672+
}
673+
647674
entry.Glyph = glyph;
648675
entry.IconPlugin = string.Empty;
676+
entry.IconImage = string.Empty;
649677
}
650678
}
651679

652680
ImGui.SetCursorScreenPos(origin);
653681
ImGui.Dummy(new Vector2(width, rows * cell));
654682
}
655683

656-
private void DrawIconPluginRow(ShortcutEntry entry, bool usesPluginIcon, float scale)
684+
private void DrawIconSourceRows(ShortcutEntry entry, bool usesPluginIcon, bool usesCustomIcon, float scale)
657685
{
658-
var card = GroupCard.Begin(theme, 1, Metrics.Size.Row);
659-
var value = usesPluginIcon
686+
var card = GroupCard.Begin(theme, 2, Metrics.Size.Row);
687+
var pluginValue = usesPluginIcon
660688
? catalog.DisplayName(entry.IconPlugin)
661689
: Loc.T(L.Shortcuts.PluginIconNone);
662-
var picked = SettingsRow.Disclosure(card.NextRow(), Loc.T(L.Shortcuts.PluginIcon), value, theme,
690+
var pickedPlugin = SettingsRow.Disclosure(card.NextRow(), Loc.T(L.Shortcuts.PluginIcon), pluginValue, theme,
663691
"shortcuts.iconPlugin");
692+
var customValue = usesCustomIcon ? Loc.T(L.Shortcuts.CustomIcon) : Loc.T(L.Shortcuts.PluginIconNone);
693+
var pickedCustom = SettingsRow.Disclosure(card.NextRow(), Loc.T(L.Shortcuts.CustomIcon), customValue, theme,
694+
"shortcuts.iconImage");
664695
card.End();
665-
if (picked)
696+
if (pickedPlugin)
666697
{
667698
OpenPluginPicker(true);
668699
}
669700

701+
if (pickedCustom)
702+
{
703+
OpenCustomIconPicker();
704+
}
705+
670706
ImGui.Dummy(new Vector2(0f, Metrics.Space.Md * scale));
671707
}
672708
}

src/Aetherphone/Apps/Shortcuts/ShortcutsApp.Plugins.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,14 @@ private void UsePluginIcon(PluginEntry entry)
384384
{
385385
if (draft is not null)
386386
{
387+
var unsavedIcon = UnsavedIconOf(draft);
388+
if (unsavedIcon.Length > 0)
389+
{
390+
store.ReleaseIcon(unsavedIcon);
391+
}
392+
387393
draft.IconPlugin = entry.InternalName;
394+
draft.IconImage = string.Empty;
388395
}
389396

390397
router.Pop();

src/Aetherphone/Apps/Shortcuts/ShortcutsApp.cs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
using Aetherphone.Core.Confirm;
44
using Aetherphone.Core.Localization;
55
using Aetherphone.Core.Onboarding;
6+
using Aetherphone.Core.Photos;
67
using Aetherphone.Core.Shortcuts;
78
using Aetherphone.Core.Theme;
9+
using Aetherphone.Core.Wallpapers;
810
using Aetherphone.Windows.Components;
911
using Dalamud.Bindings.ImGui;
1012
using Dalamud.Interface;
@@ -21,6 +23,7 @@ private enum ShortcutsScreen : byte
2123
Plugin,
2224
PluginPicker,
2325
Import,
26+
CustomIcon,
2427
}
2528

2629
private const float RowHeight = 62f;
@@ -34,6 +37,7 @@ private enum ShortcutsScreen : byte
3437
private readonly ShortcutRunner runner;
3538
private readonly PluginCatalog catalog;
3639
private readonly ConfirmService confirm;
40+
private readonly ImagePickCrop iconPicker;
3741
private readonly AppSkin ui = new(AppPalettes.Shortcuts);
3842
private readonly ViewRouter<ShortcutsScreen> router;
3943
private readonly RouterDraw<ShortcutsScreen> drawView;
@@ -56,12 +60,14 @@ private enum ShortcutsScreen : byte
5660
private float copiedClock;
5761
private string pluginQuery = string.Empty;
5862

59-
public ShortcutsApp(ShortcutStore store, ShortcutRunner runner, ConfirmService confirm)
63+
public ShortcutsApp(ShortcutStore store, ShortcutRunner runner, ConfirmService confirm, PhotoLibrary photoLibrary,
64+
WallpaperImageCache wallpaperImages)
6065
{
6166
this.store = store;
6267
this.runner = runner;
6368
this.confirm = confirm;
6469
catalog = store.Catalog;
70+
iconPicker = new ImagePickCrop(photoLibrary, wallpaperImages);
6571
router = new ViewRouter<ShortcutsScreen>(ShortcutsScreen.Home);
6672
drawView = DrawView;
6773
back = GoBack;
@@ -81,6 +87,7 @@ public void OnOpened()
8187
public void OnClosed()
8288
{
8389
router.Reset();
90+
DiscardUnsavedIcon();
8491
draft = null;
8592
importEntry = null;
8693
}
@@ -97,6 +104,8 @@ public void Draw(in PhoneContext context)
97104
copiedClock -= delta;
98105
}
99106

107+
ConsumeBakedIcon();
108+
100109
var scale = UiScale.Current;
101110
var screen = SceneChrome.ScreenFrom(context.Content, context.Theme, scale);
102111
ui.Backdrop(screen);
@@ -124,6 +133,9 @@ private void DrawView(ShortcutsScreen screen, Rect area, int depth)
124133
case ShortcutsScreen.Import:
125134
DrawImport(area, scale);
126135
return;
136+
case ShortcutsScreen.CustomIcon:
137+
DrawCustomIconPicker(area);
138+
return;
127139
default:
128140
DrawHome(area, scale);
129141
return;

0 commit comments

Comments
 (0)