diff --git a/CHANGELOG.md b/CHANGELOG.md index 47c6367fc..2d5161fe6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,39 @@ All notable changes to OneConfig are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.8.0] - 2026-11-14 + +### Added +- Mod cards and list entries can be moved with the keyboard: focus one and press Alt+Up or Alt+Down +- Multi-select options have a Select all row +- Deleting a profile now asks you to confirm first +- API: The UI kit has enough controls to build a whole mod screen, including select fields, split buttons, dialogs, toasts, grid reordering and progress bars +- API: `OneConfigComposeScreen`, HUD layers, inline canvases, offscreen rendering and entity previews for drawing with Compose in your own screens and HUDs +- API: Theme tokens for state colours, typography, spacing, shapes and surfaces, and read-only player preferences such as Reduced Motion +- API: Typed config metadata keys, a tree builder with group enable toggles, self-updating dependencies, share codes and per-profile config migrations +- API: Custom pages that take an argument, typed destinations for `OneConfigUI.open` and read-only mod toggles +- API: HUD styles, borders, cloning, click regions, styled text runs and icons +- API: HUDs can hide or replace vanilla HUD elements, including the crosshair and attack indicator +- API: Attack, place block, player turn and block update events +- API: Keybinds can stay out of Minecraft's controls screen +- API: Notification handles, a warning type and theme colours for notifications + +### Changed +- Every OneConfig screen now uses the same controls, so buttons, menus, fields and tooltips look and behave the same everywhere +- Menus open from the control you clicked, follow the theme's shape and panels, and give keyboard focus back when they close +- Tooltips wait 300 ms before they show +- A disabled option now dims its label and control instead of greying out the whole card +- Number fields drop trailing zeros, so 0.50 shows as 0.5 +- Scrolling glides in lists added by mods +- The HUD editor's selection box follows your theme's accent colour +- Reduced Motion now also applies to page slides, scrolling and the HUD editor +- API: The `Notifications` calls that take a float duration are deprecated in favour of `durationMillis`. Mods built against older versions keep working + +### Fixed +- Fixed the keyboard focus ring never showing in the Minecraft themes +- Colours stored inside a config object now load their saved value instead of the default +- The resource reload event no longer fires a second time for server datapack reloads + ## [1.7.1] - 2026-11-12 ### Fixed diff --git a/buildSrc/src/main/kotlin/OneConfigSetupPlugin.kt b/buildSrc/src/main/kotlin/OneConfigSetupPlugin.kt index 29622e82b..0f3879c65 100644 --- a/buildSrc/src/main/kotlin/OneConfigSetupPlugin.kt +++ b/buildSrc/src/main/kotlin/OneConfigSetupPlugin.kt @@ -432,6 +432,7 @@ class OneConfigSetupPlugin : Plugin { tasks.withType().configureEach { // FixedScrollConfig relies on Compose internals, for which there is no public alternative compilerOptions.freeCompilerArgs.add("-Xdont-warn-on-error-suppression") + compilerOptions.optIn.add("org.polyfrost.oneconfig.api.ui.v1.InternalOneConfigUiApi") } tasks.withType().configureEach { diff --git a/gradle.properties b/gradle.properties index 6d533ecee..44c4f4783 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,4 +3,4 @@ org.gradle.configureondemand=true org.gradle.jvmargs=-Xmx4096m group=org.polyfrost.oneconfig -version=1.7.1 +version=1.8.0 diff --git a/minecraft/src/main/java/org/polyfrost/oneconfig/api/ui/v1/keybind/internal/MinecraftKeybindBridgeImpl.java b/minecraft/src/main/java/org/polyfrost/oneconfig/api/ui/v1/keybind/internal/MinecraftKeybindBridgeImpl.java index fb4a662d1..618022103 100644 --- a/minecraft/src/main/java/org/polyfrost/oneconfig/api/ui/v1/keybind/internal/MinecraftKeybindBridgeImpl.java +++ b/minecraft/src/main/java/org/polyfrost/oneconfig/api/ui/v1/keybind/internal/MinecraftKeybindBridgeImpl.java @@ -112,7 +112,7 @@ public static MinecraftKeybindBridgeImpl instance() { @Override public synchronized void register(OneConfigKeybind bind) { - if (bind.getName() == null) return; + if (bind.getName() == null || !bind.getMirrorToVanilla()) return; KeyMapping existing = mappings.get(bind); if (existing != null) { applyKeyTo(existing, bind); diff --git a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/OneConfig.java b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/OneConfig.java index 72a088d40..0429d6f77 100644 --- a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/OneConfig.java +++ b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/OneConfig.java @@ -109,6 +109,10 @@ public class OneConfig implements ClientModInitializer { private static final Logger LOGGER = LogManager.getLogger("OneConfig"); private boolean initialized = false; + public static boolean isInitialized() { + return INSTANCE.initialized; + } + private static void registerCommands() { Command executor = (c) -> { OneConfigUIScreen.openLastSession(); diff --git a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/OneConfigMixinInit.java b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/OneConfigMixinInit.java index 30ae988e0..4b91b0f11 100644 --- a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/OneConfigMixinInit.java +++ b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/OneConfigMixinInit.java @@ -185,6 +185,10 @@ public List getMixins() { mixins.add("events.Mixin_ModernWindowFocusEvent"); mixins.add("skia.Mixin_SkiaFrame"); + mixins.add("render.Mixin_FramePresentHooks"); + mixins.add("render.Mixin_EntityPreviewNameTag"); + mixins.add("render.Mixin_HudFrameState"); + mixins.add("render.Mixin_VanillaHudElements"); //? if >= 26.1 { mixins.add("blaze3d.GpuDeviceAccessor"); mixins.add("blaze3d.GlDeviceAccessor"); diff --git a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/Mixin_SimpleReloadInstance.java b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/Mixin_SimpleReloadInstance.java index 864921d92..240322b3f 100644 --- a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/Mixin_SimpleReloadInstance.java +++ b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/Mixin_SimpleReloadInstance.java @@ -1,7 +1,6 @@ package org.polyfrost.oneconfig.internal.mixin; import net.minecraft.server.packs.resources.ReloadInstance; -import net.minecraft.server.packs.resources.SimpleReloadInstance; import org.polyfrost.oneconfig.api.event.v1.EventManager; import org.polyfrost.oneconfig.api.event.v1.events.ResourceFinishedLoading; import org.spongepowered.asm.mixin.Mixin; @@ -9,15 +8,14 @@ import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; -//~ if = 1.8.9 'SimpleReloadInstance' -> 'SimpleReloadableResourceManager' -@Mixin(SimpleReloadInstance.class) +//? if > 1.8.9 { +import net.minecraft.server.packs.resources.ReloadableResourceManager; + +@Mixin(ReloadableResourceManager.class) public class Mixin_SimpleReloadInstance { - //~ if = 1.8.9 'create' -> 'startReload' - @Inject(method = "create", at = @At("RETURN")) - //~ if = 1.8.9 'ReloadInstance' -> 'ResourceReload' - private static void oneconfig$onResourceFinishedLoading(CallbackInfoReturnable cir) { - //~ if = 1.8.9 'done()' -> 'result()' + @Inject(method = "createReload", at = @At("RETURN")) + private void oneconfig$onResourceFinishedLoading(CallbackInfoReturnable cir) { cir.getReturnValue().done().whenComplete((result, throwable) -> { if (throwable != null) return; EventManager.INSTANCE.post(ResourceFinishedLoading.INSTANCE); @@ -25,3 +23,23 @@ public class Mixin_SimpleReloadInstance { } } +//?} else { +/*import net.minecraft.server.packs.resources.SimpleReloadInstance; + +@Mixin(SimpleReloadableResourceManager.class) +public class Mixin_SimpleReloadInstance { + + @Inject( + method = "startReload(Ljava/util/concurrent/Executor;Ljava/util/concurrent/Executor;Ljava/util/concurrent/CompletableFuture;Ljava/util/List;)Lnet/ornithemc/osl/resource/loader/api/resource/reload/ResourceReload;", + at = @At("RETURN") + ) + private void oneconfig$onResourceFinishedLoading(CallbackInfoReturnable cir) { + if ((Object) this != SimpleReloadableResourceManager.client()) return; + cir.getReturnValue().result().whenComplete((result, throwable) -> { + if (throwable != null) return; + EventManager.INSTANCE.post(ResourceFinishedLoading.INSTANCE); + }); + } + +} +*///?} diff --git a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/events/Mixin_AttackEntityEvent.java b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/events/Mixin_AttackEntityEvent.java new file mode 100644 index 000000000..02db6f687 --- /dev/null +++ b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/events/Mixin_AttackEntityEvent.java @@ -0,0 +1,44 @@ +package org.polyfrost.oneconfig.internal.mixin.events; + +import org.polyfrost.oneconfig.api.event.v1.EventManager; +import org.polyfrost.oneconfig.api.event.v1.events.AttackEntityEvent; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +//? if > 1.8.9 { +import net.minecraft.client.multiplayer.MultiPlayerGameMode; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +//?} else { +/*import net.minecraft.client.ClientPlayerInteractionManager; +import net.minecraft.entity.Entity; +import net.minecraft.entity.living.player.PlayerEntity; +*///?} + +//? if > 1.8.9 { +@Mixin(MultiPlayerGameMode.class) +public class Mixin_AttackEntityEvent { + @Inject(method = "attack", at = @At("HEAD"), cancellable = true) + private void ocfg$onAttack(Player player, Entity target, CallbackInfo ci) { + AttackEntityEvent event = new AttackEntityEvent(player, target); + EventManager.INSTANCE.post(event); + if (event.cancelled) { + ci.cancel(); + } + } +} +//?} else { +/*@Mixin(ClientPlayerInteractionManager.class) +public class Mixin_AttackEntityEvent { + @Inject(method = "attackEntity", at = @At("HEAD"), cancellable = true) + private void ocfg$onAttack(PlayerEntity player, Entity target, CallbackInfo ci) { + AttackEntityEvent event = new AttackEntityEvent(player, target); + EventManager.INSTANCE.post(event); + if (event.cancelled) { + ci.cancel(); + } + } +} +*///?} diff --git a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/events/Mixin_BlockUpdateEvent.java b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/events/Mixin_BlockUpdateEvent.java new file mode 100644 index 000000000..e528a2727 --- /dev/null +++ b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/events/Mixin_BlockUpdateEvent.java @@ -0,0 +1,50 @@ +package org.polyfrost.oneconfig.internal.mixin.events; + +import org.polyfrost.oneconfig.api.event.v1.EventManager; +import org.polyfrost.oneconfig.api.event.v1.events.BlockUpdateEvent; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +//? if > 1.8.9 { +import net.minecraft.client.multiplayer.ClientPacketListener; +import net.minecraft.network.protocol.game.ClientboundBlockUpdatePacket; +import net.minecraft.network.protocol.game.ClientboundSectionBlocksUpdatePacket; + +@Mixin(ClientPacketListener.class) +public class Mixin_BlockUpdateEvent { + @Inject(method = "handleBlockUpdate", at = @At("TAIL")) + private void ocfg$onBlockUpdate(ClientboundBlockUpdatePacket packet, CallbackInfo ci) { + if (!EventManager.INSTANCE.hasListeners(BlockUpdateEvent.class)) return; + EventManager.INSTANCE.post(new BlockUpdateEvent(packet.getPos(), packet.getBlockState())); + } + + @Inject(method = "handleChunkBlocksUpdate", at = @At("TAIL")) + private void ocfg$onSectionBlocksUpdate(ClientboundSectionBlocksUpdatePacket packet, CallbackInfo ci) { + if (!EventManager.INSTANCE.hasListeners(BlockUpdateEvent.class)) return; + packet.runUpdates((pos, state) -> EventManager.INSTANCE.post(new BlockUpdateEvent(pos.immutable(), state))); + } +} +//?} else { +/*import net.minecraft.client.network.handler.ClientPlayNetworkHandler; +import net.minecraft.network.packet.s2c.play.BlockUpdateS2CPacket; +import net.minecraft.network.packet.s2c.play.BlocksUpdateS2CPacket; + +@Mixin(ClientPlayNetworkHandler.class) +public class Mixin_BlockUpdateEvent { + @Inject(method = "handleBlockUpdate", at = @At("TAIL")) + private void ocfg$onBlockUpdate(BlockUpdateS2CPacket packet, CallbackInfo ci) { + if (!EventManager.INSTANCE.hasListeners(BlockUpdateEvent.class)) return; + EventManager.INSTANCE.post(new BlockUpdateEvent(packet.getPos(), packet.getBlockState())); + } + + @Inject(method = "handleBlocksUpdate", at = @At("TAIL")) + private void ocfg$onSectionBlocksUpdate(BlocksUpdateS2CPacket packet, CallbackInfo ci) { + if (!EventManager.INSTANCE.hasListeners(BlockUpdateEvent.class)) return; + for (BlocksUpdateS2CPacket.BlockUpdate update : packet.getUpdates()) { + EventManager.INSTANCE.post(new BlockUpdateEvent(update.getBlockPos(), update.getBlockState())); + } + } +} +*///?} diff --git a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/events/Mixin_KeyInputEvent.java b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/events/Mixin_KeyInputEvent.java index 996319a4c..dad144443 100644 --- a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/events/Mixin_KeyInputEvent.java +++ b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/events/Mixin_KeyInputEvent.java @@ -4,6 +4,7 @@ import org.polyfrost.oneconfig.api.event.v1.EventManager; import org.polyfrost.oneconfig.api.event.v1.events.KeyInputEvent; import org.polyfrost.oneconfig.api.platform.v1.Platform; +import org.polyfrost.oneconfig.api.ui.v1.platform.ComposeHudInput; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Unique; import org.spongepowered.asm.mixin.injection.At; @@ -22,6 +23,7 @@ //? if = 1.8.9 { /*import org.lwjgl.input.Keyboard; +import org.polyfrost.oneconfig.internal.legacy.KeyCodes; import org.polyfrost.oneconfig.internal.legacy.LegacyInputEvents; import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; *///?} @@ -31,6 +33,13 @@ public class Mixin_KeyInputEvent { //? if >= 1.21.10 { + @Inject(method = "keyPress", at = @At("HEAD"), cancellable = true) + private void oneconfig$hudEscape(long handle, int action, KeyEvent event, CallbackInfo ci) { + if (handle == Platform.compatibility().windowHandle() && ComposeHudInput.consumeKey(event.key(), oneconfig$state(action))) { + ci.cancel(); + } + } + @ModifyVariable(method = "keyPress", at = @At(value = "STORE"), ordinal = 0) private boolean keyCallback(boolean original, long handle, int action, KeyEvent event) { EventManager.INSTANCE.post(new KeyInputEvent(event.key(), (char) 0, oneconfig$state(action))); @@ -46,8 +55,12 @@ private void charCallback(long handle, CharacterEvent event, CallbackInfo ci) { EventManager.INSTANCE.post(new KeyInputEvent(0, (char) event.codepoint(), KeyInputEvent.PRESSED)); } //?} elif > 1.8.9 { - /*@Inject(method = "keyPress", at = @At("HEAD")) + /*@Inject(method = "keyPress", at = @At("HEAD"), cancellable = true) private void keyCallback(long window, int key, int scancode, int action, int mods, CallbackInfo ci) { + if (window == Platform.compatibility().windowHandle() && ComposeHudInput.consumeKey(key, oneconfig$state(action))) { + ci.cancel(); + return; + } EventManager.INSTANCE.post(new KeyInputEvent(key, (char) 0, oneconfig$state(action))); } @@ -59,9 +72,22 @@ private void charCallback(long window, int codepoint, int mods, CallbackInfo ci) EventManager.INSTANCE.post(new KeyInputEvent(0, (char) codepoint, KeyInputEvent.PRESSED)); } *///?} else { - /*@Inject(method = "next", at = @At("RETURN"), remap = false) + /*@Inject(method = "next", at = @At("RETURN"), remap = false, cancellable = true) private static void keyCallback(CallbackInfoReturnable cir) { - if (cir.getReturnValue() && !LegacyInputEvents.deferred) LegacyInputEvents.postKeyboard(); + if (!cir.getReturnValue() || LegacyInputEvents.deferred) return; + if (oneconfig$hudConsumesLegacy()) { + cir.setReturnValue(Keyboard.next()); + return; + } + LegacyInputEvents.postKeyboard(); + } + + @Unique + private static boolean oneconfig$hudConsumesLegacy() { + int keyCode = Keyboard.getEventKey(); + if (keyCode <= 0) return false; + int state = Keyboard.isRepeatEvent() ? KeyInputEvent.REPEAT : Keyboard.getEventKeyState() ? KeyInputEvent.PRESSED : KeyInputEvent.RELEASED; + return ComposeHudInput.consumeKey(KeyCodes.fromLegacy(keyCode).getValue(), state); } *///?} diff --git a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/events/Mixin_MouseInputEvent.java b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/events/Mixin_MouseInputEvent.java index b8ec0a4e2..c03e4bb93 100644 --- a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/events/Mixin_MouseInputEvent.java +++ b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/events/Mixin_MouseInputEvent.java @@ -5,6 +5,7 @@ import org.polyfrost.oneconfig.api.event.v1.EventManager; import org.polyfrost.oneconfig.api.event.v1.events.MouseInputEvent; import org.polyfrost.oneconfig.api.notifications.v1.NotificationsRenderer; +import org.polyfrost.oneconfig.api.ui.v1.platform.ComposeHudInput; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Unique; import org.spongepowered.asm.mixin.injection.At; @@ -22,6 +23,7 @@ //? if = 1.8.9 { /*import org.lwjgl.input.Mouse; +import org.polyfrost.oneconfig.internal.legacy.KeyCodes; import org.polyfrost.oneconfig.internal.legacy.LegacyInputEvents; import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; *///?} @@ -34,7 +36,7 @@ public class Mixin_MouseInputEvent { //? if >= 1.21.10 { @Inject(method = "onButton", at = @At("HEAD"), cancellable = true) private void mouseCallback(long window, MouseButtonInfo buttonInfo, int action, CallbackInfo ci) { - if (oneconfig$toastConsumes(buttonInfo.button(), action)) { + if (oneconfig$toastConsumes(buttonInfo.button(), action) || oneconfig$hudConsumes(buttonInfo.button(), action)) { ci.cancel(); return; } @@ -56,7 +58,7 @@ private void mouseMoveCallback(long handle, double x, double y, double xrel, dou //?} elif > 1.8.9 { /*@Inject(method = "onPress", at = @At("HEAD"), cancellable = true) private void mouseCallback(long handle, int button, int action, int mods, CallbackInfo ci) { - if (oneconfig$toastConsumes(button, action)) { + if (oneconfig$toastConsumes(button, action) || oneconfig$hudConsumes(button, action)) { ci.cancel(); return; } @@ -72,14 +74,27 @@ private void mouseMoveCallback(long handle, double x, double y, CallbackInfo ci) *///?} else { /*@Inject(method = "next", at = @At("RETURN"), remap = false, cancellable = true) private static void mouseCallback(CallbackInfoReturnable cir) { - if (cir.getReturnValue() && !LegacyInputEvents.deferred && LegacyInputEvents.postMouse()) cir.setReturnValue(Mouse.next()); + if (cir.getReturnValue() && !LegacyInputEvents.deferred && (oneconfig$hudConsumesLegacy() || LegacyInputEvents.postMouse())) cir.setReturnValue(Mouse.next()); + } + + @Unique + private static boolean oneconfig$hudConsumesLegacy() { + int button = Mouse.getEventButton(); + if (button >= 0) { + boolean pressed = Mouse.getEventButtonState(); + if (pressed && NotificationsRenderer.isPointerOverToast()) return false; + return ComposeHudInput.consumeButton(KeyCodes.mouseFromLegacy(button), pressed); + } + double wheel = Mouse.getEventDWheelPrecise(); + if (wheel == 0.0 || NotificationsRenderer.isPointerOverToast()) return false; + return ComposeHudInput.consumeScroll(0.0, wheel); } *///? } //? if > 1.8.9 { @Inject(method = "onScroll", at = @At("HEAD"), cancellable = true) private void oneconfig$captureRawScroll(long window, double x, double y, CallbackInfo ci) { - if (NotificationsRenderer.isPointerOverToast()) { + if (NotificationsRenderer.isPointerOverToast() || ComposeHudInput.consumeScroll(x, y)) { ci.cancel(); return; } @@ -94,6 +109,12 @@ private static void mouseCallback(CallbackInfoReturnable cir) { return NotificationsRenderer.consumeMouseButton(button, action == InputConstants.PRESS); } + @Unique + private static boolean oneconfig$hudConsumes(int button, int action) { + if (action != InputConstants.PRESS && action != InputConstants.RELEASE) return false; + return ComposeHudInput.consumeButton(button, action == InputConstants.PRESS); + } + @Unique private static int oneconfig$state(int action) { if (action == InputConstants.PRESS) return MouseInputEvent.PRESSED; diff --git a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/events/Mixin_PlaceBlockEvent.java b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/events/Mixin_PlaceBlockEvent.java new file mode 100644 index 000000000..180cd28cb --- /dev/null +++ b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/events/Mixin_PlaceBlockEvent.java @@ -0,0 +1,54 @@ +package org.polyfrost.oneconfig.internal.mixin.events; + +import net.minecraft.client.Minecraft; +import net.minecraft.world.item.BlockItem; +import org.polyfrost.oneconfig.api.event.v1.EventManager; +import org.polyfrost.oneconfig.api.event.v1.events.PlaceBlockEvent; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +//? if > 1.8.9 { +import net.minecraft.core.BlockPos; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.context.BlockPlaceContext; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.state.BlockState; +//?} else { +/*import net.minecraft.core.Direction; +import net.minecraft.entity.living.player.PlayerEntity; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.World; +import net.minecraft.world.item.ItemStack; +*///?} + +@Mixin(BlockItem.class) +public class Mixin_PlaceBlockEvent { + + //? if > 1.8.9 { + @Inject(method = "placeBlock", at = @At("RETURN")) + private void ocfg$onPlaceBlock(BlockPlaceContext context, BlockState placementState, CallbackInfoReturnable cir) { + if (!cir.getReturnValueZ()) return; + Player player = context.getPlayer(); + if (player == null || player != Minecraft.getInstance().player) return; + Level level = context.getLevel(); + BlockPos pos = context.getClickedPos(); + EventManager.INSTANCE.post(new PlaceBlockEvent(player, level, pos, level.getBlockState(pos))); + } + //?} else { + /*@Inject( + method = "useOn", + at = @At( + value = "INVOKE", + target = "Lnet/minecraft/world/World;playSound(DDDLjava/lang/String;FF)V", + shift = At.Shift.AFTER + ) + ) + private void ocfg$onPlaceBlock(ItemStack stack, PlayerEntity player, World world, BlockPos pos, Direction direction, float hitX, float hitY, float hitZ, CallbackInfoReturnable cir) { + if (!world.isClient || player != Minecraft.getInstance().player) return; + EventManager.INSTANCE.post(new PlaceBlockEvent(player, world, pos, world.getBlockState(pos))); + } + *///?} + +} diff --git a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/events/Mixin_PlayerTurnEvent.java b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/events/Mixin_PlayerTurnEvent.java new file mode 100644 index 000000000..82f51e0c2 --- /dev/null +++ b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/events/Mixin_PlayerTurnEvent.java @@ -0,0 +1,41 @@ +package org.polyfrost.oneconfig.internal.mixin.events; + +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; +import net.minecraft.client.player.LocalPlayer; +import org.polyfrost.oneconfig.api.event.v1.EventManager; +import org.polyfrost.oneconfig.api.event.v1.events.PlayerTurnEvent; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; + +//? if > 1.8.9 { +import net.minecraft.client.MouseHandler; + +@Mixin(MouseHandler.class) +public class Mixin_PlayerTurnEvent { + @WrapOperation(method = "turnPlayer", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/player/LocalPlayer;turn(DD)V")) + private void ocfg$onTurn(LocalPlayer player, double yaw, double pitch, Operation original) { + if ((yaw != 0.0 || pitch != 0.0) && EventManager.INSTANCE.hasListeners(PlayerTurnEvent.class)) { + PlayerTurnEvent event = new PlayerTurnEvent(yaw * 0.15, pitch * 0.15); + EventManager.INSTANCE.post(event); + if (event.cancelled) return; + } + original.call(player, yaw, pitch); + } +} +//?} else { +/*import net.minecraft.client.render.GameRenderer; + +@Mixin(GameRenderer.class) +public class Mixin_PlayerTurnEvent { + @WrapOperation(method = "render(FJ)V", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/player/LocalPlayer;updateLocalPlayerCamera(FF)V")) + private void ocfg$onTurn(LocalPlayer player, float yaw, float pitch, Operation original) { + if ((yaw != 0.0F || pitch != 0.0F) && EventManager.INSTANCE.hasListeners(PlayerTurnEvent.class)) { + PlayerTurnEvent event = new PlayerTurnEvent(yaw * 0.15, -pitch * 0.15); + EventManager.INSTANCE.post(event); + if (event.cancelled) return; + } + original.call(player, yaw, pitch); + } +} +*///?} diff --git a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/keybind/Mixin_KeyMappingResetDetect.java b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/keybind/Mixin_KeyMappingResetDetect.java index a81610077..8f344c5e9 100644 --- a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/keybind/Mixin_KeyMappingResetDetect.java +++ b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/keybind/Mixin_KeyMappingResetDetect.java @@ -3,6 +3,7 @@ import com.mojang.blaze3d.platform.InputConstants; import net.minecraft.client.KeyMapping; import org.polyfrost.oneconfig.api.ui.v1.keybind.internal.MinecraftKeybindBridgeImpl; +import org.polyfrost.oneconfig.internal.ui.keybind.VanillaKeyMappingProperties; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; @@ -22,4 +23,11 @@ public class Mixin_KeyMappingResetDetect { if (!key.equals(self.getDefaultKey())) return; if (bridge.menuReset(self)) ci.cancel(); } + + //~ if = 1.8.9 'setKey' -> 'setKeyCode' + @Inject(method = "setKey", at = @At("RETURN")) + //~ if = 1.8.9 'InputConstants.Key key' -> 'int keyCode' + private void oneconfig$syncVanillaProperty(InputConstants.Key key, CallbackInfo ci) { + VanillaKeyMappingProperties.onKeyChanged((KeyMapping) (Object) this); + } } diff --git a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/keybind/Mixin_OptionsSaveDetect.java b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/keybind/Mixin_OptionsSaveDetect.java index 94723740c..3385afad4 100644 --- a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/keybind/Mixin_OptionsSaveDetect.java +++ b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/keybind/Mixin_OptionsSaveDetect.java @@ -2,6 +2,7 @@ import net.minecraft.client.Options; import org.polyfrost.oneconfig.internal.ui.keybind.MinecraftKeybindProfiles; +import org.polyfrost.oneconfig.internal.ui.keybind.VanillaKeyMappingProperties; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; @@ -12,5 +13,6 @@ public class Mixin_OptionsSaveDetect { @Inject(method = "save", at = @At("RETURN")) private void oneconfig$captureSavedControls(CallbackInfo ci) { MinecraftKeybindProfiles.onOptionsSaved(); + VanillaKeyMappingProperties.onOptionsSaved(); } } diff --git a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/render/Mixin_EntityPreviewNameTag.java b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/render/Mixin_EntityPreviewNameTag.java new file mode 100644 index 000000000..97a9eacc0 --- /dev/null +++ b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/render/Mixin_EntityPreviewNameTag.java @@ -0,0 +1,35 @@ +package org.polyfrost.oneconfig.internal.mixin.render; + +import net.minecraft.client.renderer.entity.LivingEntityRenderer; +import net.minecraft.world.entity.LivingEntity; +import org.polyfrost.oneconfig.internal.ui.PreviewNameTag; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(LivingEntityRenderer.class) +public class Mixin_EntityPreviewNameTag { + + @Inject( + //? if >= 1.21.4 { + method = "shouldShowName(Lnet/minecraft/world/entity/LivingEntity;D)Z", + //?} elif > 1.8.9 { + /*method = "shouldShowName(Lnet/minecraft/world/entity/LivingEntity;)Z", + *///?} else + //method = "shouldRenderNameTag(Lnet/minecraft/entity/living/LivingEntity;)Z", + at = @At("HEAD"), + cancellable = true + ) + private void oneconfig$entityPreviewNameTag( + LivingEntity entity, + //? if >= 1.21.4 + double distanceToCameraSq, + CallbackInfoReturnable cir + ) { + if (PreviewNameTag.rendering) { + cir.setReturnValue(PreviewNameTag.show); + } + } + +} diff --git a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/render/Mixin_FramePresentHooks.java b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/render/Mixin_FramePresentHooks.java new file mode 100644 index 000000000..53febdc61 --- /dev/null +++ b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/render/Mixin_FramePresentHooks.java @@ -0,0 +1,42 @@ +package org.polyfrost.oneconfig.internal.mixin.render; + +//? if >= 1.21.10 && < 26.2 +//import com.mojang.blaze3d.pipeline.RenderTarget; +import net.minecraft.client.Minecraft; +import org.polyfrost.oneconfig.internal.ui.FramePresentHooks; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +//? if >= 26.2 { +@Mixin(value = Minecraft.class, priority = 1500) +//? } else if >= 1.21.10 { +/*@Mixin(RenderTarget.class) +*///? } else { +/*@Mixin(Minecraft.class) +*///? } +public class Mixin_FramePresentHooks { + + //? if >= 26.2 { + @Inject( + method = "renderFrame", + at = @At( + value = "INVOKE", + target = "Lcom/mojang/renderpearl/api/device/GpuSurface;blitFromTexture(Lcom/mojang/renderpearl/api/commands/CommandEncoder;Lcom/mojang/renderpearl/api/textures/GpuTextureView;)V" + ) + ) + private void oneconfig$beforePresent(CallbackInfo ci) { + FramePresentHooks.run(); + } + //? } else if >= 1.21.10 { + /*@Inject(method = "blitToScreen", at = @At("HEAD")) + private void oneconfig$beforePresent(CallbackInfo ci) { + RenderTarget self = (RenderTarget) (Object) this; + Minecraft client = Minecraft.getInstance(); + if (client == null || self != client.getMainRenderTarget()) return; + FramePresentHooks.run(); + } + *///? } + +} diff --git a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/render/Mixin_HudFrameState.java b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/render/Mixin_HudFrameState.java new file mode 100644 index 000000000..89b8bc246 --- /dev/null +++ b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/render/Mixin_HudFrameState.java @@ -0,0 +1,44 @@ +package org.polyfrost.oneconfig.internal.mixin.render; + +import org.polyfrost.oneconfig.api.ui.v1.platform.HudFrameHooks; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +//? if > 1.8.9 { +import net.minecraft.client.DeltaTracker; +//?} + +//? if >= 26.2 { +import net.minecraft.client.gui.Gui; +//?} elif > 1.8.9 { +/*import net.minecraft.client.renderer.GameRenderer; +*///?} else +//import net.minecraft.client.gui.GameGui; + +//? if >= 26.2 { +@Mixin(Gui.class) +//?} elif > 1.8.9 { +/*@Mixin(GameRenderer.class) +*///?} else +//@Mixin(GameGui.class) +public class Mixin_HudFrameState { + //? if >= 26.1 { + //~ if >= 26.2 'extractGui' -> 'extractRenderState' + @Inject(method = "extractRenderState", at = @At("HEAD")) + private void oneconfig$beginHudFrame(DeltaTracker deltaTracker, boolean renderLevel, boolean resourcesLoaded, CallbackInfo ci) { + HudFrameHooks.beginFrame(deltaTracker.getGameTimeDeltaPartialTick(false)); + } + //?} elif > 1.8.9 { + /*@Inject(method = "render", at = @At("HEAD")) + private void oneconfig$beginHudFrame(DeltaTracker deltaTracker, boolean renderLevel, CallbackInfo ci) { + HudFrameHooks.beginFrame(deltaTracker.getGameTimeDeltaPartialTick(false)); + } + *///?} else { + /*@Inject(method = "render", at = @At("HEAD")) + private void oneconfig$beginHudFrame(float partialTicks, CallbackInfo ci) { + HudFrameHooks.beginFrame(partialTicks); + } + *///?} +} diff --git a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/render/Mixin_VanillaHudElements.java b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/render/Mixin_VanillaHudElements.java new file mode 100644 index 000000000..575a3d7d8 --- /dev/null +++ b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/render/Mixin_VanillaHudElements.java @@ -0,0 +1,283 @@ +//~ gui_graphics +package org.polyfrost.oneconfig.internal.mixin.render; + +//? if > 1.8.9 { +import com.llamalad7.mixinextras.injector.v2.WrapWithCondition; +import net.minecraft.client.DeltaTracker; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.network.chat.Component; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.item.ItemStack; +//? if >= 26.2 { +import net.minecraft.client.gui.Hud; +//?} else { +/*import net.minecraft.client.gui.Gui; +*///?} +//? if >= 26.3 { +import com.mojang.renderpearl.api.pipeline.RenderPipeline; +//?} else if >= 1.21.6 { +/*import com.mojang.blaze3d.pipeline.RenderPipeline; +*///?} else if >= 1.21.4 { +/*import java.util.function.Function; +*///?} +//? if >= 1.21.11 { +import net.minecraft.resources.Identifier; +//?} else { +/*import net.minecraft.resources.ResourceLocation; +*///?} +import org.polyfrost.oneconfig.api.ui.v1.platform.VanillaHudElement; +import org.polyfrost.oneconfig.api.ui.v1.platform.VanillaHudHooks; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.ModifyArgs; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import org.spongepowered.asm.mixin.injection.invoke.arg.Args; + +//? if >= 26.2 { +@Mixin(Hud.class) +//?} else { +/*@Mixin(Gui.class) +*///?} +public abstract class Mixin_VanillaHudElements { + @Shadow + private Component overlayMessageString; + + @Shadow + private int overlayMessageTime; + + @Shadow + private Component title; + + @Shadow + private int titleTime; + + @Shadow + private int toolHighlightTimer; + + @Shadow + private ItemStack lastToolHighlight; + + @Unique + private static final String CROSSHAIR = + //~ if >= 26.1 'renderCrosshair' -> 'extractCrosshair' + "extractCrosshair"; + + @Unique + private static final String BLIT_SPRITE = + //? if >= 26.3 { + "Lnet/minecraft/client/gui/GuiGraphicsExtractor;blitSprite(Lcom/mojang/renderpearl/api/pipeline/RenderPipeline;Lnet/minecraft/resources/Identifier;IIII)V"; + //?} else if >= 1.21.11 { + /*"Lnet/minecraft/client/gui/GuiGraphicsExtractor;blitSprite(Lcom/mojang/blaze3d/pipeline/RenderPipeline;Lnet/minecraft/resources/Identifier;IIII)V"; + *///?} else if >= 1.21.6 { + /*"Lnet/minecraft/client/gui/GuiGraphicsExtractor;blitSprite(Lcom/mojang/blaze3d/pipeline/RenderPipeline;Lnet/minecraft/resources/ResourceLocation;IIII)V"; + *///?} else if >= 1.21.4 { + /*"Lnet/minecraft/client/gui/GuiGraphicsExtractor;blitSprite(Ljava/util/function/Function;Lnet/minecraft/resources/ResourceLocation;IIII)V"; + *///?} else { + /*"Lnet/minecraft/client/gui/GuiGraphicsExtractor;blitSprite(Lnet/minecraft/resources/ResourceLocation;IIII)V"; + *///?} + + @Unique + private static final String BLIT_SPRITE_REGION = + //? if >= 26.3 { + "Lnet/minecraft/client/gui/GuiGraphicsExtractor;blitSprite(Lcom/mojang/renderpearl/api/pipeline/RenderPipeline;Lnet/minecraft/resources/Identifier;IIIIIIII)V"; + //?} else if >= 1.21.11 { + /*"Lnet/minecraft/client/gui/GuiGraphicsExtractor;blitSprite(Lcom/mojang/blaze3d/pipeline/RenderPipeline;Lnet/minecraft/resources/Identifier;IIIIIIII)V"; + *///?} else if >= 1.21.6 { + /*"Lnet/minecraft/client/gui/GuiGraphicsExtractor;blitSprite(Lcom/mojang/blaze3d/pipeline/RenderPipeline;Lnet/minecraft/resources/ResourceLocation;IIIIIIII)V"; + *///?} else if >= 1.21.4 { + /*"Lnet/minecraft/client/gui/GuiGraphicsExtractor;blitSprite(Ljava/util/function/Function;Lnet/minecraft/resources/ResourceLocation;IIIIIIII)V"; + *///?} else { + /*"Lnet/minecraft/client/gui/GuiGraphicsExtractor;blitSprite(Lnet/minecraft/resources/ResourceLocation;IIIIIIII)V"; + *///?} + + @Unique + private static final int BLIT_X = + //? if >= 1.21.4 { + 2; + //?} else { + /*1; + *///?} + + @Unique + private static final int BLIT_Y = BLIT_X + 1; + + @Unique + private boolean oneconfig$crosshairReached; + + @Unique + private boolean oneconfig$attackIndicatorReached; + + @Unique + private boolean oneconfig$attackIndicatorVanilla; + + @Unique + private static final String EFFECTS_DRAW_START = + //? if >= 1.21.4 { + "Lcom/google/common/collect/Ordering;natural()Lcom/google/common/collect/Ordering;"; + //?} else { + /*"Lcom/mojang/blaze3d/systems/RenderSystem;enableBlend()V"; + *///?} + + @Unique + private static final String DISPLAY_SCOREBOARD_SIDEBAR = + //? if >= 26.2 { + "Lnet/minecraft/client/gui/Hud;displayScoreboardSidebar(Lnet/minecraft/client/gui/GuiGraphicsExtractor;Lnet/minecraft/world/scores/Objective;)V"; + //?} else { + /*"Lnet/minecraft/client/gui/Gui;displayScoreboardSidebar(Lnet/minecraft/client/gui/GuiGraphicsExtractor;Lnet/minecraft/world/scores/Objective;)V"; + *///?} + + @WrapWithCondition(method = CROSSHAIR, at = @At(value = "INVOKE", target = BLIT_SPRITE, ordinal = 0)) + private boolean oneconfig$crosshair( + GuiGraphicsExtractor graphics, + //? if >= 1.21.6 { + RenderPipeline pipeline, + //?} else if >= 1.21.4 { + /*Function renderType, + *///?} + //? if >= 1.21.11 { + Identifier sprite, + //?} else { + /*ResourceLocation sprite, + *///?} + int x, int y, int width, int height + ) { + oneconfig$crosshairReached = true; + return !VanillaHudHooks.intercept(VanillaHudElement.CROSSHAIR, graphics); + } + + @WrapWithCondition(method = CROSSHAIR, at = { + @At(value = "INVOKE", target = BLIT_SPRITE, ordinal = 1), + @At(value = "INVOKE", target = BLIT_SPRITE, ordinal = 2), + }) + private boolean oneconfig$attackIndicator( + GuiGraphicsExtractor graphics, + //? if >= 1.21.6 { + RenderPipeline pipeline, + //?} else if >= 1.21.4 { + /*Function renderType, + *///?} + //? if >= 1.21.11 { + Identifier sprite, + //?} else { + /*ResourceLocation sprite, + *///?} + int x, int y, int width, int height + ) { + oneconfig$attackIndicatorReached = true; + oneconfig$attackIndicatorVanilla = !VanillaHudHooks.intercept(VanillaHudElement.ATTACK_INDICATOR, graphics); + return oneconfig$attackIndicatorVanilla; + } + + @WrapWithCondition(method = CROSSHAIR, at = @At(value = "INVOKE", target = BLIT_SPRITE_REGION)) + private boolean oneconfig$attackIndicatorProgress( + GuiGraphicsExtractor graphics, + //? if >= 1.21.6 { + RenderPipeline pipeline, + //?} else if >= 1.21.4 { + /*Function renderType, + *///?} + //? if >= 1.21.11 { + Identifier sprite, + //?} else { + /*ResourceLocation sprite, + *///?} + int spriteWidth, int spriteHeight, int u, int v, int x, int y, int width, int height + ) { + return oneconfig$attackIndicatorVanilla; + } + + @ModifyArgs(method = CROSSHAIR, at = { + @At(value = "INVOKE", target = BLIT_SPRITE, ordinal = 1), + @At(value = "INVOKE", target = BLIT_SPRITE, ordinal = 2), + }) + private void oneconfig$moveAttackIndicator(Args args) { + oneconfig$move(args, BLIT_X, BLIT_Y); + } + + @ModifyArgs(method = CROSSHAIR, at = @At(value = "INVOKE", target = BLIT_SPRITE_REGION)) + private void oneconfig$moveAttackIndicatorProgress(Args args) { + oneconfig$move(args, BLIT_X + 4, BLIT_Y + 4); + } + + @Unique + private static void oneconfig$move(Args args, int xIndex, int yIndex) { + int x = args.get(xIndex); + int y = args.get(yIndex); + args.set(xIndex, VanillaHudHooks.moveX(x, y)); + args.set(yIndex, VanillaHudHooks.moveY(x, y)); + } + + @Inject(method = CROSSHAIR, at = @At("RETURN")) + private void oneconfig$crosshairHidden(GuiGraphicsExtractor graphics, DeltaTracker deltaTracker, CallbackInfo ci) { + if (!oneconfig$crosshairReached) { + VanillaHudHooks.interceptHidden(VanillaHudElement.CROSSHAIR, graphics); + } + if (!oneconfig$attackIndicatorReached) { + VanillaHudHooks.interceptHidden(VanillaHudElement.ATTACK_INDICATOR, graphics); + } + oneconfig$crosshairReached = false; + oneconfig$attackIndicatorReached = false; + } + + //~ if >= 26.1 'renderEffects' -> 'extractEffects' + @Inject(method = "extractEffects", at = @At(value = "INVOKE", target = EFFECTS_DRAW_START, ordinal = 0), cancellable = true) + private void oneconfig$statusEffects(GuiGraphicsExtractor graphics, DeltaTracker deltaTracker, CallbackInfo ci) { + if (VanillaHudHooks.intercept(VanillaHudElement.STATUS_EFFECTS, graphics)) ci.cancel(); + } + + //~ if >= 26.1 'renderScoreboardSidebar' -> 'extractScoreboardSidebar' + @Inject(method = "extractScoreboardSidebar", at = @At(value = "INVOKE", target = DISPLAY_SCOREBOARD_SIDEBAR), cancellable = true) + private void oneconfig$scoreboard(GuiGraphicsExtractor graphics, DeltaTracker deltaTracker, CallbackInfo ci) { + if (VanillaHudHooks.intercept(VanillaHudElement.SCOREBOARD, graphics)) ci.cancel(); + } + + //~ if >= 26.1 'renderOverlayMessage' -> 'extractOverlayMessage' + @Inject(method = "extractOverlayMessage", at = @At("HEAD"), cancellable = true) + private void oneconfig$actionBar(GuiGraphicsExtractor graphics, DeltaTracker deltaTracker, CallbackInfo ci) { + if (overlayMessageString == null || overlayMessageTime <= 0) return; + if (VanillaHudHooks.intercept(VanillaHudElement.ACTION_BAR, graphics)) ci.cancel(); + } + + //~ if >= 26.1 'renderTitle' -> 'extractTitle' + @Inject(method = "extractTitle", at = @At("HEAD"), cancellable = true) + private void oneconfig$title(GuiGraphicsExtractor graphics, DeltaTracker deltaTracker, CallbackInfo ci) { + if (title == null || titleTime <= 0) return; + if (VanillaHudHooks.intercept(VanillaHudElement.TITLE, graphics)) ci.cancel(); + } + + //~ if >= 26.1 'renderSelectedItemName' -> 'extractSelectedItemName' + @Inject(method = "extractSelectedItemName", at = @At("HEAD"), cancellable = true) + private void oneconfig$heldItemName(GuiGraphicsExtractor graphics, CallbackInfo ci) { + if (toolHighlightTimer <= 0 || lastToolHighlight.isEmpty()) return; + if (VanillaHudHooks.intercept(VanillaHudElement.HELD_ITEM_NAME, graphics)) ci.cancel(); + } + + //~ if >= 26.1 'renderVignette' -> 'extractVignette' + @Inject(method = "extractVignette", at = @At("HEAD"), cancellable = true) + private void oneconfig$vignette(GuiGraphicsExtractor graphics, Entity entity, CallbackInfo ci) { + if (VanillaHudHooks.intercept(VanillaHudElement.VIGNETTE, graphics)) ci.cancel(); + } +} +//?} else { +/*import com.llamalad7.mixinextras.injector.ModifyExpressionValue; +import net.minecraft.client.gui.GameGui; +import org.polyfrost.oneconfig.api.ui.v1.platform.VanillaHudElement; +import org.polyfrost.oneconfig.api.ui.v1.platform.VanillaHudHooks; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; + +@Mixin(GameGui.class) +public abstract class Mixin_VanillaHudElements { + @ModifyExpressionValue(method = "render", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/gui/GameGui;hasCrosshair()Z")) + private boolean oneconfig$crosshair(boolean original) { + if (!original) { + VanillaHudHooks.interceptHidden(VanillaHudElement.CROSSHAIR, null); + return false; + } + return !VanillaHudHooks.intercept(VanillaHudElement.CROSSHAIR, null); + } +} +*///?} diff --git a/minecraft/src/main/java/org/polyfrost/oneconfig/test/TestCommand_Test.java b/minecraft/src/main/java/org/polyfrost/oneconfig/test/TestCommand_Test.java index fa72195cc..722b41fca 100644 --- a/minecraft/src/main/java/org/polyfrost/oneconfig/test/TestCommand_Test.java +++ b/minecraft/src/main/java/org/polyfrost/oneconfig/test/TestCommand_Test.java @@ -67,8 +67,8 @@ private void notifyTest() { // /testmod notifyTest Notifications.success("Modpack downloaded!", "All-of-Fabric-6-0.1.0"); Notifications.error("Game crashed on launch", "Main profile"); Notifications.builder("8 mods have updates", "DamageTint, Hytils-Reborn, Chatting, OverflowAnimations, PolyBlur, 3 more") - .action(new NotificationAction("Update all", true, () -> Platform.compatibility().displayChatMessage("Update all clicked"))) - .action(new NotificationAction("See mods", false, () -> Platform.compatibility().displayChatMessage("See mods clicked"))) + .action(new NotificationAction("Update all", true, true, true, () -> Platform.compatibility().displayChatMessage("Update all clicked"))) + .action(new NotificationAction("See mods", false, true, true, () -> Platform.compatibility().displayChatMessage("See mods clicked"))) .persistent() .send(); } diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIBridgeImpl.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIBridgeImpl.kt index 1a5622ce8..f5f31b61e 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIBridgeImpl.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIBridgeImpl.kt @@ -1,10 +1,20 @@ package org.polyfrost.oneconfig.api.ui.v1.internal +import java.util.concurrent.atomic.AtomicBoolean +import net.minecraft.client.Minecraft import org.apache.logging.log4j.LogManager import org.polyfrost.oneconfig.api.platform.v1.Platform +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi import org.polyfrost.oneconfig.api.ui.v1.OneConfigUIBridge +import org.polyfrost.oneconfig.api.ui.v1.page.OneConfigPage +import org.polyfrost.oneconfig.api.ui.v1.page.OneConfigPages +import org.polyfrost.oneconfig.internal.OneConfig +import org.polyfrost.oneconfig.internal.ui.compose.ComposeScreen import org.polyfrost.oneconfig.internal.ui.compose.impls.OneConfigUIScreen +import org.polyfrost.oneconfig.internal.ui.navigation.PageArguments +import org.polyfrost.oneconfig.internal.ui.navigation.PageRoute import org.polyfrost.oneconfig.internal.ui.navigation.graph.ModsGraph +import org.polyfrost.oneconfig.internal.ui.navigation.pageRoute import org.polyfrost.oneconfig.internal.ui.navigation.resolveOpenTarget import org.polyfrost.oneconfig.internal.ui.shell.ShellController @@ -33,5 +43,40 @@ class OneConfigUIBridgeImpl : OneConfigUIBridge { } } + @OptIn(ExperimentalOneConfigUiApi::class) + override fun openPage(page: OneConfigPage, argument: Any?) { + if (argument != null && OneConfigPages.byId(page.id) === page && pageRoute(page) == PageRoute(page.id)) { + PageArguments.offer(page.id, argument) + } + open(page) + } + + @OptIn(ExperimentalOneConfigUiApi::class) + override fun takePageArgument(): Any? = PageArguments.take() + override fun isOneConfigScreen(screen: Any?): Boolean = screen is OneConfigUIScreen + + private val startupChecked = AtomicBoolean(false) + + @OptIn(ExperimentalOneConfigUiApi::class) + @Suppress("SENSELESS_COMPARISON") + override fun ensureInitialized() { + if (Minecraft.getInstance() == null || !startupChecked.compareAndSet(false, true)) return + try { + Class.forName(ComposeScreen::class.java.name, true, ComposeScreen::class.java.classLoader) + } catch (t: Throwable) { + LOGGER.warn("Failed to arm the OneConfig screen input handlers", t) + } + Minecraft.getInstance().schedule(Runnable { finishSkippedStartup() }) + } + + private fun finishSkippedStartup() { + if (OneConfig.isInitialized()) return + LOGGER.warn("OneConfig did not start with the game; starting it now so its screens work") + try { + OneConfig.INSTANCE.onInitializeClient() + } catch (t: Throwable) { + LOGGER.error("OneConfig failed to start late", t) + } + } } diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIPlayerHeadBridgeImpl.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIPlayerHeadBridgeImpl.kt index e2465bbaf..918b004f5 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIPlayerHeadBridgeImpl.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIPlayerHeadBridgeImpl.kt @@ -34,7 +34,9 @@ import net.ornithemc.osl.core.api.util.NamespacedIdentifiers *///?} class OneConfigUIPlayerHeadBridgeImpl : OneConfigUIPlayerHeadBridge { - private class Face(val bitmap: ImageBitmap?, val isDefault: Boolean) + private class FaceImage(val pixels: PolyBitmap, val image: ImageBitmap) + + private class Face(val face: FaceImage?, val isDefault: Boolean) private val faces = object : LinkedHashMap(16, 0.75f, true) { override fun removeEldestEntry(eldest: Map.Entry) = size > MAX_ENTRIES @@ -42,7 +44,7 @@ class OneConfigUIPlayerHeadBridgeImpl : OneConfigUIPlayerHeadBridge { private val observers = HashMap>>>() - private val defaults = HashMap() + private val defaults = HashMap() private val fetching = HashMap() @@ -56,8 +58,12 @@ class OneConfigUIPlayerHeadBridgeImpl : OneConfigUIPlayerHeadBridge { EventManager.register(ResourceFinishedLoading::class.java, Runnable(::clearDefaults)) } - override fun face(id: UUID): ImageBitmap? { - synchronized(faces) { faces[id]?.let { return it.bitmap } } + override fun face(id: UUID): ImageBitmap? = cachedFace(id)?.image + + override fun bitmap(id: UUID): PolyBitmap? = cachedFace(id)?.pixels + + private fun cachedFace(id: UUID): FaceImage? { + synchronized(faces) { faces[id]?.let { return it.face } } ensureFetch(id) return null } @@ -69,13 +75,15 @@ class OneConfigUIPlayerHeadBridgeImpl : OneConfigUIPlayerHeadBridge { val list = observers.getOrPut(id) { ArrayList() } list.removeAll { it.get() == null } list += WeakReference(state) - faces[id]?.also { state.value = it.bitmap } + faces[id]?.also { state.value = it.face?.image } } if (known == null) ensureFetch(id) return state } - override fun defaultFace(id: UUID): ImageBitmap? { + override fun defaultFace(id: UUID): ImageBitmap? = defaultFaceImage(id)?.image + + private fun defaultFaceImage(id: UUID): FaceImage? { val texture = defaultTexture(id) ?: return null return synchronized(defaults) { defaults.getOrPut(texture.key) { runCatching { texture.load() }.getOrNull() } @@ -94,7 +102,7 @@ class OneConfigUIPlayerHeadBridgeImpl : OneConfigUIPlayerHeadBridge { loaders.execute { for (id in shown) { synchronized(faces) { fetchedDefault += id } - publish(id, defaultFace(id), true) + publish(id, defaultFaceImage(id), true) } } } @@ -108,10 +116,11 @@ class OneConfigUIPlayerHeadBridgeImpl : OneConfigUIPlayerHeadBridge { if (start) loaders.execute { fetch(id) } } - private fun publish(id: UUID, bitmap: ImageBitmap?, settle: Boolean) { + private fun publish(id: UUID, face: FaceImage?, settle: Boolean) { + val bitmap = face?.image val listeners = synchronized(faces) { if (settle) { - faces[id] = Face(bitmap, isDefault = bitmap == null || fetchedDefault.remove(id)) + faces[id] = Face(face, isDefault = face == null || fetchedDefault.remove(id)) fetching.remove(id) } observers[id]?.also { list -> list.removeAll { it.get() == null } }?.toList() @@ -131,7 +140,7 @@ class OneConfigUIPlayerHeadBridgeImpl : OneConfigUIPlayerHeadBridge { publish(id, face, true) return } - val fallback = defaultFace(id) + val fallback = defaultFaceImage(id) if (missing) { synchronized(faces) { fetchedDefault += id } publish(id, fallback, true) @@ -155,7 +164,7 @@ class OneConfigUIPlayerHeadBridgeImpl : OneConfigUIPlayerHeadBridge { loaders.schedule({ fetch(id) }, retryIn, TimeUnit.MILLISECONDS) } - private fun loadFace(id: UUID): ImageBitmap? { + private fun loadFace(id: UUID): FaceImage? { val url = skinUrl(id) ?: return null val connection = URI(url).toURL().openConnection(proxy()) as HttpURLConnection val skin = try { @@ -190,7 +199,7 @@ class OneConfigUIPlayerHeadBridgeImpl : OneConfigUIPlayerHeadBridge { } } - private class DefaultTexture(val key: String, val load: () -> ImageBitmap?) + private class DefaultTexture(val key: String, val load: () -> FaceImage?) private fun defaultTexture(id: UUID): DefaultTexture? { //? if > 1.8.9 { @@ -222,7 +231,7 @@ class OneConfigUIPlayerHeadBridgeImpl : OneConfigUIPlayerHeadBridge { *///?} }.getOrNull() ?: Proxy.NO_PROXY - private fun buildFace(skin: BufferedImage): ImageBitmap { + private fun buildFace(skin: BufferedImage): FaceImage { val legacy = skin.height <= 32 val showHat = !legacy || !isOpaque(skin, 32, 0, 64, 32) val out = ByteArray(FACE_SIZE * FACE_SIZE * 4) @@ -240,7 +249,8 @@ class OneConfigUIPlayerHeadBridgeImpl : OneConfigUIPlayerHeadBridge { out[i + 3] = 0xFF.toByte() } } - return PolyBitmap(FACE_SIZE, FACE_SIZE, out).asComposeImageBitmap() + val pixels = PolyBitmap(FACE_SIZE, FACE_SIZE, out) + return FaceImage(pixels, pixels.asComposeImageBitmap()) } private fun blend(base: Int, top: Int): Int { diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/platform/ComposeHudInput.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/platform/ComposeHudInput.kt new file mode 100644 index 000000000..bb7a7679c --- /dev/null +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/platform/ComposeHudInput.kt @@ -0,0 +1,94 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.platform + +import androidx.compose.ui.input.pointer.PointerButton +import com.mojang.blaze3d.platform.InputConstants +import java.util.Collections +import java.util.WeakHashMap +import org.polyfrost.oneconfig.api.event.v1.events.KeyInputEvent +import org.polyfrost.oneconfig.api.platform.v1.Platform +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.components.EscapeStack +import org.polyfrost.oneconfig.internal.ui.compose.ComposeScreen + +internal object ComposeHudInput { + private class Press(val receivers: List, val consumed: Boolean) + + private val layers: MutableSet = Collections.newSetFromMap(WeakHashMap()) + private val presses = HashMap() + private var escapeConsumed = false + + fun register(layer: ComposeHudLayer) { + synchronized(layers) { layers += layer } + } + + fun unregister(layer: ComposeHudLayer) { + synchronized(layers) { layers -= layer } + } + + @JvmStatic + fun consumeButton(button: Int, pressed: Boolean): Boolean = + consumeLayerButton(button, pressed) || HudFrameHooks.consumeButton(button, pressed) + + private fun consumeLayerButton(button: Int, pressed: Boolean): Boolean { + val pointerButton = when (button) { + InputConstants.MOUSE_BUTTON_LEFT -> PointerButton.Primary + InputConstants.MOUSE_BUTTON_RIGHT -> PointerButton.Secondary + InputConstants.MOUSE_BUTTON_MIDDLE -> PointerButton.Tertiary + else -> return false + } + if (!pressed) { + val press = presses.remove(button) ?: return false + for (layer in press.receivers) layer.release(pointerButton) + return press.consumed + } + presses.remove(button) + val receivers = ArrayList() + var consumed = false + for (layer in topmostFirst()) { + if (!layer.takingInput) continue + receivers += layer + if (layer.press(pointerButton)) { + consumed = true + break + } + } + if (receivers.isNotEmpty()) presses[button] = Press(receivers, consumed) + return consumed + } + + @JvmStatic + fun consumeScroll(x: Double, y: Double): Boolean { + if (x == 0.0 && y == 0.0) return false + for (layer in topmostFirst()) { + if (layer.takingInput && layer.scroll(x, y)) return true + } + return false + } + + @JvmStatic + fun consumeKey(key: Int, state: Int): Boolean { + if (key != InputConstants.KEY_ESCAPE) return false + return when (state) { + KeyInputEvent.PRESSED -> { + escapeConsumed = escapeClosesPopup() + escapeConsumed + } + KeyInputEvent.REPEAT -> escapeConsumed + KeyInputEvent.RELEASED -> escapeConsumed.also { escapeConsumed = false } + else -> false + } + } + + private fun escapeClosesPopup(): Boolean { + if (runCatching { Platform.screen().current() }.getOrNull() is ComposeScreen) return false + if (topmostFirst().none { it.takingInput }) return false + return EscapeStack.dispatch() + } + + private fun topmostFirst(): List { + val live = synchronized(layers) { if (layers.isEmpty()) return emptyList() else layers.toList() } + return live.sortedByDescending { it.drawnAt } + } +} diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/platform/ComposeHudLayer.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/platform/ComposeHudLayer.kt new file mode 100644 index 000000000..3167cbd89 --- /dev/null +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/platform/ComposeHudLayer.kt @@ -0,0 +1,323 @@ +@file:OptIn(InternalComposeUiApi::class, ExperimentalComposeUiApi::class, ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.platform + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.InternalComposeUiApi +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerButton +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.platform.FrameRecomposer +import androidx.compose.ui.scene.CanvasLayersComposeScene +import androidx.compose.ui.scene.ComposeScene +import androidx.compose.ui.scene.PointerEventResult +import androidx.compose.ui.scene.SingleComposeSceneRenderingScope +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntSize +import java.util.concurrent.atomic.AtomicReference +import kotlin.math.abs +import kotlinx.coroutines.CoroutineExceptionHandler +import net.minecraft.client.Minecraft +import net.minecraft.client.gui.screens.ChatScreen +import org.apache.logging.log4j.LogManager +import org.polyfrost.compose.backend.PolyCanvas +import org.polyfrost.compose.backend.PolyCompose +import org.polyfrost.oneconfig.api.platform.v1.DesktopHelper +import org.polyfrost.oneconfig.api.platform.v1.Platform +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.components.EscapeStack +import org.polyfrost.oneconfig.api.ui.v1.theme.ProvideOneConfigTheme +import org.polyfrost.oneconfig.internal.ui.UiErrors +import org.polyfrost.oneconfig.internal.ui.components.AnimationsMotionScale +import org.polyfrost.oneconfig.internal.ui.components.LocalWheelRouter +import org.polyfrost.oneconfig.internal.ui.components.WheelRouter +import org.polyfrost.oneconfig.internal.ui.compose.ComposeSceneContextImpl +import org.polyfrost.oneconfig.internal.ui.compose.ComposeSupport +import org.polyfrost.oneconfig.internal.ui.compose.FixedScrollConfig +import org.polyfrost.oneconfig.internal.ui.compose.RenderThreadDispatcher +import org.polyfrost.oneconfig.internal.ui.compose.impls.OneConfigUIScreen +import org.polyfrost.oneconfig.internal.ui.compose.sceneDensity + +//? if > 1.8.9 { +import net.minecraft.client.gui.GuiGraphicsExtractor +//?} + +//? if = 1.8.9 { +/*import org.lwjgl.input.Mouse +*///?} + +@ExperimentalOneConfigUiApi +class ComposeHudLayer(val id: String, private val content: @Composable () -> Unit) : AutoCloseable { + var visible: Boolean = true + set(value) { + field = value + if (!value) stopInput() + } + + var acceptsInput: Boolean = true + set(value) { + field = value + if (!value) stopInput() + } + + val density: Float + get() = runCatching { sceneDensity() }.getOrDefault(1f) + + val pixelRatio: Float + get() = runCatching { Platform.screen().pixelRatio() }.getOrDefault(1f).takeIf { it > 0f } ?: 1f + + private val surface = InlineCanvasSurface("hud/$id") + private var scene: ComposeScene? = null + private var recomposer: FrameRecomposer? = null + private var scope: SingleComposeSceneRenderingScope? = null + private var sceneFailure: AtomicReference? = null + private var failures = 0 + private var retryAt = 0L + private var inputActive = false + private var lastPointer: Offset? = null + private var lastDrawn = 0L + private val wheelRouter = WheelRouter() + private val scrollConfig = FixedScrollConfig(wheelRouter) + private var glideAnchor = Offset.Zero + private var glideCarry = Offset.Zero + + internal val drawnAt: Long + get() = lastDrawn + + //? if > 1.8.9 { + fun draw(graphics: GuiGraphicsExtractor): Boolean = drawLayer { surface.draw(graphics, it) } + //?} else { + /* + fun draw(): Boolean = drawLayer { surface.draw(it) } + *///?} + + override fun close() { + releaseScene() + surface.close() + } + + private inline fun drawLayer(paint: ((PolyCanvas) -> Unit) -> Boolean): Boolean { + if (!visible) return false + val w = Platform.screen().windowWidth() + val h = Platform.screen().windowHeight() + if (w <= 0 || h <= 0) return false + val scene = ensureScene() ?: return false + val recomposer = recomposer ?: return false + val scope = scope ?: return false + return try { + scene.density = Density(density) + scene.size = IntSize(w, h) + ComposeSceneContextImpl.updateContainerSize(w, h) + lastDrawn = System.nanoTime() + routePointer(scene) + val nanos = System.nanoTime() + recomposer.performFrame(nanos) + scene.measureAndLayout() + rethrowSceneFailure() + val ratio = (Platform.screen().viewportWidth().toFloat() / w).coerceAtLeast(0.01f) + val drawn = paint { canvas -> + canvas.save() + if (ratio != 1f) canvas.scale(ratio, ratio) + try { + with(scope) { scene.render(recomposer, canvas, nanos) } + } finally { + canvas.restore() + } + } + rethrowSceneFailure() + failures = 0 + drawn + } catch (t: Throwable) { + fail(t) + false + } + } + + private fun rethrowSceneFailure() { + sceneFailure?.get()?.let { throw it } + } + + private fun ensureScene(): ComposeScene? { + scene?.let { return it } + if (failures > 0 && System.nanoTime() - retryAt < 0) return null + if (!ComposeSupport.isAvailable) return null + return try { + OneConfigUIScreen.stopWarmUp() + PolyCompose.install() + val failure = AtomicReference() + val handler = CoroutineExceptionHandler { _, t -> failure.compareAndSet(null, t) } + val newRecomposer = FrameRecomposer(RenderThreadDispatcher + handler + AnimationsMotionScale) {} + val newScope = SingleComposeSceneRenderingScope {} + val newScene = try { + CanvasLayersComposeScene( + frameRecomposer = newRecomposer, + platformContext = ComposeSceneContextImpl.platformContext, + invalidateLayout = newScope::onSceneInvalidation, + invalidateDraw = newScope::onSceneInvalidation, + ) + } catch (t: Throwable) { + runCatching { newRecomposer.close() }.exceptionOrNull()?.let(t::addSuppressed) + throw t + } + scene = newScene + recomposer = newRecomposer + scope = newScope + sceneFailure = failure + ComposeHudInput.register(this) + newScene.setContent { + CompositionLocalProvider(scrollConfig.provided(), LocalWheelRouter provides wheelRouter) { + ProvideOneConfigTheme { content() } + } + } + newScene + } catch (t: Throwable) { + fail(t) + null + } + } + + private fun fail(cause: Throwable) { + failures++ + retryAt = System.nanoTime() + retryDelayNanos(failures) + UiErrors.error("Compose HUD layer '$id' failed; it will be rebuilt after a delay", cause, LOGGER) + releaseScene() + } + + private fun releaseScene() { + stopInput() + val oldScene = scene + val oldRecomposer = recomposer + scene = null + recomposer = null + scope = null + sceneFailure = null + ComposeHudInput.unregister(this) + runCatching { oldScene?.close() } + runCatching { oldRecomposer?.close() } + } + + private fun routePointer(scene: ComposeScene) { + if (!acceptsInput || !inputAllowed()) { + stopInput() + return + } + inputActive = true + val pointer = pointerPosition() + if (pointer != lastPointer) { + lastPointer = pointer + scene.sendPointerEvent(PointerEventType.Move, pointer) + } + wheelRouter.glideFrame { step -> + glideCarry += step + val scale = density + if (abs(glideCarry.x * scale) >= 1f || abs(glideCarry.y * scale) >= 1f) { + val carried = glideCarry + glideCarry = Offset.Zero + scene.sendPointerEvent(PointerEventType.Scroll, glideAnchor, scrollDelta = carried) + if (pointer != glideAnchor) scene.sendPointerEvent(PointerEventType.Move, pointer) + } + } + } + + private fun stopInput() { + wheelRouter.stopFallback() + glideCarry = Offset.Zero + if (!inputActive) return + inputActive = false + val pointer = lastPointer + lastPointer = null + val scene = scene ?: return + runCatching { + scene.cancelPointerInput() + if (pointer != null) scene.sendPointerEvent(PointerEventType.Exit, pointer) + ComposeSceneContextImpl.resetPointerIcon() + } + } + + internal val takingInput: Boolean + get() = inputActive && visible && acceptsInput && scene != null && + System.nanoTime() - lastDrawn < INPUT_STALE_NANOS && inputAllowed() + + internal fun press(button: PointerButton): Boolean = sendButton(PointerEventType.Press, button) + + internal fun release(button: PointerButton) { + sendButton(PointerEventType.Release, button) + } + + internal fun scroll(x: Double, y: Double): Boolean { + if (!takingInput) return false + val scene = scene ?: return false + val delta = Offset((-x * WHEEL_UNIT_DP).toFloat(), (-y * WHEEL_UNIT_DP).toFloat()) + val position = pointerPosition() + var hit = false + return try { + wheelRouter.stopFallback() + glideAnchor = position + glideCarry = Offset.Zero + wheelRouter.route(delta) { + lastPointer = position + scene.sendPointerEvent(PointerEventType.Move, position) + hit = scene.sendPointerEvent(PointerEventType.Scroll, position, scrollDelta = delta) in HIT_RESULTS + } + hit + } catch (t: Throwable) { + fail(t) + false + } + } + + private fun sendButton(type: PointerEventType, button: PointerButton): Boolean { + if (!takingInput) return false + val scene = scene ?: return false + return try { + if (type == PointerEventType.Press) wheelRouter.stopFallback() + scene.sendPointerEvent(type, pointerPosition(), button = button) in HIT_RESULTS + } catch (t: Throwable) { + fail(t) + false + } + } + + @ExperimentalOneConfigUiApi + interface InputScreen + + private companion object { + val LOGGER = LogManager.getLogger("OneConfig/ComposeHudLayer") + + const val INPUT_STALE_NANOS = 250_000_000L + const val MAX_RETRY_DELAY_NANOS = 60_000_000_000L + + val WHEEL_UNIT_DP = if (DesktopHelper.isMac) 50f else 150f + + val HIT_RESULTS: Set = listOf(false, true).flatMap { moved -> + listOf(false, true).map { changed -> + PointerEventResult( + anyMovementConsumed = moved, + anyChangeConsumed = changed, + dispatchedToAPointerInputModifier = true, + ) + } + }.toSet() + + fun retryDelayNanos(failures: Int): Long = + (1_000_000_000L shl (failures - 1).coerceIn(0, 6)).coerceAtMost(MAX_RETRY_DELAY_NANOS) + + fun inputAllowed(): Boolean { + if (!Minecraft.getInstance().isWindowActive) return false + val screen = runCatching { Platform.screen().current() }.getOrNull() ?: return false + return screen is ChatScreen || screen is InputScreen + } + + fun pointerPosition(): Offset { + //? if > 1.8.9 { + val mouse = Minecraft.getInstance().mouseHandler + return Offset(mouse.xpos().toFloat(), mouse.ypos().toFloat()) + //?} else { + /*val pixelRatio = Platform.screen().pixelRatio() + return Offset(Mouse.getX() / pixelRatio, (Platform.screen().viewportHeight() - Mouse.getY() - 1) / pixelRatio) + *///?} + } + } +} diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/platform/EntityPreview.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/platform/EntityPreview.kt new file mode 100644 index 000000000..b397fcc30 --- /dev/null +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/platform/EntityPreview.kt @@ -0,0 +1,499 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.platform + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.min +import com.mojang.blaze3d.pipeline.RenderTarget +import java.util.UUID +import kotlin.math.ceil +import net.minecraft.client.Minecraft +import org.polyfrost.compose.backend.drawPoly +import org.polyfrost.compose.render.RenderContext +import org.polyfrost.oneconfig.api.platform.v1.Platform +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.components.PlayerHead +import org.polyfrost.oneconfig.internal.ui.PreviewNameTag +import org.slf4j.LoggerFactory + +//? if > 1.8.9 { +import com.mojang.blaze3d.platform.Lighting +import com.mojang.blaze3d.systems.RenderSystem +import com.mojang.blaze3d.vertex.PoseStack +import net.minecraft.world.entity.player.Player +import org.joml.Quaternionf +//?} else { +/*import com.mojang.blaze3d.platform.GlStateManager +import net.minecraft.client.render.platform.GLX +import net.minecraft.client.render.platform.Lighting +import net.minecraft.entity.living.player.PlayerEntity as Player +import org.lwjgl.opengl.GL11 +*///?} + +//? if >= 26.1 { +import net.minecraft.client.renderer.state.level.CameraRenderState +//?} elif >= 1.21.10 +//import net.minecraft.client.renderer.state.CameraRenderState + +//? if >= 1.21.10 { +import net.minecraft.client.renderer.entity.state.AvatarRenderState +import net.minecraft.world.phys.Vec3 +//?} + +//? if >= 26.2 +import net.minecraft.client.renderer.SubmitNodeStorage + +//? if >= 26.3 { +import net.minecraft.client.renderer.feature.FeatureRenderDispatcher +import java.util.Optional +import java.util.OptionalDouble +//?} + +private const val MAX_DIM = 1024 +private const val FULL_BRIGHT = 0xF000F0 +private const val OFFSET_Y = 0.0625f +private const val HEIGHT_CATCHUP = 12f +private const val IDLE_NANOS = 250_000_000L +private const val FIT_HEIGHT = 2.1f +private const val FIT_WIDTH = 1.1f +private const val NAME_TAG_HEIGHT = 0.6f +private const val FALLBACK_HEAD_FRACTION = 0.5f + +private val MinPreviewWidth = 48.dp +private val MinPreviewHeight = 96.dp + +@ExperimentalOneConfigUiApi +@Composable +fun EntityPreview( + playerId: UUID?, + modifier: Modifier = Modifier, + yaw: Float = 0f, + pitch: Float = 0f, + scale: Float = 1f, + showNameTag: Boolean = false, +) { + val slot = remember { PreviewSlot(playerId) } + SideEffect { + if (slot.playerId != playerId) slot.retarget(playerId) + slot.yaw = yaw + slot.pitch = pitch + slot.scale = scale + slot.showNameTag = showNameTag + } + DisposableEffect(slot) { + EntityPreviews.register(slot) + onDispose { EntityPreviews.unregister(slot) } + } + val frameNanos by produceState(0L) { while (true) withFrameNanos { value = it } } + + BoxWithConstraints( + modifier.defaultMinSize(MinPreviewWidth, MinPreviewHeight), + contentAlignment = Alignment.Center, + ) { + if (slot.available) { + Box( + Modifier.fillMaxSize().drawBehind { + if (frameNanos == 0L) return@drawBehind + val w = size.width + val h = size.height + slot.layoutWidth = w + slot.layoutHeight = h + drawPoly { slot.draw(it, w, h) } + }, + ) + } else { + val headId = playerId ?: localProfileId() + if (headId != null) { + val w = if (constraints.hasBoundedWidth) maxWidth else minWidth + val h = if (constraints.hasBoundedHeight) maxHeight else minHeight + PlayerHead(headId, min(w, h) * FALLBACK_HEAD_FRACTION) + } + } + } +} + +private class PreviewSlot(initialId: UUID?) { + var playerId: UUID? = initialId + private set + var yaw = 0f + var pitch = 0f + var scale = 1f + var showNameTag = false + + var available by mutableStateOf(findPlayer(initialId) != null) + + var layoutWidth = 0f + var layoutHeight = 0f + var lastDrawNanos = 0L + var failed = false + + var anchorHeight = -1f + var lastAnchorNanos = 0L + + var offscreen: PolyOffscreen? = null + + fun retarget(id: UUID?) { + playerId = id + failed = false + anchorHeight = -1f + lastAnchorNanos = 0L + } + + fun draw(ctx: RenderContext, w: Float, h: Float) { + lastDrawNanos = System.nanoTime() + val target = offscreen ?: return + with(target) { ctx.blit(0f, 0f, w, h) } + } + + fun release() { + offscreen?.close() + offscreen = null + } + + fun smoothAnchorHeight(target: Float): Float { + val now = System.nanoTime() + val previous = anchorHeight + val elapsed = if (lastAnchorNanos == 0L) 0.0 else (now - lastAnchorNanos) / 1_000_000_000.0 + lastAnchorNanos = now + if (previous <= 0f || elapsed <= 0.0 || elapsed > 0.5) { + anchorHeight = target + return target + } + val step = (1.0 - Math.exp(-HEIGHT_CATCHUP * elapsed)).toFloat() + anchorHeight = previous + (target - previous) * step + return anchorHeight + } + + fun modelSize(width: Int, height: Int): Float { + val tag = if (showNameTag) NAME_TAG_HEIGHT else 0f + return scale * minOf(height / (FIT_HEIGHT + tag), width / FIT_WIDTH) + } +} + +private object EntityPreviews { + private val LOGGER = LoggerFactory.getLogger("OneConfig/EntityPreview") + + private val slots = LinkedHashSet() + private var hook: AutoCloseable? = null + + fun register(slot: PreviewSlot) { + slots += slot + if (hook == null) hook = PolyOffscreen.onBeforePresent(::renderAll) + } + + fun unregister(slot: PreviewSlot) { + slots -= slot + slot.release() + if (slots.isEmpty()) { + hook?.close() + hook = null + } + } + + private fun renderAll() { + if (slots.isEmpty()) return + val now = System.nanoTime() + val ratio = Platform.screen().surfaceRatio().coerceAtLeast(0.0001f) + val partialTick = partialTick() + for (slot in slots.toList()) { + val player = if (slot.failed) null else findPlayer(slot.playerId) + val available = player != null + if (slot.available != available) slot.available = available + if (player == null) { + slot.release() + continue + } + if (now - slot.lastDrawNanos > IDLE_NANOS) continue + val requestedWidth = ceil(slot.layoutWidth * ratio).toInt() + val requestedHeight = ceil(slot.layoutHeight * ratio).toInt() + if (requestedWidth <= 0 || requestedHeight <= 0) continue + val fit = minOf(1f, MAX_DIM.toFloat() / requestedWidth, MAX_DIM.toFloat() / requestedHeight) + val width = (requestedWidth * fit).toInt().coerceIn(1, MAX_DIM) + val height = (requestedHeight * fit).toInt().coerceIn(1, MAX_DIM) + + val offscreen = slot.offscreen ?: PolyOffscreen(width, height).also { slot.offscreen = it } + offscreen.resize(width, height) + try { + offscreen.render { + val rt = offscreen.renderTarget ?: return@render + renderPlayer(slot, rt, player, partialTick) + } + } catch (t: Throwable) { + LOGGER.warn("Entity preview render failed; showing the player head instead", t) + slot.failed = true + slot.available = false + slot.release() + } + } + } +} + +private fun findPlayer(id: UUID?): Player? { + val client = Minecraft.getInstance() + val level = client.level ?: return null + if (id == null) return client.player + //? if > 1.8.9 { + return level.getPlayerByUUID(id) + //?} else + //return level.getPlayer(id) +} + +private fun localProfileId(): UUID? = runCatching { Minecraft.getInstance().user.profileId }.getOrNull() + +private fun partialTick(): Float { + //? if >= 1.21.4 { + return Minecraft.getInstance().deltaTracker.getGameTimeDeltaPartialTick(false) + //?} elif > 1.8.9 { + /*return Minecraft.getInstance().timer.getGameTimeDeltaPartialTick(false) + *///?} else + //return 1f +} + +//? if >= 1.21.10 { +private fun renderPlayer(slot: PreviewSlot, rt: RenderTarget, player: Player, partialTick: Float) { + val client = Minecraft.getInstance() + val width = rt.width + val height = rt.height + + PreviewNameTag.rendering = true + PreviewNameTag.show = slot.showNameTag + val state = try { + client.entityRenderDispatcher.extractEntity(player, partialTick) as? AvatarRenderState ?: return + } finally { + PreviewNameTag.rendering = false + PreviewNameTag.show = false + } + + state.lightCoords = FULL_BRIGHT + if (!slot.showNameTag) state.nameTag = null + state.bodyRot = 180f + slot.yaw + state.yRot = 0f + state.xRot = 0f + val boxHeight = state.boundingBoxHeight / state.scale + state.boundingBoxWidth /= state.scale + state.boundingBoxHeight = boxHeight + state.scale = 1f + + val anchor = slot.smoothAnchorHeight(boxHeight) + val tag = if (slot.showNameTag) NAME_TAG_HEIGHT else 0f + val size = slot.modelSize(width, height) + val tilt = Math.toRadians(slot.pitch.toDouble()).toFloat() + val rotation = Quaternionf().rotateZ(Math.PI.toFloat()) + if (tilt != 0f) rotation.mul(Quaternionf().rotateX(tilt)) + + val pose = PoseStack() + pose.translate(width / 2f, height / 2f, 0f) + pose.scale(size, size, -size) + pose.translate(0f, (anchor + tag) / 2f + OFFSET_Y, 0f) + //? if >= 26.3 { + pose.rotate(rotation) + //? } else { + /*pose.mulPose(rotation) + *///? } + + //? if >= 26.2 { + client.gameRenderer.lighting().setupFor(Lighting.Entry.ENTITY_IN_UI) + //? } else { + /*client.gameRenderer.lighting.setupFor(Lighting.Entry.ENTITY_IN_UI) + *///? } + + val camera = CameraRenderState().apply { + orientation = Quaternionf().rotateY(Math.PI.toFloat()).apply { if (tilt != 0f) mul(Quaternionf().rotateX(tilt)) } + pos = Vec3.ZERO + //? if < 26.1 { + /*entityPos = Vec3.ZERO + *///? } + } + + //? if >= 26.3 { + val colorView = rt.colorTextureView ?: return + val depthView = rt.depthTextureView ?: return + val storage = SubmitNodeStorage() + client.entityRenderDispatcher.submit(state, camera, 0.0, 0.0, 0.0, pose, storage) + client.gameRenderer.featureRenderDispatcher().prepareFrame(storage).use { frame -> + RenderSystem.getDevice().createCommandEncoder().createRenderPass( + { "oneconfig_entity_preview" }, + colorView, + Optional.empty(), + depthView, + OptionalDouble.empty(), + ).use { pass -> + RenderSystem.bindDefaultUniforms(pass) + FeatureRenderDispatcher.renderAllFeatures(pass, frame) + } + } + //? } else if >= 26.2 { + /*val storage = SubmitNodeStorage() + client.entityRenderDispatcher.submit(state, camera, 0.0, 0.0, 0.0, pose, storage) + client.gameRenderer.featureRenderDispatcher().renderAllFeatures(storage) + *///? } else { + /*val features = client.gameRenderer.featureRenderDispatcher + client.entityRenderDispatcher.submit(state, camera, 0.0, 0.0, 0.0, pose, features.submitNodeStorage) + features.renderAllFeatures() + client.renderBuffers().bufferSource().endBatch() + *///? } +} +//?} else if > 1.8.9 { +/*@Suppress("DEPRECATION") +private fun renderPlayer(slot: PreviewSlot, rt: RenderTarget, player: Player, partialTick: Float) { + val client = Minecraft.getInstance() + val width = rt.width + val height = rt.height + val entityScale = player.scale.coerceAtLeast(0.0001f) + val bodyRot = 180f + slot.yaw + + val anchor = slot.smoothAnchorHeight(player.bbHeight / entityScale) + val tag = if (slot.showNameTag) NAME_TAG_HEIGHT else 0f + val size = slot.modelSize(width, height) + val modelScale = size / entityScale + val tilt = Math.toRadians(slot.pitch.toDouble()).toFloat() + val cameraRotation = Quaternionf().rotateX(tilt) + val rotation = Quaternionf().rotateZ(Math.PI.toFloat()).mul(cameraRotation) + + val pose = PoseStack() + pose.translate(width / 2f, height / 2f, 0f) + pose.scale(modelScale, modelScale, -modelScale) + pose.translate(0f, entityScale * ((anchor + tag) / 2f + OFFSET_Y), 0f) + pose.mulPose(rotation) + + val savedBodyRot = player.yBodyRot + val savedBodyRotO = player.yBodyRotO + val savedHeadRot = player.yHeadRot + val savedHeadRotO = player.yHeadRotO + val savedYRot = player.yRot + val savedXRot = player.xRot + val savedXRotO = player.xRotO + + val dispatcher = client.entityRenderDispatcher + val buffers = client.renderBuffers().bufferSource() + + PreviewNameTag.rendering = true + PreviewNameTag.show = slot.showNameTag + player.yBodyRot = bodyRot + player.yBodyRotO = bodyRot + player.yHeadRot = bodyRot + player.yHeadRotO = bodyRot + player.yRot = bodyRot + player.xRot = 0f + player.xRotO = 0f + //? if >= 1.21.8 { + client.gameRenderer.lighting.setupFor(Lighting.Entry.ENTITY_IN_UI) + //?} else + //Lighting.setupForEntityInInventory() + dispatcher.overrideCameraOrientation(cameraRotation.conjugate(Quaternionf()).rotateY(Math.PI.toFloat())) + dispatcher.setRenderShadow(false) + + //? if = 1.21.8 { + val savedHitboxes = dispatcher.shouldRenderHitBoxes() + dispatcher.setRenderHitBoxes(false) + //?} + + try { + //? if >= 1.21.4 { + dispatcher.render(player, 0.0, 0.0, 0.0, partialTick, pose, buffers, FULL_BRIGHT) + //?} else + //RenderSystem.runAsFancy { dispatcher.render(player, 0.0, 0.0, 0.0, 0f, partialTick, pose, buffers, FULL_BRIGHT) } + buffers.endBatch() + } finally { + dispatcher.setRenderShadow(true) + //? if = 1.21.8 + dispatcher.setRenderHitBoxes(savedHitboxes) + //? if < 1.21.8 + //Lighting.setupFor3DItems() + player.yBodyRot = savedBodyRot + player.yBodyRotO = savedBodyRotO + player.yHeadRot = savedHeadRot + player.yHeadRotO = savedHeadRotO + player.yRot = savedYRot + player.xRot = savedXRot + player.xRotO = savedXRotO + PreviewNameTag.rendering = false + PreviewNameTag.show = false + } +} +*///?} else { +/*private fun renderPlayer(slot: PreviewSlot, rt: RenderTarget, player: Player, partialTick: Float) { + val client = Minecraft.getInstance() + val width = rt.width + val height = rt.height + val bodyRot = slot.yaw + + val anchor = slot.smoothAnchorHeight(player.height) + val tag = if (slot.showNameTag) NAME_TAG_HEIGHT else 0f + val size = slot.modelSize(width, height) + + val savedBodyRot = player.bodyYaw + val savedBodyRotO = player.lastBodyYaw + val savedHeadRot = player.headYaw + val savedHeadRotO = player.lastHeadYaw + val savedYRot = player.yaw + val savedYRotO = player.lastYaw + val savedXRot = player.pitch + val savedXRotO = player.lastPitch + + val dispatcher = client.entityRenderDispatcher + + PreviewNameTag.rendering = true + PreviewNameTag.show = slot.showNameTag + player.bodyYaw = bodyRot + player.lastBodyYaw = bodyRot + player.headYaw = bodyRot + player.lastHeadYaw = bodyRot + player.yaw = bodyRot + player.lastYaw = bodyRot + player.pitch = 0f + player.lastPitch = 0f + + GL11.glEnable(GL11.GL_NORMALIZE) + GlStateManager.enableColorMaterial() + GlStateManager.pushMatrix() + try { + GlStateManager.translatef(width / 2f, height / 2f, 50f) + GlStateManager.scalef(-size, size, size) + GlStateManager.translatef(0f, (anchor + tag) / 2f + OFFSET_Y, 0f) + GlStateManager.rotatef(180f, 0f, 0f, 1f) + if (slot.pitch != 0f) GlStateManager.rotatef(slot.pitch, 1f, 0f, 0f) + GlStateManager.rotatef(135f, 0f, 1f, 0f) + Lighting.turnOn() + GlStateManager.rotatef(-135f, 0f, 1f, 0f) + dispatcher.setCameraYaw(180f) + dispatcher.setRenderShadow(false) + dispatcher.render(player, 0.0, 0.0, 0.0, 0f, partialTick, true) + } finally { + dispatcher.setRenderShadow(true) + GlStateManager.popMatrix() + Lighting.turnOff() + GlStateManager.disableRescaleNormal() + GlStateManager.activeTexture(GLX.GL_TEXTURE1) + GlStateManager.disableTexture() + GlStateManager.activeTexture(GLX.GL_TEXTURE0) + GL11.glDisable(GL11.GL_NORMALIZE) + player.bodyYaw = savedBodyRot + player.lastBodyYaw = savedBodyRotO + player.headYaw = savedHeadRot + player.lastHeadYaw = savedHeadRotO + player.yaw = savedYRot + player.lastYaw = savedYRotO + player.pitch = savedXRot + player.lastPitch = savedXRotO + PreviewNameTag.rendering = false + PreviewNameTag.show = false + } +} +*///?} diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/platform/HudFrameHooks.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/platform/HudFrameHooks.kt new file mode 100644 index 000000000..0e2437164 --- /dev/null +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/platform/HudFrameHooks.kt @@ -0,0 +1,60 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.platform + +import net.minecraft.client.Minecraft +import net.minecraft.client.gui.screens.ChatScreen +import org.apache.logging.log4j.LogManager +import org.polyfrost.oneconfig.api.hud.v1.HudClickInput +import org.polyfrost.oneconfig.api.hud.v1.HudManager +import org.polyfrost.oneconfig.api.platform.v1.Platform +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi + +//? if = 1.8.9 { +/*import org.lwjgl.input.Mouse +*///?} + +internal object HudFrameHooks { + private val LOGGER = LogManager.getLogger("OneConfig/HudFrame") + private var pointerFailed = false + + @JvmStatic + fun beginFrame(partialTick: Float) { + HudManager.partialTick = partialTick + try { + feedPointer() + } catch (e: Throwable) { + if (e is VirtualMachineError) throw e + if (!pointerFailed) { + pointerFailed = true + LOGGER.error("Failed to read the pointer for HUD click regions", e) + } + } + } + + @JvmStatic + fun consumeButton(button: Int, pressed: Boolean): Boolean = HudClickInput.consumeButton(button, pressed) + + private fun feedPointer() { + val screen = Platform.screen() + val allowed = inputAllowed() + //? if > 1.8.9 { + val mouse = Minecraft.getInstance().mouseHandler + val windowX = mouse.xpos().toFloat() + val windowY = mouse.ypos().toFloat() + //?} else { + /*val pixelRatio = screen.pixelRatio() + val windowX = Mouse.getX() / pixelRatio + val windowY = (screen.viewportHeight() - Mouse.getY() - 1) / pixelRatio + *///?} + val guiX = windowX * screen.guiWidth() / screen.windowWidth().coerceAtLeast(1) + val guiY = windowY * screen.guiHeight() / screen.windowHeight().coerceAtLeast(1) + HudClickInput.updatePointer(guiX, guiY, allowed) + } + + private fun inputAllowed(): Boolean { + if (!Minecraft.getInstance().isWindowActive) return false + val screen = runCatching { Platform.screen().current() }.getOrNull() ?: return false + return screen is ChatScreen || screen is ComposeHudLayer.InputScreen + } +} diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/platform/InlinePolyCanvas.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/platform/InlinePolyCanvas.kt new file mode 100644 index 000000000..f8d3fdf5f --- /dev/null +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/platform/InlinePolyCanvas.kt @@ -0,0 +1,184 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.platform + +import net.minecraft.client.Minecraft +import org.polyfrost.compose.backend.PolyCanvas +import org.polyfrost.oneconfig.api.event.v1.EventManager +import org.polyfrost.oneconfig.api.event.v1.events.FramebufferRenderEvent +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi + +//? if > 1.8.9 { +import net.minecraft.client.gui.GuiGraphicsExtractor +//?} + +//? if >= 1.21.11 { +import org.polyfrost.oneconfig.internal.ui.compose.BlazeRenderer +import org.polyfrost.oneconfig.internal.ui.compose.GlyphAtlas +//?} else { +/*import org.polyfrost.oneconfig.internal.ui.RenderTargetFbo +import org.polyfrost.oneconfig.internal.ui.compose.GlHost +import org.polyfrost.oneconfig.internal.ui.compose.GlRenderer +import org.polyfrost.oneconfig.internal.ui.compose.opengl.StoredGLState +*///?} + +//? if >= 1.21.8 && < 1.21.11 { +/*import com.mojang.blaze3d.pipeline.RenderTarget +import net.minecraft.client.renderer.RenderPipelines +import net.minecraft.client.renderer.texture.AbstractTexture +import net.minecraft.resources.Identifier +import org.polyfrost.oneconfig.internal.ui.OffscreenTarget +*///?} + +//? if = 1.8.9 { +/*import org.polyfrost.oneconfig.api.platform.v1.Platform +*///?} + +@ExperimentalOneConfigUiApi +object InlinePolyCanvas { + private val surfaces = ArrayList() + private var used = 0 + private var frame = 0L + + init { + EventManager.register(FramebufferRenderEvent.End::class.java) { _ -> endFrame() } + } + + //? if > 1.8.9 { + @JvmStatic + fun draw(graphics: GuiGraphicsExtractor?, block: (PolyCanvas) -> Unit): Boolean = next().draw(graphics, block) + //?} else { + /* + @JvmStatic + fun draw(block: (PolyCanvas) -> Unit): Boolean = next().draw(block) + *///?} + + private fun next(): InlineCanvasSurface { + val surface = surfaces.getOrNull(used) ?: InlineCanvasSurface("pool_${surfaces.size}").also(surfaces::add) + used++ + surface.lastUsedFrame = frame + return surface + } + + private fun endFrame() { + frame++ + used = 0 + for (surface in surfaces) { + if (frame - surface.lastUsedFrame > IDLE_FRAMES) surface.close() + } + } + + private const val IDLE_FRAMES = 120 +} + +internal class InlineCanvasSurface(val name: String) { + var lastUsedFrame = 0L + + //? if >= 1.21.11 { + fun draw(ctx: GuiGraphicsExtractor?, block: (PolyCanvas) -> Unit): Boolean { + if (ctx == null) return false + val renderer = BlazeRenderer(Minecraft.getInstance().window.guiScale.toFloat()) + block(PolyCanvas(renderer)) + for (op in renderer.finish()) op(ctx) + GlyphAtlas.uploadDirty() + return true + } + + fun close() {} + //?} elif >= 1.21.8 { + /*private val textureId = Identifier.fromNamespaceAndPath("oneconfig", "inline_canvas/" + pathSafe(name)) + private val offscreen = OffscreenTarget() + private var texture: TargetTexture? = null + + private class TargetTexture : AbstractTexture() { + fun set(target: RenderTarget) { + texture = target.colorTexture + textureView = target.colorTextureView + } + + override fun close() { + texture = null + textureView = null + } + } + + fun draw(ctx: GuiGraphicsExtractor?, block: (PolyCanvas) -> Unit): Boolean { + if (ctx == null || !GlHost.legacyGl) return false + val mc = Minecraft.getInstance() + val w = mc.window.width + val h = mc.window.height + if (w <= 0 || h <= 0 || !offscreen.resolve(w, h)) return false + val target = offscreen.target ?: return false + val fbo = RenderTargetFbo.getFboId(target) + if (!drawGl(fbo, w, h, topDown = true, clear = true, block)) return false + val tex = texture ?: TargetTexture().also { + texture = it + mc.textureManager.register(textureId, it) + } + tex.set(target) + val guiScale = mc.window.guiScale.toFloat() + ctx.pose().pushMatrix() + try { + ctx.pose().scale(1f / guiScale, 1f / guiScale) + ctx.blit(RenderPipelines.GUI_TEXTURED_PREMULTIPLIED_ALPHA, textureId, 0, 0, 0f, 0f, w, h, w, h) + } finally { + ctx.pose().popMatrix() + } + return true + } + + fun close() { + offscreen.destroy() + if (texture != null) { + texture = null + Minecraft.getInstance().textureManager.release(textureId) + } + } + + private fun pathSafe(raw: String): String = + raw.lowercase().map { if (it in 'a'..'z' || it in '0'..'9' || it in "_-./") it else '_' }.joinToString("") + *///?} elif > 1.8.9 { + /*fun draw(ctx: GuiGraphicsExtractor?, block: (PolyCanvas) -> Unit): Boolean { + if (!GlHost.legacyGl) return false + ctx?.flush() + val mc = Minecraft.getInstance() + //? if = 1.21.1 { + /^if (GlHost.vanillaPipeline) { + val renderer = GlRenderer() + block(PolyCanvas(renderer)) + mc.mainRenderTarget.bindWrite(true) + renderer.drawVanilla(mc.window.width, mc.window.height, topDown = false) + return true + } + ^///?} + return drawGl(RenderTargetFbo.getFboId(mc.mainRenderTarget), mc.window.width, mc.window.height, topDown = false, clear = false, block) + } + + fun close() {} + *///?} else { + /*fun draw(block: (PolyCanvas) -> Unit): Boolean { + if (!GlHost.legacyGl) return false + val fbo = RenderTargetFbo.getFboId(Minecraft.getInstance().mainRenderTarget) + return drawGl(fbo, Platform.screen().viewportWidth(), Platform.screen().viewportHeight(), topDown = false, clear = false, block) + } + + fun close() {} + *///?} + + //? if < 1.21.11 { + /*private val glState = StoredGLState(330) + + private fun drawGl(fbo: Int, w: Int, h: Int, topDown: Boolean, clear: Boolean, block: (PolyCanvas) -> Unit): Boolean { + if (fbo <= 0 || w <= 0 || h <= 0) return false + glState.capture() + try { + val renderer = GlRenderer() + block(PolyCanvas(renderer)) + renderer.draw(fbo, w, h, topDown, clear) + } finally { + glState.restore() + } + return true + } + *///?} +} diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/platform/OneConfigComposeScreen.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/platform/OneConfigComposeScreen.kt new file mode 100644 index 000000000..c79779f77 --- /dev/null +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/platform/OneConfigComposeScreen.kt @@ -0,0 +1,131 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.platform + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.mojang.blaze3d.platform.InputConstants +import net.minecraft.client.gui.screens.Screen +import net.minecraft.network.chat.Component +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.OneConfigPrefs +import org.polyfrost.oneconfig.api.ui.v1.OneConfigUI +import org.polyfrost.oneconfig.api.ui.v1.components.handleEscapeStack +import org.polyfrost.oneconfig.api.ui.v1.theme.ProvideOneConfigTheme +import org.polyfrost.oneconfig.internal.ThemeConfig +import org.polyfrost.oneconfig.internal.ui.compose.ComposeScreen + +//? if = 1.8.9 { +/*import net.minecraft.client.Minecraft +import net.minecraft.client.gui.screens.TitleScreen +import org.lwjgl.input.Keyboard +*///?} + +@ExperimentalOneConfigUiApi +abstract class OneConfigComposeScreen( + title: Component = Component.empty(), + val previous: Screen? = OneConfigScreens.current(), + renderMode: RenderMode = RenderMode.OnDemand, +) : ComposeScreen(renderMode.internal()) { + @ExperimentalOneConfigUiApi + enum class RenderMode { + OnDemand, + + Continuous, + } + + private val screenTitle: Component = title + + private var openedAt = 0L + + init { + OneConfigUI.ensureInitialized() + } + + open val blurBackground: Boolean get() = OneConfigPrefs.backgroundBlur + + open val closeOnEscape: Boolean get() = true + + @Composable + abstract fun Content() + + open fun onClosed() {} + + fun dismiss() { + if (OneConfigScreens.current() !== this) return + OneConfigScreens.display(previous) + } + + @Composable + final override fun compose() { + ProvideOneConfigTheme { + Box(Modifier.fillMaxSize().handleEscapeStack(), propagateMinConstraints = true) { Content() } + } + } + + override fun init() { + if (openedAt == 0L) openedAt = System.currentTimeMillis() + super.init() + } + + override fun blurAlpha(): Float { + if (!blurBackground || client.level == null || ThemeConfig.reduceTransparency) return 0f + if (!OneConfigPrefs.animationsEnabled) return 1f + val start = openedAt.takeIf { it > 0L } ?: return 0f + return ((System.currentTimeMillis() - start).toFloat() / BLUR_FADE_MS).coerceIn(0f, 1f) + } + + override fun handleKeyPressed(key: Int, modifiers: Int): Boolean { + if (key != InputConstants.KEY_ESCAPE) return false + if (closeOnEscape) dismiss() + return true + } + + //? if > 1.8.9 { + override fun getTitle(): Component = screenTitle + + override fun shouldCloseOnEsc(): Boolean = closeOnEscape + + override fun onClose() { + dismiss() + } + //?} else { + /*private var escaping = false + + override fun handleKeyboard() { + escaping = Keyboard.getEventKeyState() && Keyboard.getEventKey() == Keyboard.KEY_ESCAPE + try { + super.handleKeyboard() + } finally { + escaping = false + } + } + + private fun restoreAfterEscape() { + val previous = previous ?: return + val current = OneConfigScreens.current() + if (current !== previous && (current == null || current is TitleScreen)) OneConfigScreens.display(previous) + } + *///?} + + override fun removed() { + super.removed() + //? if = 1.8.9 + //if (escaping) Minecraft.getInstance().schedule(Runnable { restoreAfterEscape() }) + onClosed() + } + + private companion object { + const val BLUR_FADE_MS = 250L + + fun RenderMode.internal(): ComposeScreen.RenderMode = when (this) { + RenderMode.OnDemand -> ComposeScreen.RenderMode.ON_DEMAND + RenderMode.Continuous -> ComposeScreen.RenderMode.CONTINUOUS + } + } +} + +@ExperimentalOneConfigUiApi +fun isOneConfigComposeScreen(screen: Any?): Boolean = screen is ComposeScreen diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/platform/OneConfigScreens.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/platform/OneConfigScreens.kt new file mode 100644 index 000000000..502d5c192 --- /dev/null +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/platform/OneConfigScreens.kt @@ -0,0 +1,68 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.platform + +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicBoolean +import net.minecraft.client.gui.screens.Screen +import org.apache.logging.log4j.LogManager +import org.polyfrost.oneconfig.api.event.v1.events.ScreenOpenEvent +import org.polyfrost.oneconfig.api.event.v1.invoke.EventHandler +import org.polyfrost.oneconfig.api.platform.v1.Platform +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi + +@ExperimentalOneConfigUiApi +object OneConfigScreens { + private val LOGGER = LogManager.getLogger("OneConfig/Screens") + + private class Guard(val owner: Any, val allow: (Screen?) -> Boolean) + + private val guards = CopyOnWriteArrayList() + + @JvmStatic + fun display(screen: Screen?) { + Platform.screen().display(screen, 0) + } + + @JvmStatic + fun current(): Screen? = Platform.screen().current() + + @JvmStatic + fun preventReplacement(owner: Any, allow: (next: Screen?) -> Boolean): AutoCloseable { + ReplacementHandler.install() + val guard = Guard(owner, allow) + guards += guard + return AutoCloseable { guards.remove(guard) } + } + + internal fun allows(next: Screen?): Boolean { + for (guard in guards) { + if (next === guard.owner) continue + val allowed = try { + guard.allow(next) + } catch (t: Throwable) { + LOGGER.error("Screen guard of {} failed; letting the screen change through", guard.owner, t) + true + } + if (!allowed) return false + } + return true + } + + private object ReplacementHandler : EventHandler() { + private val installed = AtomicBoolean(false) + + fun install() { + if (installed.compareAndSet(false, true)) register() + } + + override fun handle(event: ScreenOpenEvent): Boolean { + if (!allows(event.getScreen())) event.cancelled = true + return false + } + + override fun getEventClass(): Class = ScreenOpenEvent::class.java + + override fun getPriority(): Int = Int.MAX_VALUE + } +} diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/platform/PolyOffscreen.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/platform/PolyOffscreen.kt new file mode 100644 index 000000000..17d927f08 --- /dev/null +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/platform/PolyOffscreen.kt @@ -0,0 +1,276 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.platform + +import com.mojang.blaze3d.pipeline.RenderTarget +import org.polyfrost.compose.render.RenderContext +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.internal.ui.FramePresentHooks +import org.polyfrost.oneconfig.internal.ui.OffscreenTarget +import org.polyfrost.oneconfig.internal.ui.TextureRenderer + +//? if > 1.8.9 { +//? if >= 1.21.4 { +import com.mojang.blaze3d.ProjectionType +//?} else +//import com.mojang.blaze3d.vertex.VertexSorting +import com.mojang.blaze3d.systems.RenderSystem +import org.joml.Matrix4f +//?} else { +/*import com.mojang.blaze3d.platform.GlStateManager +import org.lwjgl.opengl.GL11 +*///?} + +//? if >= 26.1 { +import net.minecraft.client.renderer.ProjectionMatrixBuffer +//?} elif >= 1.21.8 +//import net.minecraft.client.renderer.CachedOrthoProjectionMatrixBuffer + +//? if >= 26.2 { +import net.minecraft.client.renderer.Projection +import org.joml.Vector4f +//?} + +//? if > 1.8.9 && < 1.21.8 +//import org.polyfrost.oneconfig.internal.ui.hud.GuiTargetRedirect + +//? if < 1.21.5 +//import org.polyfrost.oneconfig.internal.ui.RenderTargetFbo + +private const val PROJECTION_NAME = "oneconfig_poly_offscreen" + +private const val RELEASE_FRAMES = 2 + +@ExperimentalOneConfigUiApi +class PolyOffscreen(width: Int, height: Int) : AutoCloseable { + var width: Int = width.coerceAtLeast(1) + private set + + var height: Int = height.coerceAtLeast(1) + private set + + var hasContent: Boolean = false + private set + + var isClosed: Boolean = false + private set + + val renderTarget: RenderTarget? + get() = if (isClosed) null else offscreen.target + + private val offscreen = OffscreenTarget() + private var pending: (() -> Unit)? = null + private var presentHook: AutoCloseable? = null + + //? if >= 26.1 { + private var projection: ProjectionMatrixBuffer? = null + //?} elif >= 1.21.8 + //private var projection: CachedOrthoProjectionMatrixBuffer? = null + + fun resize(width: Int, height: Int) { + val w = width.coerceAtLeast(1) + val h = height.coerceAtLeast(1) + if (w == this.width && h == this.height) return + this.width = w + this.height = h + hasContent = false + } + + fun render(draw: () -> Unit) { + if (isClosed) return + hasContent = false + if (!offscreen.resolve(width, height)) return + val rt = offscreen.target ?: return + hasContent = drawInto(rt, draw) + } + + fun renderBeforePresent(draw: () -> Unit) { + if (isClosed) return + pending = draw + if (presentHook == null) presentHook = FramePresentHooks.add(::runPending) + } + + fun RenderContext.blit(x: Float, y: Float, w: Float, h: Float) { + if (isClosed || !hasContent) return + val rt = offscreen.target ?: return + val textures = renderer as? TextureRenderer ?: return + textures.texture(rt, x, y, w, h, 0f, 1f, 1f, 0f, 1f) + } + + override fun close() { + if (isClosed) return + isClosed = true + hasContent = false + pending = null + presentHook?.close() + presentHook = null + val released = offscreen + //? if >= 1.21.8 { + val releasedProjection = projection + projection = null + //?} + FramePresentHooks.later(RELEASE_FRAMES) { + released.dispose() + //? if >= 1.21.8 + releasedProjection?.close() + } + } + + private fun runPending() { + val draw = pending ?: return + pending = null + render(draw) + } + + //? if >= 26.1 { + private fun projectionBuffer(): ProjectionMatrixBuffer = + projection ?: ProjectionMatrixBuffer(PROJECTION_NAME).also { projection = it } + //?} elif >= 1.21.8 { + /*private fun projectionBuffer(): CachedOrthoProjectionMatrixBuffer = + projection ?: CachedOrthoProjectionMatrixBuffer(PROJECTION_NAME, -1000f, 1000f, true).also { projection = it } + *///?} + + //? if >= 1.21.8 { + private fun drawInto(rt: RenderTarget, draw: () -> Unit): Boolean { + //? if >= 1.21.10 { + val colorTexture = rt.colorTexture ?: return false + val depthTexture = rt.depthTexture ?: return false + //?} + //? if < 26.3 { + /*val colorView = rt.colorTextureView ?: return false + val depthView = rt.depthTextureView ?: return false + *///? } + + val savedLights = RenderSystem.getShaderLights() + val savedFog = RenderSystem.getShaderFog() + + //? if >= 26.2 { + RenderSystem.getDevice().createCommandEncoder() + .clearColorAndDepthTextures(colorTexture, Vector4f(0f, 0f, 0f, 0f), depthTexture, 0.0) + //?} elif >= 1.21.10 { + /*RenderSystem.getDevice().createCommandEncoder() + .clearColorAndDepthTextures(colorTexture, 0x00000000, depthTexture, 1.0) + *///?} else + //offscreen.clear() + + val w = rt.width.toFloat() + val h = rt.height.toFloat() + RenderSystem.backupProjectionMatrix() + //? if >= 26.2 { + RenderSystem.setProjectionMatrix( + projectionBuffer().getBuffer(Projection().apply { setupOrtho(-1000f, 1000f, w, h, true) }), + ProjectionType.ORTHOGRAPHIC, + ) + //? } else if >= 26.1 { + /*RenderSystem.setProjectionMatrix( + projectionBuffer().getBuffer(Matrix4f().setOrtho(0f, w, h, 0f, -1000f, 1000f)), + ProjectionType.ORTHOGRAPHIC, + ) + *///? } else { + /*RenderSystem.setProjectionMatrix(projectionBuffer().getBuffer(w, h), ProjectionType.ORTHOGRAPHIC) + *///? } + //? if < 26.3 { + /*RenderSystem.outputColorTextureOverride = colorView + RenderSystem.outputDepthTextureOverride = depthView + *///? } + + val scissor = RenderSystem.getScissorStateForRenderTypeDraws() + val hadScissor = scissor.enabled() + val scissorX = scissor.x() + val scissorY = scissor.y() + val scissorW = scissor.width() + val scissorH = scissor.height() + RenderSystem.disableScissorForRenderTypeDraws() + + //? if >= 26.2 || < 1.21.10 { + val modelView = RenderSystem.getModelViewStack() + modelView.pushMatrix() + modelView.identity() + //? } + try { + draw() + } finally { + //? if >= 26.2 || < 1.21.10 { + modelView.popMatrix() + //? } + //? if < 26.3 { + /*RenderSystem.outputColorTextureOverride = null + RenderSystem.outputDepthTextureOverride = null + *///? } + RenderSystem.restoreProjectionMatrix() + savedLights?.let { RenderSystem.setShaderLights(it) } + savedFog?.let { RenderSystem.setShaderFog(it) } + if (hadScissor) RenderSystem.enableScissorForRenderTypeDraws(scissorX, scissorY, scissorW, scissorH) + } + return true + } + //?} else if > 1.8.9 { + /*private fun drawInto(rt: RenderTarget, draw: () -> Unit): Boolean { + //? if < 1.21.5 + //val bound = RenderTargetFbo.saveBoundTarget() + offscreen.clear() + + val previousTarget = GuiTargetRedirect.target + val ortho = Matrix4f().setOrtho(0f, rt.width.toFloat(), rt.height.toFloat(), 0f, -1000f, 1000f) + RenderSystem.backupProjectionMatrix() + val modelView = RenderSystem.getModelViewStack() + modelView.pushMatrix() + modelView.identity() + //? if >= 1.21.4 { + RenderSystem.setProjectionMatrix(ortho, ProjectionType.ORTHOGRAPHIC) + //?} else { + /^RenderSystem.setProjectionMatrix(ortho, VertexSorting.ORTHOGRAPHIC_Z) + RenderSystem.applyModelViewMatrix() + ^///?} + GuiTargetRedirect.target = rt + //? if < 1.21.5 + //rt.bindWrite(true) + try { + draw() + } finally { + GuiTargetRedirect.target = previousTarget + modelView.popMatrix() + //? if < 1.21.4 + //RenderSystem.applyModelViewMatrix() + RenderSystem.restoreProjectionMatrix() + //? if < 1.21.5 + //RenderTargetFbo.restoreBoundTarget(bound) + } + return true + } + *///?} else { + /*private fun drawInto(rt: RenderTarget, draw: () -> Unit): Boolean { + val bound = RenderTargetFbo.saveBoundTarget() + offscreen.clear() + + val depthWasEnabled = GL11.glIsEnabled(GL11.GL_DEPTH_TEST) + rt.bindWrite(true) + GlStateManager.matrixMode(GL11.GL_PROJECTION) + GlStateManager.pushMatrix() + GlStateManager.loadIdentity() + GlStateManager.ortho(0.0, rt.width.toDouble(), rt.height.toDouble(), 0.0, 1000.0, 3000.0) + GlStateManager.matrixMode(GL11.GL_MODELVIEW) + GlStateManager.pushMatrix() + GlStateManager.loadIdentity() + GlStateManager.translatef(0f, 0f, -2000f) + GlStateManager.color4f(1f, 1f, 1f, 1f) + GlStateManager.enableDepthTest() + try { + draw() + } finally { + if (!depthWasEnabled) GlStateManager.disableDepthTest() + GlStateManager.matrixMode(GL11.GL_PROJECTION) + GlStateManager.popMatrix() + GlStateManager.matrixMode(GL11.GL_MODELVIEW) + GlStateManager.popMatrix() + RenderTargetFbo.restoreBoundTarget(bound) + } + return true + } + *///?} + + companion object { + @JvmStatic + fun onBeforePresent(callback: () -> Unit): AutoCloseable = FramePresentHooks.add(callback) + } +} diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/platform/ScreenBlur.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/platform/ScreenBlur.kt new file mode 100644 index 000000000..2450b042c --- /dev/null +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/platform/ScreenBlur.kt @@ -0,0 +1,58 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.platform + +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi + +//? if > 1.8.9 { +import net.minecraft.client.gui.GuiGraphicsExtractor +//?} + +//? if >= 1.21.11 { +import org.polyfrost.oneconfig.internal.ui.compose.UiBlur +//?} else { +/*import net.minecraft.client.Minecraft +import org.polyfrost.oneconfig.api.platform.v1.Platform +import org.polyfrost.oneconfig.internal.ui.RenderTargetFbo +import org.polyfrost.oneconfig.internal.ui.UiErrors +import org.polyfrost.oneconfig.internal.ui.compose.GlBlur +import org.polyfrost.oneconfig.internal.ui.compose.GlHost +import org.polyfrost.oneconfig.internal.ui.compose.opengl.StoredGLState +*///?} + +@ExperimentalOneConfigUiApi +object ScreenBlur { + //? if < 1.21.11 + //private val gl = StoredGLState(330) + + //? if > 1.8.9 { + @Suppress("UNUSED_PARAMETER") + @JvmStatic + @JvmOverloads + fun draw(graphics: GuiGraphicsExtractor, alpha: Float = 1f) { + //?} else { + /*@JvmStatic + @JvmOverloads + fun draw(alpha: Float = 1f) { + *///?} + val strength = alpha.coerceIn(0f, 1f) + if (strength <= 0f) return + //? if >= 1.21.11 { + UiBlur.extract(graphics, strength) + //?} else { + /*if (!GlHost.legacyGl || GlHost.vanillaPipeline) return + val w = Platform.screen().viewportWidth() + val h = Platform.screen().viewportHeight() + if (w <= 0 || h <= 0) return + val client = Minecraft.getInstance() + gl.capture() + try { + GlBlur.draw(RenderTargetFbo.getFboId(client.mainRenderTarget), w, h, strength, client.isPaused) + } catch (t: Throwable) { + UiErrors.warn("ScreenBlur.draw() error", t) + } finally { + gl.restore() + } + *///?} + } +} diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/platform/VanillaHud.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/platform/VanillaHud.kt new file mode 100644 index 000000000..e09e320f5 --- /dev/null +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/platform/VanillaHud.kt @@ -0,0 +1,204 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.platform + +import java.util.EnumMap +import org.apache.logging.log4j.LogManager +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi + +//? if > 1.8.9 { +import net.minecraft.client.gui.GuiGraphicsExtractor +//?} + +@ExperimentalOneConfigUiApi +enum class VanillaHudElement { + CROSSHAIR, + + ATTACK_INDICATOR, + + STATUS_EFFECTS, + + SCOREBOARD, + + ACTION_BAR, + + TITLE, + + HELD_ITEM_NAME, + + VIGNETTE; + + val isSupported: Boolean + //? if > 1.8.9 { + get() = true + //?} else { + /*get() = this == CROSSHAIR + *///?} +} + +@ExperimentalOneConfigUiApi +object VanillaHud { + fun hide(element: VanillaHudElement, owner: Any): AutoCloseable = VanillaHudHooks.claim(element, owner, null) + + //? if > 1.8.9 { + fun replace(element: VanillaHudElement, owner: Any, draw: (GuiGraphicsExtractor) -> Unit): AutoCloseable = + VanillaHudHooks.claim(element, owner) { graphics -> draw(graphics as GuiGraphicsExtractor) } + + fun interceptCrosshair(owner: Any, draw: (graphics: GuiGraphicsExtractor, vanillaVisible: Boolean) -> Boolean): AutoCloseable = + VanillaHudHooks.claimIntercept(VanillaHudElement.CROSSHAIR, owner) { graphics, visible -> draw(graphics as GuiGraphicsExtractor, visible) } + + fun interceptAttackIndicator(owner: Any, draw: (graphics: GuiGraphicsExtractor, vanillaVisible: Boolean) -> Boolean): AutoCloseable = + VanillaHudHooks.claimIntercept(VanillaHudElement.ATTACK_INDICATOR, owner) { graphics, visible -> draw(graphics as GuiGraphicsExtractor, visible) } + + fun moveAttackIndicator(owner: Any, x: (x: Int, y: Int) -> Int, y: (x: Int, y: Int) -> Int): AutoCloseable = + VanillaHudHooks.move(owner, x, y) + //?} else { + /*fun replace(element: VanillaHudElement, owner: Any, draw: () -> Unit): AutoCloseable = + VanillaHudHooks.claim(element, owner) { _ -> draw() } + + fun interceptCrosshair(owner: Any, draw: (vanillaVisible: Boolean) -> Boolean): AutoCloseable = + VanillaHudHooks.claimIntercept(VanillaHudElement.CROSSHAIR, owner) { _, visible -> draw(visible) } + *///?} + + fun isClaimed(element: VanillaHudElement): Boolean = VanillaHudHooks.top(element) != null + + fun owner(element: VanillaHudElement): Any? = VanillaHudHooks.top(element)?.owner +} + +internal object VanillaHudHooks { + private val LOGGER = LogManager.getLogger("OneConfig/VanillaHud") + private const val MAX_CONSECUTIVE_FAILURES = 3 + + class Claim( + val element: VanillaHudElement, + val owner: Any, + val draw: ((Any?) -> Unit)?, + val intercept: ((Any?, Boolean) -> Boolean)? = null, + ) : AutoCloseable { + var failed = false + var failures = 0 + + override fun close() { + release(this) + } + } + + class Move(val owner: Any, val x: (Int, Int) -> Int, val y: (Int, Int) -> Int) : AutoCloseable { + var failed = false + + override fun close() { + release(this) + } + } + + private val claims = EnumMap>(VanillaHudElement::class.java) + @Volatile private var tops = arrayOfNulls(VanillaHudElement.entries.size) + + private val moves = ArrayList(1) + @Volatile private var topMove: Move? = null + + fun claim(element: VanillaHudElement, owner: Any, draw: ((Any?) -> Unit)?): AutoCloseable = + add(Claim(element, owner, draw)) + + fun claimIntercept(element: VanillaHudElement, owner: Any, draw: (Any?, Boolean) -> Boolean): AutoCloseable = + add(Claim(element, owner, null, draw)) + + private fun add(claim: Claim): AutoCloseable { + synchronized(claims) { + val list = claims.getOrPut(claim.element) { ArrayList(1) } + list.removeAll { it.owner === claim.owner } + list.add(claim) + publish() + } + return claim + } + + fun move(owner: Any, x: (Int, Int) -> Int, y: (Int, Int) -> Int): AutoCloseable { + val move = Move(owner, x, y) + synchronized(moves) { + moves.removeAll { it.owner === owner } + moves.add(move) + topMove = move + } + return move + } + + private fun release(claim: Claim) { + synchronized(claims) { + if (claims[claim.element]?.remove(claim) == true) publish() + } + } + + private fun release(move: Move) { + synchronized(moves) { + if (moves.remove(move)) topMove = moves.lastOrNull() + } + } + + private fun publish() { + val next = arrayOfNulls(VanillaHudElement.entries.size) + for ((element, list) in claims) next[element.ordinal] = list.lastOrNull() + tops = next + } + + fun top(element: VanillaHudElement): Claim? = tops[element.ordinal] + + @JvmStatic + fun intercept(element: VanillaHudElement, graphics: Any?): Boolean { + val claim = tops[element.ordinal] ?: return false + val intercept = claim.intercept + if (intercept != null) return guarded(claim, false) { intercept(graphics, true) } + val draw = claim.draw ?: return true + return guarded(claim, false) { + draw(graphics) + true + } + } + + @JvmStatic + fun interceptHidden(element: VanillaHudElement, graphics: Any?) { + val claim = tops[element.ordinal] ?: return + val intercept = claim.intercept ?: return + guarded(claim, false) { intercept(graphics, false) } + } + + @JvmStatic + fun moveX(x: Int, y: Int): Int { + val move = topMove ?: return x + return guarded(move, x) { move.x(x, y) } + } + + @JvmStatic + fun moveY(x: Int, y: Int): Int { + val move = topMove ?: return y + return guarded(move, y) { move.y(x, y) } + } + + private inline fun guarded(claim: Claim, fallback: T, block: () -> T): T = + try { + block().also { claim.failures = 0 } + } catch (e: Throwable) { + if (e is VirtualMachineError) throw e + if (!claim.failed) { + claim.failed = true + LOGGER.error("Replacement for vanilla {} from {} failed", claim.element, claim.owner, e) + } + if (++claim.failures >= MAX_CONSECUTIVE_FAILURES) { + LOGGER.error("Releasing the replacement for vanilla {} from {} after repeated failures", claim.element, claim.owner) + release(claim) + } + fallback + } + + private inline fun guarded(move: Move, fallback: Int, block: () -> Int): Int = + try { + block() + } catch (e: Throwable) { + if (e is VirtualMachineError) throw e + if (!move.failed) { + move.failed = true + LOGGER.error("Move for vanilla {} from {} failed", VanillaHudElement.ATTACK_INDICATOR, move.owner, e) + } + fallback + } +} diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/platform/VanillaKeybinds.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/platform/VanillaKeybinds.kt new file mode 100644 index 000000000..332db4ec5 --- /dev/null +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/platform/VanillaKeybinds.kt @@ -0,0 +1,16 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) +@file:JvmName("VanillaKeybinds") + +package org.polyfrost.oneconfig.api.ui.v1.platform + +import net.minecraft.client.KeyMapping +import org.polyfrost.oneconfig.api.config.v1.Property +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.keybind.KeybindHelper +import org.polyfrost.oneconfig.api.ui.v1.keybind.KeybindMetadata +import org.polyfrost.oneconfig.api.ui.v1.keybind.OneConfigKeybind +import org.polyfrost.oneconfig.internal.ui.keybind.VanillaKeyMappingProperties + +@ExperimentalOneConfigUiApi +fun KeybindHelper.Companion.vanillaProperty(keyMapping: KeyMapping): Property = + VanillaKeyMappingProperties.propertyFor(keyMapping) diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/OffscreenTarget.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/OffscreenTarget.kt index 5e081aae8..15805b2e3 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/OffscreenTarget.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/OffscreenTarget.kt @@ -1,8 +1,14 @@ package org.polyfrost.oneconfig.internal.ui import com.mojang.blaze3d.pipeline.RenderTarget +import java.util.concurrent.CopyOnWriteArrayList import org.slf4j.LoggerFactory +//? if < 1.21.10 { +/*import org.polyfrost.oneconfig.api.event.v1.EventManager +import org.polyfrost.oneconfig.api.event.v1.events.FramebufferRenderEvent +*///?} + //? if >= 26.2 { import com.mojang.renderpearl.api.GpuFormat import org.joml.Vector4f @@ -143,3 +149,62 @@ object GpuBackend { interface TextureRenderer { fun texture(target: RenderTarget, x: Float, y: Float, w: Float, h: Float, u0: Float, v0: Float, u1: Float, v1: Float, alpha: Float) } + +object FramePresentHooks { + private val LOG = LoggerFactory.getLogger("OneConfig/FramePresentHooks") + + private class Deferred(var frames: Int, val action: () -> Unit) + + private val callbacks = CopyOnWriteArrayList<() -> Unit>() + private val deferred = ArrayList() + + init { + //? if < 1.21.10 { + /*EventManager.register(FramebufferRenderEvent.Start::class.java, Runnable { run() }) + *///?} + } + + fun add(callback: () -> Unit): AutoCloseable { + callbacks += callback + return AutoCloseable { callbacks.remove(callback) } + } + + fun later(frames: Int, action: () -> Unit) { + deferred += Deferred(frames, action) + } + + @JvmStatic + fun run() { + for (callback in callbacks) { + try { + callback() + } catch (t: Throwable) { + LOG.warn("A frame present hook failed", t) + } + } + if (deferred.isEmpty()) return + val due = ArrayList() + val iterator = deferred.iterator() + while (iterator.hasNext()) { + val entry = iterator.next() + if (--entry.frames > 0) continue + iterator.remove() + due += entry + } + for (entry in due) { + try { + entry.action() + } catch (t: Throwable) { + LOG.warn("A deferred frame task failed", t) + } + } + } +} + +object PreviewNameTag { + @JvmField + var rendering: Boolean = false + + @JvmField + var show: Boolean = false +} diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/ComposeScreen.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/ComposeScreen.kt index d1dcd4b3f..9f80e9336 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/ComposeScreen.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/ComposeScreen.kt @@ -51,7 +51,9 @@ import org.polyfrost.oneconfig.internal.ui.UiErrors import org.polyfrost.oneconfig.internal.ui.components.item.ItemCatalog import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi import org.polyfrost.oneconfig.api.ui.v1.a11y.A11y -import org.polyfrost.oneconfig.api.ui.v1.components.EscapeAwareTextMenus +import org.polyfrost.oneconfig.api.ui.v1.components.ContextMenuRoot +import org.polyfrost.oneconfig.api.ui.v1.components.FeedbackHosts +import org.polyfrost.oneconfig.api.ui.v1.components.ProvideOneConfigTextContextMenu import org.polyfrost.oneconfig.api.ui.v1.components.EscapeStack import org.polyfrost.oneconfig.internal.ui.keybind.KeybindRecordingBus import org.polyfrost.oneconfig.utils.v1.ClipboardHelper @@ -382,10 +384,10 @@ abstract class ComposeScreen( LocalClipboardManager provides SystemClipboardManager, LocalClipboard provides SystemClipboard, LocalUiOversample provides surfaceScale(), - FixedScrollConfig.provided(), + scrollConfig.provided(), LocalWheelRouter provides wheelRouter, ) { - EscapeAwareTextMenus { compose() } + ProvideOneConfigTextContextMenu { ContextMenuRoot { FeedbackHosts { compose() } } } } } } @@ -496,6 +498,7 @@ abstract class ComposeScreen( showing = false sceneContextOrNull?.sync() ComposeSceneContextImpl.resetPointerIcon() + wheelRouter.stopFallback() releaseHeldButtons() consumedButtons.clear() lightInside = false @@ -579,11 +582,25 @@ abstract class ComposeScreen( withScene { it.invalidatePositionInWindow() } } + wheelRouter.glideFrame { step -> + glideCarry += step + val density = sceneDensity() + if (abs(glideCarry.x * density) >= 1f || abs(glideCarry.y * density) >= 1f) { + val carried = glideCarry + glideCarry = Offset.Zero + val pointer = pointerPosition() + withScene { + it.sendPointerEvent(PointerEventType.Scroll, glideAnchor, scrollDelta = carried) + if (pointer != glideAnchor) it.sendPointerEvent(PointerEventType.Move, pointer) + } + } + } + // Recompose and lay out before rendering so icons revealed by scrolling or filtering draw on the same frame. val nanos = System.nanoTime() withScene { scene -> recomposerOrNull?.performFrame(nanos) - scene.measureAndLayout() + retryingDisposedLayers { scene.measureAndLayout() } } if (ItemCatalog.renderIcons()) sceneDirty = true @@ -623,7 +640,7 @@ abstract class ComposeScreen( val scope = renderScopeOrNull val canvas = PolyCanvas(ctx.renderer) val rendered = if (recomposer == null || scope == null) null else withScene { - with(scope) { it.render(recomposer, canvas, nanos) } + retryingDisposedLayers { with(scope) { it.render(recomposer, canvas, nanos) } } } if (rendered != null) sceneRebuilds = 0 } catch (t: Throwable) { @@ -784,10 +801,14 @@ abstract class ComposeScreen( // side buttons are handled before this, and Compose ignores other clicks while one is held else -> return } + if (type == PointerEventType.Press) wheelRouter.stopFallback() withScene { it.sendPointerEvent(type, button = pointerButton, position = pointerPosition()) } } private val wheelRouter = WheelRouter() + private val scrollConfig = FixedScrollConfig(wheelRouter) + private var glideAnchor = Offset.Zero + private var glideCarry = Offset.Zero private var lastPreciseScrollNanos = 0L @@ -817,7 +838,14 @@ abstract class ComposeScreen( it.sendPointerEvent(PointerEventType.Scroll, position, scrollDelta = delta) } } - if (precise) dispatch() else wheelRouter.route(delta, dispatch) + if (precise) { + wheelRouter.stopFallback() + dispatch() + } else { + glideAnchor = position + glideCarry = Offset.Zero + wheelRouter.route(delta, dispatch) + } } /** Whether a scroll came from a trackpad, which is applied directly instead of gliding */ @@ -1179,6 +1207,21 @@ abstract class ComposeScreen( } } +private const val DISPOSED_LAYER_RETRIES = 3 + +private const val DISPOSED_OWNER_MESSAGE = "RootNodeOwner is already disposed" + +private inline fun retryingDisposedLayers(block: () -> T): T { + var attempt = 0 + while (true) { + try { + return block() + } catch (e: IllegalArgumentException) { + if (e.message != DISPOSED_OWNER_MESSAGE || ++attempt > DISPOSED_LAYER_RETRIES) throw e + } + } +} + internal fun sceneDensity(): Float { val pixelRatio = Platform.screen().pixelRatio().takeIf { it > 0f } ?: 1f //? if sdl { diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/FixedScrollConfig.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/FixedScrollConfig.kt index f3e41fb46..215957c4f 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/FixedScrollConfig.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/FixedScrollConfig.kt @@ -10,12 +10,14 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.PointerEvent import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntSize +import org.polyfrost.oneconfig.internal.ui.components.WheelRouter /** Override Compose's scrolling behaviour to get the same scrolling on every OS */ -internal object FixedScrollConfig : ScrollConfig { +internal class FixedScrollConfig(private val router: WheelRouter) : ScrollConfig { override val isSmoothScrollingEnabled: Boolean get() = false override fun Density.calculateMouseWheelScroll(event: PointerEvent, bounds: IntSize): Offset { + if (router.defersWheelScroll()) return Offset.Zero var delta = Offset.Zero event.changes.forEach { delta += it.scrollDelta } return delta * -density diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/MinecraftKeyboardAdapter.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/MinecraftKeyboardAdapter.kt index ab93dce26..7d232adfe 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/MinecraftKeyboardAdapter.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/MinecraftKeyboardAdapter.kt @@ -40,6 +40,7 @@ internal object MinecraftKeyboardAdapter { SDLK_HOME -> KeyEvent.VK_HOME SDLK_END -> KeyEvent.VK_END SDLK_INSERT -> KeyEvent.VK_INSERT + SDLK_APPLICATION -> KeyEvent.VK_CONTEXT_MENU SDLK_CAPSLOCK -> KeyEvent.VK_CAPS_LOCK SDLK_LSHIFT, SDLK_RSHIFT -> KeyEvent.VK_SHIFT SDLK_LCTRL, SDLK_RCTRL -> KeyEvent.VK_CONTROL diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/keybind/VanillaKeyMappingProperties.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/keybind/VanillaKeyMappingProperties.kt new file mode 100644 index 000000000..274715aaa --- /dev/null +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/keybind/VanillaKeyMappingProperties.kt @@ -0,0 +1,138 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.internal.ui.keybind + +import com.mojang.blaze3d.platform.InputConstants +import java.util.IdentityHashMap +import net.minecraft.client.KeyMapping +import org.apache.logging.log4j.LogManager +import org.polyfrost.oneconfig.api.config.v1.Property +import org.polyfrost.oneconfig.api.config.v1.Visualizer +import org.polyfrost.oneconfig.api.config.v1.dsl.visualizer +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.keybind.KeyModifiers +import org.polyfrost.oneconfig.api.ui.v1.keybind.KeybindMetadata +import org.polyfrost.oneconfig.api.ui.v1.keybind.OneConfigKeybind +import org.polyfrost.oneconfig.api.ui.v1.keybind.internal.MinecraftKeybindCodec + +//? if > 1.8.9 { +import org.polyfrost.oneconfig.internal.mixin.keybind.KeyMappingAccessor +//?} else +//import org.polyfrost.oneconfig.internal.legacy.KeyCodes + +object VanillaKeyMappingProperties { + private val LOGGER = LogManager.getLogger("OneConfig/Keybinds") + private val properties = IdentityHashMap() + + @JvmStatic + @Synchronized + fun propertyFor(mapping: KeyMapping): Property = + properties.getOrPut(mapping) { VanillaKeyMappingProperty(mapping) } + + @JvmStatic + fun onKeyChanged(mapping: KeyMapping) { + val property = synchronized(this) { properties[mapping] } ?: return + pull(property) + } + + @JvmStatic + fun onOptionsSaved() { + val all = synchronized(this) { + if (properties.isEmpty()) return + properties.values.toList() + } + for (property in all) pull(property) + } + + private fun pull(property: VanillaKeyMappingProperty) { + try { + property.pullFromVanilla() + } catch (t: Throwable) { + LOGGER.error("Failed to sync keybind property '{}' from Minecraft's Controls", property.id, t) + } + } + + @Suppress("UNCHECKED_CAST") + private val KEYBIND_TYPE = OneConfigKeybind::class.java as Class + + private class VanillaKeyMappingProperty(private val mapping: KeyMapping) : + Property("vanilla.${mapping.name}", mapping.name, null, KEYBIND_TYPE) { + private var value: OneConfigKeybind? = read(mapping) + private var incoming: OneConfigKeybind? = null + private var writing = false + + init { + visualizer = Visualizer.KeybindVisualizer::class.java + addMetadata("titleKey", mapping.name) + addMetadata("singleKey", true) + addMetadata("default", toKeybind(mapping.defaultKey)) + addMetadata(KeybindMetadata.NO_MIRROR, true) + } + + override fun get(): OneConfigKeybind? = value + + override fun set0(value: OneConfigKeybind?) { + if (value != null && value === incoming) { + this.value = value + return + } + writing = true + try { + write(mapping, value) + } finally { + writing = false + } + this.value = read(mapping) + } + + fun pullFromVanilla() { + if (writing || incoming != null) return + val now = read(mapping) + if (sameInput(value, now)) return + incoming = now + try { + setReferential(now) + } finally { + incoming = null + } + } + } + + private fun sameInput(a: OneConfigKeybind?, b: OneConfigKeybind): Boolean { + if (a == null || !a.isBound) return !b.isBound + return b.isBound && a.boundCode == b.boundCode && a.isMousePrimary == b.isMousePrimary + } + + private fun read(mapping: KeyMapping): OneConfigKeybind { + //? if > 1.8.9 { + val key = (mapping as KeyMappingAccessor).`oneconfig$getKey`() + //?} else + //val key = KeyCodes.fromLegacy(mapping.keyCode) + return toKeybind(key) + } + + private fun toKeybind(key: InputConstants.Key): OneConfigKeybind { + if (key == InputConstants.UNKNOWN) return unbound() + val value = key.value + return when { + key.type == InputConstants.Type.MOUSE -> OneConfigKeybind(null, intArrayOf(value), KeyModifiers.NONE, 0L, NO_OP) + value >= 0 -> OneConfigKeybind(intArrayOf(value), null, KeyModifiers.NONE, 0L, NO_OP) + else -> unbound() + } + } + + private fun unbound(): OneConfigKeybind = OneConfigKeybind(null, null, KeyModifiers.NONE, 0L, NO_OP) + + private fun write(mapping: KeyMapping, bind: OneConfigKeybind?) { + val key = when { + bind == null || !bind.isBound -> InputConstants.UNKNOWN + bind.isMousePrimary -> MinecraftKeybindCodec.mouse(bind.boundCode) + else -> MinecraftKeybindCodec.keysym(bind.boundCode) + } + mapping.setKey(key) + KeyMapping.resetMapping() + minecraftOrNull()?.options?.save() + } + + private val NO_OP: (Boolean) -> Boolean = { true } +} diff --git a/minecraft/src/main/resources/META-INF/services/org.polyfrost.oneconfig.api.ui.v1.internal.OneConfigUIFeedbackBridge b/minecraft/src/main/resources/META-INF/services/org.polyfrost.oneconfig.api.ui.v1.internal.OneConfigUIFeedbackBridge new file mode 100644 index 000000000..f0216225a --- /dev/null +++ b/minecraft/src/main/resources/META-INF/services/org.polyfrost.oneconfig.api.ui.v1.internal.OneConfigUIFeedbackBridge @@ -0,0 +1 @@ +org.polyfrost.oneconfig.api.ui.v1.internal.OneConfigUIFeedbackBridgeImpl diff --git a/minecraft/src/main/resources/META-INF/services/org.polyfrost.oneconfig.api.ui.v1.internal.OneConfigUIGlassBridge b/minecraft/src/main/resources/META-INF/services/org.polyfrost.oneconfig.api.ui.v1.internal.OneConfigUIGlassBridge new file mode 100644 index 000000000..13470a445 --- /dev/null +++ b/minecraft/src/main/resources/META-INF/services/org.polyfrost.oneconfig.api.ui.v1.internal.OneConfigUIGlassBridge @@ -0,0 +1 @@ +org.polyfrost.oneconfig.api.ui.v1.internal.OneConfigUIGlassBridgeImpl diff --git a/minecraft/src/main/resources/META-INF/services/org.polyfrost.oneconfig.api.ui.v1.internal.OneConfigUIKeybindRecordingBridge b/minecraft/src/main/resources/META-INF/services/org.polyfrost.oneconfig.api.ui.v1.internal.OneConfigUIKeybindRecordingBridge new file mode 100644 index 000000000..74d073b75 --- /dev/null +++ b/minecraft/src/main/resources/META-INF/services/org.polyfrost.oneconfig.api.ui.v1.internal.OneConfigUIKeybindRecordingBridge @@ -0,0 +1 @@ +org.polyfrost.oneconfig.api.ui.v1.internal.OneConfigUIKeybindRecordingBridgeImpl diff --git a/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json b/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json index f6484d6ee..1d2a671ef 100644 --- a/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json +++ b/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json @@ -567,5 +567,302 @@ "key.mouse": "Button %1$s", "key.mouse.left": "Left Button", "key.mouse.middle": "Middle Button", - "key.mouse.right": "Right Button" + "key.mouse.right": "Right Button", + "oneconfig.ui.search.clear": "Clear search", + "oneconfig.ui.number.increase": "Increase", + "oneconfig.ui.number.decrease": "Decrease", + "oneconfig.ui.confirm.armed": "Click again to %s", + "oneconfig.ui.toast.dismiss": "Dismiss", + "oneconfig.ui.dialog.save": "Save", + "oneconfig.colour": "Colour", + "oneconfig.control.on": "On", + "oneconfig.control.off": "Off", + "oneconfig.keybind.none": "None", + "oneconfig.keybind.recording": "Press keys...", + "oneconfig.ui.loading": "Loading", + "oneconfig.ui.retry": "Try again", + "oneconfig.ui.selection.count": "%s selected", + "oneconfig.ui.selection.clear": "Clear selection", + "oneconfig.ui.image.previous": "Previous image", + "oneconfig.ui.image.next": "Next image", + "oneconfig.ui.image.position": "%s of %s", + "oneconfig.ui.select.placeholder": "Select", + "oneconfig.ui.select.none": "None", + "oneconfig.ui.select.count": "%s selected", + "oneconfig.ui.splitbutton.more": "More options", + "oneconfig.ui.more_actions": "More actions", + "oneconfig.menu.text.cut": "Cut", + "oneconfig.menu.text.copy": "Copy", + "oneconfig.menu.text.paste": "Paste", + "oneconfig.menu.text.select_all": "Select all", + "oneconfig.menu.option.reset": "Reset to default", + "oneconfig.menu.option.copy_value": "Copy value", + "oneconfig.menu.option.paste_value": "Paste value", + "oneconfig.menu.option.copy_hex": "Copy hex", + "oneconfig.menu.option.paste_hex": "Paste hex", + "oneconfig.mods.toggle.locked": "This mod is switched on and off somewhere else.", + "oneconfig.format.time.just_now": "just now", + "oneconfig.format.time.moment": "in a moment", + "oneconfig.format.time.seconds_ago": "%s seconds ago", + "oneconfig.format.time.minute_ago": "%s minute ago", + "oneconfig.format.time.minutes_ago": "%s minutes ago", + "oneconfig.format.time.hour_ago": "%s hour ago", + "oneconfig.format.time.hours_ago": "%s hours ago", + "oneconfig.format.time.day_ago": "%s day ago", + "oneconfig.format.time.days_ago": "%s days ago", + "oneconfig.format.time.month_ago": "%s month ago", + "oneconfig.format.time.months_ago": "%s months ago", + "oneconfig.format.time.year_ago": "%s year ago", + "oneconfig.format.time.years_ago": "%s years ago", + "oneconfig.format.time.in_seconds": "in %s seconds", + "oneconfig.format.time.in_minute": "in %s minute", + "oneconfig.format.time.in_minutes": "in %s minutes", + "oneconfig.format.time.in_hour": "in %s hour", + "oneconfig.format.time.in_hours": "in %s hours", + "oneconfig.format.time.in_day": "in %s day", + "oneconfig.format.time.in_days": "in %s days", + "oneconfig.format.time.in_month": "in %s month", + "oneconfig.format.time.in_months": "in %s months", + "oneconfig.format.time.in_year": "in %s year", + "oneconfig.format.time.in_years": "in %s years", + "oneconfig.format.time.today_at": "Today, %s", + "oneconfig.format.time.yesterday_at": "Yesterday, %s", + "oneconfig.format.count.thousand": "%sK", + "oneconfig.format.count.million": "%sM", + "oneconfig.format.count.billion": "%sB", + "oneconfig.format.count.trillion": "%sT", + "oneconfig.format.size.bytes": "%s B", + "oneconfig.format.size.kilobytes": "%s KB", + "oneconfig.format.size.megabytes": "%s MB", + "oneconfig.format.size.gigabytes": "%s GB", + "oneconfig.format.size.terabytes": "%s TB", + "oneconfig.themes.empty": "No theme settings available", + "oneconfig.themes.no_backdrop_glass": "PolyGlass shows without frosted glass while VulkanMod is installed on this Minecraft version.", + "oneconfig.themes.menu.activate": "Activate", + "oneconfig.themes.menu.edit": "Edit", + "oneconfig.themes.custom.delete_title": "Delete \"%s\"?", + "oneconfig.themes.custom.delete_text": "This custom theme will be gone for good. Copy its code first if you want to keep it.", + "oneconfig.hud.locked.title": "HUD element locked", + "oneconfig.hud.locked.message": "Unlock in settings to change its position.", + "oneconfig.hud.placement.another_mod": "another mod", + "oneconfig.hud.placement.moved_scaled": "Moved and scaled by %s", + "oneconfig.hud.placement.scaled": "Scaled by %s", + "oneconfig.hud.placement.moved": "Moved by %s", + "oneconfig.hud.menu.settings": "Settings", + "oneconfig.hud.menu.show": "Show", + "oneconfig.hud.menu.hide": "Hide", + "oneconfig.hud.menu.lock": "Lock", + "oneconfig.hud.menu.unlock": "Unlock", + "oneconfig.hud.menu.delete": "Delete HUD", + "oneconfig.hud.hint.anchor_self": "Click the point on this HUD that gets anchored · Esc to cancel", + "oneconfig.hud.hint.anchor_target": "Click the point to anchor it to · Right click to remove the anchor · Esc to go back", + "oneconfig.hud.hint.locked": "This HUD is locked · Use the lock button above it to move or resize it", + "oneconfig.hud.hint.merged": "Drag to move the merged HUDs together · Click it first, then drag, to pull it out", + "oneconfig.hud.tab.settings": "Settings", + "oneconfig.hud.tab.style": "Style", + "oneconfig.hud.tab.layout": "Layout", + "oneconfig.hud.panel.search": "Search settings...", + "oneconfig.hud.panel.search_button": "Search settings", + "oneconfig.hud.library.title": "HUDs", + "oneconfig.hud.library.search": "Search HUDs...", + "oneconfig.hud.library.hint": "Click to add · Drag to place it yourself", + "oneconfig.hud.library.empty": "No HUDs found", + "oneconfig.hud.library.none_installed": "No HUDs installed", + "oneconfig.hud.library.evergreen_body": "Install EvergreenHUD for FPS, CPS, coordinates, ping, and many more HUDs.", + "oneconfig.hud.library.evergreen_action": "Get EvergreenHUD", + "oneconfig.hud.library.other": "Other", + "oneconfig.hud.library.add": "Add to screen", + "oneconfig.hud.library.mod_settings": "Open mod settings", + "oneconfig.hud.legacy": "Legacy HUD", + "oneconfig.mods.category.all": "All", + "oneconfig.mods.category.favorited": "Favorites", + "oneconfig.mods.category.hypixel": "Hypixel", + "oneconfig.mods.category.performance": "Performance", + "oneconfig.mods.category.visuals": "Visuals", + "oneconfig.mods.category.hud": "HUD", + "oneconfig.mods.category.utility": "Utility", + "oneconfig.mods.category.qol": "Quality of Life", + "oneconfig.mods.category.other": "Other", + "oneconfig.mods.category.hidden": "Hidden", + "oneconfig.mods.empty.hidden": "No hidden mods", + "oneconfig.mods.empty.hidden.text": "Mods you hide from their card show up here.", + "oneconfig.mods.empty.favorites": "No favorite mods", + "oneconfig.mods.empty.favorites.text": "Star a mod on its card to keep it here.", + "oneconfig.mods.menu.open": "Open settings", + "oneconfig.mods.menu.favorite": "Add to favorites", + "oneconfig.mods.menu.unfavorite": "Remove from favorites", + "oneconfig.mods.menu.hide": "Hide mod", + "oneconfig.mods.menu.show": "Show mod", + "oneconfig.mods.menu.enable": "Enable", + "oneconfig.mods.menu.disable": "Disable", + "oneconfig.mods.menu.hud_editor": "Open HUD editor", + "oneconfig.mods.menu.reset": "Reset to defaults", + "oneconfig.mods.menu.reset.title": "Reset %s?", + "oneconfig.mods.menu.reset.text": "Every setting of this mod goes back to its default. This can't be undone.", + "oneconfig.mods.menu.reset.confirm": "Reset settings", + "oneconfig.mods.menu.copy_id": "Copy mod id", + "oneconfig.mods.toggle.available": "This mod can be toggled on and off.", + "oneconfig.mods.toggle.unavailable": "This mod doesn't support being toggled.", + "oneconfig.mods.sort.custom": "Custom order", + "oneconfig.mods.sort.alphabetical": "A to Z", + "oneconfig.mods.sort.tooltip": "Sorted by: %s", + "oneconfig.mods.sort.confirm.title": "Sort by %s?", + "oneconfig.mods.sort.confirm": "Switch", + "oneconfig.modconfig.unknown": "Mod not found", + "oneconfig.modconfig.unknown.text": "No mod with the id %s is loaded.", + "oneconfig.modconfig.external": "This mod opens its own settings screen.", + "oneconfig.modconfig.external.open": "Open settings", + "oneconfig.modconfig.own": "This mod manages its own configuration.", + "oneconfig.preferences.empty": "No preferences available", + "oneconfig.search.other": "Other", + "oneconfig.sidebar.notifications": "Notifications", + "oneconfig.menu.option.unbind": "Unbind", + "oneconfig.option.more_actions": "More actions. Right-click option as shortcut.", + "oneconfig.keybind.conflicts": "Conflicts with %s", + "oneconfig.list.move_up": "Move up", + "oneconfig.list.move_down": "Move down", + "oneconfig.list.move_up.shortcut": "Alt+Up", + "oneconfig.list.move_down.shortcut": "Alt+Down", + "oneconfig.list.duplicate": "Duplicate", + "oneconfig.list.insert_above": "Insert above", + "oneconfig.list.remove": "Remove", + "oneconfig.notifications.empty": "You're all caught up.", + "oneconfig.notifications.dismiss": "Dismiss", + "oneconfig.notifications.copy": "Copy text", + "oneconfig.notifications.clear_all": "Clear all notifications", + "oneconfig.changelog.modrinth.close": "Close", + "oneconfig.profiles.category.all": "All profiles", + "oneconfig.profiles.category.favorited": "Favorites", + "oneconfig.profiles.separate_controls": "Separate controls", + "oneconfig.profiles.open_folder": "Open folder", + "oneconfig.profiles.open_folder.failed": "Could not open the profiles folder", + "oneconfig.profiles.action_failed": "Profile action failed", + "oneconfig.profiles.loading": "Loading profiles", + "oneconfig.profiles.load_failed": "Couldn't load profiles", + "oneconfig.profiles.no_favorites": "No favorite profiles.", + "oneconfig.profiles.nomatch": "No profiles match \"%s\"", + "oneconfig.profiles.default": "Default", + "oneconfig.profiles.export.title": "Export profile", + "oneconfig.profiles.export.filter": "Zip archive", + "oneconfig.profiles.busy.title": "Applying profile changes…", + "oneconfig.profiles.busy.text": "The game will stop responding until it finishes.", + "oneconfig.profiles.new": "New profile", + "oneconfig.profiles.name_placeholder": "Profile name", + "oneconfig.profiles.name_required": "Enter a name", + "oneconfig.profiles.rename.title": "Rename profile", + "oneconfig.profiles.clone.title": "Clone profile", + "oneconfig.profiles.menu.apply": "Apply", + "oneconfig.profiles.menu.rename": "Rename", + "oneconfig.profiles.menu.clone": "Clone", + "oneconfig.profiles.menu.icon": "Change icon", + "oneconfig.profiles.menu.favorite": "Favorite", + "oneconfig.profiles.menu.unfavorite": "Remove favorite", + "oneconfig.profiles.menu.export": "Export / share", + "oneconfig.profiles.menu.delete": "Delete", + "oneconfig.profiles.delete.title": "Delete profile?", + "oneconfig.profiles.delete.text": "The profile \"%s\" and all its settings will be deleted.", + "oneconfig.profiles.delete.confirm": "Delete profile", + "oneconfig.hud.panel.text": "Text", + "oneconfig.hud.panel.font": "Font", + "oneconfig.hud.panel.size": "Size", + "oneconfig.hud.panel.modifiers": "Modifiers", + "oneconfig.hud.panel.weight": "Weight", + "oneconfig.hud.panel.case": "Case", + "oneconfig.hud.panel.color": "Color", + "oneconfig.hud.panel.shadow": "Shadow", + "oneconfig.hud.panel.mode.normal": "Normal", + "oneconfig.hud.panel.mode.full": "Full", + "oneconfig.hud.panel.shadow_color": "Shadow color", + "oneconfig.hud.panel.text_color": "Text color", + "oneconfig.hud.panel.background": "Background", + "oneconfig.hud.panel.radius": "Radius", + "oneconfig.hud.panel.merge": "Merge with neighbours", + "oneconfig.hud.panel.mode.diagonal": "Diagonal", + "oneconfig.hud.panel.keep_hidden": "Keep hidden", + "oneconfig.hud.panel.border": "Border", + "oneconfig.hud.panel.border_size": "Border size", + "oneconfig.hud.panel.border_color": "Border color", + "oneconfig.hud.panel.mode.off": "Off", + "oneconfig.hud.panel.position_scale": "Position & Scale", + "oneconfig.hud.panel.position": "Position", + "oneconfig.hud.panel.lock_position": "Lock position", + "oneconfig.hud.panel.scale": "Scale", + "oneconfig.hud.panel.follow_gui_scale": "Follow GUI scale", + "oneconfig.hud.panel.reset_to_follow": "Reset position to follow it", + "oneconfig.hud.panel.size_alignment": "Size & Alignment", + "oneconfig.hud.panel.static_size": "Static size", + "oneconfig.hud.panel.width": "Width", + "oneconfig.hud.panel.height": "Height", + "oneconfig.hud.panel.anchor": "Anchor", + "oneconfig.hud.panel.content_alignment": "Content alignment", + "oneconfig.hud.panel.content_alignment.hint": "Aligns the content inside the fixed box", + "oneconfig.hud.panel.anchor.hint": "Pins this corner or edge in place;\nthe HUD only grows away from it", + "oneconfig.hud.panel.padding": "Padding", + "oneconfig.hud.panel.horizontal": "Horizontal", + "oneconfig.hud.panel.vertical": "Vertical", + "oneconfig.hud.panel.per_side": "Per side", + "oneconfig.hud.panel.left": "Left", + "oneconfig.hud.panel.right": "Right", + "oneconfig.hud.panel.top": "Top", + "oneconfig.hud.panel.bottom": "Bottom", + "oneconfig.hud.panel.visibility": "Visibility", + "oneconfig.hud.panel.show_in_f3": "Show in F3 Screen", + "oneconfig.hud.panel.show_in_tab": "Show in Tab List", + "oneconfig.hud.panel.show_in_screens": "Show in GUIs", + "oneconfig.hud.panel.show_in_chat": "Show in Chat", + "oneconfig.hud.panel.reset_card": "Reset %s", + "oneconfig.hud.menu.anchor": "Anchor", + "oneconfig.hud.menu.remove_anchor": "Remove anchor", + "oneconfig.hud.menu.duplicate": "Duplicate", + "oneconfig.hud.menu.undo": "Undo", + "oneconfig.hud.menu.redo": "Redo", + "oneconfig.hud.menu.reset_position": "Reset position", + "oneconfig.hud.menu.reset_settings": "Reset HUD settings", + "oneconfig.hud.align.left": "Align left", + "oneconfig.hud.align.centerx": "Align centre", + "oneconfig.hud.align.right": "Align right", + "oneconfig.hud.align.top": "Align top", + "oneconfig.hud.align.centery": "Align middle", + "oneconfig.hud.align.bottom": "Align bottom", + "oneconfig.option.button.default": "Click", + "oneconfig.list.add": "Add", + "oneconfig.colour.picker": "Colour picker", + "oneconfig.colour.picker.close": "Close", + "oneconfig.colour.opacity": "Opacity", + "oneconfig.colour.chroma": "Chroma", + "oneconfig.colour.gradient": "Gradient", + "oneconfig.colour.speed": "Speed", + "oneconfig.option.multi_select.deselect_all": "Deselect all", + "oneconfig.button.back": "Back", + "oneconfig.button.forward": "Forward", + "oneconfig.button.close": "Close", + "oneconfig.button.browse": "Browse", + "oneconfig.hud.panel.bold": "Bold", + "oneconfig.hud.panel.italic": "Italic", + "oneconfig.hud.panel.underline": "Underline", + "oneconfig.profiles.switched.title": "Profile switched", + "oneconfig.profiles.switched.text": "Now using %s.", + "oneconfig.profiles.switch_failed": "Could not switch profile", + "oneconfig.credits.by": "by", + "oneconfig.credits.role.ceo": "CEO", + "oneconfig.credits.role.cto": "CTO", + "oneconfig.credits.role.oneconfig_founding": "OneConfig founding engineer", + "oneconfig.credits.role.oneconfig_major": "OneConfig major engineer", + "oneconfig.credits.role.polycompose_founding": "PolyCompose founding engineer", + "oneconfig.credits.role.compatengine_founding": "CompatEngine founding engineer", + "oneconfig.credits.role.config_gui_hud": "Config backend, GUI frontend, HUD", + "oneconfig.credits.role.cosmetics_lead": "Lead cosmetics designer, OneClient", + "oneconfig.credits.role.designer": "Designer", + "oneconfig.credits.role.sound": "Sound design", + "oneconfig.credits.role.utilities": "OneConfig utilities", + "oneconfig.credits.role.designer_v0": "Designer, v0", + "oneconfig.credits.role.tester": "Major tester", + "oneconfig.credits.role.saving": "For saving us", + "oneconfig.credits.role.contributions": "Contributions to OneConfig and Polyfrost mods", + "oneconfig.credits.role.modding_impact": "Lasting impact on Minecraft modding", + "oneconfig.credits.thanks.team": "The Polyfrost team", + "oneconfig.credits.thanks.team.detail": "For their support and contributions", + "oneconfig.credits.thanks.translators": "Translators", + "oneconfig.credits.thanks.contributors": "Contributors and testers", + "oneconfig.credits.thanks.contributors.detail": "For helping make modding accessible to everyone" } diff --git a/minecraft/src/main/resources/mixins.oneconfigv1.json b/minecraft/src/main/resources/mixins.oneconfigv1.json index c3c0b9198..7e5ecfbc9 100644 --- a/minecraft/src/main/resources/mixins.oneconfigv1.json +++ b/minecraft/src/main/resources/mixins.oneconfigv1.json @@ -12,6 +12,8 @@ "verbose": true, "client": [ "Mixin_PackRepository", + "events.Mixin_AttackEntityEvent", + "events.Mixin_BlockUpdateEvent", "events.Mixin_ChatReceiveEvent", "events.Mixin_ChatSendEvent", "events.Mixin_DebugOpenEvent", @@ -23,8 +25,10 @@ "events.Mixin_ModernWindowFocusEvent", "events.Mixin_MouseInputEvent", "events.Mixin_MusicDucking", + "events.Mixin_PlaceBlockEvent", "events.Mixin_PlayerInteractEvent_LeftAction", "events.Mixin_PlayerInteractEvent_RightAction", + "events.Mixin_PlayerTurnEvent", "events.Mixin_ReceivePacketEvent", "events.Mixin_RenderLivingEntityEvent", "events.Mixin_ResizeEvent", diff --git a/modules/config-impl/api/config-impl.api b/modules/config-impl/api/config-impl.api index 0f33aae78..6be8eaae9 100644 --- a/modules/config-impl/api/config-impl.api +++ b/modules/config-impl/api/config-impl.api @@ -115,6 +115,23 @@ public abstract interface class org/polyfrost/oneconfig/api/config/v1/ConfigMana public abstract fun onTreeRegistered (Lorg/polyfrost/oneconfig/api/config/v1/Tree;)V } +public final class org/polyfrost/oneconfig/api/config/v1/Dependencies { + public static final fun addDependency (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;)V + public static final fun addDependency (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;Z)V + public static synthetic fun addDependency$default (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;ZILjava/lang/Object;)V + public static final fun dependsOn (Lorg/polyfrost/oneconfig/api/config/v1/Property;Lorg/polyfrost/oneconfig/api/config/v1/Property;)V + public static final fun dependsOn (Lorg/polyfrost/oneconfig/api/config/v1/Property;Lorg/polyfrost/oneconfig/api/config/v1/Property;Z)V + public static final fun dependsOn (Lorg/polyfrost/oneconfig/api/config/v1/Property;Lorg/polyfrost/oneconfig/api/config/v1/Property;ZZ)V + public static synthetic fun dependsOn$default (Lorg/polyfrost/oneconfig/api/config/v1/Property;Lorg/polyfrost/oneconfig/api/config/v1/Property;ZZILjava/lang/Object;)V + public static final fun disableAllUnless (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;)V + public static final fun disabledWhen (Lorg/polyfrost/oneconfig/api/config/v1/Property;[Lorg/polyfrost/oneconfig/api/config/v1/Property;ILkotlin/jvm/functions/Function0;)V + public static synthetic fun disabledWhen$default (Lorg/polyfrost/oneconfig/api/config/v1/Property;[Lorg/polyfrost/oneconfig/api/config/v1/Property;ILkotlin/jvm/functions/Function0;ILjava/lang/Object;)V + public static final fun hiddenWhen (Lorg/polyfrost/oneconfig/api/config/v1/Property;[Lorg/polyfrost/oneconfig/api/config/v1/Property;ILkotlin/jvm/functions/Function0;)V + public static synthetic fun hiddenWhen$default (Lorg/polyfrost/oneconfig/api/config/v1/Property;[Lorg/polyfrost/oneconfig/api/config/v1/Property;ILkotlin/jvm/functions/Function0;ILjava/lang/Object;)V + public static final fun showWhen (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;[Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)V + public static synthetic fun showWhen$default (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;[Ljava/lang/String;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)V +} + public class org/polyfrost/oneconfig/api/config/v1/KtConfig : org/polyfrost/oneconfig/api/config/v1/Config { public static final field $stable I public fun (Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/config/v1/Config$Category;Ljava/lang/String;)V @@ -225,11 +242,156 @@ protected final class org/polyfrost/oneconfig/api/config/v1/KtConfig$Provider : public fun provideDelegate (Lorg/polyfrost/oneconfig/api/config/v1/KtConfig;Lkotlin/reflect/KProperty;)Lkotlin/properties/ReadWriteProperty; } +public final class org/polyfrost/oneconfig/api/config/v1/PolyColorJson { + public static final field $stable I + public static final field INSTANCE Lorg/polyfrost/oneconfig/api/config/v1/PolyColorJson; + public static final fun decode (Ljava/lang/String;)Lorg/polyfrost/compose/render/PolyColor; + public static final fun decodeOrNull (Ljava/lang/String;)Lorg/polyfrost/compose/render/PolyColor; + public static final fun encode (Lorg/polyfrost/compose/render/PolyColor;)Ljava/lang/String; +} + +public final class org/polyfrost/oneconfig/api/config/v1/PolyColors { + public static final fun copy (Lorg/polyfrost/compose/render/PolyColor;)Lorg/polyfrost/compose/render/PolyColor; + public static final fun copy (Lorg/polyfrost/compose/render/PolyColor;I)Lorg/polyfrost/compose/render/PolyColor; + public static final fun copy (Lorg/polyfrost/compose/render/PolyColor;IZ)Lorg/polyfrost/compose/render/PolyColor; + public static final fun copy (Lorg/polyfrost/compose/render/PolyColor;IZF)Lorg/polyfrost/compose/render/PolyColor; + public static final fun copy (Lorg/polyfrost/compose/render/PolyColor;IZFZ)Lorg/polyfrost/compose/render/PolyColor; + public static final fun copy (Lorg/polyfrost/compose/render/PolyColor;IZFZI)Lorg/polyfrost/compose/render/PolyColor; + public static synthetic fun copy$default (Lorg/polyfrost/compose/render/PolyColor;IZFZIILjava/lang/Object;)Lorg/polyfrost/compose/render/PolyColor; +} + +public abstract class org/polyfrost/oneconfig/api/config/v1/ShareCodeResult { + public static final field $stable I + public final fun isSuccess ()Z +} + +public final class org/polyfrost/oneconfig/api/config/v1/ShareCodeResult$Corrupt : org/polyfrost/oneconfig/api/config/v1/ShareCodeResult { + public static final field $stable I + public fun (Ljava/lang/Throwable;)V + public final fun component1 ()Ljava/lang/Throwable; + public final fun copy (Ljava/lang/Throwable;)Lorg/polyfrost/oneconfig/api/config/v1/ShareCodeResult$Corrupt; + public static synthetic fun copy$default (Lorg/polyfrost/oneconfig/api/config/v1/ShareCodeResult$Corrupt;Ljava/lang/Throwable;ILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/config/v1/ShareCodeResult$Corrupt; + public fun equals (Ljava/lang/Object;)Z + public final fun getCause ()Ljava/lang/Throwable; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/polyfrost/oneconfig/api/config/v1/ShareCodeResult$Success : org/polyfrost/oneconfig/api/config/v1/ShareCodeResult { + public static final field $stable I + public fun (Ljava/util/List;Ljava/util/List;)V + public final fun component1 ()Ljava/util/List; + public final fun component2 ()Ljava/util/List; + public final fun copy (Ljava/util/List;Ljava/util/List;)Lorg/polyfrost/oneconfig/api/config/v1/ShareCodeResult$Success; + public static synthetic fun copy$default (Lorg/polyfrost/oneconfig/api/config/v1/ShareCodeResult$Success;Ljava/util/List;Ljava/util/List;ILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/config/v1/ShareCodeResult$Success; + public fun equals (Ljava/lang/Object;)Z + public final fun getApplied ()Ljava/util/List; + public final fun getSkipped ()Ljava/util/List; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/polyfrost/oneconfig/api/config/v1/ShareCodeResult$WrongPrefix : org/polyfrost/oneconfig/api/config/v1/ShareCodeResult { + public static final field $stable I + public static final field INSTANCE Lorg/polyfrost/oneconfig/api/config/v1/ShareCodeResult$WrongPrefix; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/polyfrost/oneconfig/api/config/v1/ShareCodes { + public static final fun applyShareCode (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/ShareCodeResult; + public static final fun applyShareCode (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Lorg/polyfrost/oneconfig/api/config/v1/ShareCodeResult; + public static synthetic fun applyShareCode$default (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/config/v1/ShareCodeResult; + public static final fun toShareCode (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;)Ljava/lang/String; + public static final fun toShareCode (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Ljava/lang/String; + public static synthetic fun toShareCode$default (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ljava/lang/String; +} + public abstract interface class org/polyfrost/oneconfig/api/config/v1/SliderBounds { public fun max (F)F public fun min (F)F } +public final class org/polyfrost/oneconfig/api/config/v1/TreeBuilders { + public static final fun button (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function0;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static final fun button (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function0;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static final fun button (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function0;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static final fun button (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function0;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static synthetic fun button$default (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static final fun color (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/compose/render/PolyColor;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static final fun color (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/compose/render/PolyColor;Z)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static final fun color (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/compose/render/PolyColor;ZZ)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static final fun color (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/compose/render/PolyColor;ZZLjava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static final fun color (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/compose/render/PolyColor;ZZLjava/lang/String;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static final fun color (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/compose/render/PolyColor;ZZLjava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static synthetic fun color$default (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/compose/render/PolyColor;ZZLjava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static final fun dropdown (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;ILjava/util/List;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static final fun dropdown (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;ILjava/util/List;Ljava/util/Map;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static final fun dropdown (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;ILjava/util/List;Ljava/util/Map;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static final fun dropdown (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;ILjava/util/List;Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static final fun dropdown (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;ILjava/util/List;Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static final fun dropdown (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Enum;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static final fun dropdown (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Enum;Ljava/util/Map;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static final fun dropdown (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Enum;Ljava/util/Map;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static final fun dropdown (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Enum;Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static final fun dropdown (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Enum;Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static synthetic fun dropdown$default (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;ILjava/util/List;Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static synthetic fun dropdown$default (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Enum;Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static final fun group (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Lorg/polyfrost/oneconfig/api/config/v1/Tree; + public static final fun group (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)Lorg/polyfrost/oneconfig/api/config/v1/Tree; + public static final fun group (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;ZZLkotlin/jvm/functions/Function1;)Lorg/polyfrost/oneconfig/api/config/v1/Tree; + public static final fun group (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;ZZZLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Lorg/polyfrost/oneconfig/api/config/v1/Tree; + public static final fun group (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;ZZZLjava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Lorg/polyfrost/oneconfig/api/config/v1/Tree; + public static final fun group (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;ZZZLjava/lang/String;Lkotlin/jvm/functions/Function1;)Lorg/polyfrost/oneconfig/api/config/v1/Tree; + public static final fun group (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;ZZZLkotlin/jvm/functions/Function1;)Lorg/polyfrost/oneconfig/api/config/v1/Tree; + public static synthetic fun group$default (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;ZZZLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/config/v1/Tree; + public static final fun hidden (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/Object;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static final fun keybind (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static final fun keybind (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/ui/v1/keybind/OneConfigKeybind;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static final fun keybind (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/ui/v1/keybind/OneConfigKeybind;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static final fun keybind (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/ui/v1/keybind/OneConfigKeybind;Ljava/lang/String;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static final fun keybind (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/ui/v1/keybind/OneConfigKeybind;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static synthetic fun keybind$default (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/ui/v1/keybind/OneConfigKeybind;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static final fun slider (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;FFF)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static final fun slider (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;FFFF)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static final fun slider (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;FFFFLjava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static final fun slider (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;FFFFLjava/lang/String;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static final fun slider (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;FFFFLjava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static final fun slider (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;FFFFLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static synthetic fun slider$default (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;FFFFLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static final fun switch (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;Z)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static final fun switch (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;ZLjava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static final fun switch (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;ZLjava/lang/String;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static final fun switch (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;ZLjava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static synthetic fun switch$default (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;ZLjava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static final fun text (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static final fun text (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static final fun text (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static final fun text (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static final fun text (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLjava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static final fun text (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLjava/lang/String;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static final fun text (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLjava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static final fun text (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static synthetic fun text$default (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/config/v1/Property; +} + +public final class org/polyfrost/oneconfig/api/config/v1/TreeObjects { + public static final fun setOptions (Lorg/polyfrost/oneconfig/api/config/v1/Property;Ljava/util/List;)V + public static final fun setOptions (Lorg/polyfrost/oneconfig/api/config/v1/Property;Ljava/util/List;Ljava/util/Map;)V + public static synthetic fun setOptions$default (Lorg/polyfrost/oneconfig/api/config/v1/Property;Ljava/util/List;Ljava/util/Map;ILjava/lang/Object;)V + public static final synthetic fun treeFromObject (Ljava/lang/Object;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/Tree; + public static synthetic fun treeFromObject$default (Ljava/lang/Object;Ljava/lang/String;ILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/config/v1/Tree; +} + +public final class org/polyfrost/oneconfig/api/config/v1/Trees { + public static final field $stable I + public static final field INSTANCE Lorg/polyfrost/oneconfig/api/config/v1/Trees; + public static final fun fromObject (Ljava/lang/Object;)Lorg/polyfrost/oneconfig/api/config/v1/Tree; + public static final fun fromObject (Ljava/lang/Object;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/Tree; + public static synthetic fun fromObject$default (Ljava/lang/Object;Ljava/lang/String;ILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/config/v1/Tree; +} + public abstract interface class org/polyfrost/oneconfig/api/config/v1/ValueFormatter { public abstract fun format (Ljava/lang/Number;)Ljava/lang/String; } @@ -640,6 +802,7 @@ public abstract interface annotation class org/polyfrost/oneconfig/api/config/v1 public abstract fun descriptionKey ()Ljava/lang/String; public abstract fun descriptionTranslation ()Z public abstract fun icon ()Ljava/lang/String; + public abstract fun mirrorToVanilla ()Z public abstract fun subcategory ()Ljava/lang/String; public abstract fun subcategoryKey ()Ljava/lang/String; public abstract fun subcategoryTranslation ()Z @@ -1244,6 +1407,36 @@ public final class org/polyfrost/oneconfig/api/config/v1/dsl/Tree_extensionsKt { public static final fun setVisualizer (Lorg/polyfrost/oneconfig/api/config/v1/Property;Ljava/lang/Class;)V } +public final class org/polyfrost/oneconfig/api/config/v1/migrate/ConfigMigrations { + public static final field $stable I + public static final field INSTANCE Lorg/polyfrost/oneconfig/api/config/v1/migrate/ConfigMigrations; + public static final fun register (Ljava/lang/String;ILkotlin/jvm/functions/Function1;)V +} + +public final class org/polyfrost/oneconfig/api/config/v1/migrate/MigrationScope { + public static final field $stable I + public final fun deleteFile (Ljava/lang/String;)Z + public final fun editJson (Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)Z + public static synthetic fun editJson$default (Lorg/polyfrost/oneconfig/api/config/v1/migrate/MigrationScope;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)Z + public final fun exists (Ljava/lang/String;)Z + public final fun getModId ()Ljava/lang/String; + public final fun getProfileFolder ()Ljava/nio/file/Path; + public final fun getVersion ()I + public final fun listFiles (Ljava/lang/String;Ljava/lang/String;)Ljava/util/List; + public static synthetic fun listFiles$default (Lorg/polyfrost/oneconfig/api/config/v1/migrate/MigrationScope;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Ljava/util/List; + public final fun moveFile (Ljava/lang/String;Ljava/lang/String;Z)Z + public static synthetic fun moveFile$default (Lorg/polyfrost/oneconfig/api/config/v1/migrate/MigrationScope;Ljava/lang/String;Ljava/lang/String;ZILjava/lang/Object;)Z + public final fun moveKey (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Z + public static synthetic fun moveKey$default (Lorg/polyfrost/oneconfig/api/config/v1/migrate/MigrationScope;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Z + public final fun readJson (Ljava/lang/String;)Ljava/util/Map; + public final fun readText (Ljava/lang/String;)Ljava/lang/String; + public final fun removeKeys (Ljava/lang/String;[Ljava/lang/String;)Z + public final fun renameKey (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Z + public final fun resolve (Ljava/lang/String;)Ljava/nio/file/Path; + public final fun writeJson (Ljava/lang/String;Ljava/util/Map;)V + public final fun writeText (Ljava/lang/String;Ljava/lang/String;)V +} + public final class org/polyfrost/oneconfig/api/config/v1/serialize/adapter/impl/OneConfigKeybindAdapter : org/polyfrost/oneconfig/api/config/v1/serialize/adapter/Adapter { public fun ()V public fun (Lorg/polyfrost/oneconfig/api/ui/v1/keybind/internal/KeybindCodec;)V diff --git a/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/Config.java b/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/Config.java index 114919589..f795641a6 100644 --- a/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/Config.java +++ b/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/Config.java @@ -26,7 +26,6 @@ package org.polyfrost.oneconfig.api.config.v1; -import java.lang.ref.WeakReference; import java.lang.reflect.Array; import java.nio.file.Path; import java.util.ArrayList; @@ -241,7 +240,11 @@ private static void captureDefaults(Tree tree, String prefix, Map p = (Property) node; Object value = p.get(); - if (value == null) continue; + if (value == null) { + if (p.getMetadata("default") == null) p.addMetadata("defaultNull", true); + if (out != null) out.put(prefix + entry.getKey(), null); + continue; + } Object def = p.getOrPutMetadata("default", () -> copyDefault(p.type, value)); if (out != null) out.put(prefix + entry.getKey(), def); } else if (node instanceof Tree) { @@ -255,8 +258,12 @@ private static void applyDefaultSnapshot(Tree tree, String prefix, Map p = (Property) node; - Object def = snapshot.get(prefix + entry.getKey()); - if (def == null) continue; + String key = prefix + entry.getKey(); + Object def = snapshot.get(key); + if (def == null) { + if (snapshot.containsKey(key)) p.addMetadata("defaultNull", true); + continue; + } p.addMetadata("default", copyDefault(p.type, def)); } else if (node instanceof Tree) { applyDefaultSnapshot((Tree) node, prefix + entry.getKey() + ".", snapshot); @@ -363,15 +370,14 @@ protected void addDependency(String option, String name, Supplier { Property opt = getProperty(option).addDisplayCondition(condition); if (name != null) opt.getOrPutMetadata("dependencyNames", () -> new ArrayList(3)).add(name); - WeakReference> ref = new WeakReference<>(opt); - tree.onAllProps((s, p) -> { - if (p == opt) return; - p.addCallback(t -> { - Property self = ref.get(); - if (self != null) self.revaluateDisplay(); - return false; - }); - }); + watchAll(tree, opt); + }); + } + + private static void watchAll(Tree tree, Property dependent) { + tree.onAll((id, node) -> { + if (node instanceof Tree) watchAll((Tree) node, dependent); + else if (node instanceof Property && node != dependent) ((Property) node).addDependent(dependent); }); } diff --git a/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/ConfigManager.java b/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/ConfigManager.java index 2ead2a7ef..2b9f24598 100644 --- a/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/ConfigManager.java +++ b/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/ConfigManager.java @@ -61,6 +61,7 @@ import org.polyfrost.oneconfig.api.config.v1.backend.impl.FileBackend; import org.polyfrost.oneconfig.api.config.v1.collect.PropertyCollector; import org.polyfrost.oneconfig.api.config.v1.collect.impl.OneConfigCollector; +import org.polyfrost.oneconfig.api.config.v1.migrate.MigrationRunner; import org.polyfrost.oneconfig.api.config.v1.serialize.ObjectSerializer; import org.polyfrost.oneconfig.api.config.v1.serialize.adapter.impl.OneConfigKeybindAdapter; import org.polyfrost.oneconfig.api.config.v1.serialize.adapter.impl.PolyColorAdapter; @@ -482,6 +483,7 @@ private static void openProfile(String profile, boolean restoreDefaults) { LOGGER.info("opening profile {}", profile); active = new ConfigManager(PROFILES_DIR.resolve(profile), serializers).withWatcher(); } + MigrationRunner.profileOpened(active.getFolder()); boolean wasRebinding = REBINDING_PROFILES.get(); REBINDING_PROFILES.set(Boolean.TRUE); try { @@ -1388,19 +1390,23 @@ public Collection trees() { } public @Nullable Tree load(String id) { + runPendingMigrations(); return backend.load(id); } public Tree get(String id) { + runPendingMigrations(); return backend.get(id); } public boolean exists(String id) { + runPendingMigrations(); return backend.exists(id); } @ApiStatus.Internal public Tree getNoRegister(Path p) throws Exception { + runPendingMigrations(); return backend.load0(p, p.getFileName().toString()); } @@ -1420,7 +1426,23 @@ public Path getFolder() { return backend.folder; } + private void runPendingMigrations() { + if (this == active && MigrationRunner.beforeLoad(getFolder())) reloadLoadedTrees(); + } + + private void reloadLoadedTrees() { + for (Tree tree : new ArrayList<>(backend.getTrees())) { + if (tree.getID() == null) continue; + try { + backend.load(tree); + } catch (Throwable t) { + LOGGER.error("Failed to reload tree {} after config migrations", tree.getID(), t); + } + } + } + public Backend.RegistrationResult register(Tree t) { + runPendingMigrations(); if (this == active && !Boolean.TRUE.equals(t.getMetadata(CompatSnapshots.SNAPSHOT_METADATA))) { Config.captureDefaults(t); } @@ -1458,6 +1480,7 @@ public Tree unregister(String id) { @ApiStatus.Internal public Collection gatherAll(String sub) { + runPendingMigrations(); return backend.gatherAll(sub); } diff --git a/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/annotations/Keybind.java b/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/annotations/Keybind.java index 0bd5bc9c0..125efa54d 100644 --- a/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/annotations/Keybind.java +++ b/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/annotations/Keybind.java @@ -79,4 +79,6 @@ @Deprecated String subcategoryKey() default ""; + + boolean mirrorToVanilla() default true; } diff --git a/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/Dependencies.kt b/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/Dependencies.kt new file mode 100644 index 000000000..e9282cc96 --- /dev/null +++ b/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/Dependencies.kt @@ -0,0 +1,104 @@ +@file:JvmName("Dependencies") + +package org.polyfrost.oneconfig.api.config.v1 + +import java.lang.ref.WeakReference +import java.util.function.Supplier +import org.jetbrains.annotations.ApiStatus +import org.polyfrost.oneconfig.api.event.v1.events.TickEvent +import org.polyfrost.oneconfig.api.event.v1.invoke.EventHandler + +@ApiStatus.Experimental +@JvmOverloads +public fun Property<*>.dependsOn(toggle: Property, expected: Boolean = true, hide: Boolean = false) { + val off = if (hide) Property.Display.HIDDEN else Property.Display.DISABLED + addDisplayCondition(Supplier { if (toggle.get() == !expected) off else Property.Display.SHOWN }) + toggle.addDependent(this) + addDependencyName(toggle) +} + +@ApiStatus.Experimental +public fun Property<*>.hiddenWhen(vararg watch: Property<*>, pollEveryTicks: Int = 0, condition: () -> Boolean) { + displayWhen(Property.Display.HIDDEN, watch, pollEveryTicks, condition) +} + +@ApiStatus.Experimental +public fun Property<*>.disabledWhen(vararg watch: Property<*>, pollEveryTicks: Int = 0, condition: () -> Boolean) { + displayWhen(Property.Display.DISABLED, watch, pollEveryTicks, condition) +} + +@ApiStatus.Experimental +public fun Tree.showWhen(parent: String, vararg children: String, hide: Boolean = true, test: (Any?) -> Boolean = { it == true }) { + val source = requireProperty(parent) + val off = if (hide) Property.Display.HIDDEN else Property.Display.DISABLED + for (child in children) { + forEachProperty(requireNode(child)) { prop -> + prop.addDisplayCondition(Supplier { if (test(source.get())) Property.Display.SHOWN else off }) + source.addDependent(prop) + } + } +} + +@ApiStatus.Experimental +@JvmOverloads +public fun Tree.addDependency(option: String, toggle: String, hide: Boolean = false) { + val source = requireToggle(toggle) + forEachProperty(requireNode(option)) { if (it !== source) it.dependsOn(source, true, hide) } +} + +@ApiStatus.Experimental +public fun Tree.disableAllUnless(toggle: String) { + val source = requireToggle(toggle) + forEachProperty(this) { if (it !== source) it.dependsOn(source) } +} + +private fun Property<*>.displayWhen( + off: Property.Display, + watch: Array>, + pollEveryTicks: Int, + condition: () -> Boolean, +) { + addDisplayCondition(Supplier { if (condition()) off else Property.Display.SHOWN }) + for (source in watch) source.addDependent(this) + if (pollEveryTicks > 0) pollDisplay(pollEveryTicks) +} + +private fun Property<*>.pollDisplay(everyTicks: Int) { + val ref = WeakReference(this) + var ticks = 0 + EventHandler.ofRemoving(TickEvent.End::class.java) { + val self = ref.get() ?: return@ofRemoving true + if (++ticks >= everyTicks) { + ticks = 0 + self.revaluateDisplay() + } + false + }.register() +} + +private fun Property<*>.addDependencyName(toggle: Property<*>) { + val name = toggle.title?.toString() ?: toggle.id ?: return + val names = getOrPutMetadata>("dependencyNames") { ArrayList(3) } + if (name !in names) names.add(name) +} + +private fun Tree.requireNode(id: String): Node = + get(id) ?: (if ('.' in id) get(*id.split('.').toTypedArray()) else null) + ?: throw IllegalArgumentException("Tree ${this.id} does not contain $id") + +private fun Tree.requireProperty(id: String): Property<*> = + requireNode(id) as? Property<*> ?: throw IllegalArgumentException("$id in tree ${this.id} is not a property") + +@Suppress("UNCHECKED_CAST") +private fun Tree.requireToggle(id: String): Property { + val prop = requireProperty(id) + require(prop.type == Boolean::class.javaPrimitiveType) { "$id in tree ${this.id} is not a boolean property" } + return prop as Property +} + +private fun forEachProperty(node: Node, action: (Property<*>) -> Unit) { + when (node) { + is Property<*> -> action(node) + is Tree -> node.onAll { _, child -> forEachProperty(child, action) } + } +} diff --git a/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/KtConfig.kt b/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/KtConfig.kt index 709397988..2e6477999 100644 --- a/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/KtConfig.kt +++ b/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/KtConfig.kt @@ -87,7 +87,9 @@ public open class KtConfig(id: String, title: String, category: Category, icon: category: String? = null, subcategory: String? = null ): Provider = - Provider(def, name, description, category, subcategory, Boolean::class.java, Visualizer.SwitchVisualizer()) + Provider(def, name, description, category, subcategory, Boolean::class.java, Visualizer.SwitchVisualizer(), { t, id -> + t.switch(id, name ?: id, def, description.orEmpty(), category, subcategory) + }, null) /** * The switch that turns the whole mod on and off, shown on the mod's card and never on the config page @@ -114,7 +116,9 @@ public open class KtConfig(id: String, title: String, category: Category, icon: subcategory: String? = "General", subcategoryKey: String? = null, swatch: Boolean = false, - ): Provider = Provider(def, name, description, category, subcategory, PolyColor::class.java, Visualizer.ColorVisualizer()) { + ): Provider = Provider(def, name, description, category, subcategory, PolyColor::class.java, Visualizer.ColorVisualizer(), { t, id -> + t.color(id, name, def, alpha, swatch, description.orEmpty(), category, subcategory) + }) { addMetadata( name, nameKey, @@ -126,8 +130,6 @@ public open class KtConfig(id: String, title: String, category: Category, icon: subcategory, subcategoryKey ) - if (!alpha) this.addMetadata("noAlpha", Unit) - if (swatch) this.addMetadata("swatch", true) } @Deprecated(message = "Binary compatibility for mods compiled before swatch was added", level = DeprecationLevel.HIDDEN) @@ -153,7 +155,9 @@ public open class KtConfig(id: String, title: String, category: Category, icon: category: String? = null, subcategory: String? = null ): Provider = - Provider(def, name, description, category, subcategory, PolyColor::class.java, Visualizer.ColorVisualizer()) + Provider(def, name, description, category, subcategory, PolyColor::class.java, Visualizer.ColorVisualizer(), { t, id -> + t.color(id, name ?: id, def, description = description.orEmpty(), category = category, subcategory = subcategory) + }, null) @JvmSynthetic protected fun slider( @@ -165,10 +169,9 @@ public open class KtConfig(id: String, title: String, category: Category, icon: category: String? = null, subcategory: String? = null ): Provider = - Provider(def, name, description, category, subcategory, Float::class.java, Visualizer.SliderVisualizer()) { - addMetadata("min", min) - addMetadata("max", max) - } + Provider(def, name, description, category, subcategory, Float::class.java, Visualizer.SliderVisualizer(), { t, id -> + t.slider(id, name ?: id, def, min, max, description = description.orEmpty(), category = category, subcategory = subcategory) + }, null) @JvmSynthetic @Deprecated(message = "Use other text method.", level = DeprecationLevel.HIDDEN) @@ -219,7 +222,9 @@ public open class KtConfig(id: String, title: String, category: Category, icon: placeholder: String? = null, placeholderKey: String? = "oneconfig.textinput.placeholder", regex: String? = null - ): Provider = Provider(def, name, description, category, subcategory, String::class.java, Visualizer.TextVisualizer()) { + ): Provider = Provider(def, name, description, category, subcategory, String::class.java, Visualizer.TextVisualizer(), { t, id -> + t.text(id, name, def, placeholder, multiline, regex, description.orEmpty(), category, subcategory) + }) { addMetadata( name, nameKey, @@ -231,10 +236,8 @@ public open class KtConfig(id: String, title: String, category: Category, icon: subcategory, subcategoryKey ) - this.addMetadata("placeholder", placeholder) - this.addMetadata("multiline", multiline) - this.addMetadata("placeholderKey", placeholderKey) - this.addMetadata("regex", regex) + if (placeholderKey.isNullOrEmpty()) this.removeMetadata("placeholderKey") + else this.addMetadata("placeholderKey", placeholderKey) } @JvmSynthetic @@ -293,7 +296,9 @@ public open class KtConfig(id: String, title: String, category: Category, icon: categoryKey: String? = null, subcategory: String? = "General", subcategoryKey: String? = null, - ): Provider = Provider(def, name, description, category, subcategory, Boolean::class.java, Visualizer.SwitchVisualizer()) { + ): Provider = Provider(def, name, description, category, subcategory, Boolean::class.java, Visualizer.SwitchVisualizer(), { t, id -> + t.switch(id, name, def, description.orEmpty(), category, subcategory) + }) { addMetadata( name, nameKey, @@ -389,8 +394,9 @@ public open class KtConfig(id: String, title: String, category: Category, icon: max: Float = 100f, step: Float = 1f, formatter: ValueFormatter? = null, - ): Provider = Provider(def, name, description, category, subcategory, Float::class.java, Visualizer.SliderVisualizer()) { - requireStep(name, min, max, step) + ): Provider = Provider(def, name, description, category, subcategory, Float::class.java, Visualizer.SliderVisualizer(), { t, id -> + t.slider(id, name, def, min, max, step, unit, description.orEmpty(), category, subcategory) + }) { addMetadata( name, nameKey, @@ -402,10 +408,7 @@ public open class KtConfig(id: String, title: String, category: Category, icon: subcategory, subcategoryKey ) - this.addMetadata("unit", unit) this.addMetadata("unitKey", unitKey) - this.addMetadata("min", min) - this.addMetadata("max", max) this.addMetadata("step", step) this.addMetadata("formatter", formatter) } @@ -451,7 +454,8 @@ public open class KtConfig(id: String, title: String, category: Category, icon: category, subcategory, OneConfigKeybind::class.java, - Visualizer.KeybindVisualizer() + Visualizer.KeybindVisualizer(), + { t, id -> t.keybind(id, name, def, description.orEmpty(), category, subcategory) }, ) { addMetadata( name, @@ -488,7 +492,9 @@ public open class KtConfig(id: String, title: String, category: Category, icon: category: String? = null, subcategory: String? = null ): Provider = - Provider(def, name, description, category, subcategory, Int::class.java, Visualizer.DropdownVisualizer()) { + Provider(def, name, description, category, subcategory, Int::class.java, Visualizer.DropdownVisualizer(), { t, id -> + t.dropdown(id, name ?: id, def, options.toList(), description = description.orEmpty(), category = category, subcategory = subcategory) + }) { addMetadata("options", options) } @@ -516,7 +522,10 @@ public open class KtConfig(id: String, title: String, category: Category, icon: category, subcategory, Int::class.java, - Visualizer.DropdownVisualizer() + Visualizer.DropdownVisualizer(), + { t, id -> + t.dropdown(id, name, selectedIndex, options.map { stringTransformer(it) }, description = description.orEmpty(), category = category, subcategory = subcategory) + }, ) { addMetadata( name, @@ -529,7 +538,6 @@ public open class KtConfig(id: String, title: String, category: Category, icon: subcategory, subcategoryKey ) - addMetadata("options", options.map { stringTransformer(it) }) addMetadata("optionsKey", optionKeys) } return CachedTransformedProvider(property, options::get, options::indexOf) @@ -597,12 +605,10 @@ public open class KtConfig(id: String, title: String, category: Category, icon: text: String? = null, textKey: String? = null, action: () -> Unit, - ): DummyProvider = DummyProvider(name, description, category, subcategory, Visualizer.ButtonVisualizer()) { + ): DummyProvider = DummyProvider(name, description, category, subcategory, Visualizer.ButtonVisualizer(), { t, id -> + t.button(id, name, text.orEmpty(), description.orEmpty(), category, subcategory, action) + }) { addMetadata(name, nameKey, description, descriptionKey, icon, category, categoryKey, subcategory, subcategoryKey) - this.addMetadata("runnable", Runnable { - runCatching(action).onFailure { ConfigManager.LOGGER.error("Failed to run button {}", name, it) } - }) - this.addMetadata("text", text) this.addMetadata("textKey", textKey) } @@ -1132,16 +1138,33 @@ public open class KtConfig(id: String, title: String, category: Category, icon: private val visualizer: Visualizer, private val extra: (Property.() -> Unit)? = null ) : PropertyDelegateProvider> { + private var build: ((Tree, String) -> Property)? = null + + internal constructor( + def: T?, + name: String?, + description: String?, + category: String?, + subcategory: String?, + type: Class, + visualizer: Visualizer, + build: (Tree, String) -> Property, + extra: (Property.() -> Unit)?, + ) : this(def, name, description, category, subcategory, type, visualizer, extra) { + this.build = build + } + override operator fun provideDelegate( thisRef: KtConfig, property: KProperty<*> ): ReadWriteProperty { - val p = Properties.simple(property.name, name ?: property.name, description, def, type) + val tree = thisRef.tree ?: thisRef.pendingTree() + val p = build?.invoke(tree, property.name) ?: Properties.simple(property.name, name ?: property.name, description, def, type) extra?.invoke(p) p.addMetadata("visualizer", visualizer) p.addMetadata("category", category) p.addMetadata("subcategory", subcategory) - (thisRef.tree ?: thisRef.pendingTree()).put(p) + tree.put(p) return PropertyDelegate(p) } } @@ -1155,13 +1178,28 @@ public open class KtConfig(id: String, title: String, category: Category, icon: private val visualizer: Visualizer, private val extra: Property.() -> Unit ) : PropertyDelegateProvider> { + private var build: ((Tree, String) -> Property)? = null + + internal constructor( + name: String?, + description: String?, + category: String?, + subcategory: String?, + visualizer: Visualizer, + build: (Tree, String) -> Property, + extra: Property.() -> Unit, + ) : this(name, description, category, subcategory, visualizer, extra) { + this.build = build + } + override operator fun provideDelegate(thisRef: KtConfig, property: KProperty<*>): ReadOnlyProperty { - val p = Properties.dummy(property.name, name, description) + val tree = thisRef.tree ?: thisRef.pendingTree() + val p = build?.invoke(tree, property.name) ?: Properties.dummy(property.name, name, description) p.extra() p.addMetadata("visualizer", visualizer) p.addMetadata("category", category) p.addMetadata("subcategory", subcategory) - (thisRef.tree ?: thisRef.pendingTree()).put(p) + tree.put(p) return ReadOnlyProperty { _, _ -> } } } diff --git a/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/PolyColors.kt b/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/PolyColors.kt new file mode 100644 index 000000000..3cca87d63 --- /dev/null +++ b/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/PolyColors.kt @@ -0,0 +1,59 @@ +@file:JvmName("PolyColors") + +package org.polyfrost.oneconfig.api.config.v1 + +import com.electronwill.nightconfig.json.JsonFormat +import com.electronwill.nightconfig.json.JsonParser +import java.io.StringWriter +import org.jetbrains.annotations.ApiStatus +import org.polyfrost.compose.render.PolyColor +import org.polyfrost.oneconfig.api.config.v1.serialize.adapter.impl.PolyColorAdapter + +@ApiStatus.Experimental +@JvmOverloads +public fun PolyColor.copy( + argb: Int = rawArgb, + chroma: Boolean = this.chroma, + chromaSpeed: Float = this.chromaSpeed, + gradientEnabled: Boolean = this.gradientEnabled, + gradientArgb: Int = this.gradientArgb, +): PolyColor = PolyColor(argb, chroma, chromaSpeed).also { + it.gradientEnabled = gradientEnabled + it.gradientArgb = gradientArgb +} + +@ApiStatus.Experimental +public object PolyColorJson { + private val adapter = PolyColorAdapter() + + @JvmStatic + public fun encode(color: PolyColor): String { + val out = StringWriter() + JsonFormat.minimalInstance().createWriter().writeValue(toJsonValue(adapter.serialize(color)), out) + return out.toString() + } + + @JvmStatic + public fun decode(json: String): PolyColor { + val text = json.trim() + val value: Any? = try { + when { + text.startsWith('{') || text.startsWith('[') -> toPlain(JsonParser().parseDocument(text)) + text.length >= 2 && text.startsWith('"') && text.endsWith('"') -> text.substring(1, text.length - 1) + else -> text.toLongOrNull() ?: text + } + } catch (e: Exception) { + throw IllegalArgumentException("Not a PolyColor: $json", e) + } + return try { + adapter.deserialize(value) + } catch (e: IllegalArgumentException) { + throw e + } catch (e: Exception) { + throw IllegalArgumentException("Not a PolyColor: $json", e) + } + } + + @JvmStatic + public fun decodeOrNull(json: String): PolyColor? = runCatching { decode(json) }.getOrNull() +} diff --git a/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/ShareCodes.kt b/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/ShareCodes.kt new file mode 100644 index 000000000..d8bbbcb34 --- /dev/null +++ b/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/ShareCodes.kt @@ -0,0 +1,295 @@ +@file:JvmName("ShareCodes") + +package org.polyfrost.oneconfig.api.config.v1 + +import com.electronwill.nightconfig.core.Config +import com.electronwill.nightconfig.core.NullObject +import com.electronwill.nightconfig.core.UnmodifiableConfig +import com.electronwill.nightconfig.json.JsonFormat +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.InputStream +import java.util.Base64 +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream +import org.jetbrains.annotations.ApiStatus +import org.polyfrost.oneconfig.api.config.v1.serialize.ObjectSerializer +import org.polyfrost.oneconfig.api.config.v1.serialize.adapter.Adapter +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.OptionWidgets +import org.polyfrost.oneconfig.api.ui.v1.keybind.KeybindManager +import org.polyfrost.oneconfig.api.ui.v1.keybind.OneConfigKeybind +import org.polyfrost.oneconfig.utils.v1.WrappingUtils +import java.lang.reflect.Array as JArray + +@ApiStatus.Experimental +public sealed class ShareCodeResult { + public val isSuccess: Boolean get() = this is Success + + public data class Success(public val applied: List, public val skipped: List) : ShareCodeResult() + + public data object WrongPrefix : ShareCodeResult() + + public data class Corrupt(public val cause: Throwable) : ShareCodeResult() +} + +@ApiStatus.Experimental +@JvmOverloads +public fun Tree.toShareCode(prefix: String, include: (Property<*>) -> Boolean = { true }): String { + val json = encodeTree(this, include) ?: Config.inMemoryUniversal() + val text = JsonFormat.minimalInstance().createWriter().writeToString(json) + val bytes = ByteArrayOutputStream() + GZIPOutputStream(bytes).use { it.write(text.toByteArray(Charsets.UTF_8)) } + return prefix + Base64.getEncoder().encodeToString(bytes.toByteArray()) +} + +@ApiStatus.Experimental +@JvmOverloads +public fun Tree.applyShareCode(code: String, prefix: String, include: (Property<*>) -> Boolean = { true }): ShareCodeResult { + val trimmed = code.trim() + if (!trimmed.startsWith(prefix)) return ShareCodeResult.WrongPrefix + val data = try { + readPayload(trimmed.substring(prefix.length)) + } catch (e: Exception) { + return ShareCodeResult.Corrupt(e) + } + val applied = ArrayList() + val skipped = ArrayList() + applyTree(this, data, "", include, applied, skipped) + return ShareCodeResult.Success(applied, skipped) +} + +private const val MAX_PAYLOAD_BYTES = 4 shl 20 +private const val MAX_CODE_CHARS = 8 shl 20 + +private fun encodeTree(tree: Tree, include: (Property<*>) -> Boolean): Config? { + val out = Config.inMemoryUniversal() + tree.onAll { key, node -> + val value = when (node) { + is Tree -> encodeTree(node, include) + is Property<*> -> if (isButton(node) || !include(node)) null else exportValue(node) + else -> null + } + if (value != null) out.add(listOf(key), value) + } + return if (out.isEmpty) null else out +} + +private fun isButton(prop: Property<*>): Boolean = prop.type == Void.TYPE || prop.type == Void::class.java + +private fun exportValue(prop: Property<*>): Any? { + val value = prop.get() ?: return if (prop.type.isPrimitive) null else NullObject.NULL_OBJECT + return encodeValue(prop, value) +} + +private fun encodeValue(prop: Property<*>, value: Any): Any? = runCatching { + when { + value is Float && !value.isFinite() -> null + value is Double && !value.isFinite() -> null + value is Enum<*> -> value.name + value is Char -> value.toString() + WrappingUtils.isSimpleObject(value) -> value + else -> { + val adapter = adapterFor(value.javaClass) + if (adapter != null && value.javaClass == WrappingUtils.getWrapped(prop.type)) { + toJsonValue(adapter.serialize(value)) + } else { + toJsonValue(ObjectSerializer.INSTANCE.serialize(value, true)) + } + } + } +}.getOrNull() + +internal fun toJsonValue(value: Any?): Any? = when (value) { + null -> null + is Map<*, *> -> Config.inMemoryUniversal().also { config -> + for ((key, item) in value) config.add(listOf(key.toString()), toJsonValue(item) ?: NullObject.NULL_OBJECT) + } + is Collection<*> -> value.map(::toJsonValue) + is Enum<*> -> value.name + is Char -> value.toString() + is Number, is Boolean, is CharSequence -> value + else -> if (value.javaClass.isArray) List(JArray.getLength(value)) { toJsonValue(JArray.get(value, it)) } else null +} + +internal fun toPlain(raw: Any?): Any? = when { + raw == null || raw === NullObject.NULL_OBJECT -> null + raw is UnmodifiableConfig -> LinkedHashMap().also { map -> + for (entry in raw.entrySet()) map[entry.key] = toPlain(entry.getRawValue()) + } + raw is List<*> -> raw.map(::toPlain) + else -> raw +} + +private fun readPayload(body: String): UnmodifiableConfig { + if (body.length > MAX_CODE_CHARS) throw IOException("share code is longer than $MAX_CODE_CHARS characters") + val bytes = Base64.getDecoder().decode(body.filterNot(Char::isWhitespace)) + val text = GZIPInputStream(ByteArrayInputStream(bytes)).use { readCapped(it) }.toString(Charsets.UTF_8) + return JsonFormat.minimalInstance().createParser().parse(text) +} + +private fun readCapped(input: InputStream): ByteArray { + val out = ByteArrayOutputStream() + val buffer = ByteArray(8192) + while (true) { + val read = input.read(buffer) + if (read < 0) break + out.write(buffer, 0, read) + if (out.size() > MAX_PAYLOAD_BYTES) throw IOException("share code payload is larger than $MAX_PAYLOAD_BYTES bytes") + } + return out.toByteArray() +} + +@OptIn(ExperimentalOneConfigUiApi::class) +private fun applyTree( + target: Tree, + data: UnmodifiableConfig, + path: String, + include: (Property<*>) -> Boolean, + applied: MutableList, + skipped: MutableList, +) { + for (entry in data.entrySet()) { + val key = entry.key + val raw = entry.getRawValue() + val full = if (path.isEmpty()) key else "$path.$key" + when (val node = target.get(key)) { + is Tree -> if (raw is UnmodifiableConfig) applyTree(node, raw, full, include, applied, skipped) else skipped.add(full) + is Property<*> -> if (!isButton(node) && include(node) && applyValue(node, raw)) { + applied.add(full) + OptionWidgets.refresh(node) + } else skipped.add(full) + else -> skipped.add(full) + } + } +} + +@Suppress("UNCHECKED_CAST") +private fun applyValue(prop: Property<*>, raw: Any?): Boolean = try { + val current = prop.get() + val plain = toPlain(raw) + val value = decodeAs(WrappingUtils.getWrapped(prop.type), plain, current) + when { + plain == null -> applyNull(prop as Property, current) + value == null -> false + value is OneConfigKeybind && current is OneConfigKeybind -> applyKeybind(prop as Property, current, value) + else -> !(prop as Property).set(value) + } +} catch (e: Exception) { + false +} + +private fun applyNull(prop: Property, current: Any?): Boolean { + if (prop.type.isPrimitive) return false + if (prop.set(null)) return false + if (current is OneConfigKeybind) KeybindManager.unregister(current) + return true +} + +private fun applyKeybind(prop: Property, current: OneConfigKeybind, imported: OneConfigKeybind): Boolean { + val next = current.copyWith(imported.keyCodes, imported.mouseBtns, imported.mods) + next.durationNanos = imported.durationNanos + if (prop.set(next)) return false + val applied = prop.get() as? OneConfigKeybind ?: next + applied.mergeOverwrite(imported) + KeybindManager.replace(current, applied) + return true +} + +private fun decodeAs(target: Class<*>, plain: Any?, current: Any?): Any? { + if (plain == null) return null + if (plain is Map<*, *> && Map::class.java.isAssignableFrom(target)) return decodeMap(target, plain, current) + classTagged(plain)?.let { return deserializeTagged(target, it, taggedClass(it)) } + if (Enum::class.java.isAssignableFrom(target)) return enumConstant(target, plain) + if (plain is List<*> && (target.isArray || Collection::class.java.isAssignableFrom(target))) { + val element = target.componentType?.let(WrappingUtils::getWrapped) ?: elementClass(current) + require(element != Any::class.java || plain.none { it is Number } || hasElements(current)) { + "cannot tell the number type of an empty ${target.name}" + } + val items = plain.map { decodeElement(element, it) } + return when { + target.isArray -> WrappingUtils.richCast(items, target) + target.isInstance(ArrayList()) -> ArrayList(items) + target.isInstance(LinkedHashSet()) -> LinkedHashSet(items) + else -> throw IllegalArgumentException("cannot build a ${target.name} from a share code") + } + } + if (target.isInstance(plain)) return plain + asChar(target, plain)?.let { return it } + adapterFor(target)?.let { return it.deserialize(WrappingUtils.richCast(plain, it.outputClass)) } + return WrappingUtils.richCast(plain, target).takeIf { target.isInstance(it) } + ?: throw IllegalArgumentException("cannot read ${plain.javaClass.name} as ${target.name}") +} + +private fun decodeMap(target: Class<*>, plain: Map<*, *>, current: Any?): Map { + val existing = current as? Map<*, *> + val keyType = elementClass(existing?.keys) + require(keyType == String::class.java || keyType == Any::class.java) { "cannot read the keys of a ${target.name} from a share code" } + val valueType = elementClass(existing?.values) + val out = LinkedHashMap() + for ((key, item) in plain) out[key] = decodeElement(valueType, item) + require(target.isInstance(out)) { "cannot build a ${target.name} from a share code" } + return out +} + +private fun decodeElement(element: Class<*>, item: Any?): Any? { + if (item == null) return null + if (item is Map<*, *> && Map::class.java.isAssignableFrom(element)) return decodeMap(element, item, null) + classTagged(item)?.let { tagged -> + val cls = if (element == Any::class.java) runCatching { taggedClass(tagged) }.getOrNull() else taggedClass(tagged) + if (cls != null) return deserializeTagged(element, tagged, cls) + } + if (Enum::class.java.isAssignableFrom(element)) return enumConstant(element, item) + if (element.isInstance(item) || element == Any::class.java) return item + asChar(element, item)?.let { return it } + adapterFor(element)?.let { return it.deserialize(WrappingUtils.richCast(item, it.outputClass)) } + return WrappingUtils.richCast(item, element) +} + +@Suppress("UNCHECKED_CAST") +private fun adapterFor(type: Class<*>): Adapter? = + ObjectSerializer.INSTANCE.getAdapter(type as Class) as Adapter? + +@Suppress("UNCHECKED_CAST") +private fun classTagged(plain: Any): Map? = + (plain as? Map)?.takeIf { it["class"] is String } + +private fun taggedClass(map: Map): Class<*> = + Class.forName(map["class"] as String, false, ObjectSerializer::class.java.classLoader) + +private fun deserializeTagged(target: Class<*>, map: Map, cls: Class<*>): Any { + val allowed = if (target == Any::class.java) cls.isEnum || adapterFor(cls)?.targetClass == cls else target.isAssignableFrom(cls) + require(allowed) { "${cls.name} is not a ${target.name}" } + return ObjectSerializer.INSTANCE.deserialize(map) +} + +private fun enumConstant(type: Class<*>, value: Any): Any { + val constants = (if (type.isEnum) type else type.superclass).enumConstants + ?: throw IllegalArgumentException("${type.name} has no constants") + return when (value) { + is Number -> constants.getOrNull(value.toInt()) + is String -> constants.firstOrNull { (it as Enum<*>).name == value } + ?: constants.firstOrNull { (it as Enum<*>).name.equals(value, ignoreCase = true) } + else -> null + } ?: throw IllegalArgumentException("$value is not a constant of ${type.name}") +} + +private fun asChar(target: Class<*>, value: Any): Char? = + if (WrappingUtils.getWrapped(target) == Char::class.javaObjectType && value is String && value.length == 1) value[0] else null + +private fun hasElements(current: Any?): Boolean = when (current) { + is Iterable<*> -> current.any { it != null } + is Array<*> -> current.any { it != null } + else -> false +} + +private fun elementClass(current: Any?): Class<*> { + val items: Iterable<*> = when (current) { + is Iterable<*> -> current + is Array<*> -> current.asIterable() + else -> return Any::class.java + } + val classes = items.mapNotNull { it?.javaClass }.distinct() + return classes.singleOrNull() ?: Any::class.java +} diff --git a/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/TreeBuilders.kt b/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/TreeBuilders.kt new file mode 100644 index 000000000..7dee3a803 --- /dev/null +++ b/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/TreeBuilders.kt @@ -0,0 +1,221 @@ +@file:JvmName("TreeBuilders") + +package org.polyfrost.oneconfig.api.config.v1 + +import java.util.function.Supplier +import org.apache.logging.log4j.LogManager +import org.jetbrains.annotations.ApiStatus +import org.polyfrost.compose.render.PolyColor +import org.polyfrost.oneconfig.api.ui.v1.keybind.OneConfigKeybind + +private val LOGGER = LogManager.getLogger("OneConfig/Config") + +private const val TEXT_PLACEHOLDER_KEY = "oneconfig.textinput.placeholder" + +private const val GROUP_TOGGLE_ID = "enabled" + +@ApiStatus.Experimental +@JvmOverloads +public fun Tree.switch( + id: String, + title: String, + default: Boolean, + description: String = "", + category: String? = null, + subcategory: String? = null, +): Property = + option(Properties.simple(id, title, description, default, Boolean::class.java), Visualizer.SwitchVisualizer::class.java, category, subcategory) + +@ApiStatus.Experimental +@JvmOverloads +public fun Tree.slider( + id: String, + title: String, + default: Float, + min: Float, + max: Float, + step: Float = 0f, + unit: String? = null, + description: String = "", + category: String? = null, + subcategory: String? = null, +): Property { + require(step <= 0f || step <= max - min) { "slider '$id' has step ($step) larger than its range ($min to $max)" } + return option(Properties.simple(id, title, description, default, Float::class.java), Visualizer.SliderVisualizer::class.java, category, subcategory) { + this[MetaKeys.MIN] = min + this[MetaKeys.MAX] = max + if (step != 0f) this[MetaKeys.STEP] = step + this[MetaKeys.UNIT] = unit + } +} + +@ApiStatus.Experimental +@JvmOverloads +public fun > Tree.dropdown( + id: String, + title: String, + default: E, + labels: Map = emptyMap(), + description: String = "", + category: String? = null, + subcategory: String? = null, +): Property = + option(Properties.simple(id, title, description, default, default.declaringJavaClass), Visualizer.DropdownVisualizer::class.java, category, subcategory) { + if (labels.isNotEmpty()) this[MetaKeys.LABELS] = default.declaringJavaClass.enumConstants.map { labels[it] ?: it.name } + } + +@ApiStatus.Experimental +@JvmOverloads +public fun Tree.dropdown( + id: String, + title: String, + default: Int, + options: List, + labels: Map = emptyMap(), + description: String = "", + category: String? = null, + subcategory: String? = null, +): Property = + option(Properties.simple(id, title, description, default, Int::class.java), Visualizer.DropdownVisualizer::class.java, category, subcategory) { + this[MetaKeys.OPTIONS] = options.toList() + if (labels.isNotEmpty()) this[MetaKeys.LABELS] = options.map { labels[it] ?: it } + } + +@ApiStatus.Experimental +@JvmOverloads +public fun Tree.color( + id: String, + title: String, + default: PolyColor, + alpha: Boolean = true, + swatch: Boolean = false, + description: String = "", + category: String? = null, + subcategory: String? = null, +): Property = + option(Properties.simple(id, title, description, default, PolyColor::class.java), Visualizer.ColorVisualizer::class.java, category, subcategory) { + if (!alpha) addMetadata("noAlpha", Unit) + if (swatch) addMetadata("swatch", true) + } + +@ApiStatus.Experimental +@JvmOverloads +public fun Tree.button( + id: String, + title: String, + text: String, + description: String = "", + category: String? = null, + subcategory: String? = null, + action: () -> Unit, +): Property = + option(Properties.dummy(id, title, description), Visualizer.ButtonVisualizer::class.java, category, subcategory) { + addMetadata("text", text) + addMetadata("runnable", Runnable { + runCatching(action).onFailure { LOGGER.error("Failed to run button {}", id, it) } + }) + } + +@ApiStatus.Experimental +@JvmOverloads +public fun Tree.text( + id: String, + title: String, + default: String = "", + placeholder: String? = null, + multiline: Boolean = false, + regex: String? = null, + description: String = "", + category: String? = null, + subcategory: String? = null, +): Property = + option(Properties.simple(id, title, description, default, String::class.java), Visualizer.TextVisualizer::class.java, category, subcategory) { + addMetadata("placeholder", placeholder) + if (placeholder.isNullOrEmpty()) addMetadata("placeholderKey", TEXT_PLACEHOLDER_KEY) + addMetadata("multiline", multiline) + addMetadata("regex", regex) + } + +@ApiStatus.Experimental +@JvmOverloads +public fun Tree.keybind( + id: String, + title: String, + default: OneConfigKeybind? = null, + description: String = "", + category: String? = null, + subcategory: String? = null, +): Property = + option(Properties.simple(id, title, description, default, OneConfigKeybind::class.java), Visualizer.KeybindVisualizer::class.java, category, subcategory) + +@ApiStatus.Experimental +public fun Tree.hidden(id: String, default: T): Property { + val cls = default.javaClass + @Suppress("UNCHECKED_CAST") + val type = (if (!cls.isEnum && cls.superclass?.isEnum == true) cls.superclass else cls) as Class + return option(Properties.simple(id, null, null, default, type), null, null, null) { + this[MetaKeys.HIDDEN] = true + } +} + +@ApiStatus.Experimental +@JvmOverloads +public fun Tree.group( + id: String, + title: String, + collapsed: Boolean = false, + enableToggle: Boolean = false, + enabledByDefault: Boolean = true, + description: String = "", + category: String? = null, + subcategory: String? = null, + block: Tree.() -> Unit, +): Tree { + val group = Tree(id, title, description, null) + group[MetaKeys.CATEGORY] = category + group[MetaKeys.SUBCATEGORY] = subcategory + if (collapsed) group[MetaKeys.COLLAPSED] = true + val toggle = if (enableToggle) { + Properties.simple(GROUP_TOGGLE_ID, title, null, enabledByDefault, Boolean::class.java).also { + group.put(it) + group.addMetadata(ENABLE_TOGGLE_KEY, it) + } + } else null + group.block() + group.onAll { childId, node -> + check(node !is Tree) { "Group $id contains the nested group $childId, but groups cannot be nested; put the options in the outer group or use a submenu" } + } + if (toggle != null) { + check(group.get(GROUP_TOGGLE_ID) === toggle) { "Group $id uses an enable toggle, so its options cannot use the id $GROUP_TOGGLE_ID" } + group.disableUnless(toggle) + } + put(group) + return group +} + +private fun Tree.disableUnless(toggle: Property) { + onAll { _, node -> + when (node) { + is Tree -> node.disableUnless(toggle) + is Property<*> -> if (node !== toggle) { + node.addDisplayCondition(Supplier { if (toggle.get() == false) Property.Display.DISABLED else Property.Display.SHOWN }) + toggle.addDependent(node) + } + } + } +} + +private inline fun

> Tree.option( + property: P, + visualizer: Class?, + category: String?, + subcategory: String?, + extra: P.() -> Unit = {}, +): P { + if (visualizer != null) property[MetaKeys.VISUALIZER] = visualizer + property[MetaKeys.CATEGORY] = category + property[MetaKeys.SUBCATEGORY] = subcategory + property.extra() + put(property) + return property +} diff --git a/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/TreeObjects.kt b/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/TreeObjects.kt new file mode 100644 index 000000000..ccf4cb7af --- /dev/null +++ b/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/TreeObjects.kt @@ -0,0 +1,58 @@ +@file:JvmName("TreeObjects") + +package org.polyfrost.oneconfig.api.config.v1 + +import org.jetbrains.annotations.ApiStatus +import org.polyfrost.oneconfig.api.config.v1.collect.impl.OneConfigCollector +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.OptionWidgets + +@ApiStatus.Experimental +@JvmSynthetic +public fun treeFromObject(obj: Any, id: String? = null): Tree { + val tree = Tree(id ?: (obj as? Config)?.id, null, null, null) + OneConfigCollector().handle(tree, obj, 0) + tree.wireConditions(ArrayDeque()) + return tree +} + +@ApiStatus.Experimental +public object Trees { + @JvmStatic + @JvmOverloads + public fun fromObject(obj: Any, id: String? = null): Tree = treeFromObject(obj, id) +} + +@ApiStatus.Experimental +@JvmOverloads +@OptIn(ExperimentalOneConfigUiApi::class) +public fun Property<*>.setOptions(options: List, labels: Map = emptyMap()) { + if (getMetadata(MetaKeys.OPTIONS.name) is Array<*>) addMetadata(MetaKeys.OPTIONS.name, options.toTypedArray()) + else this[MetaKeys.OPTIONS] = options.toList() + removeMetadata("optionsKey") + if (type == String::class.java) addMetadata("optionValues", options.toList()) + this[MetaKeys.LABELS] = if (labels.isEmpty()) null else options.map { labels[it] ?: it } + OptionWidgets.refresh(this) +} + +private fun Tree.wireConditions(enclosing: ArrayDeque) { + enclosing.addFirst(this) + onAll { _, node -> + when (node) { + is Tree -> node.wireConditions(enclosing) + is Property<*> -> { + val conditions = node.consumeMetadata>("conditions") ?: return@onAll + for (cond in conditions) { + val condition = enclosing.firstNotNullOfOrNull { scope -> + scope.getProp(cond) ?: if (cond.indexOf('.') >= 0) scope.getProp(*cond.split('.').toTypedArray()) else null + } ?: throw IllegalArgumentException("Property ${node.id} is dependant on property $cond, but that property does not exist") + require(condition.type == Boolean::class.javaPrimitiveType) { + "Property ${node.id} is dependant on property $cond, but it is not a boolean property" + } + node.addDisplayCondition(Property.recast(condition), false) + } + } + } + } + enclosing.removeFirst() +} diff --git a/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/migrate/ConfigMigrations.kt b/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/migrate/ConfigMigrations.kt new file mode 100644 index 000000000..0619b6f10 --- /dev/null +++ b/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/migrate/ConfigMigrations.kt @@ -0,0 +1,179 @@ +package org.polyfrost.oneconfig.api.config.v1.migrate + +import org.apache.logging.log4j.LogManager +import org.apache.logging.log4j.Logger +import org.jetbrains.annotations.ApiStatus +import java.nio.file.Files +import java.nio.file.Path +import java.util.TreeMap + +@ApiStatus.Experimental +public object ConfigMigrations { + internal const val STORE_FILE: String = "oneconfig-migrations.json" + private const val RETRY_DELAY_MS = 30_000L + + private class Store(val versions: MutableMap, val invalid: Map) + + private class Gate(val generation: Int, val until: Long) + + private val LOGGER: Logger = LogManager.getLogger("OneConfig/Migrations") + private val lock = Any() + private val steps = LinkedHashMap Unit>>() + private val checked = HashMap() + private val retryAt = HashMap>() + private val gates = HashMap() + private val warnedInvalid = HashSet() + private var running = false + + @Volatile + private var generation = 0 + + @JvmStatic + public fun register(modId: String, version: Int, migrate: MigrationScope.() -> Unit) { + require(modId.isNotBlank()) { "Migration mod id cannot be blank" } + require(version > 0) { "Migration versions start at 1, got $version for $modId" } + synchronized(lock) { + val forMod = steps.getOrPut(modId) { TreeMap() } + require(version !in forMod) { "Migration $version of $modId is already registered" } + forMod[version] = migrate + generation++ + } + } + + internal fun runPending(folder: Path, reload: Boolean): Boolean { + if (generation == 0) return false + val root = folder.toAbsolutePath().normalize() + synchronized(lock) { + if (running) return false + if (reload) { + retryAt.remove(root) + gates.remove(root) + } else { + if (checked[root] == generation) return false + val gate = gates[root] + if (gate != null && gate.generation == generation && gate.until > System.currentTimeMillis()) return false + } + running = true + try { + return runLocked(root) + } finally { + running = false + } + } + } + + private fun runLocked(root: Path): Boolean { + if (steps.isEmpty()) { + checked[root] = generation + return false + } + val now = System.currentTimeMillis() + val store = readStore(root) + if (store == null) { + gates[root] = Gate(generation, now + RETRY_DELAY_MS) + return false + } + val pending = steps.entries.filter { (modId, forMod) -> + if (forMod.higherKey(store.versions[modId] ?: 0) == null) return@filter false + if (modId in store.invalid) { + if (warnedInvalid.add("$root|$modId")) { + LOGGER.error("The stored config version of {} in {} is not a whole number; skipping its migrations", modId, root.resolve(STORE_FILE)) + } + return@filter false + } + true + } + if (pending.isEmpty()) { + checked[root] = generation + gates.remove(root) + return false + } + val modRetry = retryAt.getOrPut(root) { HashMap() } + val due = pending.filter { (modRetry[it.key] ?: 0L) <= now } + if (due.isEmpty()) { + gates[root] = Gate(generation, modRetry.values.min()) + return false + } + if (!writeStore(root, store)) { + LOGGER.error("Skipping config migrations in {} because their versions cannot be saved", root) + gates[root] = Gate(generation, now + RETRY_DELAY_MS) + return false + } + var changed = false + for ((modId, forMod) in due) { + val stored = store.versions[modId] ?: 0 + var modFailed = false + for ((version, step) in forMod.tailMap(stored, false).entries.toList()) { + try { + MigrationScope(modId, version, root).step() + } catch (t: Throwable) { + LOGGER.error("Migration {} of {} failed in {}; it stays at version {}", version, modId, root, store.versions[modId] ?: 0, t) + modFailed = true + break + } + changed = true + store.versions[modId] = version + if (!writeStore(root, store)) { + LOGGER.error("Stopping config migrations in {} because version {} of {} could not be saved", root, version, modId) + gates[root] = Gate(generation, System.currentTimeMillis() + RETRY_DELAY_MS) + return changed + } + LOGGER.info("Migrated {} to config version {} in {}", modId, version, root) + } + if (modFailed) modRetry[modId] = System.currentTimeMillis() + RETRY_DELAY_MS else modRetry.remove(modId) + } + if (modRetry.isEmpty()) { + checked[root] = generation + retryAt.remove(root) + gates.remove(root) + } else { + gates[root] = Gate(generation, modRetry.values.min()) + } + return changed + } + + private fun readStore(root: Path): Store? { + val file = root.resolve(STORE_FILE) + if (!Files.isRegularFile(file)) return Store(LinkedHashMap(), emptyMap()) + return try { + val versions = LinkedHashMap() + val invalid = LinkedHashMap() + for ((key, value) in MigrationJson.parse(String(Files.readAllBytes(file), Charsets.UTF_8))) { + val version = wholeVersion(value) + if (version == null) invalid[key] = value else versions[key] = version + } + Store(versions, invalid) + } catch (t: Throwable) { + LOGGER.error("Could not read config versions from {}; skipping migrations for this profile", file, t) + null + } + } + + private fun wholeVersion(value: Any?): Int? { + if (value !is Number) return null + val number = value.toDouble() + if (number < 0.0 || number > Int.MAX_VALUE || number != Math.floor(number)) return null + return number.toInt() + } + + private fun writeStore(root: Path, store: Store): Boolean { + return try { + val out = LinkedHashMap(store.invalid) + out.putAll(store.versions) + MigrationFiles.write(root.resolve(STORE_FILE), MigrationJson.write(out)) + true + } catch (t: Throwable) { + LOGGER.error("Could not save config versions to {}", root.resolve(STORE_FILE), t) + false + } + } +} + +@ApiStatus.Internal +public object MigrationRunner { + @JvmStatic + public fun profileOpened(folder: Path): Boolean = ConfigMigrations.runPending(folder, true) + + @JvmStatic + public fun beforeLoad(folder: Path): Boolean = ConfigMigrations.runPending(folder, false) +} diff --git a/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/migrate/MigrationScope.kt b/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/migrate/MigrationScope.kt new file mode 100644 index 000000000..2cc007a9c --- /dev/null +++ b/modules/config-impl/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/migrate/MigrationScope.kt @@ -0,0 +1,179 @@ +package org.polyfrost.oneconfig.api.config.v1.migrate + +import com.electronwill.nightconfig.core.Config +import com.electronwill.nightconfig.core.NullObject +import com.electronwill.nightconfig.json.JsonFormat +import org.apache.logging.log4j.LogManager +import org.apache.logging.log4j.Logger +import org.jetbrains.annotations.ApiStatus +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption + +@ApiStatus.Experimental +public class MigrationScope internal constructor( + public val modId: String, + public val version: Int, + public val profileFolder: Path, +) { + public fun resolve(path: String): Path { + val target = profileFolder.resolve(path).normalize() + require(target.startsWith(profileFolder)) { "$path is outside the profile folder" } + return target + } + + public fun exists(path: String): Boolean = Files.isRegularFile(resolve(path)) + + public fun listFiles(directory: String = "", glob: String = "*"): List { + val dir = resolve(directory) + if (!Files.isDirectory(dir)) return emptyList() + val out = ArrayList() + Files.newDirectoryStream(dir, glob).use { stream -> + for (file in stream) { + if (Files.isRegularFile(file)) out += profileFolder.relativize(file).joinToString("/") + } + } + out.sort() + return out + } + + public fun readJson(path: String): MutableMap? { + val text = readText(path) ?: return null + return try { + MigrationJson.parse(text) + } catch (t: Throwable) { + LOGGER.warn("{} migration {}: {} is not a JSON object, treating it as missing", modId, version, path, t) + null + } + } + + private fun readJsonOrNew(path: String): MutableMap? { + if (!Files.exists(resolve(path))) return LinkedHashMap() + return readJson(path) + } + + public fun writeJson(path: String, json: Map) { + writeText(path, MigrationJson.write(json)) + } + + public fun editJson(path: String, create: Boolean = false, edit: MutableMap.() -> Unit): Boolean { + val json = (if (create) readJsonOrNew(path) else readJson(path)) ?: return false + json.edit() + writeJson(path, json) + return true + } + + public fun renameKey(path: String, from: String, to: String): Boolean { + val json = readJson(path) ?: return false + if (from !in json) return false + json[to] = json.remove(from) + writeJson(path, json) + return true + } + + public fun moveKey(fromPath: String, key: String, toPath: String, toKey: String = key): Boolean { + if (resolve(fromPath) == resolve(toPath)) return renameKey(fromPath, key, toKey) + val source = readJson(fromPath) ?: return false + if (key !in source) return false + val value = source.remove(key) + val target = readJsonOrNew(toPath) ?: return false + target[toKey] = value + writeJson(toPath, target) + writeJson(fromPath, source) + return true + } + + public fun removeKeys(path: String, vararg keys: String): Boolean { + val json = readJson(path) ?: return false + var changed = false + for (key in keys) { + if (key !in json) continue + json.remove(key) + changed = true + } + if (changed) writeJson(path, json) + return changed + } + + public fun readText(path: String): String? { + val file = resolve(path) + if (!Files.isRegularFile(file)) return null + return String(Files.readAllBytes(file), Charsets.UTF_8) + } + + public fun writeText(path: String, text: String) { + MigrationFiles.write(resolve(path), text) + } + + public fun moveFile(from: String, to: String, replace: Boolean = false): Boolean { + val source = resolve(from) + val target = resolve(to) + if (!Files.isRegularFile(source) || source == target) return false + if (!replace && Files.exists(target)) return false + target.parent?.let { Files.createDirectories(it) } + if (replace) Files.move(source, target, StandardCopyOption.REPLACE_EXISTING) else Files.move(source, target) + return true + } + + public fun deleteFile(path: String): Boolean { + val file = resolve(path) + if (!Files.isRegularFile(file)) return false + return Files.deleteIfExists(file) + } + + private companion object { + val LOGGER: Logger = LogManager.getLogger("OneConfig/Migrations") + } +} + +internal object MigrationFiles { + fun write(file: Path, text: String) { + file.parent?.let { Files.createDirectories(it) } + val temp = file.resolveSibling(file.fileName.toString() + ".migrating") + Files.write(temp, text.toByteArray(Charsets.UTF_8)) + try { + Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING) + } + } +} + +internal object MigrationJson { + private val format = JsonFormat.fancyInstance() + + fun parse(text: String): MutableMap = fromConfig(format.createParser().parse(text)) + + fun write(json: Map): String = format.createWriter().writeToString(toConfig(json)) + + private fun fromConfig(config: Config): MutableMap { + val out = LinkedHashMap() + for (entry in config.entrySet()) out[entry.key] = fromValue(entry.getRawValue()) + return out + } + + private fun fromValue(value: Any?): Any? = when (value) { + null, NullObject.NULL_OBJECT -> null + is Config -> fromConfig(value) + is List<*> -> value.mapTo(ArrayList()) { fromValue(it) } + else -> value + } + + private fun toConfig(json: Map<*, *>): Config { + val config = Config.inMemory() + for ((key, value) in json) config.set(listOf(key.toString()), toValue(value)) + return config + } + + private fun toValue(value: Any?): Any = when (value) { + null -> NullObject.NULL_OBJECT + is Map<*, *> -> toConfig(value) + is Iterable<*> -> value.map { toValue(it) } + is Array<*> -> value.map { toValue(it) } + is Number, is Boolean, is String -> value + is Char -> value.toString() + is Enum<*> -> value.name + else -> throw IllegalArgumentException("Cannot write ${value.javaClass.name} as JSON") + } +} diff --git a/modules/config/api/config.api b/modules/config/api/config.api index 9dcf9f933..bd5fdaf48 100644 --- a/modules/config/api/config.api +++ b/modules/config/api/config.api @@ -1,3 +1,39 @@ +public final class org/polyfrost/oneconfig/api/config/v1/MetaKey { + public fun (Ljava/lang/String;Ljava/lang/Class;)V + public fun equals (Ljava/lang/Object;)Z + public final fun getName ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/polyfrost/oneconfig/api/config/v1/MetaKeys { + public static final field CATEGORY Lorg/polyfrost/oneconfig/api/config/v1/MetaKey; + public static final field COLLAPSED Lorg/polyfrost/oneconfig/api/config/v1/MetaKey; + public static final field DESCRIPTION Lorg/polyfrost/oneconfig/api/config/v1/MetaKey; + public static final field FULL_WIDTH Lorg/polyfrost/oneconfig/api/config/v1/MetaKey; + public static final field HIDDEN Lorg/polyfrost/oneconfig/api/config/v1/MetaKey; + public static final field HIDE_LABEL Lorg/polyfrost/oneconfig/api/config/v1/MetaKey; + public static final field ICON Lorg/polyfrost/oneconfig/api/config/v1/MetaKey; + public static final field INSTANCE Lorg/polyfrost/oneconfig/api/config/v1/MetaKeys; + public static final field LABELS Lorg/polyfrost/oneconfig/api/config/v1/MetaKey; + public static final field MAX Lorg/polyfrost/oneconfig/api/config/v1/MetaKey; + public static final field MIN Lorg/polyfrost/oneconfig/api/config/v1/MetaKey; + public static final field OPTIONS Lorg/polyfrost/oneconfig/api/config/v1/MetaKey; + public static final field ORDER Lorg/polyfrost/oneconfig/api/config/v1/MetaKey; + public static final field STEP Lorg/polyfrost/oneconfig/api/config/v1/MetaKey; + public static final field SUBCATEGORY Lorg/polyfrost/oneconfig/api/config/v1/MetaKey; + public static final field TITLE Lorg/polyfrost/oneconfig/api/config/v1/MetaKey; + public static final field UNIT Lorg/polyfrost/oneconfig/api/config/v1/MetaKey; + public static final field VISUALIZER Lorg/polyfrost/oneconfig/api/config/v1/MetaKey; +} + +public final class org/polyfrost/oneconfig/api/config/v1/MetaKeysKt { + public static final field ENABLE_TOGGLE_KEY Ljava/lang/String; + public static final fun get (Lorg/polyfrost/oneconfig/api/config/v1/Node;Lorg/polyfrost/oneconfig/api/config/v1/MetaKey;)Ljava/lang/Object; + public static final fun getEnableToggle (Lorg/polyfrost/oneconfig/api/config/v1/Tree;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public static final fun set (Lorg/polyfrost/oneconfig/api/config/v1/Node;Lorg/polyfrost/oneconfig/api/config/v1/MetaKey;Ljava/lang/Object;)V +} + public abstract class org/polyfrost/oneconfig/api/config/v1/Node { protected static final field LOGGER Lorg/apache/logging/log4j/Logger; public field description Ljava/lang/Object; @@ -57,6 +93,7 @@ public abstract class org/polyfrost/oneconfig/api/config/v1/Property : org/polyf public final fun addCallback (Ljava/util/Collection;)Lorg/polyfrost/oneconfig/api/config/v1/Property; public final fun addCallback (Ljava/util/function/Predicate;)Lorg/polyfrost/oneconfig/api/config/v1/Property; public final fun addCallback ([Ljava/util/function/Predicate;)Lorg/polyfrost/oneconfig/api/config/v1/Property; + public final fun addDependent (Lorg/polyfrost/oneconfig/api/config/v1/Property;)V public final fun addDisplayCondition (Ljava/util/Collection;)Lorg/polyfrost/oneconfig/api/config/v1/Property; public final fun addDisplayCondition (Ljava/util/function/Supplier;)Lorg/polyfrost/oneconfig/api/config/v1/Property; public final fun addDisplayCondition (Lorg/polyfrost/oneconfig/api/config/v1/Property;Z)Lorg/polyfrost/oneconfig/api/config/v1/Property; @@ -77,6 +114,7 @@ public abstract class org/polyfrost/oneconfig/api/config/v1/Property : org/polyf public static fun recast (Lorg/polyfrost/oneconfig/api/config/v1/Property;)Lorg/polyfrost/oneconfig/api/config/v1/Property; public final fun removeCallback (Ljava/util/function/Predicate;)Z public final fun removeCallbackIf (Ljava/util/function/Predicate;)Z + public final fun removeDependent (Lorg/polyfrost/oneconfig/api/config/v1/Property;)Z public final fun removeDisplayConditionIf (Ljava/util/function/Predicate;)Z public final fun removeDisplayListener (Ljava/util/function/Consumer;)Z public fun revaluateDisplay ()V diff --git a/modules/config/src/main/java/org/polyfrost/oneconfig/api/config/v1/Node.java b/modules/config/src/main/java/org/polyfrost/oneconfig/api/config/v1/Node.java index 7c4aa9e69..d990bc433 100644 --- a/modules/config/src/main/java/org/polyfrost/oneconfig/api/config/v1/Node.java +++ b/modules/config/src/main/java/org/polyfrost/oneconfig/api/config/v1/Node.java @@ -102,6 +102,10 @@ public void setTitle(Object title) { this.title = title; } + final void replaceTitle(@Nullable Object title) { + this.title = strv(title); + } + public final void addMetadata(String key, Object value) { if (value == null || value.equals("")) return; if (key.equals("title")) { diff --git a/modules/config/src/main/java/org/polyfrost/oneconfig/api/config/v1/Property.java b/modules/config/src/main/java/org/polyfrost/oneconfig/api/config/v1/Property.java index df540f7fe..ccc4bb3c1 100644 --- a/modules/config/src/main/java/org/polyfrost/oneconfig/api/config/v1/Property.java +++ b/modules/config/src/main/java/org/polyfrost/oneconfig/api/config/v1/Property.java @@ -62,6 +62,7 @@ public abstract class Property extends Node implements Serializable { private transient List> displayListeners = null; private transient Display display = Display.SHOWN; private transient List> conditions = null; + private transient List>> dependents = null; protected Property(@Nullable String id, @Nullable Object title, @Nullable Object description, @NotNull Class type) { super(id, title, description); @@ -178,6 +179,32 @@ public void revaluateDisplay() { } } + @ApiStatus.Experimental + public final void addDependent(@NotNull Property dependent) { + if (dependents == null) dependents = new ArrayList<>(2); + for (WeakReference> ref : dependents) { + if (ref.get() == dependent) return; + } + dependents.add(new WeakReference<>(dependent)); + } + + @ApiStatus.Experimental + public final boolean removeDependent(@NotNull Property dependent) { + return dependents != null && dependents.removeIf(ref -> { + Property p = ref.get(); + return p == null || p == dependent; + }); + } + + private void notifyDependents() { + if (dependents == null) return; + for (WeakReference> ref : new ArrayList<>(dependents)) { + Property p = ref.get(); + if (p == null) dependents.remove(ref); + else p.revaluateDisplay(); + } + } + protected final void clearDisplayConditions() { conditions = null; display = Display.SHOWN; @@ -259,6 +286,12 @@ public void overwrite(Node with, boolean preserveMissingOptions, boolean skipOve } if (that.conditions != null) this.addDisplayCondition(that.conditions); if (that.callbacks != null) addCallback((Collection) that.callbacks); + if (that.dependents != null) { + for (WeakReference> ref : that.dependents) { + Property p = ref.get(); + if (p != null) addDependent(p); + } + } if (root != null) { overwritten = root.getOrPutChild("reserved:overwritten"); ((Tree) overwritten).put(new Tree(that.getID(), null, null, null)); @@ -318,6 +351,7 @@ public final boolean set(@Nullable T value) { * Use this method with caution */ public boolean setReferential(@Nullable T value) { + boolean cancelled = false; if (callbacks != null) { T prev = get(); set0(value); @@ -326,14 +360,16 @@ public boolean setReferential(@Nullable T value) { if (p.test(value)) { LOGGER.info("property {} set cancelled by {}", this.getID(), p); set0(prev); - return true; + cancelled = true; + break; } } catch (Throwable t) { LOGGER.error("failed to call cancellable callback {} on property {}", p, this.getID(), t); } } } else set0(value); - return false; + notifyDependents(); + return cancelled; } protected abstract void set0(@Nullable T value); diff --git a/modules/config/src/main/java/org/polyfrost/oneconfig/api/config/v1/serialize/ObjectSerializer.java b/modules/config/src/main/java/org/polyfrost/oneconfig/api/config/v1/serialize/ObjectSerializer.java index c40f4d06e..bee8b1d99 100644 --- a/modules/config/src/main/java/org/polyfrost/oneconfig/api/config/v1/serialize/ObjectSerializer.java +++ b/modules/config/src/main/java/org/polyfrost/oneconfig/api/config/v1/serialize/ObjectSerializer.java @@ -385,7 +385,15 @@ private T _deserialize(Map in, @NotNull T theObject, Class m = (Map) value; - f.set(theObject, _deserialize(m, f.getType())); + Object adapted = null; + if (m.get("class") instanceof String && getAdapter(f.getType()) != null) { + try { + adapted = deserialize(m); + } catch (Exception e) { + LOGGER.warn("Failed to read field {} of {} through its adapter", f.getName(), cls.getName(), e); + } + } + f.set(theObject, adapted != null && f.getType().isInstance(adapted) ? adapted : _deserialize(m, f.getType())); } else { Object out = richCast(value, f.getType()); f.set(theObject, out); diff --git a/modules/config/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/MetaKeys.kt b/modules/config/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/MetaKeys.kt new file mode 100644 index 000000000..c31ad3ea9 --- /dev/null +++ b/modules/config/src/main/kotlin/org/polyfrost/oneconfig/api/config/v1/MetaKeys.kt @@ -0,0 +1,93 @@ +package org.polyfrost.oneconfig.api.config.v1 + +import org.jetbrains.annotations.ApiStatus + +@ApiStatus.Experimental +public class MetaKey internal constructor(public val name: String, private val convert: (Any) -> T?) { + @Suppress("UNCHECKED_CAST") + public constructor(name: String, type: Class) : this(name, { value -> + if ((type as Class).kotlin.javaObjectType.isInstance(value)) value as T else null + }) + + internal fun read(value: Any?): T? = value?.let(convert) + + override fun equals(other: Any?): Boolean = other is MetaKey<*> && other.name == name + + override fun hashCode(): Int = name.hashCode() + + override fun toString(): String = "MetaKey($name)" +} + +@ApiStatus.Experimental +public object MetaKeys { + @JvmField public val TITLE: MetaKey = MetaKey("title", String::class.java) + + @JvmField public val DESCRIPTION: MetaKey = MetaKey("description", String::class.java) + + @JvmField public val CATEGORY: MetaKey = MetaKey("category", String::class.java) + @JvmField public val SUBCATEGORY: MetaKey = MetaKey("subcategory", String::class.java) + + @JvmField public val MIN: MetaKey = MetaKey("min", Number::class.java) + + @JvmField public val MAX: MetaKey = MetaKey("max", Number::class.java) + + @JvmField public val STEP: MetaKey = MetaKey("step", Number::class.java) + + @JvmField public val UNIT: MetaKey = MetaKey("unit", String::class.java) + + @JvmField public val OPTIONS: MetaKey> = MetaKey("options", ::stringList) + + @JvmField public val LABELS: MetaKey> = MetaKey("optionLabels", ::stringList) + + @JvmField public val VISUALIZER: MetaKey = MetaKey("visualizer", Any::class.java) + + @JvmField public val COLLAPSED: MetaKey = MetaKey("collapsed", Boolean::class.java) + + @JvmField public val ICON: MetaKey = MetaKey("icon", Any::class.java) + + @JvmField public val ORDER: MetaKey = MetaKey("index", Int::class.java) + + @JvmField public val HIDDEN: MetaKey = MetaKey("hidden", Boolean::class.java) + + @JvmField public val FULL_WIDTH: MetaKey = MetaKey("fullWidth", Boolean::class.java) + + @JvmField public val HIDE_LABEL: MetaKey = MetaKey("hideLabel", Boolean::class.java) + + @Suppress("UNCHECKED_CAST") + private fun stringList(value: Any): List? { + val items = when (value) { + is Array<*> -> value.asList() + is Iterable<*> -> value.toList() + else -> return null + } + return if (items.all { it is String }) items as List else null + } + + internal fun storedValue(key: MetaKey<*>, value: Any): Any = when (key.name) { + MIN.name, MAX.name, STEP.name -> (value as? Number)?.toFloat() ?: value + else -> value + } +} + +@ApiStatus.Experimental +public operator fun Node.get(key: MetaKey): T? = when (key.name) { + MetaKeys.TITLE.name -> key.read(title) + MetaKeys.DESCRIPTION.name -> key.read(description) + else -> key.read(getMetadata(key.name)) +} + +@ApiStatus.Experimental +public operator fun Node.set(key: MetaKey, value: T?) { + when (key.name) { + MetaKeys.TITLE.name -> replaceTitle(value) + MetaKeys.DESCRIPTION.name -> description = value + else -> if (value == null) removeMetadata(key.name) else addMetadata(key.name, MetaKeys.storedValue(key, value)) + } +} + +@get:ApiStatus.Experimental +public val Tree.enableToggle: Property? + @Suppress("UNCHECKED_CAST") + get() = getMetadata(ENABLE_TOGGLE_KEY) as? Property + +public const val ENABLE_TOGGLE_KEY: String = "enableToggle" diff --git a/modules/events/api/events.api b/modules/events/api/events.api index fcddf4f49..87a14ad6d 100644 --- a/modules/events/api/events.api +++ b/modules/events/api/events.api @@ -52,6 +52,18 @@ public final synthetic class org/polyfrost/oneconfig/api/event/v1/ExtensionsKt { public static final synthetic fun eventHandlerWorldZ (Lkotlin/jvm/functions/Function1;)Ljava/lang/Void; } +public class org/polyfrost/oneconfig/api/event/v1/events/AttackEntityEvent : org/polyfrost/oneconfig/api/event/v1/events/Event$Cancellable { + public fun (Ljava/lang/Object;Ljava/lang/Object;)V + public fun getPlayer ()Ljava/lang/Object; + public fun getTarget ()Ljava/lang/Object; +} + +public class org/polyfrost/oneconfig/api/event/v1/events/BlockUpdateEvent : org/polyfrost/oneconfig/api/event/v1/events/Event { + public fun (Ljava/lang/Object;Ljava/lang/Object;)V + public fun getPos ()Ljava/lang/Object; + public fun getState ()Ljava/lang/Object; +} + public abstract class org/polyfrost/oneconfig/api/event/v1/events/ChatEvent : org/polyfrost/oneconfig/api/event/v1/events/Event$Cancellable { public fun ()V } @@ -170,6 +182,14 @@ public final class org/polyfrost/oneconfig/api/event/v1/events/PacketEvent$Send public fun (Ljava/lang/Object;)V } +public class org/polyfrost/oneconfig/api/event/v1/events/PlaceBlockEvent : org/polyfrost/oneconfig/api/event/v1/events/Event { + public fun (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)V + public fun getLevel ()Ljava/lang/Object; + public fun getPlayer ()Ljava/lang/Object; + public fun getPos ()Ljava/lang/Object; + public fun getState ()Ljava/lang/Object; +} + public class org/polyfrost/oneconfig/api/event/v1/events/PlayerInteractEvent : org/polyfrost/oneconfig/api/event/v1/events/Event$Cancellable { public fun (Ljava/lang/Object;Lorg/polyfrost/oneconfig/api/event/v1/events/PlayerInteractEvent$Action;Lorg/polyfrost/oneconfig/api/event/v1/events/PlayerInteractEvent$Type;)V public fun getAction ()Lorg/polyfrost/oneconfig/api/event/v1/events/PlayerInteractEvent$Action; @@ -192,6 +212,14 @@ public final class org/polyfrost/oneconfig/api/event/v1/events/PlayerInteractEve public static fun values ()[Lorg/polyfrost/oneconfig/api/event/v1/events/PlayerInteractEvent$Type; } +public class org/polyfrost/oneconfig/api/event/v1/events/PlayerTurnEvent : org/polyfrost/oneconfig/api/event/v1/events/Event$Cancellable { + public fun (DD)V + public fun component1 ()D + public fun component2 ()D + public fun getPitchDelta ()D + public fun getYawDelta ()D +} + public abstract class org/polyfrost/oneconfig/api/event/v1/events/RenderLivingEvent : org/polyfrost/oneconfig/api/event/v1/events/Event$Cancellable { public fun (Ljava/lang/Object;FDDD)V public fun getEntity ()Ljava/lang/Object; diff --git a/modules/events/src/main/java/org/polyfrost/oneconfig/api/event/v1/events/AttackEntityEvent.java b/modules/events/src/main/java/org/polyfrost/oneconfig/api/event/v1/events/AttackEntityEvent.java new file mode 100644 index 000000000..e6d884458 --- /dev/null +++ b/modules/events/src/main/java/org/polyfrost/oneconfig/api/event/v1/events/AttackEntityEvent.java @@ -0,0 +1,24 @@ +package org.polyfrost.oneconfig.api.event.v1.events; + +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Experimental +public class AttackEntityEvent extends Event.Cancellable { + private final Object player; + private final Object target; + + public AttackEntityEvent(Object player, Object target) { + this.player = player; + this.target = target; + } + + @SuppressWarnings("unchecked") + public T getPlayer() { + return (T) player; + } + + @SuppressWarnings("unchecked") + public T getTarget() { + return (T) target; + } +} diff --git a/modules/events/src/main/java/org/polyfrost/oneconfig/api/event/v1/events/BlockUpdateEvent.java b/modules/events/src/main/java/org/polyfrost/oneconfig/api/event/v1/events/BlockUpdateEvent.java new file mode 100644 index 000000000..94f25511d --- /dev/null +++ b/modules/events/src/main/java/org/polyfrost/oneconfig/api/event/v1/events/BlockUpdateEvent.java @@ -0,0 +1,24 @@ +package org.polyfrost.oneconfig.api.event.v1.events; + +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Experimental +public class BlockUpdateEvent implements Event { + private final Object pos; + private final Object state; + + public BlockUpdateEvent(Object pos, Object state) { + this.pos = pos; + this.state = state; + } + + @SuppressWarnings("unchecked") + public T getPos() { + return (T) pos; + } + + @SuppressWarnings("unchecked") + public T getState() { + return (T) state; + } +} diff --git a/modules/events/src/main/java/org/polyfrost/oneconfig/api/event/v1/events/PlaceBlockEvent.java b/modules/events/src/main/java/org/polyfrost/oneconfig/api/event/v1/events/PlaceBlockEvent.java new file mode 100644 index 000000000..e5b2a7cc5 --- /dev/null +++ b/modules/events/src/main/java/org/polyfrost/oneconfig/api/event/v1/events/PlaceBlockEvent.java @@ -0,0 +1,38 @@ +package org.polyfrost.oneconfig.api.event.v1.events; + +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Experimental +public class PlaceBlockEvent implements Event { + private final Object player; + private final Object level; + private final Object pos; + private final Object state; + + public PlaceBlockEvent(Object player, Object level, Object pos, Object state) { + this.player = player; + this.level = level; + this.pos = pos; + this.state = state; + } + + @SuppressWarnings("unchecked") + public T getPlayer() { + return (T) player; + } + + @SuppressWarnings("unchecked") + public T getLevel() { + return (T) level; + } + + @SuppressWarnings("unchecked") + public T getPos() { + return (T) pos; + } + + @SuppressWarnings("unchecked") + public T getState() { + return (T) state; + } +} diff --git a/modules/events/src/main/java/org/polyfrost/oneconfig/api/event/v1/events/PlayerTurnEvent.java b/modules/events/src/main/java/org/polyfrost/oneconfig/api/event/v1/events/PlayerTurnEvent.java new file mode 100644 index 000000000..230311c8e --- /dev/null +++ b/modules/events/src/main/java/org/polyfrost/oneconfig/api/event/v1/events/PlayerTurnEvent.java @@ -0,0 +1,30 @@ +package org.polyfrost.oneconfig.api.event.v1.events; + +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Experimental +public class PlayerTurnEvent extends Event.Cancellable { + private final double yawDelta; + private final double pitchDelta; + + public PlayerTurnEvent(double yawDelta, double pitchDelta) { + this.yawDelta = yawDelta; + this.pitchDelta = pitchDelta; + } + + public double getYawDelta() { + return yawDelta; + } + + public double getPitchDelta() { + return pitchDelta; + } + + public double component1() { + return yawDelta; + } + + public double component2() { + return pitchDelta; + } +} diff --git a/modules/hud/api/hud.api b/modules/hud/api/hud.api index 1d45c059d..225c1201d 100644 --- a/modules/hud/api/hud.api +++ b/modules/hud/api/hud.api @@ -44,6 +44,7 @@ public abstract class org/polyfrost/oneconfig/api/hud/v1/Hud : org/polyfrost/one public final fun clearAnchor ()V public synthetic fun clone ()Ljava/lang/Object; public fun clone ()Lorg/polyfrost/oneconfig/api/hud/v1/Hud; + protected final fun cloneOptions ()V protected final fun contentChanged ()V public fun defaultPosition ()Lkotlin/Pair; public fun deletable ()Z @@ -58,11 +59,19 @@ public abstract class org/polyfrost/oneconfig/api/hud/v1/Hud : org/polyfrost/one public final fun getBgChromaSpeed ()F public final fun getBgColor ()I public final fun getBgMerged ()Z + public final fun getBgPolyColor ()Lorg/polyfrost/compose/render/PolyColor; public final fun getBgRadius ()F + public fun getBorderChroma ()Z + public fun getBorderChromaSpeed ()F + public fun getBorderColor ()I + public fun getBorderEnabled ()Z + public final fun getBorderPolyColor ()Lorg/polyfrost/compose/render/PolyColor; + public fun getBorderSize ()F public final fun getCaseType ()I public final fun getCategory ()Lorg/polyfrost/oneconfig/api/hud/v1/Hud$Category; public final fun getConfigId ()Ljava/lang/String; public fun getCustomScale ()F + public fun getDefaultStaticWidth ()Z public fun getDescription ()Ljava/lang/String; public final fun getEffectiveAnchorParent ()Lorg/polyfrost/oneconfig/api/hud/v1/Hud; public final fun getEffectiveAnchorPoint ()Lorg/polyfrost/oneconfig/api/hud/v1/HudAnchor; @@ -73,6 +82,7 @@ public abstract class org/polyfrost/oneconfig/api/hud/v1/Hud : org/polyfrost/one public final fun getFont ()Lorg/polyfrost/oneconfig/api/hud/v1/Font; public final fun getFullShadow ()Z public final fun getGrowthAnchor ()Lorg/polyfrost/oneconfig/api/hud/v1/HudAnchor; + public final fun getHasBeenMoved ()Z public fun getHidden ()Z public final fun getKeepBgWhenHidden ()Z public fun getLocked ()Z @@ -90,6 +100,7 @@ public abstract class org/polyfrost/oneconfig/api/hud/v1/Hud : org/polyfrost/one public fun getRelativeY ()F public fun getRenderedH ()F public fun getRenderedW ()F + public fun getReplacesIds ()Ljava/util/Set; public fun getResizeAxes ()Lorg/polyfrost/oneconfig/api/hud/v1/HudResize; protected fun getResizePin ()Lorg/polyfrost/oneconfig/api/hud/v1/HudAnchor; public final fun getRuntime ()Lorg/polyfrost/compose/runtime/PolyComposeRuntime; @@ -104,6 +115,7 @@ public abstract class org/polyfrost/oneconfig/api/hud/v1/Hud : org/polyfrost/one public final fun getShadowColor ()I public final fun getShadowOffsetX ()F public final fun getShadowOffsetY ()F + public final fun getShadowPolyColor ()Lorg/polyfrost/compose/render/PolyColor; public final fun getShowBackground ()Z public final fun getShowInChat ()Z public final fun getShowInF3 ()Z @@ -120,10 +132,12 @@ public abstract class org/polyfrost/oneconfig/api/hud/v1/Hud : org/polyfrost/one public final fun getTextChromaSpeed ()F public final fun getTextColor ()I public final fun getTextItalic ()Z + public final fun getTextPolyColor ()Lorg/polyfrost/compose/render/PolyColor; public final fun getTextScale ()F public final fun getTextUnderline ()Z public final fun getTextWeight ()Lorg/polyfrost/oneconfig/api/hud/v1/Weight; public final fun getToggleKey ()I + public final fun getToggleKeybind ()Lorg/polyfrost/oneconfig/api/ui/v1/keybind/OneConfigKeybind; public final fun getUseGuiScale ()Z public fun getX ()F public fun getY ()F @@ -132,13 +146,16 @@ public abstract class org/polyfrost/oneconfig/api/hud/v1/Hud : org/polyfrost/one public final fun hudBackground (Lorg/polyfrost/compose/composables/PolyModifier;)Lorg/polyfrost/compose/composables/PolyModifier; public static synthetic fun hudBackground$default (Lorg/polyfrost/oneconfig/api/hud/v1/Hud;Lorg/polyfrost/compose/composables/PolyModifier;ILjava/lang/Object;)Lorg/polyfrost/compose/composables/PolyModifier; public final fun isAnchored ()Z + public final fun isAtDefaultPosition ()Z public final fun isMergeAnchored ()Z public final fun isReal ()Z protected final fun measureNaturalContentSize ()Lkotlin/Pair; public fun minimumSize ()Lkotlin/Pair; public fun multipleInstancesAllowed ()Z + public fun onCloned ()V public fun onEditorDragEnd ()V public fun onEditorDragStart ()V + public fun onUserMoved ()V public fun remove ()V public final fun reseedStaticHeight ()V public final fun reseedStaticSizeIfNeeded ()V @@ -152,7 +169,14 @@ public abstract class org/polyfrost/oneconfig/api/hud/v1/Hud : org/polyfrost/one public final fun setBgChroma (Z)V public final fun setBgChromaSpeed (F)V public final fun setBgColor (I)V + public final fun setBgPolyColor (Lorg/polyfrost/compose/render/PolyColor;)V public final fun setBgRadius (F)V + public fun setBorderChroma (Z)V + public fun setBorderChromaSpeed (F)V + public fun setBorderColor (I)V + public fun setBorderEnabled (Z)V + public final fun setBorderPolyColor (Lorg/polyfrost/compose/render/PolyColor;)V + public fun setBorderSize (F)V public final fun setCaseType (I)V public final fun setConfigId (Ljava/lang/String;)V public fun setCustomScale (F)V @@ -183,6 +207,7 @@ public abstract class org/polyfrost/oneconfig/api/hud/v1/Hud : org/polyfrost/one public final fun setShadowColor (I)V public final fun setShadowOffsetX (F)V public final fun setShadowOffsetY (F)V + public final fun setShadowPolyColor (Lorg/polyfrost/compose/render/PolyColor;)V public final fun setShowBackground (Z)V public final fun setShowInChat (Z)V public final fun setShowInF3 (Z)V @@ -199,10 +224,12 @@ public abstract class org/polyfrost/oneconfig/api/hud/v1/Hud : org/polyfrost/one public final fun setTextChromaSpeed (F)V public final fun setTextColor (I)V public final fun setTextItalic (Z)V + public final fun setTextPolyColor (Lorg/polyfrost/compose/render/PolyColor;)V public final fun setTextScale (F)V public final fun setTextUnderline (Z)V public final fun setTextWeight (Lorg/polyfrost/oneconfig/api/hud/v1/Weight;)V public final fun setToggleKey (I)V + public final fun setToggleKeybind (Lorg/polyfrost/oneconfig/api/ui/v1/keybind/OneConfigKeybind;)V public final fun setUseGuiScale (Z)V public fun setX (F)V public fun setY (F)V @@ -215,6 +242,7 @@ public abstract class org/polyfrost/oneconfig/api/hud/v1/Hud : org/polyfrost/one protected fun updateRelativeX (F)V protected fun updateRelativeY (F)V protected final fun updateWhenChanged (Ljava/lang/String;)V + public final fun withSystemPosition (Lkotlin/jvm/functions/Function0;)Ljava/lang/Object; public fun xForWidth (F)F public fun yForHeight (F)F } @@ -223,17 +251,22 @@ public final class org/polyfrost/oneconfig/api/hud/v1/Hud$Category { public static final field $stable I public static final field Companion Lorg/polyfrost/oneconfig/api/hud/v1/Hud$Category$Companion; public fun (Ljava/lang/String;B)V + public static final fun create (Ljava/lang/String;Lorg/polyfrost/oneconfig/api/ui/v1/page/PageText;)Lorg/polyfrost/oneconfig/api/hud/v1/Hud$Category; + public fun equals (Ljava/lang/Object;)Z public static final fun getCOMBAT ()Lorg/polyfrost/oneconfig/api/hud/v1/Hud$Category; public static final fun getCOMPAT ()Lorg/polyfrost/oneconfig/api/hud/v1/Hud$Category; + public final fun getCategoryId ()Ljava/lang/String; public static final fun getINFO ()Lorg/polyfrost/oneconfig/api/hud/v1/Hud$Category; public final fun getId ()B public final fun getName ()Ljava/lang/String; public static final fun getPLAYER ()Lorg/polyfrost/oneconfig/api/hud/v1/Hud$Category; + public final fun getTitle ()Lorg/polyfrost/oneconfig/api/ui/v1/page/PageText; public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class org/polyfrost/oneconfig/api/hud/v1/Hud$Category$Companion { + public final fun create (Ljava/lang/String;Lorg/polyfrost/oneconfig/api/ui/v1/page/PageText;)Lorg/polyfrost/oneconfig/api/hud/v1/Hud$Category; public final fun getCOMBAT ()Lorg/polyfrost/oneconfig/api/hud/v1/Hud$Category; public final fun getCOMPAT ()Lorg/polyfrost/oneconfig/api/hud/v1/Hud$Category; public final fun getINFO ()Lorg/polyfrost/oneconfig/api/hud/v1/Hud$Category; @@ -263,6 +296,23 @@ public final class org/polyfrost/oneconfig/api/hud/v1/HudAnchor$Companion { public final fun of (Lorg/polyfrost/compose/layout/PolyAlign;)Lorg/polyfrost/oneconfig/api/hud/v1/HudAnchor; } +public final class org/polyfrost/oneconfig/api/hud/v1/HudClickRegions { + public static final fun clickRegion (Lorg/polyfrost/oneconfig/api/hud/v1/Hud;Ljava/lang/String;FFFFLkotlin/jvm/functions/Function0;)Z + public static final fun removeClickRegion (Lorg/polyfrost/oneconfig/api/hud/v1/Hud;Ljava/lang/String;)Z +} + +public final class org/polyfrost/oneconfig/api/hud/v1/HudColors { + public static final fun argbToColor (I)J + public static final fun toComposeColor (Lorg/polyfrost/compose/render/PolyColor;)J + public static final fun toPolyColor-8_81llA (J)Lorg/polyfrost/compose/render/PolyColor; +} + +public final class org/polyfrost/oneconfig/api/hud/v1/HudIcons { + public static final field $stable I + public static final field INSTANCE Lorg/polyfrost/oneconfig/api/hud/v1/HudIcons; + public final fun bitmap (Ljava/lang/String;IZLandroidx/compose/runtime/Composer;II)Lorg/polyfrost/compose/render/PolyBitmap; +} + public final class org/polyfrost/oneconfig/api/hud/v1/HudManager { public static final field $stable I public static final field INSTANCE Lorg/polyfrost/oneconfig/api/hud/v1/HudManager; @@ -277,6 +327,7 @@ public final class org/polyfrost/oneconfig/api/hud/v1/HudManager { public static field layoutRefHeight F public static field layoutRefWidth F public static field overrideShowInScreens Z + public static field partialTick F public final fun getActiveInstances ()Ljava/util/ArrayList; public final fun getHudsOfType (Ljava/lang/Class;)Ljava/util/ArrayList; public static final fun getLayoutReferenceHeight ()F @@ -290,19 +341,28 @@ public final class org/polyfrost/oneconfig/api/hud/v1/HudManager { public static final fun invalidate ()V public final fun isEditing ()Z public final fun isEditorOpen ()Z + public static final fun isSuppressed (Lorg/polyfrost/oneconfig/api/hud/v1/Hud;)Z public static final fun markProviderKnown (Lorg/polyfrost/oneconfig/api/hud/v1/Hud;)V public final fun providers ()Ljava/util/Collection; public static final fun register (Lorg/polyfrost/oneconfig/api/hud/v1/Hud;)V public static final fun register (Lorg/polyfrost/oneconfig/api/hud/v1/Hud;Ljava/lang/String;)V public static final fun register (Lorg/polyfrost/oneconfig/api/hud/v1/Hud;Ljava/lang/String;Ljava/lang/String;)V + public static final fun register (Lorg/polyfrost/oneconfig/api/hud/v1/Hud;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function0;)V public static final fun register ([Lorg/polyfrost/oneconfig/api/hud/v1/Hud;)V + public static synthetic fun register$default (Lorg/polyfrost/oneconfig/api/hud/v1/Hud;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V + public static final fun registerLater (Lkotlin/jvm/functions/Function0;)V public final fun removeHud (Lorg/polyfrost/oneconfig/api/hud/v1/Hud;Z)V public static synthetic fun removeHud$default (Lorg/polyfrost/oneconfig/api/hud/v1/HudManager;Lorg/polyfrost/oneconfig/api/hud/v1/Hud;ZILjava/lang/Object;)V + public static final fun suppressionReasons (Lorg/polyfrost/oneconfig/api/hud/v1/Hud;)Ljava/util/List; public final fun toggleAllHuds (Lorg/polyfrost/oneconfig/api/hud/v1/Hud;Z)V public final fun unregister (Lorg/polyfrost/oneconfig/api/hud/v1/Hud;ZZ)Ljava/util/ArrayList; public static synthetic fun unregister$default (Lorg/polyfrost/oneconfig/api/hud/v1/HudManager;Lorg/polyfrost/oneconfig/api/hud/v1/Hud;ZZILjava/lang/Object;)Ljava/util/ArrayList; } +public final class org/polyfrost/oneconfig/api/hud/v1/HudPreview { + public static final fun previewValue (Lorg/polyfrost/oneconfig/api/hud/v1/Hud;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Ljava/lang/Object;)Ljava/lang/Object; +} + public final class org/polyfrost/oneconfig/api/hud/v1/HudResize : java/lang/Enum { public static final field Both Lorg/polyfrost/oneconfig/api/hud/v1/HudResize; public static final field None Lorg/polyfrost/oneconfig/api/hud/v1/HudResize; @@ -312,6 +372,61 @@ public final class org/polyfrost/oneconfig/api/hud/v1/HudResize : java/lang/Enum public static fun values ()[Lorg/polyfrost/oneconfig/api/hud/v1/HudResize; } +public final class org/polyfrost/oneconfig/api/hud/v1/HudStyle { + public static final field $stable I + public fun ()V + public fun (Lorg/polyfrost/oneconfig/api/hud/v1/Font;Lorg/polyfrost/compose/render/PolyColor;ZLorg/polyfrost/compose/render/PolyColor;ZLorg/polyfrost/compose/render/PolyColor;ZLorg/polyfrost/compose/render/PolyColor;FFFF)V + public synthetic fun (Lorg/polyfrost/oneconfig/api/hud/v1/Font;Lorg/polyfrost/compose/render/PolyColor;ZLorg/polyfrost/compose/render/PolyColor;ZLorg/polyfrost/compose/render/PolyColor;ZLorg/polyfrost/compose/render/PolyColor;FFFFILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lorg/polyfrost/oneconfig/api/hud/v1/Font; + public final fun component10 ()F + public final fun component11 ()F + public final fun component12 ()F + public final fun component2 ()Lorg/polyfrost/compose/render/PolyColor; + public final fun component3 ()Z + public final fun component4 ()Lorg/polyfrost/compose/render/PolyColor; + public final fun component5 ()Z + public final fun component6 ()Lorg/polyfrost/compose/render/PolyColor; + public final fun component7 ()Z + public final fun component8 ()Lorg/polyfrost/compose/render/PolyColor; + public final fun component9 ()F + public final fun copy (Lorg/polyfrost/oneconfig/api/hud/v1/Font;Lorg/polyfrost/compose/render/PolyColor;ZLorg/polyfrost/compose/render/PolyColor;ZLorg/polyfrost/compose/render/PolyColor;ZLorg/polyfrost/compose/render/PolyColor;FFFF)Lorg/polyfrost/oneconfig/api/hud/v1/HudStyle; + public static synthetic fun copy$default (Lorg/polyfrost/oneconfig/api/hud/v1/HudStyle;Lorg/polyfrost/oneconfig/api/hud/v1/Font;Lorg/polyfrost/compose/render/PolyColor;ZLorg/polyfrost/compose/render/PolyColor;ZLorg/polyfrost/compose/render/PolyColor;ZLorg/polyfrost/compose/render/PolyColor;FFFFILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/hud/v1/HudStyle; + public fun equals (Ljava/lang/Object;)Z + public final fun getBackground ()Z + public final fun getBackgroundColor ()Lorg/polyfrost/compose/render/PolyColor; + public final fun getBorder ()Z + public final fun getBorderColor ()Lorg/polyfrost/compose/render/PolyColor; + public final fun getBorderSize ()F + public final fun getFont ()Lorg/polyfrost/oneconfig/api/hud/v1/Font; + public final fun getPadding ()F + public final fun getRadius ()F + public final fun getScale ()F + public final fun getShadow ()Z + public final fun getShadowColor ()Lorg/polyfrost/compose/render/PolyColor; + public final fun getTextColor ()Lorg/polyfrost/compose/render/PolyColor; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/polyfrost/oneconfig/api/hud/v1/HudStyles { + public static final field $stable I + public static final field INSTANCE Lorg/polyfrost/oneconfig/api/hud/v1/HudStyles; + public static final fun applyToAll (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V + public static final fun getBuiltIn ()Lorg/polyfrost/oneconfig/api/hud/v1/HudStyle; + public static final fun getDefault (Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/hud/v1/HudStyle; + public static final fun setDefault (Ljava/lang/String;Lorg/polyfrost/oneconfig/api/hud/v1/HudStyle;)V +} + +public final class org/polyfrost/oneconfig/api/hud/v1/HudSuppression { + public static final fun suppress (Lorg/polyfrost/oneconfig/api/hud/v1/Hud;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/AutoCloseable; +} + +public final class org/polyfrost/oneconfig/api/hud/v1/HudTextKt { + public static final fun HudText (Lorg/polyfrost/oneconfig/api/hud/v1/Hud;Ljava/util/List;ZLandroidx/compose/runtime/Composer;II)V + public static final fun plainText (Ljava/util/List;)Ljava/lang/String; + public static final fun toTextRuns (Lnet/kyori/adventure/text/Component;)Ljava/util/List; +} + public abstract interface class org/polyfrost/oneconfig/api/hud/v1/OneConfigHudWrapper { public fun getHidden ()Z public abstract fun getId ()Ljava/lang/String; @@ -390,6 +505,7 @@ public abstract class org/polyfrost/oneconfig/api/hud/v1/TextHud : org/polyfrost public final fun getPrefix ()Ljava/lang/String; public final fun getSuffix ()Ljava/lang/String; protected abstract fun getText ()Ljava/lang/String; + protected fun getTextRuns ()Ljava/util/List; public fun minimumSize ()Lkotlin/Pair; public final fun setBrackets (Z)V public final fun setPrefix (Ljava/lang/String;)V @@ -401,6 +517,27 @@ public abstract class org/polyfrost/oneconfig/api/hud/v1/TextHud : org/polyfrost public final class org/polyfrost/oneconfig/api/hud/v1/TextHud$Companion { } +public final class org/polyfrost/oneconfig/api/hud/v1/TextRun { + public static final field $stable I + public fun (Ljava/lang/String;Lorg/polyfrost/compose/render/PolyColor;ZZZ)V + public synthetic fun (Ljava/lang/String;Lorg/polyfrost/compose/render/PolyColor;ZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Lorg/polyfrost/compose/render/PolyColor; + public final fun component3 ()Z + public final fun component4 ()Z + public final fun component5 ()Z + public final fun copy (Ljava/lang/String;Lorg/polyfrost/compose/render/PolyColor;ZZZ)Lorg/polyfrost/oneconfig/api/hud/v1/TextRun; + public static synthetic fun copy$default (Lorg/polyfrost/oneconfig/api/hud/v1/TextRun;Ljava/lang/String;Lorg/polyfrost/compose/render/PolyColor;ZZZILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/hud/v1/TextRun; + public fun equals (Ljava/lang/Object;)Z + public final fun getBold ()Z + public final fun getColor ()Lorg/polyfrost/compose/render/PolyColor; + public final fun getItalic ()Z + public final fun getText ()Ljava/lang/String; + public final fun getUnderline ()Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class org/polyfrost/oneconfig/api/hud/v1/Weight : java/lang/Enum { public static final field Black Lorg/polyfrost/oneconfig/api/hud/v1/Weight; public static final field Bold Lorg/polyfrost/oneconfig/api/hud/v1/Weight; diff --git a/modules/hud/build.gradle.kts b/modules/hud/build.gradle.kts index ee6211119..2e2a2fbd7 100644 --- a/modules/hud/build.gradle.kts +++ b/modules/hud/build.gradle.kts @@ -33,6 +33,7 @@ dependencies { api(project(":modules:ui")) api(project(":modules:poly-compose")) api(project(":modules:events")) + implementation(project(":modules:notifications")) compileOnly(libs.jetbrains.compose.runtime) } diff --git a/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/Hud.kt b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/Hud.kt index 4ba9e2307..55e454ee1 100644 --- a/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/Hud.kt +++ b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/Hud.kt @@ -24,6 +24,8 @@ * */ +@file:OptIn(ExperimentalOneConfigUiApi::class) + package org.polyfrost.oneconfig.api.hud.v1 import androidx.compose.runtime.ProvidableCompositionLocal @@ -41,6 +43,7 @@ import org.jetbrains.annotations.ApiStatus import org.jetbrains.annotations.MustBeInvokedByOverriders import org.polyfrost.compose.composables.PolyModifier import org.polyfrost.compose.composables.background +import org.polyfrost.compose.composables.border import org.polyfrost.compose.layout.PolyAlign import org.polyfrost.compose.render.PolyColor import org.polyfrost.compose.runtime.PolyComposeRuntime @@ -54,6 +57,12 @@ import org.polyfrost.oneconfig.api.config.v1.annotations.Switch import org.polyfrost.oneconfig.api.config.v1.backend.Backend import org.polyfrost.oneconfig.api.hud.v1.HudManager.LOGGER import org.polyfrost.oneconfig.api.platform.v1.Platform +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.keybind.KeyModifiers +import org.polyfrost.oneconfig.api.ui.v1.keybind.KeybindManager +import org.polyfrost.oneconfig.api.ui.v1.keybind.OneConfigKeybind +import org.polyfrost.oneconfig.api.ui.v1.page.PageText +import kotlin.math.abs @ApiStatus.Internal public val LocalHud: ProvidableCompositionLocal = compositionLocalOf { null } @@ -119,6 +128,8 @@ private const val MAX_ANCHOR_DEPTH = 16 private const val MIN_VISIBLE = 12f +private val BORDER_OPTIONS = arrayOf("border", "borderEnabled", "borderColor", "borderChroma", "borderChromaSpeed", "borderSize") + internal const val POS_SCHEMA = 1 // every open getter the cached frame geometry or the background merge key reads @@ -180,6 +191,12 @@ public abstract class Hud( @get:ApiStatus.Experimental public open val externalPlacement: ExternalPlacement? get() = null + @ExperimentalOneConfigUiApi + public open val replacesIds: Set get() = emptySet() + + @ExperimentalOneConfigUiApi + public open val defaultStaticWidth: Boolean get() = false + private var _staticWidth: MutableState = mutableStateOf(false) public var staticWidth: Boolean get() = _staticWidth.value @@ -307,15 +324,11 @@ public abstract class Hud( val curOffY = anchorOffsetY val curLinkX = mergeLinkX val curLinkY = mergeLinkY - val wasSystem = HudManager.systemReposition - HudManager.systemReposition = true - try { + withSystemPosition { setAbsolutePosition(dx, dy) t.getProp("section")?.addMetadata("default", section) t.getProp("relativeX")?.addMetadata("default", relativeX) t.getProp("relativeY")?.addMetadata("default", relativeY) - } finally { - HudManager.systemReposition = wasSystem } section = curSection relativeX = curX @@ -327,6 +340,72 @@ public abstract class Hud( mergeLinkY = curLinkY } + @ExperimentalOneConfigUiApi + public fun withSystemPosition(block: () -> T): T { + val wasSystem = HudManager.systemReposition + HudManager.systemReposition = true + try { + return block() + } finally { + HudManager.systemReposition = wasSystem + } + } + + @ExperimentalOneConfigUiApi + public open fun onUserMoved() {} + + private var _movedByPlayer: MutableState = mutableStateOf(false) + private var movedByPlayer: Boolean get() = _movedByPlayer.value; set(v) { _movedByPlayer.put(v) } + + @ExperimentalOneConfigUiApi + public val hasBeenMoved: Boolean get() { + if (!movedByPlayer) return false + val defaults = storedPositionDefaults() ?: return true + return isAnchored || !matchesPosition(defaults) + } + + @ExperimentalOneConfigUiApi + public val isAtDefaultPosition: Boolean get() { + val defaults = storedPositionDefaults() ?: return !movedByPlayer + return !isAnchored && matchesPosition(defaults) + } + + private class PositionDefaults(val section: Any, val x: Float, val y: Float) + + private fun storedPositionDefaults(): PositionDefaults? { + val t = tree ?: return null + val sec = t.getProp("section")?.getMetadata("default") ?: return null + val x = (t.getProp("relativeX")?.getMetadata("default") as? Number)?.toFloat() ?: return null + val y = (t.getProp("relativeY")?.getMetadata("default") as? Number)?.toFloat() ?: return null + return PositionDefaults(sec, x, y) + } + + private fun matchesPosition(d: PositionDefaults): Boolean = + d.section == section && abs(d.x - relativeX) < 1e-4f && abs(d.y - relativeY) < 1e-4f + + @Transient private var positionKeep = 0 + @Transient private var positionBatch = 0 + @Transient private var positionMovePending = false + + private inline fun keepingPosition(block: () -> T): T { + positionKeep++ + try { + return block() + } finally { + positionKeep-- + } + } + + private fun notePositionWrite() { + if (positionKeep > 0 || HudManager.systemReposition || !HudManager.isEditing || tree == null) return + if (positionBatch > 0) positionMovePending = true else markMovedByPlayer() + } + + private fun markMovedByPlayer() { + movedByPlayer = true + onUserMoved() + } + @Switch(title = "Show in F3") public var showInF3: Boolean = true @@ -342,6 +421,42 @@ public abstract class Hud( public var toggleKey: Int = -1 public var showKey: Int = -1 + @Transient + private var toggleBind: OneConfigKeybind? = null + + @ExperimentalOneConfigUiApi + public var toggleKeybind: OneConfigKeybind? + get() = toggleBind + set(v) { + val current = toggleBind + if (v == null) { + if (current != null) KeybindManager.unregister(current) + toggleBind = null + return + } + val bind = current ?: OneConfigKeybind(null, null, KeyModifiers.NONE, 0L, ::onToggleKeybind) + if (v !== bind) { + bind.keyCodes = v.keyCodes?.copyOf() + bind.mouseBtns = v.mouseBtns?.copyOf() + bind.mods = v.mods + bind.durationNanos = v.durationNanos + bind.unresolvedKeyInputs = v.unresolvedKeyInputs + bind.unresolvedMouseInputs = v.unresolvedMouseInputs + } + toggleBind = bind + if (isReal) syncToggleKeybind() + } + + private fun onToggleKeybind(down: Boolean): Boolean { + if (down) hidden = !hidden + return true + } + + private fun syncToggleKeybind() { + val bind = toggleBind ?: return + if (KeybindManager.isRegistered(bind)) KeybindManager.replace(bind, bind) else KeybindManager.register(bind) + } + private var _section: MutableState

= mutableStateOf(Section.TopLeft) public var section: Section get() = _section.value; set(v) { _section.put(v) } @@ -571,6 +686,7 @@ public abstract class Hud( }.let { if (HudManager.systemReposition) it else it.coerceIn(-sw, sw * 2f) } posSchema = POS_SCHEMA HudManager.noteLayoutArranged() + notePositionWrite() } protected open fun updateRelativeY(absY: Float) { @@ -595,9 +711,23 @@ public abstract class Hud( }.let { if (HudManager.systemReposition) it else it.coerceIn(-sh, sh * 2f) } posSchema = POS_SCHEMA HudManager.noteLayoutArranged() + notePositionWrite() } public fun setAbsolutePosition(absX: Float, absY: Float) { + positionBatch++ + try { + placeAbsolute(absX, absY) + } finally { + positionBatch-- + if (positionBatch == 0 && positionMovePending) { + positionMovePending = false + markMovedByPlayer() + } + } + } + + private fun placeAbsolute(absX: Float, absY: Float) { val sw = HudManager.guiScreenWidth val sh = HudManager.guiScreenHeight val gridW = sw / GRID_SIZE @@ -667,8 +797,10 @@ public abstract class Hud( val absY = y pinnedCorner = pin unpinPending = false - updateRelativeX(absX) - updateRelativeY(absY) + keepingPosition { + updateRelativeX(absX) + updateRelativeY(absY) + } } /** Only takes effect on the next [settlePin] because the final size is not laid out yet */ @@ -684,7 +816,7 @@ public abstract class Hud( val absY = rawY pinnedCorner = null unpinPending = false - Snapshot.withMutableSnapshot { setAbsolutePosition(absX, absY) } + keepingPosition { Snapshot.withMutableSnapshot { setAbsolutePosition(absX, absY) } } } private var _alignment: MutableState = mutableStateOf(PolyAlign.Center) @@ -707,8 +839,10 @@ public abstract class Hud( val absX = rawX val absY = rawY growthAnchor = anchor - updateRelativeX(absX) - updateRelativeY(absY) + keepingPosition { + updateRelativeX(absX) + updateRelativeY(absY) + } } private var _anchorTargetId: MutableState = mutableStateOf("") @@ -817,8 +951,10 @@ public abstract class Hud( val absX = rawX val absY = rawY selfAnchorPoint = point - updateRelativeX(absX) - updateRelativeY(absY) + keepingPosition { + updateRelativeX(absX) + updateRelativeY(absY) + } } private var _anchorOffsetX: MutableState = mutableStateOf(0f) @@ -959,12 +1095,9 @@ public abstract class Hud( val absY = layoutY(HudManager.guiScreenHeight) if (dropX) mergeLinkX = null if (dropY) mergeLinkY = null - HudManager.systemReposition = true - try { + withSystemPosition { updateRelativeX(absX) updateRelativeY(absY) - } finally { - HudManager.systemReposition = false } } @@ -1169,8 +1302,74 @@ public abstract class Hud( private var _shadowOffsetY: MutableState = mutableStateOf(2f) public var shadowOffsetY: Float get() = _shadowOffsetY.value; set(v) { _shadowOffsetY.value = v } + @get:ApiStatus.Internal + @set:ApiStatus.Internal + @Transient + public var hasBuiltInBorderOptions: Boolean = false + + private var _borderEnabled: MutableState = mutableStateOf(false) + + @ExperimentalOneConfigUiApi + public open var borderEnabled: Boolean get() = _borderEnabled.value; set(v) { _borderEnabled.put(v) } + + private var _borderColor: MutableState = mutableStateOf(0xFF000000.toInt()) + + @ExperimentalOneConfigUiApi + public open var borderColor: Int get() = _borderColor.value; set(v) { _borderColor.put(v) } + + private var _borderChroma: MutableState = mutableStateOf(false) + + @ExperimentalOneConfigUiApi + public open var borderChroma: Boolean get() = _borderChroma.value; set(v) { _borderChroma.put(v) } + + private var _borderChromaSpeed: MutableState = mutableStateOf(1f) + + @ExperimentalOneConfigUiApi + public open var borderChromaSpeed: Float get() = _borderChromaSpeed.value; set(v) { _borderChromaSpeed.put(v) } + + private var _borderSize: MutableState = mutableStateOf(1f) + + @ExperimentalOneConfigUiApi + public open var borderSize: Float get() = _borderSize.value; set(v) { _borderSize.put(v) } + + @ExperimentalOneConfigUiApi + public var textPolyColor: PolyColor + get() = PolyColor(textColor, textChroma, textChromaSpeed) + set(v) { + textColor = v.rawArgb + textChroma = v.chroma + textChromaSpeed = v.chromaSpeed + } + + @ExperimentalOneConfigUiApi + public var shadowPolyColor: PolyColor + get() = PolyColor(shadowColor, shadowChroma, shadowChromaSpeed) + set(v) { + shadowColor = v.rawArgb + shadowChroma = v.chroma + shadowChromaSpeed = v.chromaSpeed + } + + @ExperimentalOneConfigUiApi + public var bgPolyColor: PolyColor + get() = PolyColor(bgColor, bgChroma, bgChromaSpeed) + set(v) { + bgColor = v.rawArgb + bgChroma = v.chroma + bgChromaSpeed = v.chromaSpeed + } + + @ExperimentalOneConfigUiApi + public var borderPolyColor: PolyColor + get() = PolyColor(borderColor, borderChroma, borderChromaSpeed) + set(v) { + borderColor = v.rawArgb + borderChroma = v.chroma + borderChromaSpeed = v.chromaSpeed + } + public open val alwaysRedraw: Boolean - get() = bgChroma || textChroma || shadowChroma + get() = bgChroma || textChroma || shadowChroma || (borderEnabled && borderChroma) @ApiStatus.Internal @JvmField @@ -1472,6 +1671,8 @@ public abstract class Hud( if (tree != null) throw IllegalArgumentException("HUD is already made, it cannot be made again") val out = if (multipleInstancesAllowed()) clone() else this val treeId = with?.id ?: if (multipleInstancesAllowed()) "huds/${(1000..9999).random()}-$id" else "huds/$id" + if (out.defaultStaticWidth) out.staticWidth = true + HudStyles.applyDefaultTo(out) val tree = ConfigManager.collect(out, treeId) out.apply { tree.title = title @@ -1494,9 +1695,13 @@ public abstract class Hud( tree["selfAnchorPoint"] = ktProperty(out::selfAnchorPoint).apply { addDisplayCondition(hideFromConfigUi) } tree["anchorOffsetX"] = ktProperty(out::anchorOffsetX).apply { addDisplayCondition(hideFromConfigUi) } tree["anchorOffsetY"] = ktProperty(out::anchorOffsetY).apply { addDisplayCondition(hideFromConfigUi) } + tree["movedByPlayer"] = ktProperty(out::movedByPlayer).apply { addDisplayCondition(hideFromConfigUi) } } tree["toggleKey"] = ktProperty(out::toggleKey).apply { addDisplayCondition(hideFromConfigUi) } tree["showKey"] = ktProperty(out::showKey).apply { addDisplayCondition(hideFromConfigUi) } + if (tree["toggleKeybind"] == null) { + tree["toggleKeybind"] = ktProperty(out::toggleKeybind).apply { addDisplayCondition(hideFromConfigUi) } + } tree["alignment"] = ktProperty(out::alignment).apply { addDisplayCondition(hideFromConfigUi) } tree["growthAnchor"] = ktProperty(out::growthAnchor).apply { description = "The point this HUD stays pinned to when its size changes, so it only ever grows away from that point." @@ -1561,6 +1766,14 @@ public abstract class Hud( tree["shadowChromaSpeed"] = ktProperty(out::shadowChromaSpeed).apply { addDisplayCondition(hideFromConfigUi) } tree["shadowOffsetX"] = ktProperty(out::shadowOffsetX).apply { addDisplayCondition(hideFromConfigUi) } tree["shadowOffsetY"] = ktProperty(out::shadowOffsetY).apply { addDisplayCondition(hideFromConfigUi) } + out.hasBuiltInBorderOptions = BORDER_OPTIONS.none { tree[it] != null } + if (out.hasBuiltInBorderOptions) { + tree["borderEnabled"] = ktProperty(out::borderEnabled).apply { addDisplayCondition(hideFromConfigUi) } + tree["borderColor"] = ktProperty(out::borderColor).apply { addDisplayCondition(hideFromConfigUi) } + tree["borderChroma"] = ktProperty(out::borderChroma).apply { addDisplayCondition(hideFromConfigUi) } + tree["borderChromaSpeed"] = ktProperty(out::borderChromaSpeed).apply { addDisplayCondition(hideFromConfigUi) } + tree["borderSize"] = ktProperty(out::borderSize).apply { addDisplayCondition(hideFromConfigUi) } + } (tree["showInF3"] as? Property<*>)?.addDisplayCondition(hideFromConfigUi) (tree["showInTab"] as? Property<*>)?.addDisplayCondition(hideFromConfigUi) (tree["showInScreens"] as? Property<*>)?.addDisplayCondition(hideFromConfigUi) @@ -1576,6 +1789,7 @@ public abstract class Hud( ConfigManager.active().register(tree) sanitizeHudCapturedDefaults(tree) this.tree = tree + syncToggleKeybind() } return out } @@ -1633,19 +1847,13 @@ public abstract class Hud( */ public open fun canMergeBackground(): Boolean = this is TextHud - /** - * Applies this HUD's background to [modifier] - * - * Skipped when the background is turned off or is currently being drawn as part of a fused - * neighbour shape (see [bgMerged]) - */ @JvmOverloads - public fun hudBackground(modifier: PolyModifier = PolyModifier): PolyModifier = - if (showBackground && hasBackground() && !bgMerged) { - modifier.background(PolyColor(bgColor, bgChroma, bgChromaSpeed), bgRadius) - } else { - modifier - } + public fun hudBackground(modifier: PolyModifier = PolyModifier): PolyModifier { + if (!showBackground || !hasBackground() || bgMerged) return modifier + val filled = modifier.background(PolyColor(bgColor, bgChroma, bgChromaSpeed), bgRadius) + val size = borderSize + return if (borderEnabled && size > 0f) filled.border(borderPolyColor, size, bgRadius) else filled + } public open fun backgroundColor(): PolyColor? = null public open fun multipleInstancesAllowed(): Boolean = true @@ -1678,6 +1886,7 @@ public abstract class Hud( */ @ApiStatus.Internal internal fun detachTree() { + toggleBind?.let(KeybindManager::unregister) tree = null } @@ -1688,6 +1897,14 @@ public abstract class Hud( public open fun remove() {} + @ExperimentalOneConfigUiApi + protected fun cloneOptions() { + deepCopyHudOptions(this) + } + + @ExperimentalOneConfigUiApi + public open fun onCloned() {} + @MustBeInvokedByOverriders @Suppress("UNCHECKED_CAST") override fun clone(): Hud = (super.clone() as Hud).apply { @@ -1703,6 +1920,16 @@ public abstract class Hud( geomValid = false showKey = -1 toggleKey = -1 + toggleBind = this@Hud.toggleBind?.let { src -> + OneConfigKeybind(src.keyCodes?.copyOf(), src.mouseBtns?.copyOf(), src.mods, src.durationNanos, ::onToggleKeybind).also { + it.unresolvedKeyInputs = src.unresolvedKeyInputs + it.unresolvedMouseInputs = src.unresolvedMouseInputs + } + } + positionKeep = 0 + positionBatch = 0 + positionMovePending = false + _movedByPlayer = mutableStateOf(this@Hud.movedByPlayer) _staticWidth = mutableStateOf(this@Hud.staticWidth) _section = mutableStateOf(this@Hud.section) _relativeX = mutableStateOf(this@Hud.relativeX) @@ -1754,8 +1981,15 @@ public abstract class Hud( _shadowChromaSpeed = mutableStateOf(this@Hud.shadowChromaSpeed) _shadowOffsetX = mutableStateOf(this@Hud.shadowOffsetX) _shadowOffsetY = mutableStateOf(this@Hud.shadowOffsetY) + _borderEnabled = mutableStateOf(this@Hud._borderEnabled.value) + _borderColor = mutableStateOf(this@Hud._borderColor.value) + _borderChroma = mutableStateOf(this@Hud._borderChroma.value) + _borderChromaSpeed = mutableStateOf(this@Hud._borderChromaSpeed.value) + _borderSize = mutableStateOf(this@Hud._borderSize.value) _hidden = mutableStateOf(this@Hud.hidden) _locked = mutableStateOf(this@Hud.locked) + cloneOptions() + onCloned() } private companion object { @@ -1802,16 +2036,47 @@ public abstract class Hud( } } - public class Category(public val name: String, public val id: Byte) { - public companion object { - @JvmStatic public val COMBAT: Category = Category("oneconfig.combat", 1) - @JvmStatic public val INFO: Category = Category("oneconfig.info", 2) - @JvmStatic public val PLAYER: Category = Category("oneconfig.player", 3) - @JvmStatic public val COMPAT: Category = Category("oneconfig.compat", 4) - } + public class Category private constructor( + private val rawName: String, + public val id: Byte, + categoryId: String, + private val pageTitle: PageText?, + ) { + @Deprecated("Use Category.create(id, title), which compares categories by their id") + public constructor(name: String, id: Byte) : this(name, id, name, null) + + @ExperimentalOneConfigUiApi + public val categoryId: String = categoryId + + public val name: String + get() { + val title = pageTitle ?: return rawName + val key = title.key ?: return title.fallback + val translated = runCatching { Platform.i18n().hasTranslation(key) }.getOrDefault(false) + return if (translated) key else title.fallback + } + @ExperimentalOneConfigUiApi + public val title: PageText + get() = pageTitle ?: if (rawName.isBlank()) PageText.literal(rawName) else PageText.translatable(rawName, rawName) + + override fun equals(other: Any?): Boolean = this === other || (other is Category && other.categoryId == categoryId) + override fun hashCode(): Int = categoryId.hashCode() override fun toString(): String = name - override fun hashCode(): Int = id.toInt() + + public companion object { + @JvmStatic public val COMBAT: Category = Category("oneconfig.combat", 1, "oneconfig.combat", null) + @JvmStatic public val INFO: Category = Category("oneconfig.info", 2, "oneconfig.info", null) + @JvmStatic public val PLAYER: Category = Category("oneconfig.player", 3, "oneconfig.player", null) + @JvmStatic public val COMPAT: Category = Category("oneconfig.compat", 4, "oneconfig.compat", null) + + @ExperimentalOneConfigUiApi + @JvmStatic + public fun create(id: String, title: PageText): Category { + require(id.isNotBlank()) { "Category id must not be blank" } + return Category(id, 0, id, title) + } + } } } diff --git a/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudBackgroundMerge.kt b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudBackgroundMerge.kt index 2760f94bc..04bfcea12 100644 --- a/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudBackgroundMerge.kt +++ b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudBackgroundMerge.kt @@ -34,6 +34,7 @@ import kotlin.math.roundToInt import org.polyfrost.compose.render.PolyColor import org.polyfrost.compose.render.PolyPath import org.polyfrost.compose.render.RenderContext +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi internal enum class MergeAxis { X, Y } @@ -151,9 +152,10 @@ internal object HudBackgroundMerge { return out } + @OptIn(ExperimentalOneConfigUiApi::class) fun canMerge(hud: Hud): Boolean = hud !is LegacyHudMarker && hud.canMergeBackground() && - hud.showBackground && hud.mergeBackground && hud.hasBackground() && + hud.showBackground && hud.mergeBackground && hud.hasBackground() && !hud.borderEnabled && (!hud.hidden || hud.keepBgWhenHidden) && hud.renderedW > 0f && hud.renderedH > 0f @@ -517,6 +519,7 @@ internal object HudBackgroundMerge { pb.close() } + @OptIn(ExperimentalOneConfigUiApi::class) fun layoutKey(huds: List): Int { var key = huds.size for (hud in huds) { @@ -531,6 +534,7 @@ internal object HudBackgroundMerge { key = key * 31 + (if (hud.mergeDiagonally) 1 else 0) key = key * 31 + (if (hud.hidden) 1 else 0) key = key * 31 + (if (hud.keepBgWhenHidden) 1 else 0) + key = key * 31 + (if (hud.borderEnabled) 1 else 0) } return key } diff --git a/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudClickRegions.kt b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudClickRegions.kt new file mode 100644 index 000000000..59f3f0ea8 --- /dev/null +++ b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudClickRegions.kt @@ -0,0 +1,129 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class, InternalOneConfigUiApi::class) +@file:JvmName("HudClickRegions") + +package org.polyfrost.oneconfig.api.hud.v1 + +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.snapshots.Snapshot +import java.util.IdentityHashMap +import org.jetbrains.annotations.ApiStatus +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.InternalOneConfigUiApi + +@ExperimentalOneConfigUiApi +public fun Hud.clickRegion(id: String, x: Float, y: Float, w: Float, h: Float, onClick: () -> Unit): Boolean = + HudClickInput.region(this, id, x, y, w, h, onClick) + +@ExperimentalOneConfigUiApi +public fun Hud.removeClickRegion(id: String): Boolean = HudClickInput.remove(this, id) + +@InternalOneConfigUiApi +@ExperimentalOneConfigUiApi +@ApiStatus.Internal +public object HudClickInput { + private class Region(val hud: Hud) { + @Volatile var x = 0f + @Volatile var y = 0f + @Volatile var w = 0f + @Volatile var h = 0f + @Volatile var onClick: () -> Unit = {} + val hovered = mutableStateOf(false) + } + + private val regions = IdentityHashMap>() + @Volatile private var pointerX = Float.NaN + @Volatile private var pointerY = Float.NaN + @Volatile private var active = false + private var consumedPress = false + + internal fun region(hud: Hud, id: String, x: Float, y: Float, w: Float, h: Float, onClick: () -> Unit): Boolean { + val region = synchronized(regions) { regions.getOrPut(hud) { LinkedHashMap() }.getOrPut(id) { Region(hud) } } + region.x = x + region.y = y + region.w = w + region.h = h + region.onClick = onClick + return region.hovered.value + } + + internal fun remove(hud: Hud, id: String): Boolean { + val removed = synchronized(regions) { + val byId = regions[hud] ?: return false + val region = byId.remove(id) ?: return false + if (byId.isEmpty()) regions.remove(hud) + region + } + if (removed.hovered.value) Snapshot.withMutableSnapshot { removed.hovered.value = false } + return true + } + + public fun updatePointer(guiX: Float, guiY: Float, active: Boolean) { + pointerX = guiX + pointerY = guiY + this.active = active + val changed = ArrayList(0) + synchronized(regions) { + if (regions.isEmpty()) return + val iter = regions.entries.iterator() + while (iter.hasNext()) { + val (hud, byId) = iter.next() + if (HudManager.activeInstances.none { it === hud }) { + for (region in byId.values) if (region.hovered.value) changed += region + iter.remove() + continue + } + for (region in byId.values) if (region.hovered.value != hits(region)) changed += region + } + } + if (changed.isEmpty()) return + Snapshot.withMutableSnapshot { + for (region in changed) region.hovered.value = !region.hovered.value + } + } + + public fun consumeButton(button: Int, pressed: Boolean): Boolean { + if (button != 0) return false + if (!pressed) return consumedPress.also { consumedPress = false } + consumedPress = false + if (!active || HudManager.isEditing) return false + val target = topmostHit() ?: return false + consumedPress = true + try { + target.onClick() + } catch (e: Throwable) { + if (e is VirtualMachineError) throw e + HudManager.LOGGER.error("Click handler of HUD ${target.hud.title} failed", e) + } + return true + } + + private fun topmostHit(): Region? { + val byHud = synchronized(regions) { + if (regions.isEmpty()) return null + IdentityHashMap>(regions.size).also { copy -> + for ((hud, byId) in regions) copy[hud] = byId.values.toList() + } + } + val order = HudManager.zOrderedInstances() + for (i in order.indices.reversed()) { + val list = byHud[order[i]] ?: continue + for (j in list.indices.reversed()) if (hits(list[j])) return list[j] + } + return null + } + + private fun hits(region: Region): Boolean { + if (!active || HudManager.isEditing) return false + if (region.w <= 0f || region.h <= 0f) return false + val hud = region.hud + if (!hud.isVisible.value) return false + val scale = hud.frameEffectiveScale + if (scale <= 0f) return false + val px = pointerX + val py = pointerY + if (px.isNaN() || py.isNaN()) return false + val lx = (px - hud.frameX) / scale + val ly = (py - hud.frameY) / scale + return lx >= region.x && ly >= region.y && lx < region.x + region.w && ly < region.y + region.h + } +} diff --git a/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudCloning.kt b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudCloning.kt new file mode 100644 index 000000000..9a1eff841 --- /dev/null +++ b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudCloning.kt @@ -0,0 +1,183 @@ +package org.polyfrost.oneconfig.api.hud.v1 + +import androidx.compose.runtime.MutableDoubleState +import androidx.compose.runtime.MutableFloatState +import androidx.compose.runtime.MutableIntState +import androidx.compose.runtime.MutableLongState +import androidx.compose.runtime.SnapshotMutationPolicy +import androidx.compose.runtime.mutableDoubleStateOf +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.snapshots.SnapshotMutableState +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.snapshots.SnapshotStateMap +import java.lang.reflect.Array as ReflectArray +import java.lang.reflect.Field +import java.lang.reflect.Modifier +import java.util.ArrayDeque +import java.util.IdentityHashMap +import java.util.LinkedList +import org.polyfrost.compose.render.PolyColor +import org.polyfrost.oneconfig.api.config.v1.Config +import org.polyfrost.oneconfig.api.config.v1.annotations.Accordion +import org.polyfrost.oneconfig.api.config.v1.annotations.Include +import org.polyfrost.oneconfig.api.config.v1.annotations.MasterToggle +import org.polyfrost.oneconfig.api.config.v1.annotations.Option +import org.polyfrost.oneconfig.api.config.v1.copy +import org.polyfrost.oneconfig.api.ui.v1.keybind.KeybindManager +import org.polyfrost.oneconfig.api.ui.v1.keybind.OneConfigKeybind +import org.polyfrost.oneconfig.utils.v1.MHUtils + +private const val MAX_NESTED_DEPTH = 4 + +private class CloneField(val field: Field, val option: Boolean) + +private fun Field.isOptionBacked(): Boolean = declaredAnnotations.any { + val type = it.annotationClass.java + type == Include::class.java || type == Accordion::class.java || type == MasterToggle::class.java || + type.isAnnotationPresent(Option::class.java) +} + +private val cloneFields = object : ClassValue>() { + override fun computeValue(type: Class<*>): List { + val out = ArrayList() + var cls: Class<*>? = type + while (cls != null && cls != Hud::class.java && cls != Any::class.java) { + for (f in cls.declaredFields) { + val mods = f.modifiers + if (Modifier.isStatic(mods) || Modifier.isTransient(mods) || f.isSynthetic || f.type.isPrimitive) continue + if (runCatching { f.setAccessible(true) }.isFailure) continue + out += CloneField(f, f.isOptionBacked()) + } + cls = cls.superclass + } + return out + } +} + +private val nestedConfigClass = object : ClassValue() { + override fun computeValue(type: Class<*>): Boolean { + if (type.isInterface || type.isEnum || type.isArray || Config::class.java.isAssignableFrom(type)) return false + val name = type.name + if (name.startsWith("java.") || name.startsWith("kotlin.") || name.startsWith("androidx.")) return false + var cls: Class<*>? = type + while (cls != null && cls != Any::class.java) { + if (cls.declaredFields.any { it.isOptionBacked() }) return true + cls = cls.superclass + } + return false + } +} + +internal fun deepCopyHudOptions(hud: Hud) { + val seen = IdentityHashMap() + for (cf in cloneFields.get(hud.javaClass)) { + try { + val value = cf.field.get(hud) ?: continue + val copy = deepCopyValue(value, cf.option, 0, seen) + if (copy !== value) cf.field.set(hud, copy) + } catch (_: Exception) { + } + } +} + +private fun deepCopyValue(value: Any, nested: Boolean, depth: Int, seen: IdentityHashMap): Any { + seen[value]?.let { return it } + seen[value] = value + val result = deepCopyUncached(value, nested, depth, seen) + seen[value] = result + return result +} + +@Suppress("UNCHECKED_CAST") +private fun deepCopyUncached(value: Any, nested: Boolean, depth: Int, seen: IdentityHashMap): Any = when (value) { + is String, is Number, is Boolean, is Char, is Enum<*> -> value + is PolyColor -> value.copy() + is OneConfigKeybind -> + if (KeybindManager.isRegistered(value)) value + else value.copyWith(value.keyCodes?.copyOf(), value.mouseBtns?.copyOf(), value.mods) + is MutableIntState -> mutableIntStateOf(value.intValue) + is MutableLongState -> mutableLongStateOf(value.longValue) + is MutableFloatState -> mutableFloatStateOf(value.floatValue) + is MutableDoubleState -> mutableDoubleStateOf(value.doubleValue) + is SnapshotMutableState<*> -> + mutableStateOf(value.value?.let { deepCopyValue(it, nested, depth, seen) }, value.policy as SnapshotMutationPolicy) + is SnapshotStateList<*> -> mutableStateListOf().also { out -> + seen[value] = out + for (e in value) out.add(e?.let { deepCopyValue(it, nested, depth, seen) }) + } + is SnapshotStateMap<*, *> -> mutableStateMapOf().also { out -> + seen[value] = out + for ((k, v) in value) out[k] = v?.let { deepCopyValue(it, nested, depth, seen) } + } + else -> copyContainer(value, nested, depth, seen) ?: if (nested) copyNested(value, depth, seen) else value +} + +@Suppress("UNCHECKED_CAST") +private fun copyContainer(value: Any, nested: Boolean, depth: Int, seen: IdentityHashMap): Any? { + val cls = value.javaClass + if (cls.isArray) { + val length = ReflectArray.getLength(value) + val out = ReflectArray.newInstance(cls.componentType, length) + System.arraycopy(value, 0, out, 0, length) + seen[value] = out + if (!cls.componentType.isPrimitive) { + for (i in 0 until length) { + val e = ReflectArray.get(out, i) ?: continue + try { + ReflectArray.set(out, i, deepCopyValue(e, nested, depth, seen)) + } catch (_: IllegalArgumentException) { + } + } + } + return out + } + fun copy(e: Any?): Any? = e?.let { deepCopyValue(it, nested, depth, seen) } + fun > fill(out: C, src: Collection): C { + seen[value] = out + for (e in src) out.add(copy(e)) + return out + } + fun > fillMap(out: M, src: Map): M { + seen[value] = out + for ((k, v) in src) out[k] = copy(v) + return out + } + return when (cls) { + ArrayList::class.java -> fill(ArrayList((value as List).size), value) + LinkedList::class.java -> fill(LinkedList(), value as List) + ArrayDeque::class.java -> fill(ArrayDeque((value as Collection).size), value) + HashSet::class.java -> fill(HashSet((value as Set).size), value) + LinkedHashSet::class.java -> fill(LinkedHashSet((value as Set).size), value) + HashMap::class.java -> fillMap(HashMap((value as Map).size), value) + LinkedHashMap::class.java -> fillMap(LinkedHashMap((value as Map).size), value) + else -> null + } +} + +private fun copyNested(value: Any, depth: Int, seen: IdentityHashMap): Any { + if (depth >= MAX_NESTED_DEPTH || !nestedConfigClass.get(value.javaClass)) return value + val copy = MHUtils.instantiate(value.javaClass, true).getOrNull() ?: return value + seen[value] = copy + try { + var cls: Class<*>? = value.javaClass + while (cls != null && cls != Any::class.java) { + for (f in cls.declaredFields) { + val mods = f.modifiers + if (Modifier.isStatic(mods)) continue + f.setAccessible(true) + val v = f.get(value) + val keep = f.type.isPrimitive || f.isSynthetic || Modifier.isTransient(mods) + f.set(copy, if (keep || v == null) v else deepCopyValue(v, true, depth + 1, seen)) + } + cls = cls.superclass + } + } catch (_: Exception) { + return value + } + return copy +} diff --git a/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudColors.kt b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudColors.kt new file mode 100644 index 000000000..f7a826b17 --- /dev/null +++ b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudColors.kt @@ -0,0 +1,18 @@ +@file:JvmName("HudColors") +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.hud.v1 + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import org.polyfrost.compose.render.PolyColor +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi + +@ExperimentalOneConfigUiApi +public fun PolyColor.toComposeColor(): Color = Color(argb) + +@ExperimentalOneConfigUiApi +public fun Color.toPolyColor(): PolyColor = PolyColor(toArgb()) + +@ExperimentalOneConfigUiApi +public fun Int.argbToColor(): Color = Color(this) diff --git a/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudIcons.kt b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudIcons.kt new file mode 100644 index 000000000..94577bd3a --- /dev/null +++ b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudIcons.kt @@ -0,0 +1,80 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.hud.v1 + +import androidx.compose.runtime.Composable +import java.awt.RenderingHints +import java.awt.image.BufferedImage +import java.io.ByteArrayInputStream +import java.util.concurrent.ConcurrentHashMap +import javax.imageio.ImageIO +import kotlin.math.max +import kotlin.math.roundToInt +import org.polyfrost.compose.render.PolyBitmap +import org.polyfrost.oneconfig.api.notifications.v1.SvgMask +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme +import org.polyfrost.oneconfig.api.ui.v1.theme.ThemeStyle + +@ExperimentalOneConfigUiApi +public object HudIcons { + private const val ICON_ROOT = "/assets/oneconfig/ico/" + private const val PIXEL_ROOT = "/assets/oneconfig/ico/minecraft/" + private const val MAX_SIZE = 2048 + private const val WHITE = -1 + + private class Entry(val bitmap: PolyBitmap?) + + private val cache = ConcurrentHashMap() + + @Composable + public fun bitmap(name: String, size: Int, pixelStyle: Boolean = OneConfigTheme.current.style == ThemeStyle.Pixel): PolyBitmap? { + val px = size.coerceIn(1, MAX_SIZE) + return cache.getOrPut("$name@$px@$pixelStyle") { Entry(load(resolve(name, pixelStyle), px)) }.bitmap + } + + private fun resolve(name: String, pixelStyle: Boolean): String { + if ('/' in name || '.' in name) return name + if (pixelStyle) { + val pixel = "$PIXEL_ROOT$name.svg" + if (HudIcons::class.java.getResource(pixel) != null) return pixel + } + return "$ICON_ROOT$name.svg" + } + + private fun load(path: String, size: Int): PolyBitmap? { + if (path.endsWith(".svg", ignoreCase = true)) { + return runCatching { SvgMask.get(path, size, WHITE) }.getOrNull() + } + val bytes = readResource(path) ?: return null + return runCatching { ImageIO.read(ByteArrayInputStream(bytes))?.let { PolyBitmap.fromAwt(scale(it, size)) } }.getOrNull() + } + + private fun readResource(path: String): ByteArray? { + val relative = path.removePrefix("/") + val loaders = listOf(HudIcons::class.java.classLoader, Thread.currentThread().contextClassLoader) + for (loader in loaders) { + val stream = loader?.getResourceAsStream(relative) ?: continue + return stream.use { it.readBytes() } + } + return HudIcons::class.java.getResourceAsStream(path)?.use { it.readBytes() } + } + + private fun scale(image: BufferedImage, size: Int): BufferedImage { + val longest = max(image.width, image.height) + if (longest == size) return image + val ratio = size.toFloat() / longest + val w = (image.width * ratio).roundToInt().coerceAtLeast(1) + val h = (image.height * ratio).roundToInt().coerceAtLeast(1) + val out = BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB) + val g = out.createGraphics() + try { + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR) + g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY) + g.drawImage(image, 0, 0, w, h, null) + } finally { + g.dispose() + } + return out + } +} diff --git a/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudManager.kt b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudManager.kt index f9bab2b14..88cfa039d 100644 --- a/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudManager.kt +++ b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudManager.kt @@ -24,6 +24,8 @@ * */ +@file:OptIn(ExperimentalOneConfigUiApi::class) + package org.polyfrost.oneconfig.api.hud.v1 import androidx.compose.runtime.MutableIntState @@ -32,8 +34,10 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.runtime.snapshots.SnapshotStateObserver +import java.lang.ref.WeakReference import java.util.Collections import java.util.IdentityHashMap +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicReference @@ -53,6 +57,8 @@ import org.polyfrost.oneconfig.api.config.v1.Tree import org.polyfrost.oneconfig.api.event.v1.EventManager import org.polyfrost.oneconfig.api.hud.v1.events.HudEditorToggleEvent import org.polyfrost.oneconfig.api.platform.v1.Platform +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.InternalOneConfigUiApi import org.polyfrost.oneconfig.utils.v1.MHUtils private fun Throwable.isFatalHudFailure(): Boolean = this is VirtualMachineError @@ -61,6 +67,7 @@ public object HudManager { internal val LOGGER = LogManager.getLogger("OneConfig/HUD") private val hudProviders = HashMap, Hud>() + private val unsupportedHuds: MutableSet = ConcurrentHashMap.newKeySet() private val hudIcons = HashMap() private val registrationListeners = CopyOnWriteArrayList() @@ -104,6 +111,9 @@ public object HudManager { /** Set by the OneConfig UI screen while it is open so HUDs behind it still render as previews */ @Volatile @JvmField public var isConfigUiOpen: Boolean = false + @ExperimentalOneConfigUiApi + @Volatile @JvmField public var partialTick: Float = 1f + @Volatile @JvmField public var guiScreenWidth: Float = 960f @Volatile @JvmField public var guiScreenHeight: Float = 540f @@ -345,6 +355,8 @@ public object HudManager { @JvmStatic public fun register(hud: Hud) { + unsupportedHuds.remove(hud::class.java.name) + HudStyles.captureBuiltIn(hud) hudProviders[hud::class.java] = hud revision++ // Providers are commonly registered by later InitializationEvent handlers, after the @@ -391,6 +403,76 @@ public object HudManager { register(hud, configId) } + @ExperimentalOneConfigUiApi + @JvmStatic + public fun register(hud: Hud, configId: String? = null, icon: String? = null, supported: () -> Boolean = { true }) { + afterInit { + val ok = try { + supported() + } catch (e: Throwable) { + if (e.isFatalHudFailure()) throw e + LOGGER.error("Failed to check whether HUD ${hud.title} is supported, it will not be registered", e) + false + } + if (!ok) { + if (hud::class.java !in hudProviders) unsupportedHuds.add(hud::class.java.name) + LOGGER.info("HUD ${hud.title} is not supported here and was not registered") + return@afterInit + } + when { + configId != null && icon != null -> register(hud, configId, icon) + configId != null -> register(hud, configId) + else -> register(hud) + } + } + } + + @ExperimentalOneConfigUiApi + @JvmStatic + public fun registerLater(factory: () -> Hud) { + afterInit { + val hud = try { + factory() + } catch (e: Throwable) { + if (e.isFatalHudFailure()) throw e + LOGGER.error("Failed to create a HUD registered with registerLater", e) + return@afterInit + } + register(hud) + } + } + + private val pendingRegistrations = ArrayList<() -> Unit>() + private var registrationsOpen = false + + private fun afterInit(action: () -> Unit) { + synchronized(pendingRegistrations) { + if (!registrationsOpen) { + pendingRegistrations.add(action) + return + } + } + action() + } + + private fun runPendingRegistrations() { + while (true) { + val batch = synchronized(pendingRegistrations) { + registrationsOpen = true + if (pendingRegistrations.isEmpty()) return + ArrayList(pendingRegistrations).also { pendingRegistrations.clear() } + } + for (action in batch) { + try { + action() + } catch (e: Throwable) { + if (e.isFatalHudFailure()) throw e + LOGGER.error("Failed to run a deferred HUD registration", e) + } + } + } + } + /** The menu icon associated with [configId] via [register] or `null` if none was set */ public fun iconFor(configId: String): String? = hudIcons[configId] @@ -493,6 +575,8 @@ public object HudManager { if (it.mergeLinkX?.parent === hud || it.mergeLinkY?.parent === hud) cleanup { it.clearMergeLink() } } lastMergeKey = null + notShowing.remove(hud) + if (delete) cleanup { dropSuppressionsOf(hud) } cleanup { invalidate() } try { hud.remove() } catch (_: Throwable) {} // a HUD the user cannot delete must never lose its config because an errant @@ -591,6 +675,7 @@ public object HudManager { } @ApiStatus.Internal + @InternalOneConfigUiApi public fun updateIfDue(hud: Hud) { val frequency = hud.updateFrequency() if (frequency >= 0L) { @@ -616,12 +701,153 @@ public object HudManager { } private fun shouldDraw(hud: Hud): Boolean { - if (!isEditing && hud.frameHidden) return false + if (!isEditing && (hud.frameHidden || isSuppressed(hud))) return false return isShown(hud) } private fun keepsBackgroundOnly(hud: Hud): Boolean = - !isEditing && hud.frameHidden && hud.keepsHiddenBackground && isShown(hud) + !isEditing && hud.frameHidden && !isSuppressed(hud) && hud.keepsHiddenBackground && isShown(hud) + + private class Suppression(owner: Hud, val target: String, val reason: String) : AutoCloseable { + private val ownerRef = WeakReference(owner) + val owner: Hud? get() = ownerRef.get() + + override fun close() { + if (suppressions.remove(this)) invalidate() + } + } + + private val suppressions = CopyOnWriteArrayList() + + @Volatile private var suppressed: Map> = emptyMap() + + internal fun addSuppression(owner: Hud, otherHudId: String, reason: String): AutoCloseable { + val suppression = Suppression(owner, normaliseHudId(otherHudId), reason) + suppressions.add(suppression) + invalidate() + return suppression + } + + private fun normaliseHudId(id: String): String = id.trim().removeSuffix(".json").removePrefix("huds/") + + private fun hasHudId(hud: Hud, id: String): Boolean = + normaliseHudId(hud.id) == id || hud.tree?.id?.let(::normaliseHudId) == id || + hud.configId?.let(::normaliseHudId) == id + + private fun isActive(hud: Hud): Boolean = activeInstances.any { it === hud } + + private val frameClaims = ArrayList() + + private val notShowing: MutableSet = Collections.newSetFromMap(IdentityHashMap()) + + private fun isOwnerShown(hud: Hud): Boolean = !hud.frameHidden && isShown(hud) && hud !in notShowing + + private val declaresReplacements = object : ClassValue() { + override fun computeValue(type: Class<*>): Boolean = try { + type.getMethod("getReplacesIds").declaringClass != Hud::class.java + } catch (_: ReflectiveOperationException) { + true + } + } + + private fun refreshSuppressed() { + if (suppressions.isEmpty()) { + var any = false + for (hud in activeInstances) { + if (declaresReplacements.get(hud.javaClass)) { + any = true + break + } + } + if (!any) { + if (suppressed.isNotEmpty()) { + suppressed = emptyMap() + invalidate() + } + return + } + } + val claims = frameClaims + claims.clear() + suppressions.removeIf { it.owner == null } + for (s in suppressions) { + val owner = s.owner ?: continue + if (!isActive(owner) || !isOwnerShown(owner)) continue + claims.add(s) + } + for (hud in activeInstances) { + if (!declaresReplacements.get(hud.javaClass) || hud.hidden || !isOwnerShown(hud)) continue + val ids = try { + hud.replacesIds + } catch (e: Throwable) { + if (e.isFatalHudFailure()) throw e + LOGGER.error("Failed to read the HUDs that ${hud.title} replaces", e) + continue + } + for (id in ids) claims.add(Suppression(hud, normaliseHudId(id), hud.title)) + } + val next: Map> = if (claims.isEmpty()) { + emptyMap() + } else { + val incoming = IdentityHashMap>() + for (hud in activeInstances) { + for (claim in claims) { + if (claim.owner !== hud && hasHudId(hud, claim.target)) { + incoming.getOrPut(hud) { ArrayList(1) }.add(claim) + } + } + } + claims.clear() + val effective = IdentityHashMap() + val resolving = Collections.newSetFromMap(IdentityHashMap()) + fun isEffective(hud: Hud): Boolean { + effective[hud]?.let { return it } + if (!resolving.add(hud)) return true + val shown = incoming[hud]?.none { claim -> claim.owner?.let(::isEffective) == true } != false + resolving.remove(hud) + effective[hud] = shown + return shown + } + val map = IdentityHashMap>() + for (hud in activeInstances) { + if (isEffective(hud)) continue + val reasons = ArrayList(1) + for (claim in incoming[hud] ?: continue) { + if (claim.owner?.let(::isEffective) == true) reasons.add(claim.reason) + } + map[hud] = reasons + } + map + } + if (!sameSuppressions(next, suppressed)) { + suppressed = next + invalidate() + } + } + + private fun sameSuppressions(a: Map>, b: Map>): Boolean { + if (a.size != b.size) return false + for ((hud, reasons) in a) { + if (b[hud] != reasons) return false + } + return true + } + + private fun dropSuppressionsOf(hud: Hud) { + if (suppressions.removeIf { it.owner === hud }) invalidate() + } + + @ExperimentalOneConfigUiApi + @JvmStatic + public fun isSuppressed(hud: Hud): Boolean = suppressed.containsKey(hud) + + @ExperimentalOneConfigUiApi + @JvmStatic + public fun suppressionReasons(hud: Hud): List = suppressed[hud]?.toList() ?: emptyList() + + @Volatile private var previewSessionId = 0 + + internal val previewSession: Int get() = previewSessionId /** Everything [shouldDraw] checks apart from the HUD's own hidden flag */ private fun isShown(hud: Hud): Boolean { @@ -668,6 +894,9 @@ public object HudManager { LOGGER.error("Failed to check whether HUD ${hud.title} should show", e) true } + if (!isEditing) { + if (show) notShowing.remove(hud) else notShowing.add(hud) + } if (!show) { hud.setVisible(false) continue @@ -718,8 +947,10 @@ public object HudManager { // one immediate update on the edge instead of waiting out its remaining interval if (wasEditing != isEditing) { wasEditing = isEditing + previewSessionId++ for (hud in activeInstances) hud.lastUpdate = Long.MIN_VALUE } + refreshSuppressed() geometrySkip = false val huds = selectHuds() @@ -739,6 +970,7 @@ public object HudManager { } @ApiStatus.Internal + @InternalOneConfigUiApi @JvmOverloads public fun beginFrame(screenWidth: Float, screenHeight: Float, beforeDirtyCheck: Runnable? = null, alwaysRender: Boolean = false, liveChroma: Boolean = false): Boolean { var volatileContent = false @@ -933,6 +1165,7 @@ public object HudManager { * so without this they would show up bare */ @ApiStatus.Internal + @InternalOneConfigUiApi public fun drawMergedBackgrounds(ctx: RenderContext) { for (group in frameGroups) { try { @@ -1031,6 +1264,7 @@ public object HudManager { this == other || (this.isNaN() && other.isNaN()) @ApiStatus.Internal + @InternalOneConfigUiApi public fun prepare(screenWidth: Float, screenHeight: Float) { prepareFrame(screenWidth, screenHeight, { prepareOrder.clear() @@ -1045,6 +1279,7 @@ public object HudManager { } @ApiStatus.Internal + @InternalOneConfigUiApi @JvmOverloads public fun render(ctx: RenderContext, screenWidth: Float, screenHeight: Float, volatileCtx: RenderContext? = null) { val prepared = preparedFrameValid @@ -1193,7 +1428,7 @@ public object HudManager { } private fun drawHud(hud: Hud, root: RootNode, ctx: RenderContext) { - if (isEditing && hud.hidden) { + if (isEditing && (hud.hidden || isSuppressed(hud))) { ctx.saveLayerAlpha(HIDDEN_HUD_ALPHA) root.render(ctx) ctx.restore() @@ -1250,6 +1485,7 @@ public object HudManager { public fun initialize() { if (init) throw IllegalStateException("HudManager.initialize() called twice!") ConfigManager.active() + runPendingRegistrations() init = true ConfigManager.addProfileChangeListener(profileChangeListener) LOGGER.info("Initializing HUD...") @@ -1410,6 +1646,7 @@ public object HudManager { val clsName = data.getProp("hudClass").get() as? String ?: throw IllegalArgumentException("hud tree ${data.id} is missing class name") if (clsName.endsWith(".OneConfigHudCompat")) return@forEach + if (clsName in unsupportedHuds) return@forEach val cls = Class.forName(clsName, true, loader) as? Class ?: throw IllegalArgumentException("$clsName is not a subclass of Hud") providerClass = cls diff --git a/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudPreview.kt b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudPreview.kt new file mode 100644 index 000000000..1c0183572 --- /dev/null +++ b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudPreview.kt @@ -0,0 +1,37 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) +@file:JvmName("HudPreview") + +package org.polyfrost.oneconfig.api.hud.v1 + +import java.util.IdentityHashMap +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi + +private object HeldPreviews { + private val held = IdentityHashMap>() + private var session = Int.MIN_VALUE + + @Suppress("UNCHECKED_CAST") + fun hold(hud: Hud, key: String, live: () -> T?): T? = synchronized(this) { + val current = HudManager.previewSession + if (session != current) { + held.clear() + session = current + } + val values = held.getOrPut(hud) { HashMap() } + if (key !in values) { + values[key] = try { + live() + } catch (e: Throwable) { + if (e is VirtualMachineError) throw e + null + } + } + values[key] as T? + } +} + +@ExperimentalOneConfigUiApi +public fun Hud.previewValue(key: String, live: () -> T?, demo: T): T { + if (!HudManager.isEditing) return live() ?: demo + return HeldPreviews.hold(this, key, live) ?: demo +} diff --git a/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudStyle.kt b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudStyle.kt new file mode 100644 index 000000000..9235c6821 --- /dev/null +++ b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudStyle.kt @@ -0,0 +1,100 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.hud.v1 + +import androidx.compose.runtime.snapshots.Snapshot +import java.util.Collections +import java.util.IdentityHashMap +import java.util.concurrent.ConcurrentHashMap +import org.polyfrost.compose.render.PolyColor +import org.polyfrost.oneconfig.api.config.v1.copy +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi + +@ExperimentalOneConfigUiApi +public data class HudStyle( + public val font: Font = Font.Minecraft, + public val textColor: PolyColor = PolyColor(0xFFFFFFFF.toInt()), + public val shadow: Boolean = false, + public val shadowColor: PolyColor = PolyColor(0x40000000), + public val background: Boolean = true, + public val backgroundColor: PolyColor = PolyColor(0x80000000.toInt()), + public val border: Boolean = false, + public val borderColor: PolyColor = PolyColor(0xFF000000.toInt()), + public val borderSize: Float = 1f, + public val radius: Float = 4f, + public val padding: Float = 4f, + public val scale: Float = 1f, +) + +@ExperimentalOneConfigUiApi +public object HudStyles { + private val defaults = ConcurrentHashMap() + private val builtInPadding = ConcurrentHashMap, FloatArray>() + + @JvmStatic + public val builtIn: HudStyle get() = HudStyle() + + @JvmStatic + public fun getDefault(modId: String): HudStyle = defaults[modId]?.deepCopy() ?: HudStyle() + + @JvmStatic + public fun setDefault(modId: String, style: HudStyle) { + val next = style.deepCopy() + val previous = defaults.put(modId, next) + if (previous == next) return + Snapshot.withMutableSnapshot { forEachHud(modId) { applyStyle(it, previous, next) } } + } + + @JvmStatic + public fun applyToAll(modId: String, block: (Hud) -> Unit) { + Snapshot.withMutableSnapshot { forEachHud(modId, block) } + } + + internal fun applyDefaultTo(hud: Hud) { + val modId = hud.configId ?: return + val style = defaults[modId] ?: return + applyStyle(hud, null, style) + } + + internal fun captureBuiltIn(hud: Hud) { + builtInPadding.putIfAbsent(hud.javaClass, floatArrayOf(hud.padTop, hud.padBottom, hud.padLeft, hud.padRight)) + } + + private inline fun forEachHud(modId: String, block: (Hud) -> Unit) { + val seen = Collections.newSetFromMap(IdentityHashMap()) + for (hud in HudManager.providers().toList()) if (hud.configId == modId && seen.add(hud)) block(hud) + for (hud in HudManager.activeInstances.toList()) if (hud.configId == modId && seen.add(hud)) block(hud) + } + + private fun applyStyle(hud: Hud, previous: HudStyle?, to: HudStyle) { + val from = previous ?: HudStyle() + val pad = if (previous == null) builtInPadding[hud.javaClass] else null + if (hud.font == from.font) hud.font = to.font + if (hud.textPolyColor.sameAs(from.textColor)) hud.textPolyColor = to.textColor + if (hud.showShadow == from.shadow) hud.showShadow = to.shadow + if (hud.shadowPolyColor.sameAs(from.shadowColor)) hud.shadowPolyColor = to.shadowColor + if (hud.hasBackground()) { + if (hud.showBackground == from.background) hud.showBackground = to.background + if (hud.bgPolyColor.sameAs(from.backgroundColor)) hud.bgPolyColor = to.backgroundColor + if (hud.borderEnabled == from.border) hud.borderEnabled = to.border + if (hud.borderPolyColor.sameAs(from.borderColor)) hud.borderPolyColor = to.borderColor + if (hud.borderSize == from.borderSize) hud.borderSize = to.borderSize + if (hud.bgRadius == from.radius) hud.bgRadius = to.radius + } + if (hud.padTop == (pad?.get(0) ?: from.padding)) hud.padTop = to.padding + if (hud.padBottom == (pad?.get(1) ?: from.padding)) hud.padBottom = to.padding + if (hud.padLeft == (pad?.get(2) ?: from.padding)) hud.padLeft = to.padding + if (hud.padRight == (pad?.get(3) ?: from.padding)) hud.padRight = to.padding + if (hud.customScale == from.scale) hud.customScale = to.scale + } + + private fun PolyColor.sameAs(other: PolyColor): Boolean = + rawArgb == other.rawArgb && chroma == other.chroma && chromaSpeed == other.chromaSpeed + + private fun HudStyle.deepCopy(): HudStyle = copy( + textColor = textColor.copy(), + shadowColor = shadowColor.copy(), + backgroundColor = backgroundColor.copy(), + borderColor = borderColor.copy(), + ) +} diff --git a/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudSuppression.kt b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudSuppression.kt new file mode 100644 index 000000000..4cf206f93 --- /dev/null +++ b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudSuppression.kt @@ -0,0 +1,9 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) +@file:JvmName("HudSuppression") + +package org.polyfrost.oneconfig.api.hud.v1 + +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi + +@ExperimentalOneConfigUiApi +public fun Hud.suppress(otherHudId: String, reason: String): AutoCloseable = HudManager.addSuppression(this, otherHudId, reason) diff --git a/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudText.kt b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudText.kt new file mode 100644 index 000000000..93ed76e8b --- /dev/null +++ b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudText.kt @@ -0,0 +1,355 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.hud.v1 + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ComposeNode +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.TextComponent +import net.kyori.adventure.text.TranslatableComponent +import net.kyori.adventure.text.format.Style +import net.kyori.adventure.text.format.TextDecoration +import org.polyfrost.compose.composables.PolyBox +import org.polyfrost.compose.composables.padding +import org.polyfrost.compose.composables.size +import org.polyfrost.compose.layout.PolyAlign +import org.polyfrost.compose.layout.PolyInsets +import org.polyfrost.compose.layout.PolySize +import org.polyfrost.compose.mc.McFontQueue +import org.polyfrost.compose.node.PolyNode +import org.polyfrost.compose.render.FontManager +import org.polyfrost.compose.render.PolyColor +import org.polyfrost.compose.render.PolyFont +import org.polyfrost.compose.render.RenderContext +import org.polyfrost.compose.runtime.PolyApplier +import org.polyfrost.oneconfig.api.platform.v1.Platform +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi + +@ExperimentalOneConfigUiApi +public data class TextRun( + public val text: String, + public val color: PolyColor? = null, + public val bold: Boolean = false, + public val italic: Boolean = false, + public val underline: Boolean = false, +) + +@ExperimentalOneConfigUiApi +public fun List.plainText(): String = joinToString("") { it.text } + +@ExperimentalOneConfigUiApi +public fun Component.toTextRuns(): List { + val out = ArrayList() + appendRuns(this, Style.empty(), out) + return out +} + +@ExperimentalOneConfigUiApi +@Composable +public fun Hud.HudText(lines: List>, alignColumns: Boolean = false) { + HudRunText({ lines }, alignColumns) +} + +@Composable +internal fun Hud.HudRunText(lines: () -> List>, alignColumns: Boolean) { + val contentAlign = alignment + val padInsets = PolyInsets(padLeft, padTop, padRight, padBottom) + val isStaticValid = staticWidth && staticW > 0f && staticH > 0f + val bgModifier = hudBackground() + val outerModifier = + if (isStaticValid) bgModifier.size(staticW, staticH).padding(padInsets) + else bgModifier.padding(padInsets) + + val spec = RunTextSpec( + poppinsName = if (font == Font.Poppins) getPoppinsFontName() else null, + bold = textBold, + italic = textItalic, + underline = textUnderline, + scale = textScale, + color = PolyColor(textColor, textChroma, textChromaSpeed), + shadow = if (showShadow) PolyColor(shadowColor, shadowChroma, shadowChromaSpeed) else null, + fullShadow = fullShadow, + shadowX = shadowOffsetX, + shadowY = shadowOffsetY, + case = caseType, + alignColumns = alignColumns, + lineAlign = if (isStaticValid) horizontalFactor(contentAlign) else 0f, + ) + + PolyBox(modifier = outerModifier) { + ComposeNode( + factory = { RunTextNode() }, + update = { + set(lines) { this.lines = it } + set(spec) { this.spec = it } + set(if (isStaticValid) contentAlign else PolyAlign.TopLeft) { style.align = it } + }, + ) + } +} + +private const val LINE_GAP = 2f +private const val POPPINS_SIZE = 8f +private const val LEGACY_CHAR = '§' + +private val LEGACY_COLORS = intArrayOf( + 0x000000, 0x0000AA, 0x00AA00, 0x00AAAA, 0xAA0000, 0xAA00AA, 0xFFAA00, 0xAAAAAA, + 0x555555, 0x5555FF, 0x55FF55, 0x55FFFF, 0xFF5555, 0xFF55FF, 0xFFFF55, 0xFFFFFF, +) + +private fun horizontalFactor(align: PolyAlign): Float = when (align) { + PolyAlign.Top, PolyAlign.Center, PolyAlign.Bottom -> 0.5f + PolyAlign.TopRight, PolyAlign.Right, PolyAlign.BottomRight -> 1f + else -> 0f +} + +private fun applyCase(text: String, case: Int): String = when (case) { + 1 -> text.uppercase() + 2 -> text.lowercase() + else -> text +} + +private fun poppinsVariant(base: String, bold: Boolean, italic: Boolean): String { + val weight = if (bold) "poppins-bold" else base.removeSuffix("-italic") + return if (italic) "$weight-italic" else weight +} + +private fun appendRuns(component: Component, inherited: Style, out: MutableList) { + val style = component.style().merge(inherited, Style.Merge.Strategy.IF_ABSENT_ON_TARGET) + if (component is TextComponent) { + if (component.content().isNotEmpty()) appendLegacy(component.content(), style, out) + } else if (component is TranslatableComponent && component.arguments().isNotEmpty()) { + appendTranslated(component, style, out) + } else { + val text = runCatching { Platform.compatibility().resolveComponent(component.children(emptyList())) }.getOrNull().orEmpty() + if (text.isNotEmpty()) appendLegacy(text, style, out) + } + for (child in component.children()) appendRuns(child, style, out) +} + +private const val ARG_OPEN = '' +private const val ARG_CLOSE = '' + +private fun appendTranslated(component: TranslatableComponent, style: Style, out: MutableList) { + val args = component.arguments() + val markers = args.indices.map { Component.text("$ARG_OPEN$it$ARG_CLOSE") } + val template = runCatching { + Platform.compatibility().resolveComponent(component.arguments(markers).children(emptyList())) + }.getOrNull().orEmpty() + var pos = 0 + while (pos < template.length) { + val open = template.indexOf(ARG_OPEN, pos) + val close = if (open < 0) -1 else template.indexOf(ARG_CLOSE, open) + val index = if (close < 0) null else template.substring(open + 1, close).toIntOrNull()?.takeIf { it in args.indices } + if (index == null) { + if (pos < template.length) appendLegacy(template.substring(pos), style, out) + return + } + if (open > pos) appendLegacy(template.substring(pos, open), style, out) + val value = args[index].value() + appendRuns(if (value is Component) value else Component.text(value.toString()), style, out) + pos = close + 1 + } +} + +private fun appendLegacy(text: String, style: Style, out: MutableList) { + val baseColor = style.color()?.let { PolyColor(0xFF000000.toInt() or it.value()) } + val baseBold = style.hasDecoration(TextDecoration.BOLD) + val baseItalic = style.hasDecoration(TextDecoration.ITALIC) + val baseUnderline = style.hasDecoration(TextDecoration.UNDERLINED) + if (LEGACY_CHAR !in text) { + out.add(TextRun(text, baseColor, baseBold, baseItalic, baseUnderline)) + return + } + + var color = baseColor + var bold = baseBold + var italic = baseItalic + var underline = baseUnderline + val pending = StringBuilder() + fun flush() { + if (pending.isEmpty()) return + out.add(TextRun(pending.toString(), color, bold, italic, underline)) + pending.setLength(0) + } + + var i = 0 + while (i < text.length) { + val c = text[i] + if (c != LEGACY_CHAR || i + 1 >= text.length) { + pending.append(c) + i++ + continue + } + val code = text[i + 1].lowercaseChar() + val colorIndex = Character.digit(code, 16) + flush() + when { + colorIndex >= 0 -> { + color = PolyColor(0xFF000000.toInt() or LEGACY_COLORS[colorIndex]) + bold = false; italic = false; underline = false + } + code == 'l' -> bold = true + code == 'o' -> italic = true + code == 'n' -> underline = true + code == 'r' -> { + color = baseColor; bold = baseBold; italic = baseItalic; underline = baseUnderline + } + } + i += 2 + } + flush() +} + +private data class RunTextSpec( + val poppinsName: String?, + val bold: Boolean, + val italic: Boolean, + val underline: Boolean, + val scale: Float, + val color: PolyColor, + val shadow: PolyColor?, + val fullShadow: Boolean, + val shadowX: Float, + val shadowY: Float, + val case: Int, + val alignColumns: Boolean, + val lineAlign: Float, +) + +private class LaidRun(val text: String, val color: PolyColor?, val font: PolyFont?, val underline: Boolean, val width: Float) { + var offset = 0f +} + +private class LaidLine(val runs: List, val width: Float) + +private class RunTextNode : PolyNode() { + var lines: () -> List> = { emptyList() } + var spec: RunTextSpec? = null + private var measuredLines: List>? = null + private var measuredSpec: RunTextSpec? = null + private var measuredFonts = -1 + private var laid: List = emptyList() + private var lineHeight = 0f + private var ascent = 0f + private var chroma = false + + override fun measureContent() { + val s = spec ?: return + val source = lines() + if (source == measuredLines && s == measuredSpec && measuredFonts == FontManager.revision) return + measuredLines = source + measuredSpec = s + measuredFonts = FontManager.revision + + val base = s.poppinsName?.let { PolyFont(it, POPPINS_SIZE * s.scale) } + val measured = source.map { line -> line.map { layoutRun(s, base, it) } } + val columns = if (s.alignColumns) columnOffsets(measured) else null + laid = measured.map { runs -> + var cursor = 0f + var right = 0f + for ((i, run) in runs.withIndex()) { + run.offset = columns?.get(i) ?: cursor + cursor = run.offset + run.width + right = maxOf(right, cursor) + } + LaidLine(runs, right) + } + chroma = s.color.chroma || s.shadow?.chroma == true || source.any { line -> line.any { it.color?.chroma == true } } + + if (base != null) { + val metrics = base.metrics + ascent = metrics.ascent + lineHeight = metrics.descent - metrics.ascent + } else { + ascent = 0f + lineHeight = McFontQueue.measureBlockHeight(1, s.scale) + } + val count = laid.size + style.width = PolySize.Px(laid.maxOfOrNull { it.width } ?: 0f) + style.height = PolySize.Px(if (count == 0) 0f else count * lineHeight + (count - 1) * LINE_GAP * s.scale) + } + + private fun layoutRun(s: RunTextSpec, base: PolyFont?, run: TextRun): LaidRun { + val text = applyCase(run.text, s.case) + val underline = run.underline || s.underline + if (base != null && s.poppinsName != null) { + val italic = run.italic || s.italic + val font = if (run.bold || run.italic) PolyFont(poppinsVariant(s.poppinsName, run.bold, italic), base.size) else base + return LaidRun(text, run.color, font, underline, font.measureTextWidth(text)) + } + val formatted = buildString { + if (run.bold || s.bold) append("§l") + if (run.italic || s.italic) append("§o") + if (underline) append("§n") + append(text) + } + return LaidRun(formatted, run.color, null, false, McFontQueue.measureTextWidth(formatted, s.scale)) + } + + private fun columnOffsets(lines: List>): List { + val count = lines.maxOfOrNull { it.size } ?: return emptyList() + val widths = FloatArray(count) + for (line in lines) for ((i, run) in line.withIndex()) widths[i] = maxOf(widths[i], run.width) + var offset = 0f + return List(count) { i -> offset.also { offset += widths[i] } } + } + + override fun render(ctx: RenderContext) { + val s = spec ?: return + val ls = laid + val px = x + val py = y + val w = width + if (chroma) ctx.live { draw(this, s, ls, px, py, w) } else draw(ctx, s, ls, px, py, w) + } + + private fun draw(ctx: RenderContext, s: RunTextSpec, lines: List, x: Float, y: Float, width: Float) { + val pitch = lineHeight + LINE_GAP * s.scale + for ((i, line) in lines.withIndex()) { + val lx = x + (width - line.width) * s.lineAlign + val top = y + i * pitch + if (s.poppinsName != null) drawPoppinsLine(ctx, s, line, lx, top - ascent) else drawMcLine(ctx, s, line, lx, top) + } + } + + private fun drawPoppinsLine(ctx: RenderContext, s: RunTextSpec, line: LaidLine, x: Float, baseline: Float) { + val shadow = s.shadow + if (shadow != null) { + for (run in line.runs) { + val font = run.font ?: continue + val rx = x + run.offset + val position = font.metrics.underlinePosition + val thickness = font.metrics.underlineThickness + if (s.fullShadow) { + ctx.textOutline(run.text, rx, baseline, shadow, font, s.scale) + if (run.underline) ctx.line(rx - s.scale, baseline + position, rx + run.width + s.scale, baseline + position, shadow, thickness + s.scale * 2f) + } else { + val sx = rx + s.shadowX + val sy = baseline + s.shadowY + ctx.text(run.text, sx, sy, shadow, font) + if (run.underline) ctx.line(sx, sy + position, sx + run.width, sy + position, shadow, thickness) + } + } + } + for (run in line.runs) { + val font = run.font ?: continue + val rx = x + run.offset + val color = run.color ?: s.color + ctx.text(run.text, rx, baseline, color, font) + if (run.underline) { + val position = font.metrics.underlinePosition + ctx.line(rx, baseline + position, rx + run.width, baseline + position, color, font.metrics.underlineThickness) + } + } + } + + private fun drawMcLine(ctx: RenderContext, s: RunTextSpec, line: LaidLine, x: Float, top: Float) { + val shadow = s.shadow + for (run in line.runs) { + val color = (run.color ?: s.color).argb + if (shadow != null && s.fullShadow) ctx.mcTextOutline(run.text, x + run.offset, top, color, shadow.argb, s.scale) + else ctx.mcText(run.text, x + run.offset, top, color, shadow != null, s.scale) + } + } +} diff --git a/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/TextHud.kt b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/TextHud.kt index ea0a6cd11..a44a3a64b 100644 --- a/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/TextHud.kt +++ b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/TextHud.kt @@ -24,10 +24,13 @@ * */ +@file:OptIn(ExperimentalOneConfigUiApi::class) + package org.polyfrost.oneconfig.api.hud.v1 import androidx.compose.runtime.Composable import androidx.compose.runtime.ComposeNode +import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.util.fastFilterNotNull import androidx.compose.ui.util.fastJoinToString import java.time.LocalDateTime @@ -49,6 +52,7 @@ import org.polyfrost.compose.runtime.PolyApplier import org.polyfrost.compose.render.PolyFont import org.polyfrost.compose.render.PolyColor import org.polyfrost.oneconfig.api.config.v1.annotations.Switch +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi import org.polyfrost.oneconfig.api.config.v1.annotations.Text as TextAnnotation public abstract class TextHud( @@ -77,6 +81,8 @@ public abstract class TextHud( } @Volatile private var displayText: String = "" + @Volatile @Transient private var displayRuns: List>? = null + @Transient private var runMode = mutableStateOf(false) private fun casedText(case: Int): String = when (case) { 1 -> displayText.uppercase() @@ -86,6 +92,10 @@ public abstract class TextHud( @Composable override fun Content() { + if (runMode.value) { + HudRunText({ displayRuns.orEmpty() }, alignColumns = false) + return + } val case = caseType val contentAlign = alignment @@ -167,8 +177,71 @@ public abstract class TextHud( @Transient private var lastPrefix: String? = null @Transient private var lastSuffix: String? = null @Transient private var lastBrackets = false + @Transient private var lastSeparator: String? = null + @Transient private var lastRuns: List? = null + + @ExperimentalOneConfigUiApi + protected open fun getTextRuns(): List? = null + + private fun updateRuns(runs: List): Boolean { + val p = prefix + val sfx = suffix + if (displayRuns != null && lastPrefix === p && lastSuffix === sfx && lastBrackets == brackets && lastSeparator == concatString && runs == lastRuns) return true + lastRuns = runs.toList() + lastPrefix = p + lastSuffix = sfx + lastBrackets = brackets + lastSeparator = concatString + lastValue = null + + val separator = concatString + val joined = ArrayList() + for (part in listOf(listOf(TextRun(p)), runs, listOf(TextRun(sfx)))) { + if (part.all { it.text.isEmpty() }) continue + if (joined.isNotEmpty() && separator.isNotEmpty()) joined.add(TextRun(separator)) + joined.addAll(part) + } + if (brackets && joined.isNotEmpty()) { + joined.add(0, TextRun("[")) + joined.add(TextRun("]")) + } + + val lines = splitLines(joined) + if (lines != displayRuns) { + displayRuns = lines + displayText = joined.plainText() + contentChanged() + } + if (!runMode.value) runMode.value = true + return true + } + + private fun splitLines(runs: List): List> { + val lines = ArrayList>() + var current = ArrayList() + for (run in runs) { + val parts = run.text.split('\n') + for ((i, part) in parts.withIndex()) { + if (i > 0) { + lines.add(current) + current = ArrayList() + } + if (part.isNotEmpty()) current.add(if (parts.size == 1) run else run.copy(text = part)) + } + } + lines.add(current) + return lines + } override fun update(): Boolean { + val runs = getTextRuns() + if (runs != null) return updateRuns(runs) + if (runMode.value) { + runMode.value = false + displayRuns = null + lastRuns = null + lastPrefix = null + } val value = getText() val p = prefix val sfx = suffix @@ -205,6 +278,9 @@ public abstract class TextHud( override fun clone(): Hud = (super.clone() as TextHud).also { it.displayText = "" it.lastPrefix = null + it.displayRuns = null + it.lastRuns = null + it.runMode = mutableStateOf(false) } @ApiStatus.Internal diff --git a/modules/internal/src/main/java/org/polyfrost/oneconfig/internal/ThemeConfig.java b/modules/internal/src/main/java/org/polyfrost/oneconfig/internal/ThemeConfig.java index 1b48382ad..02e2c0990 100644 --- a/modules/internal/src/main/java/org/polyfrost/oneconfig/internal/ThemeConfig.java +++ b/modules/internal/src/main/java/org/polyfrost/oneconfig/internal/ThemeConfig.java @@ -24,6 +24,7 @@ import org.polyfrost.oneconfig.internal.ui.sound.UiSounds; import org.polyfrost.oneconfig.internal.ui.themes.GlassTint; import org.polyfrost.oneconfig.internal.ui.themes.PaletteAccent; +import org.polyfrost.oneconfig.internal.ui.themes.PrefsSync; import org.polyfrost.oneconfig.internal.ui.themes.ReduceTransparency; import org.polyfrost.oneconfig.internal.ui.themes.ProviderKt; @@ -266,11 +267,13 @@ protected void initialize(boolean byConfigManager) { "animations", (Boolean v) -> { AnimationsMotionScale.setAnimations(v); ReducedMotion.setEnabled(reducedMotion || !v); + PrefsSync.animations(v); return false; }); addCallback( "uiScale", (Number v) -> { ProviderKt.setUiScalePercent(v.floatValue()); + PrefsSync.uiScale(v.floatValue()); return false; }); addCallback( @@ -281,6 +284,17 @@ protected void initialize(boolean byConfigManager) { addCallback( "reducedMotion", (Boolean v) -> { ReducedMotion.setEnabled(v || !animations); + PrefsSync.reducedMotion(v); + return false; + }); + addCallback( + "enableBackgroundBlur", (Boolean v) -> { + PrefsSync.backgroundBlur(v); + return false; + }); + addCallback( + "guiOpenAnimation", (Boolean v) -> { + PrefsSync.guiOpenAnimation(v); return false; }); addCallback( @@ -313,6 +327,7 @@ protected void onInitialized(Tree tree) { PaletteAccent.load(paletteAccents); AnimationsMotionScale.setAnimations(animations); ReducedMotion.setEnabled(reducedMotion || !animations); + PrefsSync.sync(); } private static void updateAppearance() { diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIComponentsBridgeImpl.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIComponentsBridgeImpl.kt index 48d5231a5..a68924e5a 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIComponentsBridgeImpl.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIComponentsBridgeImpl.kt @@ -5,18 +5,23 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorProducer import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.BaselineShift +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpOffset import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp +import net.kyori.adventure.text.Component +import org.polyfrost.oneconfig.api.ui.v1.components.ColorPickerModel as KitColorPickerModel import androidx.compose.ui.window.PopupPositionProvider import androidx.compose.ui.window.PopupProperties import org.polyfrost.oneconfig.internal.ui.components.ReducedMotion @@ -28,9 +33,12 @@ import org.polyfrost.oneconfig.internal.ui.api.Tooltip as InternalTooltip import org.polyfrost.oneconfig.internal.ui.components.Chip as InternalChip import org.polyfrost.oneconfig.internal.ui.components.Icon as InternalIcon import org.polyfrost.oneconfig.internal.ui.components.IconButton as InternalIconButton -import org.polyfrost.oneconfig.internal.ui.components.Text as InternalText +import org.polyfrost.oneconfig.internal.ui.components.StyledText +import org.polyfrost.oneconfig.internal.ui.components.componentText +import org.polyfrost.oneconfig.internal.ui.components.legacyText import org.polyfrost.oneconfig.internal.ui.components.fadingEdges -import org.polyfrost.oneconfig.internal.ui.components.onClick +import androidx.compose.foundation.clickable +import org.polyfrost.oneconfig.internal.ui.components.settings.ChromaColorAnimation import org.polyfrost.oneconfig.internal.ui.components.settings.ColorPickerModel import org.polyfrost.oneconfig.internal.ui.components.settings.ColorPickerPopup as InternalColorPickerPopup import org.polyfrost.oneconfig.internal.ui.components.settings.LocalOptionWidth @@ -44,10 +52,19 @@ import org.polyfrost.oneconfig.internal.ui.themes.PolyGlassDark import org.polyfrost.oneconfig.internal.ui.themes.PolyGlassLight import org.polyfrost.oneconfig.internal.ui.themes.PolyfrostedDark import org.polyfrost.oneconfig.internal.ui.themes.PolyfrostedLight -import org.polyfrost.oneconfig.internal.ui.themes.Theme import org.polyfrost.oneconfig.internal.ui.themes.ThemeRegistry import org.polyfrost.oneconfig.internal.ui.themes.UITheme import org.polyfrost.oneconfig.internal.ui.themes.concentric +import org.polyfrost.oneconfig.api.ui.v1.components.UiSound +import org.polyfrost.oneconfig.api.ui.v1.theme.ThemeBuilder +import org.polyfrost.oneconfig.internal.ui.components.keepSearchFocus +import org.polyfrost.oneconfig.internal.ui.components.settings.LocalOptionWidthOrNull +import org.polyfrost.oneconfig.internal.ui.shell.pageSearchQueryOrNull as shellPageSearchQueryOrNull +import org.polyfrost.oneconfig.internal.ui.sound.UiSoundEvent +import org.polyfrost.oneconfig.internal.ui.sound.UiSounds +import org.polyfrost.oneconfig.internal.ui.themes.ThemeWith +import org.polyfrost.oneconfig.internal.ui.themes.modTheme +import org.polyfrost.oneconfig.internal.ui.themes.themedShape class OneConfigUIComponentsBridgeImpl : OneConfigUIComponentsBridge { @Composable @@ -57,7 +74,32 @@ class OneConfigUIComponentsBridgeImpl : OneConfigUIComponentsBridge { override fun accent(): Color = Accent @Composable - override fun ProvideTheme(content: @Composable () -> Unit) = Theme(content) + override fun ProvideTheme(theme: OneConfigUITheme?, scaleToUi: Boolean, content: @Composable () -> Unit) { + ensureThemeLoaded() + ThemeWith(theme as? UITheme, scaleToUi, content = content) + } + + @Composable + override fun themeShape(radius: Dp): Shape = themedShape(radius) + + @Composable + override fun themeIcon(name: String): String = LocalTheme.current.iconOverrides[name] ?: name + + override fun customTheme(base: OneConfigUITheme, id: String, name: String, builder: ThemeBuilder): OneConfigUITheme = + modTheme(base, id, name, builder) + + override fun playSound(sound: UiSound) { + UiSounds.play( + when (sound) { + UiSound.Click -> UiSoundEvent.CLICK + UiSound.Open -> UiSoundEvent.OPEN + UiSound.Close -> UiSoundEvent.CLOSE + UiSound.SliderTick -> UiSoundEvent.SLIDER_TICK + }, + ) + } + + override fun keepSearchFocus(modifier: Modifier): Modifier = modifier.keepSearchFocus() override fun polyfrostedDark(): OneConfigUITheme = PolyfrostedDark @@ -80,29 +122,60 @@ class OneConfigUIComponentsBridgeImpl : OneConfigUIComponentsBridge { override fun ensureThemeLoaded() { ThemeRegistry.init() - ThemeRegistry.loadFromConfig() + if (ThemeRegistry.activeTheme == null) ThemeRegistry.loadFromConfig() } @Composable override fun concentric(shape: Shape, inset: Dp, minRadius: Dp): Shape = shape.concentric(inset, minRadius) @Composable - override fun Icon(iconName: String, color: Color, modifier: Modifier, fitAspectRatio: Boolean) = - InternalIcon(iconName, color, modifier, fitAspectRatio) + override fun Icon(iconName: String, color: Color, modifier: Modifier, fitAspectRatio: Boolean, tint: (() -> Color)?) = + InternalIcon(iconName, color, modifier, fitAspectRatio, tint) + + @Composable + override fun Text( + text: String, + modifier: Modifier, + color: Color, + style: TextStyle, + maxLines: Int, + overflow: TextOverflow, + softWrap: Boolean, + textAlign: TextAlign?, + colorProducer: ColorProducer?, + onTextLayout: ((TextLayoutResult) -> Unit)?, + ) = StyledText(legacyText(text), modifier, color, style, maxLines, overflow, softWrap, textAlign, colorProducer, onTextLayout) + + @Composable + override fun Text( + text: AnnotatedString, + modifier: Modifier, + color: Color, + style: TextStyle, + maxLines: Int, + overflow: TextOverflow, + softWrap: Boolean, + textAlign: TextAlign?, + colorProducer: ColorProducer?, + onTextLayout: ((TextLayoutResult) -> Unit)?, + ) = StyledText(text, modifier, color, style, maxLines, overflow, softWrap, textAlign, colorProducer, onTextLayout) @Composable override fun Text( - text: Any, + text: Component, modifier: Modifier, color: Color, - fontSize: TextUnit, - lineHeight: TextUnit?, - shift: BaselineShift, - fontWeight: FontWeight, - textAlign: TextAlign, + style: TextStyle, maxLines: Int, overflow: TextOverflow, - ) = InternalText(text, modifier, color, fontSize, lineHeight, shift, fontWeight, textAlign, maxLines, overflow) + softWrap: Boolean, + textAlign: TextAlign?, + colorProducer: ColorProducer?, + onTextLayout: ((TextLayoutResult) -> Unit)?, + ) { + val annotated = remember(text) { componentText(text) } + StyledText(annotated, modifier, color, style, maxLines, overflow, softWrap, textAlign, colorProducer, onTextLayout) + } @Composable override fun Chip(label: String, selected: Boolean, icon: String?, onClick: () -> Unit) = @@ -134,7 +207,15 @@ class OneConfigUIComponentsBridgeImpl : OneConfigUIComponentsBridge { interactionSource: MutableInteractionSource, enabled: Boolean, onClick: () -> Unit, - ): Modifier = modifier.onClick(interactionSource, enabled, onClick) + ): Modifier = (if (enabled) modifier.keepSearchFocus() else modifier).clickable( + interactionSource = interactionSource, + indication = null, + enabled = enabled, + onClick = { + UiSounds.play(UiSoundEvent.CLICK) + onClick() + }, + ) override fun reducedMotion(): Boolean = ReducedMotion.enabled @@ -146,6 +227,7 @@ class OneConfigUIComponentsBridgeImpl : OneConfigUIComponentsBridge { .liquidGlass(shape, frosted = true) .background(theme.popupBackground, shape) .border(1.dp, theme.borderColor, shape) + .clip(shape) } @Composable @@ -160,11 +242,45 @@ class OneConfigUIComponentsBridgeImpl : OneConfigUIComponentsBridge { override fun fadingEdges(modifier: Modifier, scrollState: ScrollState, color: Color, length: Dp): Modifier = modifier.fadingEdges(scrollState, color, length) - override fun createColorPicker(initialColor: Color, hasAlpha: Boolean): Any = ColorPickerModel(initialColor, hasAlpha) + override fun createColorPicker( + initialColor: Color, + hasAlpha: Boolean, + chroma: Boolean, + chromaSpeed: Float, + gradient: Color?, + ): KitColorPickerModel = ColorPickerModel(initialColor, hasAlpha).also { configure(it, initialColor, chroma, chromaSpeed, gradient) } + + override fun updateColorPicker(model: KitColorPickerModel, color: Color, chroma: Boolean, chromaSpeed: Float, gradient: Color?) { + val real = model.implementation + if (real is ColorPickerModel) configure(real, color, chroma, chromaSpeed, gradient) else model.set(color) + } + + private fun configure(model: ColorPickerModel, color: Color, chroma: Boolean, chromaSpeed: Float, gradient: Color?) { + model.chromaEnabled = false + model.set(color) + model.chromaSpeed = chromaSpeed + model.chromaEnabled = chroma + model.gradientEnabled = gradient != null + if (gradient != null) model.gradientColor = gradient + } @Composable - override fun ColorPickerPopup(model: Any, onColorChanged: (Color) -> Unit, onClose: () -> Unit, chromaCapable: Boolean) = - InternalColorPickerPopup(model as ColorPickerModel, onColorChanged, onClose, chromaCapable) + override fun ColorPickerPanel( + model: KitColorPickerModel, + onColorChanged: (Color) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier, + chromaCapable: Boolean, + gradientCapable: Boolean, + ) { + val real = model.implementation + val picker = real as? ColorPickerModel ?: remember(real) { + ColorPickerModel(real.color, true).also { configure(it, real.color, real.chroma, real.chromaSpeed, real.gradient) } + } + val changed: (Color) -> Unit = if (picker === real) onColorChanged else { color -> real.set(color); onColorChanged(color) } + ChromaColorAnimation(picker) {} + InternalColorPickerPopup(picker, changed, onDismiss, chromaCapable, gradientCapable, modifier) + } override fun colorToHex(color: Color): String = internalColorToHex(color) @@ -173,4 +289,10 @@ class OneConfigUIComponentsBridgeImpl : OneConfigUIComponentsBridge { @Composable override fun pageSearchQuery(): String = shellPageSearchQuery() + + @Composable + override fun optionWidthOrNull(): Dp? = LocalOptionWidthOrNull.current + + @Composable + override fun pageSearchQueryOrNull(): String? = shellPageSearchQueryOrNull() } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIFeedbackBridgeImpl.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIFeedbackBridgeImpl.kt new file mode 100644 index 000000000..d615c5b9c --- /dev/null +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIFeedbackBridgeImpl.kt @@ -0,0 +1,13 @@ +package org.polyfrost.oneconfig.api.ui.v1.internal + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.polyfrost.oneconfig.internal.ui.components.NotificationsCenter as InternalNotificationsCenter + +class OneConfigUIFeedbackBridgeImpl : OneConfigUIFeedbackBridge { + @Composable + override fun NotificationsCenter(modifier: Modifier) { + Box(modifier) { InternalNotificationsCenter() } + } +} diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIGlassBridgeImpl.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIGlassBridgeImpl.kt new file mode 100644 index 000000000..b7f3f21f3 --- /dev/null +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIGlassBridgeImpl.kt @@ -0,0 +1,119 @@ +package org.polyfrost.oneconfig.api.ui.v1.internal + +import androidx.compose.foundation.LocalScrollbarStyle +import androidx.compose.foundation.ScrollbarStyle +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.ScrollableState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp +import org.polyfrost.oneconfig.internal.ui.components.backdropBlur as internalBackdropBlur +import org.polyfrost.oneconfig.internal.ui.components.wheelScroll as internalWheelScroll +import org.polyfrost.oneconfig.internal.ui.themes.Accent +import org.polyfrost.oneconfig.internal.ui.themes.CustomBase +import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme +import org.polyfrost.oneconfig.internal.ui.themes.ReduceTransparency +import org.polyfrost.oneconfig.internal.ui.themes.ThemeRegistry +import org.polyfrost.oneconfig.internal.ui.themes.UITheme +import org.polyfrost.oneconfig.internal.ui.themes.accentTextOn +import org.polyfrost.oneconfig.internal.ui.themes.animateTheme +import org.polyfrost.oneconfig.internal.ui.themes.baseTheme +import org.polyfrost.oneconfig.internal.ui.themes.customSpec +import org.polyfrost.oneconfig.internal.ui.themes.isCustom +import org.polyfrost.oneconfig.internal.ui.themes.palette +import org.polyfrost.oneconfig.internal.ui.components.animatedFrame as internalAnimatedFrame +import org.polyfrost.oneconfig.internal.ui.components.glassHover as internalGlassHover +import org.polyfrost.oneconfig.internal.ui.components.liquidGlass as internalLiquidGlass + +private val LocalVariantBase = compositionLocalOf { null } + +private const val SCROLLBAR_IDLE_ALPHA = 0.40f +private const val SCROLLBAR_HOVER_ALPHA = 0.70f + +class OneConfigUIGlassBridgeImpl : OneConfigUIGlassBridge { + @Composable + override fun glassHover(modifier: Modifier, shape: Shape, pull: Dp): Modifier = modifier.internalGlassHover(shape, pull) + + @Composable + override fun liquidGlass(modifier: Modifier, shape: Shape): Modifier = modifier.internalLiquidGlass(shape) + + override fun animatedFrame(modifier: Modifier, shape: Shape, fill: () -> Color, accentAlpha: () -> Float, border: () -> Color): Modifier = + modifier.internalAnimatedFrame(shape, fill, accentAlpha, border = border) + + @Composable + override fun liquidGlass(modifier: Modifier, shape: Shape, frosted: Boolean): Modifier = modifier.internalLiquidGlass(shape, frosted) + + @Composable + override fun backdropBlur(modifier: Modifier, shape: Shape): Modifier = modifier.internalBackdropBlur(shape) + + override fun wheelScroll(modifier: Modifier, state: ScrollableState, orientation: Orientation): Modifier = + modifier.internalWheelScroll(state, orientation) + + @Composable + override fun ProvideThemeVariant(dark: Boolean, keepAccent: Boolean, content: @Composable () -> Unit) { + val base = LocalVariantBase.current ?: LocalTheme.current + val opaque = ReduceTransparency.enabled + val accent = Accent + val target = remember(base, dark, keepAccent, opaque, accent) { variantOf(base, dark, keepAccent, opaque, accent) } + val shown = animateTheme(target) + val scrollbar = LocalScrollbarStyle.current + val scrollbarStyle = remember(scrollbar, shown.textColorSecondary) { + ScrollbarStyle( + minimalHeight = scrollbar.minimalHeight, + thickness = scrollbar.thickness, + shape = scrollbar.shape, + hoverDurationMillis = scrollbar.hoverDurationMillis, + unhoverColor = shown.textColorSecondary.copy(alpha = SCROLLBAR_IDLE_ALPHA), + hoverColor = shown.textColorSecondary.copy(alpha = SCROLLBAR_HOVER_ALPHA), + ) + } + CompositionLocalProvider( + LocalVariantBase provides base, + LocalTheme provides shown, + LocalScrollbarStyle provides scrollbarStyle, + content = content, + ) + } +} + +private fun variantOf(base: UITheme, dark: Boolean, keepAccent: Boolean, opaque: Boolean, accent: Color): UITheme { + if (base.isLight != dark) return base + val sibling = siblingOf(base, light = !dark) + val accentText = if (keepAccent) base.accentTextColor else sibling.accentTextOn(accent) + val shaped = sibling.copy( + accentTextColor = accentText, + backgroundShape = base.backgroundShape, + sideBarNavigationEntryShape = base.sideBarNavigationEntryShape, + modCardShape = base.modCardShape, + checkBoxShape = base.checkBoxShape, + buttonShape = base.buttonShape, + popupShape = base.popupShape, + circleShape = base.circleShape, + ).withControlTrackColor(sibling.controlTrackColor) + return if (opaque) shaped.withOpaqueSurfaces() else shaped +} + +private fun siblingOf(theme: UITheme, light: Boolean): UITheme { + val themes = ThemeRegistry.registry.toList() + theme.palette?.group?.let { group -> + themes.firstOrNull { it.palette?.group == group && it.isLight == light }?.let { return it } + } + theme.customSpec?.let { return baseTheme(it.base, light) } + if (!theme.isCustom) { + val family = familyName(theme.name) + themes.firstOrNull { !it.isCustom && familyName(it.name) == family && it.isLight == light }?.let { return it } + } + val style = when { + theme.liquidGlass -> CustomBase.POLYGLASS + theme.isMinecraftStyle -> CustomBase.MINECRAFT + else -> CustomBase.POLYFROSTED + } + return baseTheme(style, light) +} + +private fun familyName(name: String): String = name.removeSuffix(" Light").removeSuffix(" Dark") diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIKeybindRecordingBridgeImpl.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIKeybindRecordingBridgeImpl.kt new file mode 100644 index 000000000..fd7449cbc --- /dev/null +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIKeybindRecordingBridgeImpl.kt @@ -0,0 +1,18 @@ +package org.polyfrost.oneconfig.api.ui.v1.internal + +import org.polyfrost.oneconfig.internal.ui.keybind.KeybindRecordingBus + +class OneConfigUIKeybindRecordingBridgeImpl : OneConfigUIKeybindRecordingBridge { + override fun startRecording(onEscape: () -> Unit, onMouse: (button: Int, pressed: Boolean) -> Unit) { + KeybindRecordingBus.setEscapeHandler(onEscape) + KeybindRecordingBus.setMouseHandler(onMouse) + } + + override fun stopRecording(onEscape: () -> Unit, onMouse: (button: Int, pressed: Boolean) -> Unit) { + KeybindRecordingBus.clearEscapeHandler(onEscape) + KeybindRecordingBus.clearMouseHandler(onMouse) + } + + override val isRecording: Boolean + get() = KeybindRecordingBus.isRecording +} diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/OneConfigInterface.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/OneConfigInterface.kt index d060b07b5..55df0da90 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/OneConfigInterface.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/OneConfigInterface.kt @@ -2,7 +2,6 @@ package org.polyfrost.oneconfig.internal.ui import androidx.compose.animation.core.EaseOutCubic import androidx.compose.animation.core.Easing -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -12,8 +11,10 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.movableContentOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.withFrameNanos @@ -33,6 +34,9 @@ import kotlin.math.pow import kotlinx.coroutines.flow.first import kotlinx.coroutines.withTimeoutOrNull import org.apache.logging.log4j.LogManager +import org.polyfrost.oneconfig.api.ui.v1.InternalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.api.ui.v1.components.FeedbackHosts import org.polyfrost.oneconfig.internal.OneConfigConfig import org.polyfrost.oneconfig.internal.ThemeConfig import org.polyfrost.oneconfig.internal.ui.components.LocalSearchFieldFocused @@ -56,6 +60,7 @@ private const val MAX_CLOSE_ANIMATION_MS = 160L private const val GRAPH_WAIT_TIMEOUT_MS = 10_000L +@OptIn(InternalOneConfigUiApi::class) @Composable fun OneConfigInterface( windowWidth: Float, @@ -180,24 +185,29 @@ fun OneConfigInterface( val animMs = (ThemeConfig.animationTime * 1000f).toInt().coerceAtLeast(1) val enterMs = if (ThemeConfig.guiOpenAnimation) animMs else 1 val exitMs = guiCloseAnimationMillis().toInt().coerceAtLeast(1) - val dragAlpha by animateFloatAsState( - targetValue = if (shell.layout.hudDragging) (OneConfigConfig.hudDragUiOpacity / 100f).coerceIn(0f, 1f) else 1f, - animationSpec = tween(150), + val dragAlpha by Motion.animateFloatAsState( + target = if (shell.layout.hudDragging) (OneConfigConfig.hudDragUiOpacity / 100f).coerceIn(0f, 1f) else 1f, + spec = tween(150), label = "hudDragShellAlpha" ) - RetainedVisibility( - visible = visible, - enter = tween(enterMs, easing = EaseOutExpo), - exit = tween(exitMs, easing = EaseOutCubic), - alphaMultiplier = dragAlpha, - openKey = openRevision, - modifier = Modifier.onGloballyPositioned { - shell.layout.updateShellBounds(it.boundsInRoot()) - }, - ) { _ -> - Shell(windowWidth, windowHeight, shellBackdrop) + val shellContent: @Composable () -> Unit = { + RetainedVisibility( + visible = visible, + enter = tween(enterMs, easing = EaseOutExpo), + exit = tween(exitMs, easing = EaseOutCubic), + alphaMultiplier = dragAlpha, + openKey = openRevision, + modifier = Modifier.onGloballyPositioned { + shell.layout.updateShellBounds(it.boundsInRoot()) + }, + ) { _ -> + Shell(windowWidth, windowHeight, shellBackdrop) + } } + val latestShellContent by rememberUpdatedState(shellContent) + val movableShell = remember { movableContentOf { latestShellContent() } } + if (shell.warming) movableShell() else FeedbackHosts(movableShell) } } } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/ConfigRegistry.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/ConfigRegistry.kt index f137a4937..7a0d65b8c 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/ConfigRegistry.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/ConfigRegistry.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.snapshots.SnapshotStateList import org.apache.logging.log4j.LogManager import org.polyfrost.oneconfig.api.config.v1.ConfigManager import org.polyfrost.oneconfig.api.config.v1.Tree +import org.polyfrost.oneconfig.api.config.v1.get import org.polyfrost.oneconfig.api.platform.v1.Platform import org.polyfrost.oneconfig.internal.ui.components.asRenderText import org.polyfrost.oneconfig.internal.ui.hud.cardsSupersededByHudCards @@ -142,7 +143,7 @@ object ConfigRegistry { ): Boolean { MinecraftKeybindRegistrar.scan(tree, force = bumpRevision) if (tree.id == null || tree.title == null) return false - if (tree.getMetadata("hidden") != null) return false + if (tree.getMetadata("hidden").let { it != null && it != false }) return false return upsert(TreeConfigData(tree, source, onOpen), bumpRevision) } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/TextComponentRenderer.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/TextComponentRenderer.kt index 7f674d3a1..d3ed2036e 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/TextComponentRenderer.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/TextComponentRenderer.kt @@ -1,11 +1,10 @@ package org.polyfrost.oneconfig.internal.ui.api -import androidx.compose.foundation.* import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment +import androidx.compose.ui.BiasAlignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorProducer @@ -18,13 +17,13 @@ import androidx.compose.ui.text.style.* import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.TextUnit -import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import net.kyori.adventure.text.Component import net.kyori.adventure.text.format.Style import net.kyori.adventure.text.format.TextDecoration as KyoriDecoration import org.polyfrost.oneconfig.api.platform.v1.Platform -import org.polyfrost.oneconfig.internal.ui.components.liquidGlass +import org.polyfrost.oneconfig.api.ui.v1.components.TooltipBox +import org.polyfrost.oneconfig.api.ui.v1.components.TooltipPlacement import org.polyfrost.oneconfig.internal.ui.components.parseLegacyFormatting import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme @@ -82,7 +81,7 @@ private fun spanStyle(style: Style) = SpanStyle( ) @Composable -@OptIn(ExperimentalFoundationApi::class) +@Suppress("UNUSED_PARAMETER") fun Tooltip( text: @Composable () -> Unit, modifier: Modifier, @@ -91,21 +90,22 @@ fun Tooltip( offset: DpOffset = DpOffset.Zero, content: @Composable () -> Unit ) { - val placement = TooltipPlacement.ComponentRect( - anchor = anchor, alignment = alignment, offset = offset + TooltipBox( + tooltip = { Box(modifier) { text() } }, + placement = tooltipPlacement(anchor, alignment), + content = content, ) +} - val theme = LocalTheme.current - TooltipArea( - content = content, delayMillis = 200, tooltip = @Composable { - Box( - modifier = modifier.liquidGlass(theme.popupShape, frosted = true) - .background(theme.popupBackground, shape = theme.popupShape) - .border(1.dp, theme.borderColor, shape = theme.popupShape) - .padding(horizontal = 12.dp, vertical = 8.dp) - ) { - text() - } - }, tooltipPlacement = placement - ) +private fun tooltipPlacement(anchor: Alignment, alignment: Alignment): TooltipPlacement { + val above = ((anchor as? BiasAlignment)?.verticalBias ?: 1f) < 0f + val horizontal = (alignment as? BiasAlignment)?.horizontalBias ?: 0f + return when { + above && horizontal < 0f -> TooltipPlacement.AboveStart + above && horizontal > 0f -> TooltipPlacement.AboveEnd + above -> TooltipPlacement.AboveCenter + horizontal < 0f -> TooltipPlacement.BelowStart + horizontal > 0f -> TooltipPlacement.BelowEnd + else -> TooltipPlacement.BelowCenter + } } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/settings/BuiltinVisualizers.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/settings/BuiltinVisualizers.kt index f6a732688..4d177862c 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/settings/BuiltinVisualizers.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/settings/BuiltinVisualizers.kt @@ -14,6 +14,7 @@ import org.polyfrost.oneconfig.internal.ui.components.settings.InfoOption import org.polyfrost.oneconfig.internal.ui.components.settings.InheritableSliderOption import org.polyfrost.oneconfig.internal.ui.components.settings.ItemListOption import org.polyfrost.oneconfig.internal.ui.components.settings.KeybindOption +import org.polyfrost.oneconfig.internal.ui.components.settings.LocalOptionEnabled import org.polyfrost.oneconfig.internal.ui.components.settings.MultiSelectDropdownOption import org.polyfrost.oneconfig.internal.ui.components.settings.NumberChainOption import org.polyfrost.oneconfig.internal.ui.components.settings.NumberListOption @@ -24,17 +25,18 @@ import org.polyfrost.oneconfig.internal.ui.components.settings.RangeOption import org.polyfrost.oneconfig.internal.ui.components.settings.SliderOption import org.polyfrost.oneconfig.internal.ui.components.settings.TextListOption import org.polyfrost.oneconfig.internal.ui.components.settings.TextOption +import org.polyfrost.oneconfig.internal.ui.components.settings.enabledAwareVisualizers internal object BuiltinVisualizers { fun register() { - Visualizer.register(Visualizer.SwitchVisualizer::class.java, Visualizer { prop -> - BooleanOption(BooleanOptionData(prop, BooleanOptionData.Style.Switch)) + registerEnabledAware(Visualizer.SwitchVisualizer::class.java, Visualizer { prop -> + BooleanOption(BooleanOptionData(prop, BooleanOptionData.Style.Switch), LocalOptionEnabled.current) }) - Visualizer.register(Visualizer.CheckboxVisualizer::class.java, Visualizer { prop -> - BooleanOption(BooleanOptionData(prop, BooleanOptionData.Style.Checkbox)) + registerEnabledAware(Visualizer.CheckboxVisualizer::class.java, Visualizer { prop -> + BooleanOption(BooleanOptionData(prop, BooleanOptionData.Style.Checkbox), LocalOptionEnabled.current) }) - Visualizer.register(Visualizer.SliderVisualizer::class.java, Visualizer { prop -> - SliderOption(SliderOptionData(prop)) + registerEnabledAware(Visualizer.SliderVisualizer::class.java, Visualizer { prop -> + SliderOption(SliderOptionData(prop), LocalOptionEnabled.current) }) Visualizer.register(Visualizer.RangeSliderVisualizer::class.java, Visualizer { prop -> RangeOption(RangeOptionData(prop, RangeOptionData.Style.Slider)) @@ -48,23 +50,23 @@ internal object BuiltinVisualizers { Visualizer.register(Visualizer.NumberChainVisualizer::class.java, Visualizer { prop -> NumberChainOption(NumberChainOptionData(prop)) }) - Visualizer.register(Visualizer.NumberVisualizer::class.java, Visualizer { prop -> - NumberOption(NumberOptionData(prop)) + registerEnabledAware(Visualizer.NumberVisualizer::class.java, Visualizer { prop -> + NumberOption(NumberOptionData(prop), LocalOptionEnabled.current) }) - Visualizer.register(Visualizer.TextVisualizer::class.java, Visualizer { prop -> - TextOption(TextOptionData(prop)) + registerEnabledAware(Visualizer.TextVisualizer::class.java, Visualizer { prop -> + TextOption(TextOptionData(prop), LocalOptionEnabled.current) }) Visualizer.register(Visualizer.TextListVisualizer::class.java, Visualizer { prop -> TextListOption(TextListOptionData(prop)) }) - Visualizer.register(Visualizer.ItemListVisualizer::class.java, Visualizer { prop -> + registerEnabledAware(Visualizer.ItemListVisualizer::class.java, Visualizer { prop -> ItemListOption(ItemListOptionData(prop)) }) - Visualizer.register(Visualizer.EffectListVisualizer::class.java, Visualizer { prop -> + registerEnabledAware(Visualizer.EffectListVisualizer::class.java, Visualizer { prop -> EffectListOption(EffectListOptionData(prop)) }) - Visualizer.register(Visualizer.FileVisualizer::class.java, Visualizer { prop -> - FileOption(FileOptionData(prop)) + registerEnabledAware(Visualizer.FileVisualizer::class.java, Visualizer { prop -> + FileOption(FileOptionData(prop), LocalOptionEnabled.current) }) Visualizer.register(Visualizer.FileListVisualizer::class.java, Visualizer { prop -> FileListOption(FileListOptionData(prop)) @@ -84,29 +86,34 @@ internal object BuiltinVisualizers { Visualizer.register(Visualizer.RangeNumberListVisualizer::class.java, Visualizer { prop -> RangeListOption(RangeListOptionData(prop, RangeOptionData.Style.Spinner)) }) - Visualizer.register(Visualizer.DropdownVisualizer::class.java, Visualizer { prop -> - DropdownOption(DropdownOptionData(prop)) + registerEnabledAware(Visualizer.DropdownVisualizer::class.java, Visualizer { prop -> + DropdownOption(DropdownOptionData(prop), LocalOptionEnabled.current) }) - Visualizer.register(Visualizer.RadioVisualizer::class.java, Visualizer { prop -> - RadioButtonOption(RadioButtonOptionData(prop)) + registerEnabledAware(Visualizer.RadioVisualizer::class.java, Visualizer { prop -> + RadioButtonOption(RadioButtonOptionData(prop), LocalOptionEnabled.current) }) - Visualizer.register(Visualizer.ColorVisualizer::class.java, Visualizer { prop -> - ColorOption(ColorOptionData(prop)) + registerEnabledAware(Visualizer.ColorVisualizer::class.java, Visualizer { prop -> + ColorOption(ColorOptionData(prop), LocalOptionEnabled.current) }) - Visualizer.register(Visualizer.KeybindVisualizer::class.java, Visualizer { prop -> - KeybindOption(KeybindOptionData(prop)) + registerEnabledAware(Visualizer.KeybindVisualizer::class.java, Visualizer { prop -> + KeybindOption(KeybindOptionData(prop), LocalOptionEnabled.current) }) - Visualizer.register(Visualizer.ButtonVisualizer::class.java, Visualizer { prop -> - ButtonOption(ButtonOptionData(prop)) + registerEnabledAware(Visualizer.ButtonVisualizer::class.java, Visualizer { prop -> + ButtonOption(ButtonOptionData(prop), LocalOptionEnabled.current) }) Visualizer.register(Visualizer.DraggableListVisualizer::class.java, Visualizer { prop -> DraggableListOption(DraggableListOptionData(prop)) }) - Visualizer.register(Visualizer.MultiSelectDropdownVisualizer::class.java, Visualizer { prop -> - MultiSelectDropdownOption(MultiSelectDropdownOptionData(prop)) + registerEnabledAware(Visualizer.MultiSelectDropdownVisualizer::class.java, Visualizer { prop -> + MultiSelectDropdownOption(MultiSelectDropdownOptionData(prop), LocalOptionEnabled.current) }) Visualizer.register(Visualizer.InfoVisualizer::class.java, Visualizer { prop -> InfoOption(InfoOptionData(prop)) }) } + + private fun registerEnabledAware(type: Class, visualizer: Visualizer) { + enabledAwareVisualizers += visualizer + Visualizer.register(type, visualizer) + } } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/settings/OptionData.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/settings/OptionData.kt index 2ccb64ada..5a48f9e15 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/settings/OptionData.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/settings/OptionData.kt @@ -6,10 +6,12 @@ import java.util.function.Function import java.util.function.Supplier import kotlin.math.floor import kotlin.math.roundToInt +import org.polyfrost.oneconfig.api.config.v1.MetaKeys import org.polyfrost.oneconfig.api.config.v1.Property import org.polyfrost.oneconfig.internal.ui.api.shown import org.polyfrost.oneconfig.api.config.v1.SliderBounds import org.polyfrost.oneconfig.api.config.v1.ValueFormatter +import org.polyfrost.oneconfig.api.config.v1.get import org.polyfrost.oneconfig.internal.ui.components.asRenderText import org.polyfrost.oneconfig.internal.ui.components.effect.normalizeEffectIds import org.polyfrost.oneconfig.internal.ui.components.localizedDescription @@ -22,7 +24,7 @@ import org.polyfrost.oneconfig.internal.ui.components.settings.toNumberType sealed class OptionData(val prop: Property<*>) { val title: Any get() = localizedText(prop.getMetadata("titleKey"), prop.title ?: prop.id ?: "") val description: Any? get() = prop.localizedDescription() - val icon: String? get() = prop.getMetadata("icon") + val icon: String? get() = prop.getMetadata(MetaKeys.ICON.name) as? String } class BooleanOptionData(prop: Property<*>, val style: Style) : OptionData(prop) { @@ -33,9 +35,9 @@ class BooleanOptionData(prop: Property<*>, val style: Style) : OptionData(prop) } class SliderOptionData(prop: Property<*>) : OptionData(prop) { - val min: Float get() = prop.boundedMin(prop.getMetadata("min") ?: 0f) - val max: Float get() = prop.boundedMax(prop.getMetadata("max") ?: 100f) - val step: Float get() = prop.getMetadata("step") ?: 0f + val min: Float get() = prop.boundedMin(prop[MetaKeys.MIN]?.toFloat() ?: 0f) + val max: Float get() = prop.boundedMax(prop[MetaKeys.MAX]?.toFloat() ?: 100f) + val step: Float get() = prop[MetaKeys.STEP]?.toFloat() ?: 0f val format: ((Float) -> String)? get() = prop.valueFormat(prop.type) @Suppress("UNCHECKED_CAST") @@ -53,9 +55,9 @@ class SliderOptionData(prop: Property<*>) : OptionData(prop) { class RangeOptionData(prop: Property<*>, val style: Style) : OptionData(prop) { enum class Style { Slider, Spinner } - val min: Float get() = prop.boundedMin(prop.getMetadata("min") ?: 0f) - val max: Float get() = prop.boundedMax(prop.getMetadata("max") ?: 100f) - val step: Float get() = prop.getMetadata("step") ?: 0f + val min: Float get() = prop.boundedMin(prop[MetaKeys.MIN]?.toFloat() ?: 0f) + val max: Float get() = prop.boundedMax(prop[MetaKeys.MAX]?.toFloat() ?: 100f) + val step: Float get() = prop[MetaKeys.STEP]?.toFloat() ?: 0f val format: ((Float) -> String)? get() = prop.valueFormat(prop.type.componentType ?: Float::class.java) /** @@ -79,13 +81,13 @@ class RangeOptionData(prop: Property<*>, val style: Style) : OptionData(prop) { * Writes go through the property's setter so a backing config is free to normalize further */ class NumberChainOptionData(prop: Property<*>) : OptionData(prop) { - val min: Float get() = prop.getMetadata("min") ?: 0f - val max: Float get() = prop.getMetadata("max") ?: 100f + val min: Float get() = prop[MetaKeys.MIN]?.toFloat() ?: 0f + val max: Float get() = prop[MetaKeys.MAX]?.toFloat() ?: 100f val maxEntries: Int get() = prop.getMetadata("maxEntries") ?: Int.MAX_VALUE val lockedLeading: Int get() = prop.getMetadata("lockedLeading") ?: 0 /** Drawn after the number while an entry is being edited and expected to already be part of [label] */ - val unit: String? get() = localizedString(prop.getMetadata("unitKey"), prop.getMetadata("unit")).takeIf { it.isNotBlank() } + val unit: String? get() = localizedString(prop.getMetadata("unitKey"), prop[MetaKeys.UNIT]).takeIf { it.isNotBlank() } /** Renders one entry as a chip label falling back to the bare number */ fun label(value: Float): String { @@ -157,9 +159,9 @@ private fun Property<*>.writeNumbers(values: List) { * [inheritedValue] may be given as a `Supplier` so it tracks whatever it inherits from */ class InheritableSliderOptionData(prop: Property<*>) : OptionData(prop) { - val min: Float get() = prop.boundedMin(prop.getMetadata("min") ?: 0f) - val max: Float get() = prop.boundedMax(prop.getMetadata("max") ?: 100f) - val step: Float get() = prop.getMetadata("step") ?: 0f + val min: Float get() = prop.boundedMin(prop[MetaKeys.MIN]?.toFloat() ?: 0f) + val max: Float get() = prop.boundedMax(prop[MetaKeys.MAX]?.toFloat() ?: 100f) + val step: Float get() = prop[MetaKeys.STEP]?.toFloat() ?: 0f val format: ((Float) -> String)? get() = prop.valueFormat(prop.type) val inheritLabel: String get() = localizedString(prop.getMetadata("inheritLabelKey"), prop.getMetadata("inheritLabel")) @@ -192,11 +194,11 @@ class InheritableSliderOptionData(prop: Property<*>) : OptionData(prop) { } class NumberOptionData(prop: Property<*>) : OptionData(prop) { - val min: Float get() = prop.getMetadata("min") ?: -10f - val max: Float get() = prop.getMetadata("max") ?: 100f - val step: Float get() = prop.getMetadata("step") ?: 0f + val min: Float get() = prop[MetaKeys.MIN]?.toFloat() ?: -10f + val max: Float get() = prop[MetaKeys.MAX]?.toFloat() ?: 100f + val step: Float get() = prop[MetaKeys.STEP]?.toFloat() ?: 0f val format: ((Float) -> String)? get() = prop.valueFormat(prop.type) - val unit: String? get() = localizedString(prop.getMetadata("unitKey"), prop.getMetadata("unit")).takeIf { it.isNotBlank() } + val unit: String? get() = localizedString(prop.getMetadata("unitKey"), prop[MetaKeys.UNIT]).takeIf { it.isNotBlank() } val placeholder: String? get() = localizedString(prop.getMetadata("placeholderKey"), prop.getMetadata("placeholder")).takeIf { it.isNotBlank() } @Suppress("UNCHECKED_CAST") @@ -285,6 +287,22 @@ class ColorListOptionData(prop: Property<*>) : ListOptionData(prop) { } } + fun hasDefaultList(): Boolean = prop.getMetadata("default") != null + + fun defaultArgbAt(index: Int): Int? { + val element = when (val def = prop.getMetadata("default")) { + is List<*> -> def.getOrNull(index) + is Array<*> -> def.getOrNull(index) + is IntArray -> def.getOrNull(index) + else -> null + } + return when (element) { + is Number -> element.toInt() + is Color -> element.rgb + else -> null + } + } + fun setArgb(values: List) { val component = if (prop.type.isArray) prop.type.componentType else Int::class.javaObjectType setElements( @@ -296,9 +314,9 @@ class ColorListOptionData(prop: Property<*>) : ListOptionData(prop) { class NumberListOptionData(prop: Property<*>, val style: Style) : ListOptionData(prop) { enum class Style { Slider, Spinner } - val min: Float get() = prop.boundedMin(prop.getMetadata("min") ?: 0f) - val max: Float get() = prop.boundedMax(prop.getMetadata("max") ?: 100f) - val step: Float get() = prop.getMetadata("step") ?: 0f + val min: Float get() = prop.boundedMin(prop[MetaKeys.MIN]?.toFloat() ?: 0f) + val max: Float get() = prop.boundedMax(prop[MetaKeys.MAX]?.toFloat() ?: 100f) + val step: Float get() = prop[MetaKeys.STEP]?.toFloat() ?: 0f val elementType: Class<*> get() = numericElementType(min, max, step) @@ -313,9 +331,9 @@ class NumberListOptionData(prop: Property<*>, val style: Style) : ListOptionData * Each range takes two consecutive values so `[0, 10, 20, 30]` reads as 0-10 and 20-30 */ class RangeListOptionData(prop: Property<*>, val style: RangeOptionData.Style) : ListOptionData(prop) { - val min: Float get() = prop.boundedMin(prop.getMetadata("min") ?: 0f) - val max: Float get() = prop.boundedMax(prop.getMetadata("max") ?: 100f) - val step: Float get() = prop.getMetadata("step") ?: 0f + val min: Float get() = prop.boundedMin(prop[MetaKeys.MIN]?.toFloat() ?: 0f) + val max: Float get() = prop.boundedMax(prop[MetaKeys.MAX]?.toFloat() ?: 100f) + val step: Float get() = prop[MetaKeys.STEP]?.toFloat() ?: 0f val elementType: Class<*> get() = numericElementType(min, max, step) @@ -518,9 +536,9 @@ private fun Number.toComponentType(component: Class<*>): Any = when (component) private fun Property<*>.optionLabels(): List? { // explicit display labels win over ones derived from raw option values and are positionally aligned // with "options" - getMetadata("optionLabels").optionList().takeIf { it.isNotEmpty() }?.let { return it } + getMetadata(MetaKeys.LABELS.name).optionList().takeIf { it.isNotEmpty() }?.let { return it } - val raw = getMetadata("options") ?: return null + val raw = getMetadata(MetaKeys.OPTIONS.name) ?: return null val keys = getMetadata("optionsKey").optionList().map { it.toString() } val labels = when { raw is Iterable<*> -> raw.map { it ?: "" } @@ -536,7 +554,7 @@ private fun Property<*>.optionLabels(): List? { private fun Property<*>.optionValues(): List? = getMetadata("optionValues").optionList().takeIf { it.isNotEmpty() } private fun Property<*>.optionLabelMap(): Map { - val options = getMetadata("options").optionList().map { it.toString() } + val options = getMetadata(MetaKeys.OPTIONS.name).optionList().map { it.toString() } val labels = optionLabels() ?: return emptyMap() return options.zip(labels).toMap() } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Borders.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Borders.kt index 315bfb290..bccd8a33d 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Borders.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Borders.kt @@ -1,6 +1,5 @@ package org.polyfrost.oneconfig.internal.ui.components -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background import androidx.compose.runtime.Composable @@ -23,6 +22,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kotlin.math.ceil import kotlin.math.min +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion import org.polyfrost.oneconfig.internal.ui.themes.AccentBrush import org.polyfrost.oneconfig.internal.ui.themes.AccentSheen @@ -130,8 +130,8 @@ fun Modifier.fadingEdges( color: Color, length: Dp = 32.dp, ): Modifier { - val topAlpha by animateFloatAsState(if (scrollState.canScrollBackward) 1f else 0f) - val bottomAlpha by animateFloatAsState(if (scrollState.canScrollForward) 1f else 0f) + val topAlpha by Motion.animateFloatAsState(if (scrollState.canScrollBackward) 1f else 0f) + val bottomAlpha by Motion.animateFloatAsState(if (scrollState.canScrollForward) 1f else 0f) return this.drawWithCache { val lengthPx = length.toPx().coerceAtMost(size.height / 2f) val top = Brush.verticalGradient( diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Button.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Button.kt index 9153d10a2..1933f1c63 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Button.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Button.kt @@ -1,22 +1,32 @@ package org.polyfrost.oneconfig.internal.ui.components -import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.PointerIcon -import androidx.compose.ui.input.pointer.pointerHoverIcon -import androidx.compose.ui.unit.dp +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.api.ui.v1.components.ControlSize +import org.polyfrost.oneconfig.api.ui.v1.components.IconButtonStyle import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme +import org.polyfrost.oneconfig.api.ui.v1.components.IconButton as KitIconButton enum class IconButtonType { Ghost, Filled; } +internal fun iconButtonLabel(iconName: String): String = when (iconName) { + "left-arrow" -> translated("oneconfig.button.back", "Back") + "right-arrow" -> translated("oneconfig.button.forward", "Forward") + "close" -> translated("oneconfig.button.close", "Close") + "folder" -> translated("oneconfig.button.browse", "Browse") + "trash" -> translated("oneconfig.list.remove", "Remove") + else -> iconName.replace('-', ' ').replaceFirstChar { it.uppercase() } +} + @Composable fun IconButton( iconName: String, @@ -28,33 +38,21 @@ fun IconButton( hoverBorder: Boolean = false, onClick: () -> Unit, ) { - val interactionSource = rememberInteractionSource() - - val isHovered by interactionSource.collectIsHoveredAsState() - - val iconColor = animateColorAsState( - if (!enabled) { - LocalTheme.current.textColorSecondary.copy(alpha = 0.35f) - } else { - if (isHovered) hoveredForeground else foreground - } - ) - val borderColor = animateColorAsState( - if (hoverBorder && enabled && isHovered) LocalTheme.current.borderColor - else LocalTheme.current.borderColor.copy(0f) - ) - - Icon( + val source = remember { MutableInteractionSource() } + val hovered by source.collectIsHoveredAsState() + val tint = Motion.animateColorAsState(if (hovered) hoveredForeground else foreground) + val label = iconButtonLabel(iconName) + KitIconButton( iconName, - tint = { iconColor.value }, - modifier = Modifier.onClick(interactionSource, enabled, onClick) - .then(if (enabled) Modifier.pointerHoverIcon(PointerIcon.Hand) else Modifier) - .then(modifier) - // inset the glyph so the outline doesn't hug it; size bordered buttons 8dp larger to keep the icon size - .then( - if (hoverBorder) Modifier.animatedFrame(LocalTheme.current.buttonShape, border = { borderColor.value }).padding(4.dp) - else Modifier - ) + label, + onClick, + modifier, + enabled = enabled, + style = IconButtonStyle.Ghost, + size = if (hoverBorder) ControlSize.Regular else ControlSize.Compact, + tooltip = if (hoverBorder) label else null, + tint = tint.value, + interactionSource = source, ) } @@ -67,16 +65,20 @@ fun SelectableIconButton( hoveredForeground: Color = LocalTheme.current.textColor, onClick: () -> Unit, ) { - val interactionSource = rememberInteractionSource() - - val isHovered by interactionSource.collectIsHoveredAsState() - - val iconColor = animateColorAsState( - if (isHovered || selected) hoveredForeground else foreground - ) - - Icon( + val source = remember { MutableInteractionSource() } + val hovered by source.collectIsHoveredAsState() + val tint = Motion.animateColorAsState(if (hovered || selected) hoveredForeground else foreground) + val label = iconButtonLabel(iconName) + KitIconButton( iconName, - tint = { iconColor.value }, modifier = Modifier.onClick(interactionSource, onClick).then(modifier) + label, + onClick, + modifier, + style = IconButtonStyle.Ghost, + size = ControlSize.Compact, + selected = selected, + tooltip = null, + tint = if (selected) Color.Unspecified else tint.value, + interactionSource = source, ) -} \ No newline at end of file +} diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Chips.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Chips.kt index 2b1232dc2..5e50971c6 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Chips.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Chips.kt @@ -1,27 +1,7 @@ package org.polyfrost.oneconfig.internal.ui.components -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.compositeOver -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.input.pointer.PointerIcon -import androidx.compose.ui.input.pointer.pointerHoverIcon -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import org.polyfrost.oneconfig.internal.ui.themes.Accent -import org.polyfrost.oneconfig.internal.ui.themes.animateAccentColorAsState -import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme +import org.polyfrost.oneconfig.api.ui.v1.components.Chip as KitChip @Composable fun Chip( @@ -30,44 +10,5 @@ fun Chip( icon: String? = null, onClick: () -> Unit, ) { - val interactionSource = rememberInteractionSource() - val isHovered by interactionSource.collectIsHoveredAsState() - val theme = LocalTheme.current - val hoverBackground = Accent.copy(alpha = 0.5f).compositeOver(theme.chipBackground) - val backgroundColor = animateAccentColorAsState( - isHovered, - if (isHovered) hoverBackground else theme.chipBackground - ) - val selectedAlpha = animateFloatAsState(if (selected) 1f else 0f) - val textColor = animateColorAsState( - if (selected) LocalTheme.current.accentTextColor else LocalTheme.current.textColor - ) - val borderColor = theme.borderColor - - Row( - modifier = Modifier - .defaultMinSize(minHeight = 33.dp) - .glassHover(theme.sideBarNavigationEntryShape) - .graphicsLayer {} - .liquidGlass(theme.sideBarNavigationEntryShape) - .animatedFrame( - theme.sideBarNavigationEntryShape, - fill = { backgroundColor.value }, - accentAlpha = { selectedAlpha.value }, - border = { borderColor }, - ) - .pointerHoverIcon(PointerIcon.Hand) - .onClick(interactionSource) { onClick() }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - Row( - modifier = Modifier.padding(vertical = 7.5.dp, horizontal = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - icon?.let { Icon(it, tint = { textColor.value }) } - Text(label, fontSize = 14.sp, color = Color.Unspecified, colorProducer = { textColor.value }) - } - } -} \ No newline at end of file + KitChip(label, selected, onClick, icon = icon) +} diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/CollapsibleHeader.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/CollapsibleHeader.kt new file mode 100644 index 000000000..46ee83f35 --- /dev/null +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/CollapsibleHeader.kt @@ -0,0 +1,63 @@ +package org.polyfrost.oneconfig.internal.ui.components + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.dp +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.api.ui.v1.components.oneConfigClickable +import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme + +private const val COLLAPSED_ROTATION = 90f +private const val EXPANDED_ROTATION = 180f +private val ChevronSize = 12.dp + +@Composable +internal fun CollapseChevron(expanded: Boolean, modifier: Modifier = Modifier, color: Color = Color.Unspecified) { + val rotation by Motion.animateFloatAsState(if (expanded) EXPANDED_ROTATION else COLLAPSED_ROTATION, label = "collapseChevron") + val tint = if (color == Color.Unspecified) LocalTheme.current.textColorSecondary else color + Icon("up", color = tint, modifier = modifier.size(ChevronSize).graphicsLayer { rotationZ = rotation }) +} + +internal fun Modifier.collapsibleToggle( + label: String, + onToggle: (() -> Unit)?, + interactionSource: MutableInteractionSource? = null, +): Modifier = if (onToggle == null) this else oneConfigClickable(label, onToggle, indication = false, interactionSource = interactionSource) + +@Composable +internal fun CollapsibleHeader( + label: String, + expanded: Boolean, + onToggle: (() -> Unit)?, + modifier: Modifier = Modifier, + chevron: Boolean = onToggle != null, + contentPadding: PaddingValues = PaddingValues(0.dp), + horizontalArrangement: Arrangement.Horizontal = Arrangement.spacedBy(10.dp), + interactionSource: MutableInteractionSource? = null, + trailing: @Composable RowScope.() -> Unit = {}, + content: @Composable RowScope.() -> Unit, +) { + val source = interactionSource ?: remember { MutableInteractionSource() } + Row( + modifier = modifier.fillMaxWidth().collapsibleToggle(label, onToggle, source).padding(contentPadding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = horizontalArrangement, + ) { + content() + if (chevron) CollapseChevron(expanded) + trailing() + } +} diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Common.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Common.kt index cf481262d..fe6788fb5 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Common.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Common.kt @@ -1,6 +1,5 @@ package org.polyfrost.oneconfig.internal.ui.components -import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.InteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.runtime.Composable @@ -24,8 +23,7 @@ import androidx.compose.ui.node.ModifierNodeElement import androidx.compose.ui.node.PointerInputModifierNode import androidx.compose.ui.node.currentValueOf import androidx.compose.ui.unit.IntSize -import org.polyfrost.oneconfig.internal.ui.sound.UiSoundEvent -import org.polyfrost.oneconfig.internal.ui.sound.UiSounds +import org.polyfrost.oneconfig.api.ui.v1.components.oneConfigClickable @Composable fun rememberInteractionSource() = remember { MutableInteractionSource() } @@ -34,15 +32,12 @@ fun Modifier.onClick(interactionSource: MutableInteractionSource, onClick: () -> onClick(interactionSource, true, onClick) fun Modifier.onClick(interactionSource: MutableInteractionSource, enabled: Boolean, onClick: () -> Unit) = - // when disabled it would apply to focusable children instead - (if (enabled) keepSearchFocus() else this).clickable( - interactionSource = interactionSource, - indication = null, + oneConfigClickable( + label = null, + onClick = onClick, enabled = enabled, - onClick = { - UiSounds.play(UiSoundEvent.CLICK) - onClick() - } + indication = false, + interactionSource = interactionSource, ) val LocalSearchFieldFocused = staticCompositionLocalOf?> { null } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/ContentSlide.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/ContentSlide.kt index 3ebb30837..7ac41f764 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/ContentSlide.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/ContentSlide.kt @@ -13,6 +13,7 @@ import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion import org.polyfrost.oneconfig.internal.ThemeConfig /** @@ -26,13 +27,13 @@ object ContentSlide { fun enter(forward: Boolean): EnterTransition { val fade = fadeIn(tween(millis())) - return if (ReducedMotion.enabled) fade + return if (Motion.reduced) fade else slideInHorizontally(tween(millis())) { if (forward) it / SHIFT_DIVISOR else -it / SHIFT_DIVISOR } + fade } fun exit(forward: Boolean): ExitTransition { val fade = fadeOut(tween(millis())) - return if (ReducedMotion.enabled) fade + return if (Motion.reduced) fade else slideOutHorizontally(tween(millis())) { if (forward) -it / SHIFT_DIVISOR else it / SHIFT_DIVISOR } + fade } @@ -54,7 +55,7 @@ object ContentSlide { fun Modifier.tabSlide(progress: () -> Float, leaving: Boolean, direction: Int, fade: Boolean = true): Modifier = graphicsLayer { val p = progress() if (fade) alpha = p - if (!ReducedMotion.enabled && direction != 0) { + if (!Motion.reduced && direction != 0) { val shift = size.width / SHIFT_DIVISOR * (1f - p) * direction translationX = if (leaving) -shift else shift } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/GlassHover.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/GlassHover.kt index 3d4bc1963..3ea0eefb9 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/GlassHover.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/GlassHover.kt @@ -39,8 +39,8 @@ import kotlin.math.roundToInt import kotlin.math.sqrt import kotlinx.coroutines.launch import org.polyfrost.compose.render.PolyBitmap +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme -import org.polyfrost.oneconfig.internal.ui.themes.isLight private const val PRESS_SCALE = 1.03f @@ -58,7 +58,7 @@ fun Modifier.glassHover(shape: Shape, pull: Dp = 1.dp): Modifier { val glow = if (light) 0.45f else 0.2f return this .graphicsLayer { - if (ReducedMotion.enabled) return@graphicsLayer + if (Motion.reduced) return@graphicsLayer translationX = offset.value.x.roundToInt().toFloat() translationY = offset.value.y.roundToInt().toFloat() scaleX = scale.value @@ -103,7 +103,7 @@ fun Modifier.glassHover(shape: Shape, pull: Dp = 1.dp): Modifier { val b = bloom.value if (b >= 1f) return@drawLive val outline = shape.createOutline(size, layoutDirection, this) - val reach = max(size.width, size.height) * (if (ReducedMotion.enabled) 1f else 0.25f + b) + val reach = max(size.width, size.height) * (if (Motion.reduced) 1f else 0.25f + b) drawOutline( outline, Brush.radialGradient(listOf(Color.White.copy(alpha = glow * (1f - b)), Color.White.copy(alpha = 0f)), center = pressedAt, radius = reach), diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Header.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Header.kt index e3ab21270..54fc9f866 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Header.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Header.kt @@ -2,14 +2,10 @@ package org.polyfrost.oneconfig.internal.ui.components import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.hoverable +import androidx.compose.foundation.focusable import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Arrangement @@ -21,8 +17,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf @@ -33,32 +28,32 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.input.pointer.PointerIcon -import androidx.compose.ui.input.pointer.pointerHoverIcon -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupProperties import androidx.navigation.NavBackStackEntry import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.toRoute +import org.polyfrost.oneconfig.api.ui.v1.a11y.A11yRole +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.api.ui.v1.components.ControlSize +import org.polyfrost.oneconfig.api.ui.v1.components.IconButton +import org.polyfrost.oneconfig.api.ui.v1.components.IconButtonStyle +import org.polyfrost.oneconfig.api.ui.v1.components.ControlStyle +import org.polyfrost.oneconfig.api.ui.v1.components.SearchField +import org.polyfrost.oneconfig.api.ui.v1.components.TextButton +import org.polyfrost.oneconfig.api.ui.v1.components.TooltipBox +import org.polyfrost.oneconfig.api.ui.v1.components.TooltipPlacement +import org.polyfrost.oneconfig.api.ui.v1.components.oneConfigClickable import org.polyfrost.oneconfig.api.ui.v1.keybind.trackTextInputFocus import org.polyfrost.oneconfig.internal.ui.LocalCloseRequest import org.polyfrost.oneconfig.internal.ui.api.ConfigRegistry -import org.polyfrost.oneconfig.internal.ui.api.Tooltip -import org.polyfrost.oneconfig.internal.ui.components.dropdown.DropdownPositionProvider import org.polyfrost.oneconfig.internal.ui.navigation.currentPageTitle import org.polyfrost.oneconfig.internal.ui.navigation.graph.ModConfigRoute import org.polyfrost.oneconfig.internal.ui.navigation.searchPlaceholder @@ -94,15 +89,31 @@ fun Header(modifier: Modifier = Modifier) { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - IconButton("left-arrow", Modifier.size(32.dp), enabled = canGoBack, hoverBorder = true) { controller.back() } - IconButton("right-arrow", Modifier.size(32.dp), enabled = canGoForward, hoverBorder = true) { controller.forward() } + IconButton( + "left-arrow", + iconButtonLabel("left-arrow"), + { controller.back() }, + Modifier.size(32.dp), + enabled = canGoBack, + style = IconButtonStyle.Ghost, + tint = LocalTheme.current.textColor, + ) + IconButton( + "right-arrow", + iconButtonLabel("right-arrow"), + { controller.forward() }, + Modifier.size(32.dp), + enabled = canGoForward, + style = IconButtonStyle.Ghost, + tint = LocalTheme.current.textColor, + ) } Spacer(Modifier.size(24.dp)) Row(verticalAlignment = Alignment.CenterVertically) { AnimatedContent( title, transitionSpec = { - if (ReducedMotion.enabled) fadeIn() togetherWith fadeOut() using null + if (Motion.reduced) fadeIn() togetherWith fadeOut() using null else fadeIn() togetherWith fadeOut() } ) { headerTitle -> @@ -154,7 +165,7 @@ fun Header(modifier: Modifier = Modifier) { horizontalArrangement = Arrangement.spacedBy(26.dp) ) { GlobalSearchBar() - IconButton("close", Modifier.size(32.dp), hoverBorder = true) { closeRequest() } + IconButton("close", iconButtonLabel("close"), { closeRequest() }, Modifier.size(32.dp)) } } } @@ -212,10 +223,16 @@ private fun TitleBreadcrumb(segments: List, controller: ShellNavig } else { val interactionSource = rememberInteractionSource() val isHovered by interactionSource.collectIsHoveredAsState() - val color by animateColorAsState(if (isHovered) theme.textColor else theme.textColorSecondary) + val color by Motion.animateColorAsState(if (isHovered) theme.textColor else theme.textColorSecondary) Text( segment.text, - Modifier.pointerHoverIcon(PointerIcon.Hand).onClick(interactionSource) { controller.navigate(route) }, + Modifier.oneConfigClickable( + segment.text.asRenderText(), + { controller.navigate(route) }, + role = A11yRole.Link, + indication = false, + interactionSource = interactionSource, + ), color = color, fontSize = 24.sp, fontWeight = FontWeight.Medium, @@ -227,41 +244,17 @@ private fun TitleBreadcrumb(segments: List, controller: ShellNavig @Composable private fun OpenOriginalScreenButton(runnable: Runnable) { - val theme = LocalTheme.current - val interactionSource = rememberInteractionSource() - val isHovered by interactionSource.collectIsHoveredAsState() - val accentAlpha = animateFloatAsState(if (isHovered) 0.75f else 1f) - - Tooltip( - text = { - Text( - translated("oneconfig.header.original_menu.tooltip", "Opening the original screen may show options that OneConfig doesn't."), - color = theme.textColor, - fontSize = 13.sp, - ) - }, - modifier = Modifier.widthIn(max = 260.dp), - anchor = Alignment.BottomCenter, + TooltipBox( + translated("oneconfig.header.original_menu.tooltip", "Opening the original screen may show options that OneConfig doesn't."), + placement = TooltipPlacement.BelowCenter, ) { - Box( - modifier = Modifier - .pointerHoverIcon(PointerIcon.Hand) - .glassHover(theme.buttonShape) - .graphicsLayer {} - .liquidGlass(theme.buttonShape) - .animatedFrame(theme.buttonShape, accentAlpha = { accentAlpha.value }, border = { theme.borderColor }) - .onClick(interactionSource) { runnable.run() } - .padding(horizontal = 16.dp, vertical = 6.dp), - contentAlignment = Alignment.Center, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - Icon("external-link", color = theme.accentTextColor, modifier = Modifier.size(14.dp)) - Text(translated("oneconfig.header.original_menu", "Original menu"), color = theme.accentTextColor, fontSize = 13.sp) - } - } + TextButton( + translated("oneconfig.header.original_menu", "Original menu"), + "external-link", + { runnable.run() }, + style = ControlStyle.Primary, + size = ControlSize.Compact, + ) } } @@ -294,49 +287,33 @@ private fun TitleInfoTooltip(title: HeaderTitle) { val authors = title.authors val credits = title.credits if (version == null && authors == null && credits == null) return - val interactionSource = rememberInteractionSource() - val isHovered by interactionSource.collectIsHoveredAsState() - val density = LocalDensity.current - - Box( - modifier = Modifier.size(22.dp).hoverable(interactionSource), - contentAlignment = Alignment.Center - ) { - Icon("info", color = LocalTheme.current.textColorSecondary, modifier = Modifier.size(14.dp)) + val theme = LocalTheme.current - if (isHovered) { - // popup so the tooltip takes no layout space in the header Row and is not clipped by its - // fixed height - Popup( - popupPositionProvider = DropdownPositionProvider(DpOffset(0.dp, 6.dp), density), - properties = PopupProperties(focusable = false) - ) { - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier - .widthIn(max = 260.dp) - .liquidGlass(LocalTheme.current.popupShape, frosted = true) - .background(LocalTheme.current.popupBackground, shape = LocalTheme.current.popupShape) - .border(1.dp, LocalTheme.current.borderColor, shape = LocalTheme.current.popupShape) - .padding(horizontal = 12.dp, vertical = 8.dp) - ) { - if (authors != null) { - Text(translated("oneconfig.header.author", "Author: %s", authors), color = LocalTheme.current.textColor, fontSize = 12.sp) - } - if (credits != null) { - Text(translated("oneconfig.header.credits", "Credits: %s", credits), color = LocalTheme.current.textColor, fontSize = 12.sp) - } - if (version != null) { - Text(translated("oneconfig.header.version", "Version: %s", version), color = LocalTheme.current.textColor, fontSize = 12.sp) - } + TooltipBox( + tooltip = { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + if (authors != null) { + Text(translated("oneconfig.header.author", "Author: %s", authors), color = theme.textColor, fontSize = 12.sp) + } + if (credits != null) { + Text(translated("oneconfig.header.credits", "Credits: %s", credits), color = theme.textColor, fontSize = 12.sp) + } + if (version != null) { + Text(translated("oneconfig.header.version", "Version: %s", version), color = theme.textColor, fontSize = 12.sp) } } + }, + placement = TooltipPlacement.BelowStart, + ) { + Box( + modifier = Modifier.size(22.dp).focusable(), + contentAlignment = Alignment.Center, + ) { + Icon("info", color = theme.textColorSecondary, modifier = Modifier.size(14.dp)) } } } -private fun searchFieldValue(text: String) = TextFieldValue(text, TextRange(text.length)) - @Composable fun GlobalSearchBar() { val navController = LocalShellNavController.current @@ -344,7 +321,7 @@ fun GlobalSearchBar() { val backStackEntry by navController.currentBackStackEntryAsState() val placeholder by remember(backStackEntry) { derivedStateOf { searchPlaceholder(backStackEntry) } } - var searchText by remember { mutableStateOf(searchFieldValue(search.query)) } + var searchText by remember { mutableStateOf(TextFieldValue(search.query, TextRange(search.query.length))) } val interactionSource = rememberInteractionSource() val isFocused by interactionSource.collectIsFocusedAsState() @@ -353,7 +330,7 @@ fun GlobalSearchBar() { LaunchedEffect(searchText.text) { search.update(searchText.text) } LaunchedEffect(search.query) { - if (search.query != searchText.text) searchText = searchFieldValue(search.query) + if (search.query != searchText.text) searchText = TextFieldValue(search.query, TextRange(search.query.length)) } LaunchedEffect(isFocused) { search.updateFieldFocused(isFocused) } @@ -366,7 +343,6 @@ fun GlobalSearchBar() { if (isFocused) focusManager.clearFocus() } else { if (request == SearchFocus.SelectAll) { - // a kept query is selected so typing starts a new search while leaving it alone keeps it searchText = searchText.copy(selection = TextRange(0, searchText.text.length)) } if (!isFocused) { @@ -379,25 +355,16 @@ fun GlobalSearchBar() { search.consumeFocusRequest() } - // TextFieldState needs its own migration, as it changes how every field hoists its state - @Suppress("DEPRECATION") - BasicTextField( + SearchField( value = searchText, onValueChange = { searchText = it }, - singleLine = true, - cursorBrush = SolidColor(LocalTheme.current.textColorSecondary), - textStyle = searchFieldTextStyle(), + modifier = Modifier.width(256.dp) + .trackTextInputFocus() + .onFocusChanged { searchFocused?.value = it.hasFocus }, + placeholder = placeholder, + size = ControlSize.Regular, + focusRequester = focusRequester, interactionSource = interactionSource, - modifier = Modifier.trackTextInputFocus() - .focusRequester(focusRequester) - .onFocusChanged { searchFocused?.value = it.isFocused } - ) { innerTextField -> - SearchFieldFrame( - placeholder, - showPlaceholder = searchText.text.isEmpty() && !isFocused, - onClear = if (searchText.text.isNotEmpty()) ({ searchText = TextFieldValue() }) else null, - modifier = Modifier.size(256.dp, 32.dp), - innerTextField = innerTextField, - ) - } + hidePlaceholderWhenFocused = true, + ) } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/I18n.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/I18n.kt index d3b2a85b2..bd58eb047 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/I18n.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/I18n.kt @@ -10,6 +10,9 @@ internal fun translationKeyOrNull(value: Any?): String? { return if (Platform.i18n().hasTranslation(text)) text else null } +fun translated(key: String, fallback: String): String = + if (Platform.i18n().hasTranslation(key)) Platform.i18n().translateString(key) else fallback + fun localizedText(key: String?, fallback: Any?): Any { val translationKey = key?.trim()?.takeIf { it.isNotEmpty() } ?: return translationKeyOrNull(fallback)?.let { Platform.i18n().translate(it) } ?: fallback ?: "" diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Icon.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Icon.kt index 0aa9ecb15..e62bb614f 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Icon.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Icon.kt @@ -5,7 +5,6 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.produceState import androidx.compose.runtime.remember @@ -377,7 +376,7 @@ fun IconWithIndicator( .size(8.dp) .background( Accent, - shape = LocalTheme.current.circleShape + shape = LocalTheme.current.shapes.circle ) .blur(7.dp) ) @@ -387,7 +386,7 @@ fun IconWithIndicator( .size(6.dp) .background( Accent, - shape = LocalTheme.current.circleShape + shape = LocalTheme.current.shapes.circle ) ) } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/ModToggleState.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/ModToggleState.kt index 5fb5d6b4b..30ec5aaf4 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/ModToggleState.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/ModToggleState.kt @@ -4,8 +4,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import java.util.function.Predicate import org.polyfrost.oneconfig.api.toggle.v1.ModToggle @@ -17,7 +20,8 @@ import org.polyfrost.oneconfig.internal.ui.shell.LocalShell @Composable internal fun rememberModEnabled(toggle: ModToggle?): MutableState { val revision = ModToggles.revision - val state = remember(toggle, revision) { mutableStateOf(toggle?.isEnabled() ?: true) } + val flips = ModToggleFlips.count + val state = remember(toggle, revision, flips) { mutableStateOf(toggle?.isEnabled() ?: true) } // a switch that is one of the mod's own options changes without ModToggles hearing of it val property = if (LocalShell.current.warming) null else (toggle as? PropertyModToggle)?.property DisposableEffect(System.identityHashCode(property), state) { @@ -30,3 +34,7 @@ internal fun rememberModEnabled(toggle: ModToggle?): MutableState { } return state } + +internal object ModToggleFlips { + var count by mutableIntStateOf(0) +} diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/NotificationsCenter.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/NotificationsCenter.kt index 56509f991..7a1d97eca 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/NotificationsCenter.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/NotificationsCenter.kt @@ -1,66 +1,74 @@ package org.polyfrost.oneconfig.internal.ui.components -import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import java.time.Instant import org.polyfrost.oneconfig.api.notifications.v1.Notification import org.polyfrost.oneconfig.api.notifications.v1.NotificationAction import org.polyfrost.oneconfig.api.notifications.v1.NotificationType import org.polyfrost.oneconfig.api.notifications.v1.NotificationsManager +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.api.ui.v1.components.ControlSize +import org.polyfrost.oneconfig.api.ui.v1.components.ControlStyle +import org.polyfrost.oneconfig.api.ui.v1.components.Divider +import org.polyfrost.oneconfig.api.ui.v1.components.MenuAction +import org.polyfrost.oneconfig.api.ui.v1.components.MenuEntry +import org.polyfrost.oneconfig.api.ui.v1.components.MenuSeparator +import org.polyfrost.oneconfig.api.ui.v1.components.ProgressBar +import org.polyfrost.oneconfig.api.ui.v1.components.ScrollContainer +import org.polyfrost.oneconfig.api.ui.v1.components.SurfaceKind +import org.polyfrost.oneconfig.api.ui.v1.components.TextButton +import org.polyfrost.oneconfig.api.ui.v1.components.contextMenu +import org.polyfrost.oneconfig.api.ui.v1.components.oneConfigClickable +import org.polyfrost.oneconfig.api.ui.v1.components.themedSurface import org.polyfrost.oneconfig.internal.ui.themes.Accent -import org.polyfrost.oneconfig.internal.ui.themes.animateAccentColorAsState import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme +import org.polyfrost.oneconfig.utils.v1.ClipboardHelper +import org.polyfrost.oneconfig.utils.v1.Formatters private val PanelWidth = 368.dp -private val PanelShape @Composable get() = LocalTheme.current.popupShape private val ButtonShape @Composable get() = LocalTheme.current.buttonShape -private val SuccessColor = Color(0xFF3CAF77) -private val ErrorColor = Color(0xFFF2545A) - @Composable fun NotificationsCenter() { val theme = LocalTheme.current val history = NotificationsManager.history - val panelBg = theme.popupBackground Column( modifier = Modifier .width(PanelWidth) - .clip(PanelShape) - .liquidGlass(PanelShape, frosted = true) - .background(panelBg, PanelShape) - .border(1.dp, theme.borderColor, PanelShape) + .themedSurface(SurfaceKind.Popup) .padding(horizontal = 14.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { Text( - "Notifications", + translated("oneconfig.sidebar.notifications", "Notifications"), color = theme.textColor, fontSize = 18.sp, lineHeight = 28.sp, @@ -69,36 +77,23 @@ fun NotificationsCenter() { if (history.isEmpty()) { Text( - "You're all caught up.", + translated("oneconfig.notifications.empty", "You're all caught up."), color = theme.textColorSecondary, fontSize = 12.sp, modifier = Modifier.padding(vertical = 8.dp), ) } else { - val scrollState = rememberScrollState() - Column( - modifier = Modifier - .heightIn(max = 420.dp) - .fadingEdges(scrollState, panelBg) - .wheelScroll(scrollState) - .verticalScroll(scrollState), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - history.forEachIndexed { index, notification -> - NotificationRow(notification) - if (index != history.lastIndex) { - Box( - Modifier - .fillMaxWidth() - .height(1.dp) - .background(theme.borderColor), - ) + ScrollContainer(rememberScrollState(), Modifier.heightIn(max = 420.dp), fadingEdges = true) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + history.forEachIndexed { index, notification -> + NotificationRow(notification) + if (index != history.lastIndex) Divider() } } } } - Box(Modifier.fillMaxWidth().height(1.dp).background(theme.borderColor)) + Divider() Footer() } @@ -109,9 +104,12 @@ private fun NotificationRow(notification: Notification) { val theme = LocalTheme.current val accent = accentFor(notification.type) - Row(horizontalArrangement = Arrangement.spacedBy(13.dp)) { + Row( + modifier = Modifier.contextMenu { notificationMenu(notification) }, + horizontalArrangement = Arrangement.spacedBy(13.dp), + ) { Box(Modifier.size(32.dp).padding(4.dp), contentAlignment = Alignment.Center) { - Icon(notification.type.iconName, color = accent, modifier = Modifier.size(24.dp)) + Icon(notification.iconName ?: notification.type.iconName, color = accent, modifier = Modifier.size(24.dp)) } Column( modifier = Modifier.weight(1f), @@ -127,7 +125,7 @@ private fun NotificationRow(notification: Notification) { modifier = Modifier.weight(1f).padding(end = 8.dp), ) Text( - relativeTime(notification.createdAt), + Formatters.relativeTime(Instant.ofEpochMilli(notification.createdAt)), color = theme.textColorSecondary, fontSize = 10.sp, ) @@ -149,24 +147,9 @@ private fun NotificationRow(notification: Notification) { ) } - val progress = notification.progressOrNull() + val progress = liveProgress(notification) if (progress != null) { - Box( - Modifier - .padding(top = 8.dp) - .fillMaxWidth() - .height(6.dp) - .clip(theme.circleShape) - .background(Accent.copy(0.5f)), - ) { - Box( - Modifier - .fillMaxWidth(progress) - .height(6.dp) - .clip(theme.circleShape) - .background(Accent), - ) - } + ProgressBar(progress, Modifier.padding(top = 8.dp), ControlSize.Regular) } if (notification.actions.isNotEmpty()) { @@ -183,40 +166,45 @@ private fun NotificationRow(notification: Notification) { @Composable private fun ActionButton(notification: Notification, action: NotificationAction) { - val theme = LocalTheme.current - val source = rememberInteractionSource() - val isHovered by source.collectIsHoveredAsState() - - val base = if (action.primary) Accent else theme.textColor.copy(0.08f) - val background by animateAccentColorAsState(isHovered to action.primary, if (isHovered) theme.textColor.copy(0.12f).compositeOver(base) else base) - - Box( - modifier = Modifier - .clip(ButtonShape) - .background(background, ButtonShape) - .onClick(source) { - action.onClick.run() - NotificationsManager.remove(notification) - } - .pointerHoverIcon(PointerIcon.Hand) - .padding(horizontal = 12.dp, vertical = 6.dp), - ) { - Text( - action.label, - color = if (action.primary) theme.accentTextColor else theme.textColor, - fontSize = 12.sp, - lineHeight = 18.sp, - fontWeight = FontWeight.Medium, - ) - } + TextButton( + action.label, + { + action.onClick.run() + if (action.dismissOnClick) NotificationsManager.remove(notification) + }, + style = when { + !action.primary -> ControlStyle.Secondary + notification.type == NotificationType.ERROR -> ControlStyle.DangerFilled + else -> ControlStyle.Primary + }, + size = ControlSize.Compact, + enabled = action.enabled, + ) } +private fun notificationMenu(notification: Notification): List = listOf( + MenuAction(translated("oneconfig.notifications.dismiss", "Dismiss"), { NotificationsManager.remove(notification) }, icon = "close"), + MenuAction( + translated("oneconfig.notifications.copy", "Copy text"), + { ClipboardHelper.setString(listOf(notification.title, notification.message).filter { it.isNotEmpty() }.joinToString("\n")) }, + icon = "copy", + ), + MenuSeparator, + MenuAction( + translated("oneconfig.notifications.clear_all", "Clear all notifications"), + { NotificationsManager.clearHistory() }, + icon = "trash", + danger = true, + ), +) + @Composable private fun Footer() { val theme = LocalTheme.current val source = rememberInteractionSource() val isHovered by source.collectIsHoveredAsState() - val color by animateColorAsState(if (isHovered) theme.textColor else theme.textColor.copy(0.85f)) + val color by Motion.animateColorAsState(if (isHovered) theme.textColor else theme.textColor.copy(0.85f)) + val label = translated("oneconfig.notifications.clear_all", "Clear all notifications") Row( modifier = Modifier.fillMaxWidth(), @@ -226,13 +214,13 @@ private fun Footer() { Row( modifier = Modifier .clip(ButtonShape) - .onClick(source) { NotificationsManager.clearHistory() } + .oneConfigClickable(label, { NotificationsManager.clearHistory() }, indication = false, interactionSource = source) .pointerHoverIcon(PointerIcon.Hand), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon("trash", color = color, modifier = Modifier.size(18.dp)) - Text("Clear all notifications", color = color, fontSize = 12.sp, fontWeight = FontWeight.Medium) + Text(label, color = color, fontSize = 12.sp, fontWeight = FontWeight.Medium) } } } @@ -240,18 +228,20 @@ private fun Footer() { @Composable private fun accentFor(type: NotificationType): Color = when (type) { NotificationType.INFO -> LocalTheme.current.textColor - NotificationType.SUCCESS -> SuccessColor - NotificationType.ERROR -> ErrorColor + NotificationType.SUCCESS -> LocalTheme.current.success + NotificationType.ERROR -> LocalTheme.current.error + NotificationType.WARNING -> LocalTheme.current.warning NotificationType.PROGRESS -> Accent } -private fun relativeTime(createdAt: Long, now: Long = System.currentTimeMillis()): String { - val seconds = ((now - createdAt) / 1000L).coerceAtLeast(0L) - return when { - seconds < 5 -> "Just now" - seconds < 60 -> "${seconds}s ago" - seconds < 3600 -> "${seconds / 60}m ago" - seconds < 86400 -> "${seconds / 3600}h ago" - else -> "${seconds / 86400}d ago" +@Composable +private fun liveProgress(notification: Notification): Float? { + var frame by remember(notification.id) { mutableLongStateOf(0L) } + val progress = frame.let { notification.progressOrNull() } + if (progress != null && progress < 1f && notification.progress != null) { + LaunchedEffect(notification.id) { + while (true) withFrameNanos { frame = it } + } } + return progress } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/ReducedMotion.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/ReducedMotion.kt index a36650abc..35cdb5775 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/ReducedMotion.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/ReducedMotion.kt @@ -4,33 +4,31 @@ import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.FiniteAnimationSpec -import androidx.compose.animation.core.snap -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import androidx.compose.ui.MotionDurationScale +import org.polyfrost.oneconfig.api.ui.v1.OneConfigPrefs +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.internal.ui.themes.PrefsSync object AnimationsMotionScale : MotionDurationScale { @JvmStatic - var animations by mutableStateOf(true) + var animations: Boolean + get() = OneConfigPrefs.animationsEnabled + set(value) = PrefsSync.animations(value) - override val scaleFactor: Float get() = if (animations) 1f else 0f + override val scaleFactor: Float get() = if (OneConfigPrefs.animationsEnabled) 1f else 0f } object ReducedMotion { - private const val FADE_MILLIS = 150 - @JvmStatic - var enabled by mutableStateOf(false) + var enabled: Boolean + get() = Motion.reduced + set(value) = PrefsSync.reducedMotion(value) - fun spec(spec: AnimationSpec): AnimationSpec = if (enabled) snap() else spec + fun spec(spec: AnimationSpec): AnimationSpec = Motion.spec(spec) - fun finiteSpec(spec: FiniteAnimationSpec): FiniteAnimationSpec = if (enabled) snap() else spec + fun finiteSpec(spec: FiniteAnimationSpec): FiniteAnimationSpec = Motion.finiteSpec(spec) - fun enter(enter: EnterTransition): EnterTransition = if (enabled) fadeIn(tween(FADE_MILLIS)) else enter + fun enter(enter: EnterTransition): EnterTransition = Motion.enter(enter) - fun exit(exit: ExitTransition): ExitTransition = if (enabled) fadeOut(tween(FADE_MILLIS)) else exit + fun exit(exit: ExitTransition): ExitTransition = Motion.exit(exit) } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/ReorderableGrid.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/ReorderableGrid.kt deleted file mode 100644 index bbfc50302..000000000 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/ReorderableGrid.kt +++ /dev/null @@ -1,430 +0,0 @@ -package org.polyfrost.oneconfig.internal.ui.components - -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.VectorConverter -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.spring -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.grid.LazyGridItemInfo -import androidx.compose.foundation.lazy.grid.LazyGridState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue -import androidx.compose.runtime.withFrameNanos -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.AwaitPointerEventScope -import androidx.compose.ui.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.PointerId -import androidx.compose.ui.input.pointer.PointerInputChange -import androidx.compose.ui.input.pointer.isPrimaryPressed -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.LayoutCoordinates -import androidx.compose.ui.layout.onPlaced -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.round -import androidx.compose.ui.zIndex -import kotlin.math.abs -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.Job -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import org.polyfrost.oneconfig.internal.ui.sound.UiSoundEvent -import org.polyfrost.oneconfig.internal.ui.sound.UiSounds - -/** Distance from a viewport edge at which a drag starts scrolling the grid */ -private val AutoScrollZone = 40.dp -/** How far into the zone the item must go for auto-scroll to reach [AutoScrollMaxSpeed] */ -private val AutoScrollRamp = 240.dp -/** Distance auto-scroll covers per second at its fastest */ -private val AutoScrollMaxSpeed = 3200.dp - -private const val ScrollLiftThreshold = 1f - -private val SettleSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMediumLow, - visibilityThreshold = Offset(0.5f, 0.5f), -) - -/** - * Drag-to-reorder support for a [androidx.compose.foundation.lazy.grid.LazyVerticalGrid] - * - * The grid re-lays out around the dragged item as the pointer moves so [onMove] must mutate the backing - * list immediately and [onDrop] fires once with the item's final index when the gesture ends - * - * The caller draws the dragged item above the grid, see [reorderOverlay], as a grid item cannot pass above the - * ones after it - * - * Gestures are read by [reorderContainer] on the box around the grid, since grid items can leave composition mid drag - * - * [dragBounds] gives the slots an item may be dropped into, or null for any, and an item outside its own bounds - * cannot be dragged - * - * [onClick] fires for an item clicked while it is still settling, as [reorderContainer] catches that press - * - * [horizontalPadding] is the grid's horizontal content padding, which [LazyGridItemInfo.offset] does not include - */ -class GridReorderState internal constructor( - private val gridState: LazyGridState, - private val scope: CoroutineScope, - private val onMove: (from: Int, to: Int) -> Unit, - private val onDrop: (index: Int) -> Unit, - private val dragBounds: (index: Int) -> IntRange?, - internal val onClick: (key: Any) -> Unit, - private val density: () -> Density, - private val horizontalPadding: () -> Dp, -) { - var draggingKey by mutableStateOf(null) - private set - - /** Key of the item to draw in the overlay either the dragged one or one still settling */ - var overlayKey by mutableStateOf(null) - private set - - var overlaySize by mutableStateOf(IntSize.Zero) - private set - - private var draggingIndex by mutableIntStateOf(-1) - - /** Where the dragged item sat when the gesture started in viewport coordinates */ - private var initialOffset by mutableStateOf(Offset.Zero) - private var dragDelta by mutableStateOf(Offset.Zero) - - /** How far the settling item still is from its slot */ - private val settleOffset = Animatable(Offset.Zero, Offset.VectorConverter) - private var settling by mutableStateOf(false) - - private var slot = Offset.Zero - private var referenceKey: Any? = null - private var referenceOffset = Offset.Zero - private var settle: Job? = null - private var dragLoop: Job? = null - - internal var containerCoordinates: LayoutCoordinates? = null - internal val itemCoordinates = mutableMapOf() - - /** - * Overlay position in the coordinate space of the box wrapping the grid - * - * A settling item is placed relative to its slot so it moves with the grid as it scrolls - */ - val overlayOffset: Offset - get() = if (settling) trackSlot() + settleOffset.value else initialOffset + dragDelta - - /** - * Where the overlay item's slot is now - * - * While out of view it follows the middle visible item, which is unlikely to scroll away in one frame - */ - private fun trackSlot(): Offset { - val items = gridState.layoutInfo.visibleItemsInfo - val own = items.firstOrNull { it.key == overlayKey } - if (own != null) { - slot = own.slot() - } else { - items.firstOrNull { it.key == referenceKey }?.let { slot += it.offset.toOffset() - referenceOffset } - } - val reference = items.getOrNull(items.size / 2) - referenceKey = reference?.key - referenceOffset = reference?.offset?.toOffset() ?: Offset.Zero - return slot - } - - private fun infoAt(index: Int): LazyGridItemInfo? = - gridState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == index } - - private fun drawnBounds(info: LazyGridItemInfo): Rect { - val container = containerCoordinates ?: return info.bounds() - val item = itemCoordinates[info.key] ?: return info.bounds() - return container.localBoundingBoxOf(item, clipBounds = false) - } - - internal fun isOverSettlingItem(position: Offset): Boolean = - settling && Rect(overlayOffset, overlaySize.toSize()).contains(position) - - internal fun draggableKeyAt(position: Offset): Any? { - if (isOverSettlingItem(position)) return overlayKey - itemCoordinates.values.removeAll { !it.isAttached } - val info = gridState.layoutInfo.visibleItemsInfo.lastOrNull { drawnBounds(it).contains(position) } ?: return null - val bounds = dragBounds(info.index) - return info.key.takeIf { bounds == null || info.index in bounds } - } - - /** Lifts the item with [key] into the overlay, from where it is if still settling */ - internal fun onDragStart(key: Any): Boolean { - val info = gridState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == key } ?: return false - val from = if (settling && overlayKey == key) overlayOffset else drawnBounds(info).topLeft - settle?.cancel() - settling = false - draggingKey = key - overlayKey = key - overlaySize = info.size - draggingIndex = info.index - initialOffset = from - dragDelta = Offset.Zero - trackSlot() - dragLoop = scope.launch { dragLoop() } - return true - } - - internal fun onDrag(delta: Offset) { - if (draggingKey != null) dragDelta += delta - } - - internal fun onDragEnd() { - val key = draggingKey ?: return - settleOnHoveredItem() - val index = draggingIndex - // the grid only moves the item to its new slot next frame, so measure from that slot - val fromSlot = overlayOffset - (infoAt(index)?.slot() ?: trackSlot()) - stop() - onDrop(index) - settling = true - settle = scope.launch(start = CoroutineStart.UNDISPATCHED) { - settleOffset.snapTo(fromSlot) - settleOffset.animateTo(Offset.Zero, ReducedMotion.spec(SettleSpec)) - settling = false - if (overlayKey == key) overlayKey = null - } - } - - /** Ends the drag without dropping, for when the backing list is replaced and the index no longer applies */ - internal fun cancel() { - if (draggingKey == null) return - stop() - overlayKey = null - } - - private fun stop() { - dragLoop?.cancel() - dragLoop = null - draggingKey = null - draggingIndex = -1 - dragDelta = Offset.Zero - } - - /** - * Swaps the dragged item into the slot it now covers most - * - * Comparing against how much of its own slot it still covers adds hysteresis so a card hovering a - * boundary does not flip back and forth and the gaps between cards are not dead zones - * - * Once it covers none of the slots [dragBounds] allows it moves to the nearest one, or to any visible one if - * its own slot scrolled out of view - */ - private fun settleOnHoveredItem() { - if (draggingKey == null) return - val info = infoAt(draggingIndex) - val bounds = dragBounds(draggingIndex) - val dragged = Rect(overlayOffset, overlaySize.toSize()) - val allowed = gridState.layoutInfo.visibleItemsInfo.filter { bounds == null || it.index in bounds } - var best: LazyGridItemInfo? = null - var bestOverlap = info?.let { dragged.overlap(it.bounds()) } ?: 0f - allowed.forEach { other -> - if (other.index == draggingIndex) return@forEach - val overlap = dragged.overlap(other.bounds()) - if (overlap > bestOverlap) { - best = other - bestOverlap = overlap - } - } - if (best == null && bounds != null && bestOverlap == 0f) { - fun distance(item: LazyGridItemInfo) = (item.bounds().center - dragged.center).getDistance() - val ownDistance = info?.let(::distance) ?: Float.POSITIVE_INFINITY - best = allowed.minByOrNull(::distance)?.takeIf { distance(it) < ownDistance } - } - val target = best ?: return - // override scroll to stay in place instead of following the first visible item - val first = gridState.firstVisibleItemIndex - if (first in minOf(draggingIndex, target.index)..maxOf(draggingIndex, target.index)) { - gridState.requestScrollToItem(first, gridState.firstVisibleItemScrollOffset) - } - onMove(draggingIndex, target.index) - draggingIndex = target.index - UiSounds.play(UiSoundEvent.SLIDER_TICK) - } - - /** - * Moves the dragged item into the slot under it, and scrolls the grid while it is near the top or bottom edge, - * faster the further past it - */ - private suspend fun dragLoop() { - var lastFrame = withFrameNanos { it } - while (scope.isActive && draggingKey != null) { - val frame = withFrameNanos { it } - val seconds = ((frame - lastFrame) / 1e9f).coerceAtMost(1 / 30f) - lastFrame = frame - trackSlot() - settleOnHoveredItem() - val top = overlayOffset.y - val bottom = top + overlaySize.height - val layout = gridState.layoutInfo - val bounds = dragBounds(draggingIndex) - val inView = layout.visibleItemsInfo.filter { - it.offset.y >= layout.viewportStartOffset && it.offset.y + it.size.height <= layout.viewportEndOffset - } - val canScrollUp = bounds == null || inView.isEmpty() || bounds.first < inView.first().index - val canScrollDown = bounds == null || inView.isEmpty() || bounds.last > inView.last().index - val (zone, rampDistance, maxSpeed) = with(density()) { - Triple(AutoScrollZone.toPx(), AutoScrollRamp.toPx(), AutoScrollMaxSpeed.toPx()) - } - // an item picked up inside the zone should only scroll when further dragged towards the edge - val intoTop = minOf(layout.viewportStartOffset + zone, initialOffset.y) - top - val intoBottom = bottom - maxOf(layout.viewportEndOffset - zone, initialOffset.y + overlaySize.height) - val depth = when { - intoTop > 0f && canScrollUp -> -intoTop - intoBottom > 0f && canScrollDown -> intoBottom - else -> 0f - } - val ramp = (depth / rampDistance).coerceIn(-1f, 1f) - // raw delta so a wheel scroll mid drag cannot interrupt this loop - if (depth != 0f) gridState.dispatchRawDelta(ramp * abs(ramp) * maxSpeed * seconds) - } - } - - private fun LazyGridItemInfo.slot() = offset.toOffset() + Offset(with(density()) { horizontalPadding().toPx() }, 0f) - - private fun LazyGridItemInfo.bounds() = Rect(slot(), size.toSize()) -} - -@Composable -fun rememberGridReorderState( - gridState: LazyGridState, - onMove: (from: Int, to: Int) -> Unit, - onDrop: (index: Int) -> Unit, - dragBounds: (index: Int) -> IntRange? = { null }, - onClick: (key: Any) -> Unit = {}, - horizontalPadding: Dp = 0.dp, -): GridReorderState { - val scope = rememberCoroutineScope() - val currentOnMove by rememberUpdatedState(onMove) - val currentOnDrop by rememberUpdatedState(onDrop) - val currentDragBounds by rememberUpdatedState(dragBounds) - val currentOnClick by rememberUpdatedState(onClick) - val currentDensity by rememberUpdatedState(LocalDensity.current) - val currentHorizontalPadding by rememberUpdatedState(horizontalPadding) - return remember(gridState, scope) { - GridReorderState( - gridState = gridState, - scope = scope, - onMove = { from, to -> currentOnMove(from, to) }, - onDrop = { index -> currentOnDrop(index) }, - dragBounds = { index -> currentDragBounds(index) }, - onClick = { key -> currentOnClick(key) }, - density = { currentDensity }, - horizontalPadding = { currentHorizontalPadding }, - ) - } -} - -/** - * Reads drags for the grid's items, applied to the box wrapping the grid with the grid at its origin - * - * A drag starts once the primary button moves past touch slop or scrolls so items can still be clicked, but an item - * still settling is caught on press since it would move out from under the pointer first - * - * Events are read before the grid so it does not scroll along with the drag, but wheel scrolling still reaches it - */ -fun Modifier.reorderContainer(state: GridReorderState): Modifier = pointerInput(state) { - awaitEachGesture { - val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) - if (!currentEvent.buttons.isPrimaryPressed) return@awaitEachGesture - val key = state.draggableKeyAt(down.position) ?: return@awaitEachGesture - val caught = state.isOverSettlingItem(down.position) - var last = down.position - if (caught) { - down.consume() - } else { - var scrolled = 0f - do { - val change = awaitPressedChange(down.id, consume = false) ?: return@awaitEachGesture - val scroll = currentEvent.type == PointerEventType.Scroll - if (scroll) scrolled += abs(change.scrollDelta.y) - val passedSlop = scrolled >= ScrollLiftThreshold || - (change.position - down.position).getDistance() > viewConfiguration.touchSlop - if (passedSlop) { - if (!scroll) change.consume() - last = change.position - } - } while (!passedSlop) - } - if (!state.onDragStart(key)) return@awaitEachGesture - state.onDrag(last - down.position) - var click = caught - try { - while (true) { - val change = awaitPressedChange(down.id, consume = true) ?: break - if (currentEvent.type == PointerEventType.Scroll || - (change.position - down.position).getDistance() > viewConfiguration.touchSlop) click = false - state.onDrag(change.position - last) - last = change.position - } - } finally { - state.onDragEnd() - } - if (click) state.onClick(key) - } -}.onPlaced { state.containerCoordinates = it } - -/** The next change of the pointer while still held, consumed if [consume] unless it is a wheel scroll */ -private suspend fun AwaitPointerEventScope.awaitPressedChange(id: PointerId, consume: Boolean): PointerInputChange? { - val event = awaitPointerEvent(PointerEventPass.Initial) - val change = event.changes.firstOrNull { it.id == id } ?: return null - if (consume && event.type != PointerEventType.Scroll) change.consume() - return change.takeIf { it.pressed } -} - -/** Hides the item while the overlay draws it in its place, [key] must be the key it was declared with */ -fun Modifier.reorderableItem(state: GridReorderState, key: Any): Modifier = this - .onPlaced { state.itemCoordinates[key] = it } - .graphicsLayer { alpha = if (state.overlayKey == key) 0f else 1f } - -/** - * Positions and sizes the overlay copy of the dragged item - * - * Apply to the same content the grid would have drawn placed in a box that wraps the grid - */ -@Composable -fun Modifier.reorderOverlay(state: GridReorderState): Modifier { - val density = LocalDensity.current - val size = with(density) { state.overlaySize.let { it.width.toDp() to it.height.toDp() } } - val scale by animateFloatAsState(if (state.draggingKey != null && !ReducedMotion.enabled) 1.04f else 1f) - return this - .zIndex(1f) - .offset { state.overlayOffset.round() } - .size(size.first, size.second) - .graphicsLayer { - scaleX = scale - scaleY = scale - } -} - -private fun Rect.overlap(other: Rect): Float { - val width = minOf(right, other.right) - maxOf(left, other.left) - val height = minOf(bottom, other.bottom) - maxOf(top, other.top) - return if (width > 0f && height > 0f) width * height else 0f -} - -private fun IntOffset.toOffset() = Offset(x.toFloat(), y.toFloat()) - -private fun IntSize.toSize() = Size(width.toFloat(), height.toFloat()) diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/RetainedVisibility.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/RetainedVisibility.kt index ffda7b48e..63ccced03 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/RetainedVisibility.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/RetainedVisibility.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.unit.toOffset import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupPositionProvider import androidx.compose.ui.window.PopupProperties +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion class ShellTransition internal constructor( private val progressProvider: () -> Float, @@ -143,7 +144,7 @@ fun RetainedVisibility( openKey: Any? = null, content: @Composable (alpha: Float) -> Unit, ) { - val hiddenScale = if (ReducedMotion.enabled) 1f else hiddenScale + val hiddenScale = if (Motion.reduced) 1f else hiddenScale val progress = remember(openKey) { Animatable(0f) } LaunchedEffect(openKey, visible) { progress.animateTo(if (visible) 1f else 0f, if (visible) enter else exit) diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/SearchField.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/SearchField.kt deleted file mode 100644 index abb5e8b6d..000000000 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/SearchField.kt +++ /dev/null @@ -1,61 +0,0 @@ -package org.polyfrost.oneconfig.internal.ui.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.LineHeightStyle -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme - -@Composable -fun searchFieldTextStyle(): TextStyle { - val theme = LocalTheme.current - return TextStyle( - color = theme.textColorSecondary, - fontWeight = FontWeight.Medium, - fontSize = 12.sp, - fontFamily = theme.typography.family, - lineHeight = 12.sp, - lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.Both), - ) -} - -/** The search icon, [placeholder] and clear button drawn around a search field's [innerTextField] */ -@Composable -fun SearchFieldFrame( - placeholder: String, - showPlaceholder: Boolean, - onClear: (() -> Unit)?, - modifier: Modifier = Modifier, - innerTextField: @Composable () -> Unit, -) { - val theme = LocalTheme.current - Row( - modifier = modifier - .liquidGlass(theme.sideBarNavigationEntryShape) - .background(theme.chipBackground, theme.sideBarNavigationEntryShape) - .border(1.dp, theme.borderColor, theme.sideBarNavigationEntryShape) - .padding(horizontal = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Icon("search", color = theme.textColorSecondary, modifier = Modifier.size(14.dp)) - Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { - if (showPlaceholder) { - Text(placeholder, color = theme.textColorSecondary, fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 12.sp) - } - innerTextField() - } - if (onClear != null) IconButton("close", modifier = Modifier.size(16.dp), onClick = onClear) - } -} diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Sidebar.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Sidebar.kt index 2800b5f96..66d8d14ee 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Sidebar.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Sidebar.kt @@ -1,17 +1,15 @@ package org.polyfrost.oneconfig.internal.ui.components import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize @@ -22,7 +20,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect @@ -36,25 +33,32 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.blur import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.PopupProperties import androidx.navigation.compose.currentBackStackEntryAsState import kotlinx.coroutines.launch import org.polyfrost.oneconfig.api.hud.v1.HudManager import org.polyfrost.oneconfig.api.notifications.v1.NotificationsManager -import org.polyfrost.oneconfig.internal.ui.api.Tooltip +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.api.ui.v1.components.AnchoredPopupButton +import org.polyfrost.oneconfig.api.ui.v1.components.ControlSize +import org.polyfrost.oneconfig.api.ui.v1.components.DropdownMenu +import org.polyfrost.oneconfig.api.ui.v1.components.IconButton +import org.polyfrost.oneconfig.api.ui.v1.components.MenuAction +import org.polyfrost.oneconfig.api.ui.v1.components.NotificationsCenterPanel +import org.polyfrost.oneconfig.api.ui.v1.components.Placement +import org.polyfrost.oneconfig.api.ui.v1.components.ScrollContainer +import org.polyfrost.oneconfig.api.ui.v1.components.TooltipPlacement +import org.polyfrost.oneconfig.api.ui.v1.components.oneConfigClickable import org.polyfrost.oneconfig.internal.ui.navigation.inAboutMenu import org.polyfrost.oneconfig.internal.ui.navigation.isOn import org.polyfrost.oneconfig.internal.ui.navigation.pageRoute @@ -62,6 +66,7 @@ import org.polyfrost.oneconfig.internal.ui.navigation.registeredPages import org.polyfrost.oneconfig.internal.ui.navigation.sidebarSections import org.polyfrost.oneconfig.internal.ui.shell.LocalShell import org.polyfrost.oneconfig.internal.ui.shell.LocalShellNavController +import org.polyfrost.oneconfig.internal.ui.themes.Accent import org.polyfrost.oneconfig.internal.ui.themes.DEFAULT_SIDEBAR_OPACITY import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme import org.polyfrost.oneconfig.internal.ui.themes.surfaceOpacity @@ -112,14 +117,13 @@ private fun Navigation(modifier: Modifier) { ) { Branding() Column(Modifier.fillMaxWidth().weight(1f, fill = false)) { - Box( + ScrollContainer( + scroll, Modifier.fillMaxWidth().weight(1f, fill = false) - .onGloballyPositioned { overflow.viewportBottom = it.positionInRoot().y + it.size.height } - .verticalScroll(scroll), - contentAlignment = Alignment.TopCenter, + .onGloballyPositioned { overflow.viewportBottom = it.positionInRoot().y + it.size.height }, ) { CompositionLocalProvider(LocalNavOverflow provides overflow) { - Box(Modifier.padding(vertical = 4.dp)) { NavigationEntries() } + Box(Modifier.padding(start = (SidebarWidth - SidebarEntryWidth) / 2, top = 4.dp, bottom = 4.dp)) { NavigationEntries() } } } Box(Modifier.fillMaxWidth().height(EditHudGap)) { @@ -127,14 +131,20 @@ private fun Navigation(modifier: Modifier) { if (hidden > 0) { val source = rememberInteractionSource() val hovered by source.collectIsHoveredAsState() + val label = translated("oneconfig.sidebar.more", "+%s more", hidden) Text( - translated("oneconfig.sidebar.more", "+%s more", hidden), + label, modifier = Modifier.padding(start = 26.dp, top = 6.dp) - .onClick(source) { - scope.launch { - if (ReducedMotion.enabled) scroll.scrollTo(scroll.maxValue) else scroll.animateScrollTo(scroll.maxValue) - } - } + .oneConfigClickable( + label, + { + scope.launch { + if (Motion.reduced) scroll.scrollTo(scroll.maxValue) else scroll.animateScrollTo(scroll.maxValue) + } + }, + indication = false, + interactionSource = source, + ) .pointerHoverIcon(PointerIcon.Hand), color = LocalTheme.current.textColorSecondary.copy(alpha = if (hovered) 1f else 0.7f), fontSize = 11.sp, @@ -147,6 +157,8 @@ private fun Navigation(modifier: Modifier) { private val EditHudGap = 74.dp +private val SidebarEntryWidth = 225.dp + private class NavOverflow { val centers = mutableStateMapOf() var viewportBottom by mutableFloatStateOf(0f) @@ -252,32 +264,32 @@ private fun NavigationSection(title: String? = null, collapsed: Boolean? = null, private fun CollapsibleSection(title: String, collapsed: Boolean, onToggle: () -> Unit, trackKey: Any, content: @Composable () -> Unit) { val source = rememberInteractionSource() val hovered by source.collectIsHoveredAsState() - val chevronRotation by animateFloatAsState(if (collapsed) 90f else 180f) val theme = LocalTheme.current Column(horizontalAlignment = Alignment.Start) { TrackOverflow(trackKey, enabled = collapsed) { - Row( - Modifier.width(225.dp).padding(start = 6.dp, end = 6.dp, bottom = 2.dp) - .onClick(source) { onToggle() } - .pointerHoverIcon(PointerIcon.Hand), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + CollapsibleHeader( + label = title, + expanded = !collapsed, + onToggle = onToggle, + modifier = Modifier.width(SidebarEntryWidth), + contentPadding = PaddingValues(start = 6.dp, end = 6.dp, bottom = 2.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + interactionSource = source, ) { Text( title.uppercase(), - modifier = Modifier.weight(1f, fill = false), + modifier = Modifier.weight(1f), color = if (hovered) theme.textColor else theme.textColor.copy(0.8f), fontWeight = FontWeight.Medium, fontSize = 11.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, ) - Icon("up", color = theme.textColorSecondary, modifier = Modifier.padding(start = 8.dp).size(12.dp).rotate(chevronRotation)) } } AnimatedVisibility( visible = !collapsed, - enter = ReducedMotion.enter(expandVertically()), - exit = ReducedMotion.exit(shrinkVertically()), + enter = Motion.enter(expandVertically()), + exit = Motion.exit(shrinkVertically()), ) { Column( Modifier.padding(top = 8.dp), @@ -298,21 +310,21 @@ private fun NavigationEntry(icon: String, title: String, route: Any? = null, isS val theme = LocalTheme.current val translucent = theme.translucentSelection - val selectedAlpha = animateFloatAsState(if (isSelected) 1f else 0f) - val textAndIconColor = animateColorAsState( + val selectedAlpha = Motion.animateFloatAsState(if (isSelected) 1f else 0f) + val textAndIconColor = Motion.animateColorAsState( if (isSelected && translucent) theme.textColor else if (isSelected) theme.accentTextColor else if (isHovered) theme.textColor else theme.textColor.copy(0.8f) ) - val borderColor = animateColorAsState( + val borderColor = Motion.animateColorAsState( if (isSelected && translucent) theme.borderColor.copy(0f) else if (isHovered || isSelected) theme.borderColor else theme.borderColor.copy(0f) ) Row( - modifier = Modifier.size(225.dp, 33.dp) + modifier = Modifier.size(SidebarEntryWidth, 33.dp) .glassHover(theme.sideBarNavigationEntryShape, pull = 0.75.dp) .graphicsLayer {} .animatedFrame( @@ -322,7 +334,7 @@ private fun NavigationEntry(icon: String, title: String, route: Any? = null, isS border = { borderColor.value }, ) .then(if (translucent) Modifier.liquidGlass(theme.sideBarNavigationEntryShape) { selectedAlpha.value } else Modifier) - .onClick(interactionSource) { onSelect(route) } + .oneConfigClickable(title, { onSelect(route) }, selected = isSelected, indication = false, interactionSource = interactionSource) .pointerHoverIcon(PointerIcon.Hand), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp) @@ -334,43 +346,34 @@ private fun NavigationEntry(icon: String, title: String, route: Any? = null, isS @Composable private fun NotificationBell() { - var expanded by remember { mutableStateOf(false) } - var bellHeightPx by remember { mutableStateOf(0) } - val interactionSource = rememberInteractionSource() - - Box( - modifier = Modifier - .onSizeChanged { bellHeightPx = it.height } - .onClick(interactionSource) { - expanded = !expanded - if (!expanded) NotificationsManager.markAllRead() - } - .pointerHoverIcon(PointerIcon.Hand) - ) { - IconWithIndicator( - iconName = "bell", - color = LocalTheme.current.textColor, - modifier = Modifier.size(18.dp), - showIndicator = NotificationsManager.unreadCount > 0, - ) - - if (expanded) { - ShellPopup( - // open up and to the right of the bell - alignment = Alignment.BottomStart, - offset = IntOffset(0, -(bellHeightPx + 12)), - onDismissRequest = { - expanded = false - NotificationsManager.markAllRead() - }, - properties = PopupProperties(focusable = true), - ) { - Box(Modifier.shellTransition()) { NotificationsCenter() } + Box { + AnchoredPopupButton( + "bell", + translated("oneconfig.sidebar.notifications", "Notifications"), + placement = Placement.AboveStart, + gap = PopupGap - BellOutset, + size = ControlSize.Large, + ) { + DisposableEffect(Unit) { onDispose { NotificationsManager.markAllRead() } } + NotificationsCenterPanel(Modifier.shellTransition()) + } + if (NotificationsManager.unreadCount > 0) { + Box(Modifier.align(Alignment.Center).size(BellGlyph)) { + UnreadDot(Modifier.align(Alignment.TopEnd).offset(2.dp, (-2).dp)) } } } } +@Composable +private fun UnreadDot(modifier: Modifier) { + val circle = LocalTheme.current.shapes.circle + Box(modifier, contentAlignment = Alignment.Center) { + Box(Modifier.size(8.dp).background(Accent, circle).blur(7.dp)) + Box(Modifier.size(6.dp).background(Accent, circle)) + } +} + @Composable private fun Account() { Box( @@ -404,8 +407,8 @@ private fun Account() { } } Row( - modifier = Modifier.padding(start = 12.dp), - horizontalArrangement = Arrangement.spacedBy(14.dp), + modifier = Modifier.padding(start = 12.dp).offset(x = BellOutset), + horizontalArrangement = Arrangement.spacedBy(FooterButtonGap), verticalAlignment = Alignment.CenterVertically, ) { AboutButton() @@ -418,63 +421,47 @@ private fun Account() { @Composable private fun AboutButton() { var expanded by remember { mutableStateOf(false) } - var heightPx by remember { mutableStateOf(0) } - val interactionSource = rememberInteractionSource() - val hovered by interactionSource.collectIsHoveredAsState() - val theme = LocalTheme.current val controller = LocalShellNavController.current val shell = LocalShell.current val entry by controller.currentBackStackEntryAsState() val pages = registeredPages().filter { it.inAboutMenu && it.showInSidebar } - val active = pages.any { entry.isOn(pageRoute(it)) } - - Tooltip( - text = { Text(translated("oneconfig.sidebar.about", "Changelog and credits"), color = theme.textColor, fontSize = 12.sp) }, - modifier = Modifier, - anchor = Alignment.TopCenter, - ) { - Box( - modifier = Modifier - .onSizeChanged { heightPx = it.height } - .onClick(interactionSource) { expanded = !expanded } - .pointerHoverIcon(PointerIcon.Hand) - ) { - Icon("info", modifier = Modifier.size(18.dp), color = if (hovered || expanded || active) theme.textColor else theme.textColor.copy(0.8f)) - - if (expanded) { - ShellPopup( - alignment = Alignment.BottomStart, - offset = IntOffset(0, -(heightPx + 12)), - onDismissRequest = { expanded = false }, - properties = PopupProperties(focusable = true), - ) { - SidebarMenu { - pages.forEach { page -> - val route = pageRoute(page) - NavigationEntry(page.icon, page.title.resolve(), route, entry.isOn(route)) { - expanded = false - val nav = shell.navigation - if (nav.currentRoute == route) shell.search.clear() else nav.navigate(route) - } - } - } - } - } - } + val label = translated("oneconfig.sidebar.about", "Changelog and credits") + + Box { + IconButton( + "info", + label, + { expanded = !expanded }, + size = ControlSize.Large, + selected = expanded, + tooltip = if (expanded) null else label, + tooltipPlacement = TooltipPlacement.AboveCenter, + ) + DropdownMenu( + expanded = expanded, + onDismiss = { expanded = false }, + entries = pages.map { page -> + val route = pageRoute(page) + MenuAction( + page.title.resolve(), + { + expanded = false + val nav = shell.navigation + if (nav.currentRoute == route) shell.search.clear() else nav.navigate(route) + }, + icon = page.icon, + checked = entry.isOn(route), + ) + }, + placement = Placement.AboveStart, + ) } } -@Composable -private fun SidebarMenu(content: @Composable () -> Unit) { - val theme = LocalTheme.current - Column( - Modifier.shellTransition() - .liquidGlass(theme.popupShape, frosted = true) - .background(theme.popupBackground, theme.popupShape) - .border(1.dp, theme.borderColor, theme.popupShape) - .padding(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - content() - } -} +private val PopupGap = 12.dp + +private val FooterButtonGap = 4.dp + +private val BellGlyph = 18.dp + +private val BellOutset = (ControlSize.Large.height - BellGlyph) / 2 diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Text.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Text.kt index f9d977d60..df3f0a0fd 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Text.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Text.kt @@ -6,13 +6,17 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorProducer +import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.* import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.sp +import java.util.concurrent.ConcurrentHashMap import net.kyori.adventure.text.Component import net.kyori.adventure.text.ComponentLike +import net.kyori.adventure.text.format.Style +import net.kyori.adventure.text.format.TextDecoration as KyoriDecoration import org.apache.logging.log4j.LogManager import org.polyfrost.oneconfig.api.platform.v1.Platform import org.polyfrost.oneconfig.internal.ui.api.TextComponent @@ -33,6 +37,8 @@ fun Any.isEmptyText() = this.asRenderText().isEmpty() private val LOGGER = LogManager.getLogger("OneConfig/Text-Renderer") +private val warnedTextTypes = ConcurrentHashMap.newKeySet>() + private const val LEGACY_CHAR = '§' private val LEGACY_COLORS = mapOf( @@ -143,6 +149,7 @@ fun Text( color = color, lineHeight = lineHeight ?: TextStyle.Default.lineHeight, textAlign = textAlign, + baselineShift = shift, ), maxLines = maxLines, overflow = overflow, @@ -150,25 +157,82 @@ fun Text( color = colorProducer, ) is Iterable<*> -> { - Column { - comp.forEach { Text(it ?: return@forEach, modifier, color, fontSize, lineHeight, shift, fontWeight, textAlign, maxLines, overflow) } + Column(modifier) { + comp.forEach { Text(it ?: return@forEach, Modifier, color, fontSize, lineHeight, shift, fontWeight, textAlign, maxLines, overflow, colorProducer) } } } else -> { - LOGGER.warn("Unsupported text type " + comp.javaClass.simpleName) - BasicText( - buildAnnotatedString { withStyle(SpanStyle(color = Color.Red)) { append("Error") } }, style = TextStyle( - fontSize = fontSize, fontWeight = fontWeight, - fontFamily = LocalTheme.current.typography.family, - color = color, - lineHeight = lineHeight ?: TextStyle.Default.lineHeight, - textAlign = textAlign, - ), - modifier = modifier - ) + if (warnedTextTypes.add(comp.javaClass)) LOGGER.warn("Unsupported text type {}; drawing its toString() instead", comp.javaClass.name) + Text(comp.toString(), modifier, color, fontSize, lineHeight, shift, fontWeight, textAlign, maxLines, overflow, colorProducer) } } } + +@Composable +fun StyledText( + text: AnnotatedString, + modifier: Modifier, + color: Color, + style: TextStyle, + maxLines: Int, + overflow: TextOverflow, + softWrap: Boolean, + textAlign: TextAlign?, + colorProducer: ColorProducer?, + onTextLayout: ((TextLayoutResult) -> Unit)?, +) { + val theme = LocalTheme.current + val resolved = when { + color.isSpecified -> color + style.color.isSpecified -> style.color + else -> theme.textColor + } + BasicText( + text = text, + modifier = modifier, + style = style.merge( + TextStyle( + color = resolved, + fontFamily = style.fontFamily ?: theme.typography.family, + textAlign = textAlign ?: style.textAlign, + ), + ), + onTextLayout = onTextLayout, + overflow = overflow, + softWrap = softWrap, + maxLines = maxLines, + color = colorProducer, + ) +} + +fun legacyText(text: String): AnnotatedString = + if (text.indexOf(LEGACY_CHAR) >= 0) parseLegacyFormatting(text, Color.Unspecified) else AnnotatedString(text) + +fun componentText(component: Component): AnnotatedString = buildAnnotatedString { appendComponent(component, Style.empty()) } + +private fun AnnotatedString.Builder.appendComponent(component: Component, inherited: Style) { + val style = component.style().merge(inherited, Style.Merge.Strategy.IF_ABSENT_ON_TARGET) + withStyle(componentSpanStyle(style)) { + append(parseLegacyFormatting(Platform.compatibility().resolveComponent(component.children(emptyList())), Color.Unspecified)) + } + component.children().forEach { appendComponent(it, style) } +} + +private fun componentSpanStyle(style: Style): SpanStyle { + val strikethrough = style.hasDecoration(KyoriDecoration.STRIKETHROUGH) + val underlined = style.hasDecoration(KyoriDecoration.UNDERLINED) + return SpanStyle( + color = style.color()?.let { Color(it.value()).copy(alpha = 1f) } ?: Color.Unspecified, + fontWeight = if (style.hasDecoration(KyoriDecoration.BOLD)) FontWeight.Bold else null, + textGeometricTransform = if (style.hasDecoration(KyoriDecoration.ITALIC)) TextGeometricTransform(1f, 0.5f) else null, + textDecoration = when { + strikethrough && underlined -> TextDecoration.combine(listOf(TextDecoration.LineThrough, TextDecoration.Underline)) + strikethrough -> TextDecoration.LineThrough + underlined -> TextDecoration.Underline + else -> null + }, + ) +} diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/WheelScroll.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/WheelScroll.kt index fc3d988a4..007232d68 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/WheelScroll.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/WheelScroll.kt @@ -21,6 +21,7 @@ import kotlin.math.abs import kotlin.math.exp import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion /** Hands each mouse wheel notch to the innermost [wheelScroll] list under the pointer that can still scroll its way */ class WheelRouter { @@ -28,11 +29,20 @@ class WheelRouter { private set private var claimant: WheelScrollNode? = null + private var blocked = false + private var reachedScrollable = false + + private val fallbackX = WheelSpring() + private val fallbackY = WheelSpring() + private var fallbackPushNanos = 0L + private var fallbackFrameNanos = -1L /** Glides the list that claims this [delta] dp notch while [dispatch] delivers it */ fun route(delta: Offset, dispatch: () -> Unit) { isRouting = true claimant = null + blocked = false + reachedScrollable = false try { dispatch() } finally { @@ -40,16 +50,55 @@ class WheelRouter { } val node = claimant claimant = null - node?.push(delta) + when { + node != null -> { + stopFallback() + node.push(delta) + } + reachedScrollable && !blocked -> pushFallback(delta) + } + } + + fun defersWheelScroll(): Boolean { + if (!isRouting || Motion.reduced) return false + reachedScrollable = true + return true + } + + fun glideFrame(scroll: (Offset) -> Unit) { + if (!fallbackX.active && !fallbackY.active) { + fallbackFrameNanos = -1L + return + } + val now = System.nanoTime() + val nanos = if (fallbackFrameNanos < 0) now - fallbackPushNanos else now - fallbackFrameNanos + fallbackFrameNanos = now + val seconds = nanos / 1_000_000_000f + val step = Offset(fallbackX.advance(seconds), fallbackY.advance(seconds)) + if (step != Offset.Zero) scroll(step) + } + + fun stopFallback() { + fallbackX.reset() + fallbackY.reset() + fallbackFrameNanos = -1L + } + + private fun pushFallback(delta: Offset) { + if (delta.x != 0f) fallbackX.push(delta.x) + if (delta.y != 0f) fallbackY.push(delta.y) + fallbackPushNanos = System.nanoTime() } internal fun claim(node: WheelScrollNode) { claimant = node + blocked = false } /** Makes the notch scroll nothing, for a contained list that's already at its end */ internal fun block() { claimant = null + blocked = true } } @@ -119,7 +168,7 @@ internal class WheelScrollNode( override fun onCancelPointerInput() {} fun push(delta: Offset) { - if (ReducedMotion.enabled) { + if (Motion.reduced) { val step = amountOf(delta) * currentValueOf(LocalDensity).density coroutineScope.launch { state.scrollBy(step) } return diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/dropdown/DropdownMenu.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/dropdown/DropdownMenu.kt deleted file mode 100644 index d1ce4fc6f..000000000 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/dropdown/DropdownMenu.kt +++ /dev/null @@ -1,142 +0,0 @@ -package org.polyfrost.oneconfig.internal.ui.components.dropdown - -import androidx.compose.animation.core.* -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusManager -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.InputModeManager -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.platform.LocalInputModeManager -import androidx.compose.ui.unit.* -import androidx.compose.ui.unit.DpOffset -import androidx.compose.ui.unit.IntRect -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.PopupPositionProvider -import androidx.compose.ui.window.PopupProperties -import org.polyfrost.oneconfig.internal.ui.components.ShellPopup -import org.polyfrost.oneconfig.internal.ui.components.shellTransition -import javax.swing.Box - -class DropdownPositionProvider( - val contentOffset: DpOffset, - val density: Density, - val onPositionCalculated: (IntRect, IntRect) -> Unit = { _, _ -> } -) : PopupPositionProvider { - override fun calculatePosition( - anchorBounds: IntRect, - windowSize: IntSize, - layoutDirection: LayoutDirection, - popupContentSize: IntSize - ): IntOffset { - val contentOffsetX = with(density) { contentOffset.x.roundToPx() } - val contentOffsetY = with(density) { contentOffset.y.roundToPx() } - - val toRight = anchorBounds.left + contentOffsetX - val toLeft = anchorBounds.right - contentOffsetX - popupContentSize.width - val toDisplayRight = windowSize.width - popupContentSize.width - val toDisplayLeft = 0 - val x = if (layoutDirection == LayoutDirection.Ltr) { - sequenceOf(toRight, toLeft, toDisplayRight) - } else { - sequenceOf(toLeft, toRight, toDisplayLeft) - }.firstOrNull { - it >= 0 && it + popupContentSize.width <= windowSize.width - } ?: toLeft - - val spaceAbove = anchorBounds.top - contentOffsetY - val spaceBelow = windowSize.height - anchorBounds.bottom - contentOffsetY - var y = if (spaceBelow >= spaceAbove) { - anchorBounds.bottom + contentOffsetY - } else { - anchorBounds.top - contentOffsetY - popupContentSize.height - } - - if (y + popupContentSize.height > windowSize.height) { - y = windowSize.height - popupContentSize.height - } - - y = y.coerceAtLeast(0) - - onPositionCalculated( - anchorBounds, - IntRect(x, y, x + popupContentSize.width, y + popupContentSize.height) - ) - return IntOffset(x, y) - } -} - -@Composable -fun SimpleDropdownMenu( - expanded: Boolean, - onDismissRequest: () -> Unit, - focusable: Boolean = true, - modifier: Modifier = Modifier, - offset: DpOffset = DpOffset(0.dp, 0.dp), - pinAnchor: Boolean = false, - content: @Composable BoxScope.() -> Unit -) { - val expandedStates = remember { MutableTransitionState(false) } - expandedStates.targetState = expanded - - if (expandedStates.currentState || expandedStates.targetState) { - val density = LocalDensity.current - // the stock DropdownMenuPositionProvider cuts off long menus on large screens - // see https://github.com/JetBrains/compose-jb/issues/1388 - val pinnedAnchor = remember { mutableStateOf(null) } - val dropdownPositionProvider = remember(offset, density) { DropdownPositionProvider(offset, density) } - val popupPositionProvider = if (!pinAnchor) dropdownPositionProvider else PinnedAnchorPositionProvider(dropdownPositionProvider, pinnedAnchor) - - ShellPopup( - popupPositionProvider = popupPositionProvider, - onDismissRequest = onDismissRequest, - properties = PopupProperties(focusable = focusable) - ) { - SimpleDropdownMenuContent( - expandedStates = expandedStates, - modifier = modifier, - content = content - ) - } - } -} - -private class PinnedAnchorPositionProvider( - private val provider: PopupPositionProvider, - private val anchor: MutableState, -) : PopupPositionProvider { - override fun calculatePosition( - anchorBounds: IntRect, - windowSize: IntSize, - layoutDirection: LayoutDirection, - popupContentSize: IntSize - ): IntOffset { - val pinned = anchor.value ?: anchorBounds.also { anchor.value = it } - return provider.calculatePosition(pinned, windowSize, layoutDirection, popupContentSize) - } -} - -@Composable -fun SimpleDropdownMenuContent( - expandedStates: MutableTransitionState, - modifier: Modifier = Modifier, - content: @Composable BoxScope.() -> Unit -) { - val transition = rememberTransition(expandedStates, "SimpleDropDownMenu") - val alpha by transition.animateFloat(transitionSpec = { tween() }) { expanded -> - if (expanded) 1f else 0f - } - - Box( - modifier = Modifier - .graphicsLayer { this.alpha = alpha } - .shellTransition() - .then(modifier) - ) { - content() - } -} diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/picker/TilePicker.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/picker/TilePicker.kt index 090dc2684..1c15b6f96 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/picker/TilePicker.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/picker/TilePicker.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.LocalScrollbarStyle import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -24,7 +23,6 @@ import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import androidx.compose.foundation.text.BasicTextField import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -33,17 +31,15 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.BiasAlignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp @@ -51,10 +47,13 @@ import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import java.util.Locale -import org.polyfrost.oneconfig.api.ui.v1.keybind.trackTextInputFocus -import org.polyfrost.oneconfig.internal.ui.api.Tooltip +import org.polyfrost.oneconfig.api.ui.v1.components.ControlSize +import org.polyfrost.oneconfig.api.ui.v1.components.IconButton +import org.polyfrost.oneconfig.api.ui.v1.components.SearchField +import org.polyfrost.oneconfig.api.ui.v1.components.TooltipBox +import org.polyfrost.oneconfig.api.ui.v1.components.TooltipPlacement import org.polyfrost.oneconfig.internal.ui.components.Icon -import org.polyfrost.oneconfig.internal.ui.components.IconButton +import org.polyfrost.oneconfig.internal.ui.components.iconButtonLabel import org.polyfrost.oneconfig.internal.ui.components.ScrollbarBox import org.polyfrost.oneconfig.internal.ui.components.Text import org.polyfrost.oneconfig.internal.ui.components.containVerticalScroll @@ -172,8 +171,8 @@ fun PickerTooltip( return } val theme = LocalTheme.current - Tooltip( - text = { + TooltipBox( + tooltip = { Column { Text( entry.displayName, @@ -184,12 +183,25 @@ fun PickerTooltip( Text(entry.id, color = theme.textColorSecondary, fontSize = 9.sp) } }, - modifier = Modifier, - anchor = anchor, + placement = anchor.toTooltipPlacement(), content = content, ) } +private fun Alignment.toTooltipPlacement(): TooltipPlacement { + val bias = this as? BiasAlignment + val above = (bias?.verticalBias ?: 1f) < 0f + val horizontal = bias?.horizontalBias ?: 0f + return when { + above && horizontal < 0f -> TooltipPlacement.AboveStart + above && horizontal > 0f -> TooltipPlacement.AboveEnd + above -> TooltipPlacement.AboveCenter + horizontal < 0f -> TooltipPlacement.BelowStart + horizontal > 0f -> TooltipPlacement.BelowEnd + else -> TooltipPlacement.BelowCenter + } +} + /** Adaptive grid of [PickerTile]s where [selected] and [enabled] are queried per entry so selection rules stay with the caller */ @Composable fun TileGrid( @@ -251,9 +263,6 @@ fun PickerSearchField( autoFocus: Boolean = false, placeholderKey: String = PickerStrings.Search, ) { - val theme = LocalTheme.current - val interaction = rememberInteractionSource() - val focused by interaction.collectIsFocusedAsState() val focusRequester = remember { FocusRequester() } if (autoFocus) { @@ -262,44 +271,13 @@ fun PickerSearchField( } } - // TextFieldState needs its own migration, as it changes how every field hoists its state - @Suppress("DEPRECATION") - BasicTextField( - value = value, - onValueChange = onValueChange, - singleLine = true, - textStyle = TextStyle(color = theme.textColor, fontSize = 12.sp, fontFamily = theme.typography.family), - cursorBrush = SolidColor(theme.textColor), - interactionSource = interaction, - modifier = modifier.trackTextInputFocus().focusRequester(focusRequester), - decorationBox = { inner -> - Row( - modifier = Modifier - .fillMaxWidth() - .height(34.dp) - .clip(PickerTileShape) - .background(theme.componentBackground, PickerTileShape) - .border(1.dp, if (focused) Accent else theme.borderColor, PickerTileShape) - .padding(horizontal = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon("search", modifier = Modifier.size(14.dp), color = theme.textColorSecondary) - Box(modifier = Modifier.weight(1f)) { - if (value.isEmpty()) { - Text( - localizedString(null, placeholderKey), - color = theme.textColorSecondary, - fontSize = 12.sp, - ) - } - inner() - } - if (value.isNotEmpty()) { - IconButton("close", modifier = Modifier.size(14.dp)) { onValueChange("") } - } - } - }, + SearchField( + value, + onValueChange, + modifier.fillMaxWidth(), + placeholder = localizedString(null, placeholderKey), + size = ControlSize.Regular, + focusRequester = focusRequester, ) } @@ -369,7 +347,7 @@ fun TilePicker( } } if (onClose != null) { - IconButton("close", modifier = Modifier.size(18.dp), onClick = onClose) + IconButton("close", iconButtonLabel("close"), onClose, Modifier.size(18.dp), size = ControlSize.Compact, tooltip = null) } } } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/ButtonOption.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/ButtonOption.kt index 70debadf6..4a5243f8f 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/ButtonOption.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/ButtonOption.kt @@ -1,84 +1,35 @@ package org.polyfrost.oneconfig.internal.ui.components.settings -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.PointerIcon -import androidx.compose.ui.input.pointer.pointerHoverIcon -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp +import androidx.compose.ui.text.AnnotatedString +import org.polyfrost.oneconfig.api.ui.v1.components.ArmedConfirmButton +import org.polyfrost.oneconfig.api.ui.v1.components.ControlStyle +import org.polyfrost.oneconfig.api.ui.v1.components.TextButton import org.polyfrost.oneconfig.internal.ui.api.settings.ButtonOptionData -import org.polyfrost.oneconfig.internal.ui.components.Text -import org.polyfrost.oneconfig.internal.ui.components.animatedFrame -import org.polyfrost.oneconfig.internal.ui.components.glassHover -import org.polyfrost.oneconfig.internal.ui.components.liquidGlass -import org.polyfrost.oneconfig.internal.ui.components.onClick -import org.polyfrost.oneconfig.internal.ui.components.rememberInteractionSource -import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme - -private val DangerColor = Color(0xFFE35B5B) - -/** Ignores a confirm click this soon after the first click so double clicks don't confirm */ -private const val ConfirmClickDelayNs = 400_000_000L +import org.polyfrost.oneconfig.internal.ui.components.translated @Composable fun ButtonOption(data: ButtonOptionData) { - val interactionSource = rememberInteractionSource() - val isHovered by interactionSource.collectIsHoveredAsState() - val confirmText = data.confirmText - var firstClickAt by remember { mutableStateOf(null) } - val confirming = firstClickAt != null - val bgColor = animateColorAsState( - when { - !confirming -> Color.Transparent - isHovered -> DangerColor.copy(alpha = 0.75f) - else -> DangerColor - } - ) - val accentAlpha = animateFloatAsState(if (confirming) 0f else if (isHovered) 0.75f else 1f) - val textColor = LocalTheme.current.accentTextColor + ButtonOption(data, true) +} - Box( - modifier = Modifier - .widthIn(max = LocalOptionWidth.current) - .heightIn(min = OptionControlHeight) - .pointerHoverIcon(PointerIcon.Hand) - .glassHover(LocalTheme.current.buttonShape) - .graphicsLayer {} - .liquidGlass(LocalTheme.current.buttonShape) - .animatedFrame(LocalTheme.current.buttonShape, fill = { bgColor.value }, accentAlpha = { accentAlpha.value }, border = { Color.Transparent }) - .onClick(interactionSource) { - val clickedAt = firstClickAt - when { - confirmText == null -> data.runnable?.run() - clickedAt == null -> firstClickAt = System.nanoTime() - System.nanoTime() - clickedAt >= ConfirmClickDelayNs -> { - firstClickAt = null - data.runnable?.run() - } - } - } - .padding(horizontal = 16.dp, vertical = 4.dp), - contentAlignment = Alignment.Center, - ) { - // hidden label keeps the button width fixed - Text(data.buttonText ?: "Click", modifier = Modifier.alpha(if (confirming) 0f else 1f), color = textColor, fontSize = 13.sp) - if (confirmText != null) { - Text(confirmText, modifier = Modifier.alpha(if (confirming) 1f else 0f), color = textColor, fontSize = 13.sp) - } +@Composable +fun ButtonOption(data: ButtonOptionData, enabled: Boolean) { + val text = data.buttonText?.asStyledLabel() ?: AnnotatedString(translated("oneconfig.option.button.default", "Click")) + val confirmText = data.confirmText?.asStyledLabel() + val modifier = Modifier.widthIn(max = LocalOptionWidth.current) + if (confirmText == null) { + TextButton(text, { data.runnable?.run() }, modifier, style = ControlStyle.Primary, enabled = enabled) + } else { + ArmedConfirmButton( + text, + { data.runnable?.run() }, + modifier, + armedText = confirmText, + style = ControlStyle.Primary, + enabled = enabled, + ) } } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/CheckboxControl.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/CheckboxControl.kt index 4dcba6f87..17fd08071 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/CheckboxControl.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/CheckboxControl.kt @@ -1,31 +1,16 @@ package org.polyfrost.oneconfig.internal.ui.components.settings -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.PointerIcon -import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import org.polyfrost.oneconfig.internal.ui.components.Icon +import org.polyfrost.oneconfig.api.ui.v1.components.Checkbox import org.polyfrost.oneconfig.internal.ui.components.Text -import org.polyfrost.oneconfig.internal.ui.components.animatedFrame -import org.polyfrost.oneconfig.internal.ui.components.onClick -import org.polyfrost.oneconfig.internal.ui.components.rememberInteractionSource -import org.polyfrost.oneconfig.internal.ui.themes.Accent -import org.polyfrost.oneconfig.internal.ui.themes.animateAccentColorAsState +import org.polyfrost.oneconfig.internal.ui.components.translated import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme @Composable @@ -34,43 +19,27 @@ fun CheckboxIndicator( onToggle: () -> Unit, modifier: Modifier = Modifier, ) { - val theme = LocalTheme.current - val interactionSource = rememberInteractionSource() - val isHovered by interactionSource.collectIsHoveredAsState() - - val accentAlpha = animateFloatAsState(if (checked) 1f else 0f) - val borderColor = animateAccentColorAsState( - checked to isHovered, - when { - checked -> Accent - isHovered -> theme.textColorSecondary - else -> theme.borderColor - } - ) - - Box( - modifier = modifier - .size(24.dp) - .graphicsLayer {} - .clip(theme.checkBoxShape) - .animatedFrame(theme.checkBoxShape, fill = { theme.componentBackground }, accentAlpha = { accentAlpha.value }, borderWidth = 1.5.dp, border = { borderColor.value }) - .onClick(interactionSource, onToggle) - .pointerHoverIcon(PointerIcon.Hand), - contentAlignment = Alignment.Center, - ) { - AnimatedVisibility(checked, enter = fadeIn(), exit = fadeOut()) { - Icon("tick", modifier = Modifier.size(26.dp), color = theme.textColor) - } - } + Checkbox(checked, { onToggle() }, modifier) } @Composable fun CheckboxControl(checked: Boolean, onCheckedChange: (Boolean) -> Unit) { + CheckboxControl(checked, true, onCheckedChange) +} + +@Composable +fun CheckboxControl(checked: Boolean, enabled: Boolean, onCheckedChange: (Boolean) -> Unit) { + val theme = LocalTheme.current Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Text(if (checked) "On" else "Off", color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) - CheckboxIndicator(checked, onToggle = { onCheckedChange(!checked) }) + Text( + if (checked) translated("oneconfig.control.on", "On") else translated("oneconfig.control.off", "Off"), + modifier = Modifier.graphicsLayer { alpha = if (enabled) 1f else theme.disabledAlpha }, + color = theme.textColorSecondary, + fontSize = 12.sp, + ) + Checkbox(checked, onCheckedChange, enabled = enabled) } } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/ColorListOption.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/ColorListOption.kt index 13264c7d8..a844318df 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/ColorListOption.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/ColorListOption.kt @@ -1,6 +1,5 @@ package org.polyfrost.oneconfig.internal.ui.components.settings -import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement @@ -14,26 +13,29 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.input.pointer.PointerIcon -import androidx.compose.ui.input.pointer.pointerHoverIcon -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import org.polyfrost.oneconfig.internal.ui.components.ShellPopup -import androidx.compose.ui.window.PopupProperties +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.api.ui.v1.components.ColorPickerPopup as KitColorPickerPopup +import org.polyfrost.oneconfig.api.ui.v1.components.MenuEntry +import org.polyfrost.oneconfig.api.ui.v1.components.Placement +import org.polyfrost.oneconfig.api.ui.v1.components.contextMenu +import org.polyfrost.oneconfig.api.ui.v1.components.oneConfigClickable import org.polyfrost.oneconfig.internal.ui.api.settings.ColorListOptionData import org.polyfrost.oneconfig.internal.ui.components.Icon import org.polyfrost.oneconfig.internal.ui.components.Text -import org.polyfrost.oneconfig.internal.ui.components.onClick import org.polyfrost.oneconfig.internal.ui.components.rememberInteractionSource +import org.polyfrost.oneconfig.internal.ui.components.translated import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme import org.polyfrost.oneconfig.internal.ui.themes.readableOn +import org.polyfrost.oneconfig.utils.v1.ClipboardHelper private val DefaultEntryColor = Color.White @@ -49,10 +51,15 @@ fun ColorListOption(data: ColorListOptionData) { maxEntries = data.maxEntries, addText = data.addText, onAdd = { state.add(DefaultEntryColor.toArgb()) }, - ) { entry, _ -> + ) { entry, ctx -> ColorSwatch( argb = entry.value, hasAlpha = data.alpha, + resetColor = { + val index = state.indexOf(entry.id) + if (data.hasDefaultList()) data.defaultArgbAt(index) else DefaultEntryColor.toArgb() + }, + rowEntries = ctx.rowEntries, expanded = openEntry == entry.id, onExpandedChange = { openEntry = if (it) entry.id else -1 }, onColorChange = { state.update(entry.id, it.toArgb()) }, @@ -64,6 +71,8 @@ fun ColorListOption(data: ColorListOptionData) { private fun RowScope.ColorSwatch( argb: Int, hasAlpha: Boolean, + resetColor: () -> Int?, + rowEntries: () -> List, expanded: Boolean, onExpandedChange: (Boolean) -> Unit, onColorChange: (Color) -> Unit, @@ -75,9 +84,33 @@ private fun RowScope.ColorSwatch( val color = remember(argb) { Color(argb).let { if (hasAlpha) it else it.copy(alpha = 1f) } } val model = remember(hasAlpha) { ColorPickerModel(color, hasAlpha) } - val textColor = animateColorAsState(readableOn(color)) + val textColor = Motion.animateColorAsState(readableOn(color)) + val latestOnColorChange by rememberUpdatedState(onColorChange) + val hex = colorToHex(color) - Box(modifier = Modifier.weight(1f)) { + Box( + modifier = Modifier + .weight(1f) + .contextMenu { + swatchMenu( + model, + copy = { ClipboardHelper.setString("#$hex") }, + paste = { pasted -> + val next = if (hasAlpha) pasted else pasted.copy(alpha = 1f) + model.set(next) + latestOnColorChange(next) + }, + reset = resetColor()?.let { argbValue -> + { + val target = Color(argbValue).let { if (hasAlpha) it else it.copy(alpha = 1f) } + model.set(target) + latestOnColorChange(target) + } + }, + extra = rowEntries(), + ) + }, + ) { Row( modifier = Modifier .fillMaxHeight() @@ -85,30 +118,28 @@ private fun RowScope.ColorSwatch( .clip(shape) .background(color, shape) .border(1.dp, theme.borderColor, shape) - .pointerHoverIcon(PointerIcon.Hand) - .onClick(interactionSource) { onExpandedChange(!expanded) } + .oneConfigClickable( + label = "${translated("oneconfig.colour", "Colour")} #$hex", + onClick = { onExpandedChange(!expanded) }, + shape = shape, + indication = false, + interactionSource = interactionSource, + ) .padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Icon("paintbrush", tint = { textColor.value }, modifier = Modifier.size(12.dp)) - Text("#${colorToHex(color)}", color = Color.Unspecified, fontSize = 12.sp, colorProducer = { textColor.value }) + Text("#$hex", color = Color.Unspecified, fontSize = 12.sp, colorProducer = { textColor.value }) } if (expanded) { - ShellPopup( - alignment = Alignment.TopEnd, - offset = IntOffset(0, 38), - onDismissRequest = { onExpandedChange(false) }, - properties = PopupProperties(focusable = true), - ) { - ColorPickerPopup( - model = model, - onColorChanged = onColorChange, - onClose = { onExpandedChange(false) }, - chromaCapable = false, - ) - } + KitColorPickerPopup( + model, + onColorChange, + { onExpandedChange(false) }, + placement = Placement.BelowEnd, + ) } } } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/ColorOption.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/ColorOption.kt index 1f47c3b8c..5c919b2f1 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/ColorOption.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/ColorOption.kt @@ -1,11 +1,11 @@ package org.polyfrost.oneconfig.internal.ui.components.settings -import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.withInfiniteAnimationFrameNanos import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -18,8 +18,6 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -31,49 +29,54 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.input.pointer.PointerIcon -import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import org.polyfrost.oneconfig.internal.ui.components.ShellPopup -import androidx.compose.ui.window.PopupProperties import kotlin.math.abs import kotlin.math.roundToInt import org.polyfrost.compose.render.PolyColor import org.polyfrost.oneconfig.api.config.v1.Property -import org.polyfrost.oneconfig.api.ui.v1.keybind.trackTextInputFocus +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.api.ui.v1.components.ColorPickerModel as KitColorPickerModel +import org.polyfrost.oneconfig.api.ui.v1.components.ColorPickerPopup as KitColorPickerPopup +import org.polyfrost.oneconfig.api.ui.v1.components.MenuAction +import org.polyfrost.oneconfig.api.ui.v1.components.MenuEntry +import org.polyfrost.oneconfig.api.ui.v1.components.OptionMenuContributors +import org.polyfrost.oneconfig.api.ui.v1.components.MenuSeparator +import org.polyfrost.oneconfig.api.ui.v1.components.Placement +import org.polyfrost.oneconfig.api.ui.v1.components.SurfaceKind +import org.polyfrost.oneconfig.api.ui.v1.components.TextField +import org.polyfrost.oneconfig.api.ui.v1.components.TextFieldDefaults +import org.polyfrost.oneconfig.api.ui.v1.components.contextMenu +import org.polyfrost.oneconfig.api.ui.v1.components.oneConfigClickable +import org.polyfrost.oneconfig.api.ui.v1.components.themedSurface +import org.polyfrost.oneconfig.api.ui.v1.components.transparencyCheckerboard +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme import org.polyfrost.oneconfig.internal.ui.api.settings.ColorOptionData import org.polyfrost.oneconfig.internal.ui.api.shown import org.polyfrost.oneconfig.internal.ui.components.Icon import org.polyfrost.oneconfig.internal.ui.components.Text import org.polyfrost.oneconfig.internal.ui.components.animatedFrame -import org.polyfrost.oneconfig.internal.ui.components.liquidGlass -import org.polyfrost.oneconfig.internal.ui.components.onClick import org.polyfrost.oneconfig.internal.ui.components.rememberInteractionSource import org.polyfrost.oneconfig.internal.ui.components.shellTransition +import org.polyfrost.oneconfig.internal.ui.components.translated import org.polyfrost.oneconfig.internal.ui.themes.Accent import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme import org.polyfrost.oneconfig.internal.ui.themes.readableOn import org.polyfrost.oneconfig.internal.ui.themes.updateAccent +import org.polyfrost.oneconfig.utils.v1.ClipboardHelper private val PickerShape @Composable get() = LocalTheme.current.popupShape @@ -145,7 +148,7 @@ private fun hexToColor(hex: String): Color? { } } -internal class ColorPickerModel(initialColor: Color, hasAlpha: Boolean = true) { +internal class ColorPickerModel(initialColor: Color, hasAlpha: Boolean = true) : KitColorPickerModel() { private val hsb = colorToHsb(initialColor) var hue by mutableFloatStateOf(hsb[0]) var saturation by mutableFloatStateOf(hsb[1]) @@ -162,10 +165,21 @@ internal class ColorPickerModel(initialColor: Color, hasAlpha: Boolean = true) { chromaState = value } val alphaEnabled = hasAlpha - var chromaSpeed by mutableFloatStateOf(1f) + override var chromaSpeed: Float by mutableFloatStateOf(1f) var gradientEnabled by mutableStateOf(false) var gradientColor by mutableStateOf(initialColor) + override val color: Color get() = savedColor() + + override val chroma: Boolean get() = chromaEnabled + + override val gradient: Color? get() = if (gradientEnabled) gradientColor else null + + override fun set(color: Color) { + chromaTurn = 0f + applyFrom(color) + } + /** How far [hue] is turned past the saved hue, so the picker matches every other chroma colour right now */ private var chromaTurn = 0f @@ -208,25 +222,16 @@ internal fun ChromaColorAnimation( } private val SWATCH_SIZE = 24.dp -private val SWATCH_POPUP_OFFSET = 32.dp - -private fun DrawScope.drawCheckerboard() { - val cellSize = 6f - for (row in 0..(size.height / cellSize).toInt()) { - for (col in 0..(size.width / cellSize).toInt()) { - drawRect( - if ((row + col) % 2 == 0) Color(0xFFCCCCCC) else Color(0xFF999999), - topLeft = Offset(col * cellSize, row * cellSize), - size = Size(cellSize, cellSize), - ) - } - } -} // TODO: make this design accurate @Composable fun ColorOption(data: ColorOptionData) { + ColorOption(data, true) +} + +@Composable +fun ColorOption(data: ColorOptionData, enabled: Boolean) { val initialValue = remember(data.prop) { data.prop.shown() } val initialColor = remember(data.prop, initialValue) { when (val v = initialValue) { @@ -292,6 +297,9 @@ fun ColorOption(data: ColorOptionData) { swatch = data.swatch, chromaCapable = data.prop.type == PolyColor::class.java, gradientCapable = data.gradient && data.prop.type == PolyColor::class.java, + enabled = enabled, + onReset = if (optionHasDefault(data.prop)) ({ resetOption(data.prop) }) else null, + extraEntries = { OptionMenuContributors.entriesFor(data.prop) }, ) } @@ -304,31 +312,72 @@ internal fun ColorPicker( swatch: Boolean = false, chromaCapable: Boolean = true, gradientCapable: Boolean = false, +) { + ColorPicker(model, color, onColorChanged, swatch, chromaCapable, gradientCapable, true, null, { emptyList() }) +} + +@Composable +internal fun ColorPicker( + model: ColorPickerModel, + color: Color, + onColorChanged: (Color) -> Unit, + swatch: Boolean, + chromaCapable: Boolean, + gradientCapable: Boolean, + enabled: Boolean, + onReset: (() -> Unit)?, + extraEntries: () -> List, ) { val theme = LocalTheme.current val interactionSource = rememberInteractionSource() val isHovered by interactionSource.collectIsHoveredAsState() var expanded by remember { mutableStateOf(false) } + val latestOnColorChanged by rememberUpdatedState(onColorChanged) + val latestOnReset by rememberUpdatedState(onReset) - val textColor = animateColorAsState(readableOn(color)) + val textColor = Motion.animateColorAsState(readableOn(color)) - val borderColor = animateColorAsState( - if (isHovered || expanded) theme.textColor.copy(0.3f) else theme.borderColor + val borderColor = Motion.animateColorAsState( + if (enabled && (isHovered || expanded)) theme.textColor.copy(0.3f) else theme.borderColor ) val showGradient = gradientCapable && model.gradientEnabled && !model.chromaEnabled val fill = if (showGradient) Brush.horizontalGradient(listOf(color, model.gradientColor)) else SolidColor(color) - - Box { + val hex = colorToHex(color) + val spoken = "${translated("oneconfig.colour", "Colour")} #$hex" + val disabledAlpha = OneConfigTheme.current.disabledAlpha + + Box( + Modifier + .graphicsLayer { alpha = if (enabled) 1f else disabledAlpha } + .contextMenu(enabled) { + swatchMenu( + model, + copy = { ClipboardHelper.setString("#${colorToHex(model.savedColor())}") }, + paste = { pasted -> + model.set(if (model.alphaEnabled) pasted else pasted.copy(alpha = 1f)) + latestOnColorChanged(model.savedColor()) + }, + reset = latestOnReset, + extra = extraEntries(), + ) + }, + ) { if (swatch) { Box( modifier = Modifier .size(SWATCH_SIZE) - .onClick(interactionSource) { expanded = !expanded } - .pointerHoverIcon(PointerIcon.Hand) + .oneConfigClickable( + label = spoken, + onClick = { expanded = !expanded }, + enabled = enabled, + shape = theme.checkBoxShape, + indication = false, + interactionSource = interactionSource, + ) .graphicsLayer {} .clip(theme.checkBoxShape) - .drawBehind { drawCheckerboard() } + .transparencyCheckerboard() .background(fill, theme.checkBoxShape) .animatedFrame(theme.checkBoxShape, border = { borderColor.value }), ) @@ -336,8 +385,14 @@ internal fun ColorPicker( Row( modifier = Modifier .heightIn(min = OptionControlHeight) - .onClick(interactionSource) { expanded = !expanded } - .pointerHoverIcon(PointerIcon.Hand) + .oneConfigClickable( + label = spoken, + onClick = { expanded = !expanded }, + enabled = enabled, + shape = LocalTheme.current.sideBarNavigationEntryShape, + indication = false, + interactionSource = interactionSource, + ) .graphicsLayer {} .background(fill, LocalTheme.current.sideBarNavigationEntryShape) .animatedFrame(LocalTheme.current.sideBarNavigationEntryShape, border = { borderColor.value }) @@ -347,7 +402,7 @@ internal fun ColorPicker( ) { Icon("paintbrush", tint = { textColor.value }, modifier = Modifier.size(14.dp)) Text( - if (showGradient) "#${colorToHex(color)} → #${colorToHex(model.gradientColor)}" else "#${colorToHex(color)}", + if (showGradient) "#$hex → #${colorToHex(model.gradientColor)}" else "#$hex", color = Color.Unspecified, fontSize = 13.sp, colorProducer = { textColor.value }, @@ -355,21 +410,44 @@ internal fun ColorPicker( } } - if (expanded) { - ShellPopup( - alignment = Alignment.TopEnd, - offset = IntOffset(0, with(LocalDensity.current) { (if (swatch) SWATCH_POPUP_OFFSET else 44.dp).roundToPx() }), - onDismissRequest = { expanded = false }, - properties = PopupProperties(focusable = true), - ) { - ColorPickerPopup( - model = model, - onColorChanged = onColorChanged, - onClose = { expanded = false }, - chromaCapable = chromaCapable, - gradientCapable = gradientCapable, - ) - } + if (expanded && enabled) { + KitColorPickerPopup( + model, + onColorChanged, + { expanded = false }, + placement = Placement.BelowEnd, + chromaCapable = chromaCapable, + gradientCapable = gradientCapable, + ) + } + } +} + +internal fun swatchMenu( + model: ColorPickerModel, + copy: () -> Unit, + paste: (Color) -> Unit, + reset: (() -> Unit)?, + extra: List = emptyList(), +): List { + val pasted = ClipboardHelper.getString()?.trim()?.let(::hexToColor) + return buildList { + add(MenuAction(translated("oneconfig.menu.option.copy_hex", "Copy hex"), copy, icon = "copy")) + add( + MenuAction( + translated("oneconfig.menu.option.paste_hex", "Paste hex"), + { if (pasted != null) paste(pasted) }, + icon = "clipboard", + enabled = pasted != null && pasted.toArgb() != model.savedColor().toArgb(), + ), + ) + if (reset != null) { + add(MenuSeparator) + add(MenuAction(translated("oneconfig.menu.option.reset", "Reset to default"), reset, icon = "refresh")) + } + if (extra.isNotEmpty()) { + add(MenuSeparator) + addAll(extra) } } } @@ -381,6 +459,7 @@ internal fun ColorPickerPopup( onClose: () -> Unit, chromaCapable: Boolean = true, gradientCapable: Boolean = false, + modifier: Modifier = Modifier, ) { val theme = LocalTheme.current val currentOnColorChanged by rememberUpdatedState(onColorChanged) @@ -400,12 +479,10 @@ internal fun ColorPickerPopup( val currentColor = model.currentColor() Column( - modifier = Modifier + modifier = modifier .width(280.dp) .shellTransition() - .liquidGlass(PickerShape, frosted = true) - .background(theme.popupBackground, PickerShape) - .border(1.dp, theme.borderColor, PickerShape) + .themedSurface(SurfaceKind.Popup, PickerShape) .padding(16.dp), verticalArrangement = Arrangement.spacedBy(14.dp), ) { @@ -414,13 +491,17 @@ internal fun ColorPickerPopup( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text("Color Picker", color = theme.textColor, fontSize = 14.sp) + Text(translated("oneconfig.colour.picker", "Colour picker"), color = theme.textColor, fontSize = 14.sp) Icon( "close", color = theme.textColorSecondary, modifier = Modifier.size(14.dp) - .onClick(rememberInteractionSource()) { onClose() } - .pointerHoverIcon(PointerIcon.Hand) + .oneConfigClickable( + label = translated("oneconfig.colour.picker.close", "Close"), + onClick = onClose, + shape = LocalTheme.current.circleShape, + indication = false, + ) ) } @@ -539,7 +620,7 @@ internal fun ColorPickerPopup( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text("Opacity", color = theme.textColorSecondary, fontSize = 12.sp) + Text(translated("oneconfig.colour.opacity", "Opacity"), color = theme.textColorSecondary, fontSize = 12.sp) SliderValueField( text = alphaText, unit = "%", @@ -560,13 +641,13 @@ internal fun ColorPickerPopup( .fillMaxWidth() .height(16.dp) .clip(LocalTheme.current.sideBarNavigationEntryShape) + .transparencyCheckerboard() .drawWithCache { val opaqueColor = hsbToColor(hue, saturation, brightness, 1f) val alphaGradient = Brush.horizontalGradient( listOf(opaqueColor.copy(alpha = 0f), opaqueColor) ) onDrawBehind { - drawCheckerboard() drawRect(alphaGradient) } } @@ -637,7 +718,7 @@ internal fun ColorPickerPopup( ) ) ) - Text("Chroma", color = theme.textColor, fontSize = 13.sp) + Text(translated("oneconfig.colour.chroma", "Chroma"), color = theme.textColor, fontSize = 13.sp) } SwitchControl(chromaEnabled) { model.chromaEnabled = it @@ -663,7 +744,7 @@ internal fun ColorPickerPopup( .clip(LocalTheme.current.circleShape) .background(Brush.horizontalGradient(listOf(currentColor, model.gradientColor))) ) - Text("Gradient", color = theme.textColor, fontSize = 13.sp) + Text(translated("oneconfig.colour.gradient", "Gradient"), color = theme.textColor, fontSize = 13.sp) } SwitchControl(model.gradientEnabled) { model.gradientEnabled = it @@ -689,11 +770,9 @@ internal fun ColorPickerPopup( .background(model.gradientColor) .border(1.dp, theme.borderColor, LocalTheme.current.sideBarNavigationEntryShape) ) - // TextFieldState needs its own migration, as it changes how every field hoists its state - @Suppress("DEPRECATION") - BasicTextField( - value = gradientText, - onValueChange = { input -> + PickerTextField( + gradientText, + { input -> val filtered = input.filter { it.isLetterOrDigit() }.take(8) gradientText = filtered hexToColor(filtered)?.let { c -> @@ -701,27 +780,9 @@ internal fun ColorPickerPopup( save() } }, - singleLine = true, - textStyle = TextStyle( - color = theme.textColor, - fontSize = 13.sp, - fontFamily = theme.typography.family, - ), - cursorBrush = SolidColor(theme.textColor), - modifier = Modifier.trackTextInputFocus() - .weight(1f) - .background(theme.componentBackground, LocalTheme.current.sideBarNavigationEntryShape) - .border(1.dp, theme.borderColor, LocalTheme.current.sideBarNavigationEntryShape) - .padding(horizontal = 10.dp, vertical = 7.dp), - decorationBox = { innerTextField -> - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - Text("#", color = theme.textColorSecondary, fontSize = 13.sp) - innerTextField() - } - }, + Modifier.weight(1f), + prefix = "#", + label = translated("oneconfig.colour.gradient", "Gradient"), ) } } @@ -733,7 +794,7 @@ internal fun ColorPickerPopup( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text("Speed", color = theme.textColorSecondary, fontSize = 12.sp) + Text(translated("oneconfig.colour.speed", "Speed"), color = theme.textColorSecondary, fontSize = 12.sp) SliderValueField( text = speedText, unit = "x", @@ -816,11 +877,9 @@ internal fun ColorPickerPopup( .border(1.dp, theme.borderColor, LocalTheme.current.sideBarNavigationEntryShape) ) - // TextFieldState needs its own migration, as it changes how every field hoists its state - @Suppress("DEPRECATION") - BasicTextField( - value = if (chromaEnabled && !hexFocused) colorToHex(currentColor) else hexText, - onValueChange = { input -> + PickerTextField( + if (chromaEnabled && !hexFocused) colorToHex(currentColor) else hexText, + { input -> val filtered = input.filter { it.isLetterOrDigit() }.take(8) hexText = filtered hexToColor(filtered)?.let { c -> @@ -828,30 +887,12 @@ internal fun ColorPickerPopup( save() } }, - singleLine = true, - textStyle = TextStyle( - color = theme.textColor, - fontSize = 13.sp, - fontFamily = theme.typography.family, - ), - cursorBrush = SolidColor(theme.textColor), - modifier = Modifier.trackTextInputFocus() - .onFocusChanged { - if (it.isFocused && !hexFocused) hexText = colorToHex(model.currentColor()) - hexFocused = it.isFocused - } - .weight(1f) - .background(theme.componentBackground, LocalTheme.current.sideBarNavigationEntryShape) - .border(1.dp, theme.borderColor, LocalTheme.current.sideBarNavigationEntryShape) - .padding(horizontal = 10.dp, vertical = 7.dp), - decorationBox = { innerTextField -> - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - Text("#", color = theme.textColorSecondary, fontSize = 13.sp) - innerTextField() - } + Modifier.weight(1f), + prefix = "#", + label = translated("oneconfig.colour", "Colour"), + onFocusChange = { focused -> + if (focused && !hexFocused) hexText = colorToHex(model.currentColor()) + hexFocused = focused }, ) } @@ -864,32 +905,55 @@ private fun SliderValueField( onValueChange: (String) -> Unit, unit: String? = null, width: Dp = 56.dp, +) { + PickerTextField( + text, + onValueChange, + Modifier.width(width), + suffix = unit, + fontSize = 12.sp, + horizontalPadding = 8.dp, + verticalPadding = 4.dp, + ) +} + +@Composable +private fun PickerTextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier, + prefix: String? = null, + suffix: String? = null, + label: String? = null, + fontSize: TextUnit = 13.sp, + horizontalPadding: Dp = 10.dp, + verticalPadding: Dp = 7.dp, + onFocusChange: ((Boolean) -> Unit)? = null, ) { val theme = LocalTheme.current - Row( - modifier = Modifier - .width(width) - .background(theme.componentBackground, theme.sideBarNavigationEntryShape) - .border(1.dp, theme.borderColor, theme.sideBarNavigationEntryShape) - .padding(horizontal = 8.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, + val source = rememberInteractionSource() + val focused by source.collectIsFocusedAsState() + val latestOnFocusChange by rememberUpdatedState(onFocusChange) + LaunchedEffect(focused) { latestOnFocusChange?.invoke(focused) } + val shape = TextFieldDefaults.shape + val border = Motion.animateColorAsState(if (focused) TextFieldDefaults.focusBorder else TextFieldDefaults.border) + Box( + modifier + .background(theme.componentBackground, shape) + .border(1.dp, border.value, shape) + .padding(horizontal = horizontalPadding, vertical = verticalPadding), + contentAlignment = Alignment.CenterStart, ) { - // TextFieldState needs its own migration, as it changes how every field hoists its state - @Suppress("DEPRECATION") - BasicTextField( - value = text, - onValueChange = onValueChange, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - textStyle = TextStyle( - color = theme.textColor, - fontSize = 12.sp, - fontFamily = theme.typography.family, - textAlign = TextAlign.End, - ), - cursorBrush = SolidColor(theme.textColor), - visualTransformation = rememberUnitSuffix(unit), - modifier = Modifier.trackTextInputFocus().weight(1f), + TextField( + value, + onValueChange, + Modifier.fillMaxWidth(), + label = label, + framed = false, + fontSize = fontSize, + leading = prefix?.let { text -> @Composable { Text(text, color = theme.textColorSecondary, fontSize = fontSize) } }, + trailing = suffix?.let { text -> @Composable { Text(text, color = theme.textColorSecondary, fontSize = fontSize) } }, + interactionSource = source, ) } } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/DraggableListOption.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/DraggableListOption.kt index 95fdba9cd..fdc924c0a 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/DraggableListOption.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/DraggableListOption.kt @@ -1,13 +1,11 @@ package org.polyfrost.oneconfig.internal.ui.components.settings -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.snap import androidx.compose.animation.core.spring import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.draggable -import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Arrangement @@ -21,7 +19,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.key @@ -33,6 +30,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.platform.LocalDensity @@ -40,18 +38,26 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.zIndex import kotlin.math.roundToInt import org.polyfrost.oneconfig.api.config.v1.Property +import org.polyfrost.oneconfig.api.ui.v1.a11y.A11yRole +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.api.ui.v1.a11y.a11y +import org.polyfrost.oneconfig.api.ui.v1.components.MenuAction +import org.polyfrost.oneconfig.api.ui.v1.components.MenuEntry +import org.polyfrost.oneconfig.api.ui.v1.components.ScrollContainer +import org.polyfrost.oneconfig.api.ui.v1.components.contextMenu +import org.polyfrost.oneconfig.api.ui.v1.components.rememberReorderState +import org.polyfrost.oneconfig.api.ui.v1.components.reorderHandle +import org.polyfrost.oneconfig.api.ui.v1.components.reorderItem import org.polyfrost.oneconfig.internal.ui.api.settings.DraggableListOptionData import org.polyfrost.oneconfig.internal.ui.api.shown import org.polyfrost.oneconfig.internal.ui.components.Icon -import org.polyfrost.oneconfig.internal.ui.components.ReducedMotion import org.polyfrost.oneconfig.internal.ui.components.Text import org.polyfrost.oneconfig.internal.ui.components.animatedFrame -import org.polyfrost.oneconfig.internal.ui.components.fadingEdges +import org.polyfrost.oneconfig.internal.ui.components.asRenderText import org.polyfrost.oneconfig.internal.ui.components.rememberInteractionSource -import org.polyfrost.oneconfig.internal.ui.components.wheelScroll +import org.polyfrost.oneconfig.internal.ui.components.translated import org.polyfrost.oneconfig.internal.ui.sound.UiSoundEvent import org.polyfrost.oneconfig.internal.ui.sound.UiSounds import org.polyfrost.oneconfig.internal.ui.themes.Accent @@ -96,15 +102,8 @@ fun DraggableListOption(data: DraggableListOptionData) { mutableStateOf(items.filter { it.value in enabled }.map { it.id }.toSet()) } - var draggingIndex by remember { mutableStateOf(-1) } - var dragAccum by remember { mutableStateOf(0f) } val itemHeight = if (data.checkable) CheckableListItemHeight else PlainListItemHeight val stridePx = with(density) { (itemHeight + ListItemSpacing).toPx() } - val dragTargetIndex = if (draggingIndex == -1) { - -1 - } else { - (draggingIndex + (dragAccum / stridePx).roundToInt()).coerceIn(0, items.lastIndex) - } val contentHeight = itemHeight * items.size + ListItemSpacing * (items.size - 1).coerceAtLeast(0) fun save(list: List = items) { @@ -112,21 +111,49 @@ fun DraggableListOption(data: DraggableListOptionData) { (data.prop as Property).set(toSave.map { it.value }.toTypedArray()) } - var lastTickIndex by remember { mutableStateOf(-1) } + fun moveBy(id: Int, delta: Int): Boolean { + val from = items.indexOfFirst { it.id == id } + val to = from + delta + if (from < 0 || to !in items.indices) return false + val list = items.toMutableList() + list.add(to, list.removeAt(from)) + items = list + save(list) + return true + } - val draggableState = rememberDraggableState { delta -> - if (draggingIndex == -1) return@rememberDraggableState - dragAccum = (dragAccum + delta).coerceIn( - -draggingIndex * stridePx, - (items.lastIndex - draggingIndex) * stridePx, + fun rowMenu(item: ListEntry): List { + val index = items.indexOfFirst { it.id == item.id } + if (index < 0) return emptyList() + return listOf( + MenuAction( + translated("oneconfig.list.move_up", "Move up"), + { moveBy(item.id, -1) }, + icon = "up", + enabled = index > 0, + shortcutHint = translated("oneconfig.list.move_up.shortcut", "Alt+Up"), + ), + MenuAction( + translated("oneconfig.list.move_down", "Move down"), + { moveBy(item.id, 1) }, + icon = "down", + enabled = index < items.lastIndex, + shortcutHint = translated("oneconfig.list.move_down.shortcut", "Alt+Down"), + ), ) - val target = (draggingIndex + (dragAccum / stridePx).roundToInt()).coerceIn(0, items.lastIndex) - if (target != lastTickIndex) { - lastTickIndex = target - UiSounds.play(UiSoundEvent.SLIDER_TICK) - } } + val reorder = rememberReorderState( + Orientation.Vertical, + { from, to -> + if (from in items.indices && to in items.indices) { + items = items.toMutableList().also { it.add(to, it.removeAt(from)) } + UiSounds.play(UiSoundEvent.SLIDER_TICK) + } + }, + { save() }, + ) + Box( modifier = Modifier .width(LocalOptionWidth.current) @@ -136,13 +163,13 @@ fun DraggableListOption(data: DraggableListOptionData) { .padding(15.dp), ) { val scrollState = rememberScrollState() - Box( + ScrollContainer( + state = scrollState, modifier = Modifier .fillMaxWidth() - .heightIn(max = 280.dp) - .fadingEdges(scrollState, theme.componentBackground) - .wheelScroll(scrollState) - .verticalScroll(scrollState), + .heightIn(max = 280.dp), + scrollbar = scrollState.maxValue > 0, + fadingEdges = true, ) { Box( modifier = Modifier @@ -151,37 +178,40 @@ fun DraggableListOption(data: DraggableListOptionData) { ) { items.forEachIndexed { index, item -> key(item.id) { - val isDragging = draggingIndex == index + val isDragging = reorder.draggingKey == item.id val rowInteraction = rememberInteractionSource() val isHovered by rowInteraction.collectIsHoveredAsState() val baseOffset = index * stridePx - val targetOffset = when { - isDragging -> baseOffset + dragAccum - draggingIndex == -1 -> baseOffset - dragTargetIndex > draggingIndex && index in (draggingIndex + 1)..dragTargetIndex -> baseOffset - stridePx - dragTargetIndex < draggingIndex && index in dragTargetIndex until draggingIndex -> baseOffset + stridePx - else -> baseOffset - } - val animatedOffset = animateFloatAsState(targetOffset, ReducedMotion.spec(spring())) + val animatedOffset = Motion.animateFloatAsState(baseOffset, if (isDragging) snap() else spring(), label = "listEntryOffset") val bgColor = animateAccentColorAsState( isDragging, if (isDragging) Accent else theme.componentBackground ) - val contentColor = animateColorAsState( + val contentColor = Motion.animateColorAsState( if (isDragging || isHovered) theme.textColor else theme.textColorSecondary ) Row( modifier = Modifier .fillMaxWidth() - .offset { IntOffset(0, (if (isDragging) targetOffset else animatedOffset.value).roundToInt()) } + .offset { IntOffset(0, baseOffset.roundToInt()) } .height(itemHeight) - .zIndex(if (isDragging) 1f else 0f) + .reorderItem(reorder, item.id, index, false) + .graphicsLayer { translationY = animatedOffset.value - baseOffset } .clip(ListItemShape) .graphicsLayer {} .animatedFrame(ListItemShape, fill = { bgColor.value }, border = { theme.borderColor }) .hoverable(rowInteraction) + .contextMenu { rowMenu(item) } + .onPreviewKeyEvent { event -> + val delta = event.reorderDelta() + delta != 0 && moveBy(item.id, delta).also { moved -> + if (moved) UiSounds.play(UiSoundEvent.SLIDER_TICK) + } + } + .a11y((optionLabels[item.value] ?: item.value).asRenderText(), A11yRole.Option, null, null, ListItemShape, true) + .focusable(interactionSource = rowInteraction) .padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(15.dp), @@ -191,24 +221,7 @@ fun DraggableListOption(data: DraggableListOptionData) { modifier = Modifier .size(16.dp) .pointerHoverIcon(PointerIcon.Hand) - .draggable( - orientation = Orientation.Vertical, - state = draggableState, - onDragStarted = { draggingIndex = index; dragAccum = 0f; lastTickIndex = index }, - onDragStopped = { - if (dragTargetIndex != draggingIndex) { - val list = items.toMutableList() - val moved = list.removeAt(draggingIndex) - list.add(dragTargetIndex, moved) - items = list - save(list) - } else { - save() - } - draggingIndex = -1 - dragAccum = 0f - }, - ), + .reorderHandle(reorder, item.id), tint = { contentColor.value }, ) diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/DropdownOption.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/DropdownOption.kt index dcd4da7c2..28564fbc3 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/DropdownOption.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/DropdownOption.kt @@ -1,40 +1,23 @@ package org.polyfrost.oneconfig.internal.ui.components.settings -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.foundation.* -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.width import androidx.compose.runtime.* -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.compositeOver -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.PointerIcon -import androidx.compose.ui.input.pointer.pointerHoverIcon -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.DpOffset -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import org.polyfrost.oneconfig.api.config.v1.Property +import org.polyfrost.oneconfig.api.ui.v1.components.Select import org.polyfrost.oneconfig.internal.ui.api.settings.DropdownOptionData import org.polyfrost.oneconfig.internal.ui.api.shown import org.polyfrost.oneconfig.internal.ui.components.* -import org.polyfrost.oneconfig.internal.ui.components.dropdown.SimpleDropdownMenu -import org.polyfrost.oneconfig.internal.ui.themes.Accent -import org.polyfrost.oneconfig.internal.ui.themes.animateAccentColorAsState -import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme -import org.polyfrost.oneconfig.internal.ui.themes.concentric - -private val MenuPadding = 4.dp -private val DropdownShape @Composable get() = LocalTheme.current.sideBarNavigationEntryShape -private val MenuItemShape @Composable get() = DropdownShape.concentric(MenuPadding) @Composable fun DropdownOption(data: DropdownOptionData) { + DropdownOption(data, true) +} + +@Composable +fun DropdownOption(data: DropdownOptionData, enabled: Boolean) { val enumClass = when { data.prop.type.isEnum -> data.prop.type data.prop.type.superclass?.isEnum == true -> data.prop.type.superclass @@ -44,12 +27,15 @@ fun DropdownOption(data: DropdownOptionData) { enumClass != null -> data.optionValues?.takeIf { it.isNotEmpty() } ?: enumClass.enumConstants.toList() else -> emptyList() } - val options: List = when { - enumClass != null -> values.mapIndexed { index, value -> - data.options?.getOrNull(index) ?: (value as Enum<*>).name + val languageStamp = languageStamp() + val options: List = remember(data.prop, languageStamp) { + when { + enumClass != null -> values.mapIndexed { index, value -> + data.options?.getOrNull(index) ?: (value as Enum<*>).name + } + data.options != null -> data.options!! + else -> emptyList() } - data.options != null -> data.options!! - else -> emptyList() } val optionValues = data.optionValues ?: options @@ -64,7 +50,7 @@ fun DropdownOption(data: DropdownOptionData) { ) } - Dropdown(options, selectedIdx, onSelect = { index -> + Dropdown(options, selectedIdx, { index -> selectedIdx = index @Suppress("UNCHECKED_CAST") if (enumClass != null) { @@ -74,147 +60,28 @@ fun DropdownOption(data: DropdownOptionData) { } else { (data.prop as Property).set(index) } - }) + }, LocalOptionWidth.current, enabled) } /** [options] can be anything [Text] can show, such as translated text */ @Composable fun Dropdown(options: List, selected: Int, onSelect: (Int) -> Unit, width: Dp = LocalOptionWidth.current) { - var expanded by remember { mutableStateOf(false) } - DropdownFrame(options.getOrElse(selected) { "—" }, expanded, { expanded = it }, width) { - options.forEachIndexed { index, option -> - DropdownItem(option, selected = index == selected) { - onSelect(index) - expanded = false - } - } - } -} - -@Composable -internal fun DropdownFrame( - label: Any, - expanded: Boolean, - onExpandedChange: (Boolean) -> Unit, - width: Dp = LocalOptionWidth.current, - menu: @Composable ColumnScope.() -> Unit, -) { - val theme = LocalTheme.current - val triggerInteraction = rememberInteractionSource() - val isHovered by triggerInteraction.collectIsHoveredAsState() - val borderColor = animateAccentColorAsState( - expanded, - if (expanded) Accent - else theme.borderColor - ) - val textColor = animateColorAsState( - if (isHovered || expanded) theme.textColor - else theme.textColorSecondary - ) - val backgroundColor = animateAccentColorAsState( - expanded, - if (expanded) Accent.copy(0.2f).compositeOver(theme.componentBackground) - else theme.componentBackground - ) - val chevronRotation = animateFloatAsState( - if (expanded) 0f else 180f - ) - - Box { - Row( - modifier = Modifier - .width(width) - .height(OptionControlHeight) - .graphicsLayer {} - .liquidGlass(DropdownShape) - .animatedFrame(DropdownShape, fill = { backgroundColor.value }, border = { borderColor.value }) - .onClick(triggerInteraction) { onExpandedChange(!expanded) } - .hoverable(triggerInteraction) - .pointerHoverIcon(PointerIcon.Hand) - .padding(horizontal = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - label, - modifier = Modifier.weight(1f, fill = false).padding(end = 8.dp), - color = Color.Unspecified, - fontSize = 13.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - colorProducer = { textColor.value }, - ) - Icon("up", modifier = Modifier.graphicsLayer { rotationZ = chevronRotation.value }, tint = { textColor.value }) - } - - SimpleDropdownMenu( - expanded = expanded, - onDismissRequest = { onExpandedChange(false) }, - offset = DpOffset(0.dp, 10.dp), - ) { - val scrollState = rememberScrollState() - Box( - modifier = Modifier - .width(width) - .clip(DropdownShape) - .liquidGlass(DropdownShape, frosted = true) - .background(theme.componentBackground, DropdownShape) - .border(1.dp, theme.borderColor, DropdownShape) - ) { - Column( - modifier = Modifier - .heightIn(max = 280.dp) - .fadingEdges(scrollState, theme.componentBackground) - .wheelScroll(scrollState) - .verticalScroll(scrollState) - .padding(MenuPadding) - .fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(4.dp), - content = menu, - ) - } - } - } + Dropdown(options, selected, onSelect, width, true) } @Composable -internal fun DropdownItem( - label: Any, - selected: Boolean = false, - icon: String? = null, - leading: @Composable () -> Unit = {}, - onClick: () -> Unit, -) { - val theme = LocalTheme.current - val interactionSource = rememberInteractionSource() - val isHovered by interactionSource.collectIsHoveredAsState() - val itemShape = MenuItemShape - - val bgColor = animateAccentColorAsState( - selected to isHovered, - if (selected) Accent - else if (isHovered) theme.modCardBackground - else theme.modCardBackground.copy(0f) +fun Dropdown(options: List, selected: Int, onSelect: (Int) -> Unit, width: Dp, enabled: Boolean) { + val stamp = languageStamp() + val labels = remember(options, stamp) { options.map { it.asPlainLabel() } } + val styled = remember(options, stamp) { options.styledLabelsOrNull() } + Select( + selected = selected, + options = labels.indices.toList(), + onSelect = onSelect, + modifier = Modifier.width(width).heightIn(min = OptionControlHeight), + label = { labels[it] }, + enabled = enabled, + placeholder = "—", + styledLabel = styled?.let { list -> { list[it] } }, ) - val contentColor = animateColorAsState( - if (selected || isHovered) theme.textColor else theme.textColorSecondary - ) - - Row( - modifier = Modifier - .fillMaxWidth() - .clip(itemShape) - .graphicsLayer {} - .animatedFrame(itemShape, fill = { bgColor.value }, border = { Color.Transparent }) - .onClick(interactionSource, onClick) - .hoverable(interactionSource) - .pointerHoverIcon(PointerIcon.Hand) - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(15.dp), - ) { - leading() - icon?.takeIf { it.isNotEmpty() }?.let { Icon(it, modifier = Modifier.size(16.dp), tint = { contentColor.value }) } - Text(label, color = Color.Unspecified, fontSize = 13.sp, colorProducer = { contentColor.value }) - } } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/FileListOption.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/FileListOption.kt index 19c760e63..dd482962a 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/FileListOption.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/FileListOption.kt @@ -1,9 +1,7 @@ package org.polyfrost.oneconfig.internal.ui.components.settings -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.size -import androidx.compose.foundation.text.BasicTextField import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -11,20 +9,19 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import org.polyfrost.oneconfig.api.ui.v1.components.ControlSize +import org.polyfrost.oneconfig.api.ui.v1.components.IconButton +import org.polyfrost.oneconfig.api.ui.v1.components.TextField import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.polyfrost.oneconfig.api.ui.v1.api.TinyFdApi -import org.polyfrost.oneconfig.api.ui.v1.keybind.trackTextInputFocus import org.polyfrost.oneconfig.internal.ui.api.settings.FileListOptionData -import org.polyfrost.oneconfig.internal.ui.components.IconButton -import org.polyfrost.oneconfig.internal.ui.components.Text +import org.polyfrost.oneconfig.internal.ui.components.iconButtonLabel import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme @Composable @@ -74,10 +71,12 @@ fun FileListOption(data: FileListOptionData) { ) IconButton( "folder", - modifier = Modifier.size(16.dp).pointerHoverIcon(PointerIcon.Hand), - foreground = ctx.contentColor, - hoveredForeground = LocalTheme.current.textColor, - onClick = { openDialog(entry.id, entry.value) }, + iconButtonLabel("folder"), + { openDialog(entry.id, entry.value) }, + Modifier.size(16.dp).pointerHoverIcon(PointerIcon.Hand), + size = ControlSize.Compact, + tooltip = null, + tint = ctx.contentColor, ) } } @@ -89,29 +88,14 @@ private fun RowScope.PathField( ctx: ListRowContext, onValueChange: (String) -> Unit, ) { - val theme = LocalTheme.current - // TextFieldState needs its own migration, as it changes how every field hoists its state - @Suppress("DEPRECATION") - BasicTextField( - value = value, - onValueChange = onValueChange, - singleLine = true, - textStyle = TextStyle( - color = theme.textColor, - fontSize = 13.sp, - fontFamily = theme.typography.family, - ), + TextField( + value, + onValueChange, + Modifier.weight(1f), + placeholder = placeholder, + framed = false, + fontSize = 13.sp, interactionSource = ctx.fieldInteraction, - cursorBrush = SolidColor(theme.textColor), visualTransformation = rememberPathTransformation(), - modifier = Modifier.trackTextInputFocus().weight(1f), - decorationBox = { innerTextField -> - Box { - if (value.isEmpty() && placeholder != null && !ctx.focused) { - Text(placeholder, color = theme.textColorSecondary, fontSize = 13.sp) - } - innerTextField() - } - }, ) } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/FileOption.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/FileOption.kt index 3f640429f..56c296895 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/FileOption.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/FileOption.kt @@ -1,16 +1,10 @@ package org.polyfrost.oneconfig.internal.ui.components.settings -import androidx.compose.animation.animateColorAsState -import androidx.compose.foundation.interaction.collectIsFocusedAsState -import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.text.BasicTextField import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -19,54 +13,37 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.PointerIcon -import androidx.compose.ui.input.pointer.pointerHoverIcon -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.TextRange -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.input.OffsetMapping -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.polyfrost.oneconfig.api.ui.v1.api.TinyFdApi -import org.polyfrost.oneconfig.api.ui.v1.keybind.trackTextInputFocus +import org.polyfrost.oneconfig.api.ui.v1.components.TextField import org.polyfrost.oneconfig.internal.ui.api.settings.FileOptionData import org.polyfrost.oneconfig.internal.ui.api.shown import org.polyfrost.oneconfig.internal.ui.components.IconButton -import org.polyfrost.oneconfig.internal.ui.components.Text -import org.polyfrost.oneconfig.internal.ui.components.animatedFrame -import org.polyfrost.oneconfig.internal.ui.components.rememberInteractionSource import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme @Composable fun FileOption(data: FileOptionData) { + FileOption(data, true) +} + +@Composable +fun FileOption(data: FileOptionData, enabled: Boolean) { val theme = LocalTheme.current val scope = rememberCoroutineScope() - val interactionSource = rememberInteractionSource() - val isHovered by interactionSource.collectIsHoveredAsState() - val isFocused by interactionSource.collectIsFocusedAsState() + var text by remember(data.prop) { mutableStateOf(data.strProp.shown() ?: "") } - val borderColor = animateColorAsState(if (isHovered || isFocused) theme.textColorSecondary else theme.borderColor) - - var value by remember(data.prop) { - val initial = data.strProp.shown() ?: "" - mutableStateOf(TextFieldValue(initial, selection = TextRange(initial.length))) - } - val text = value.text - - val pathTransformation = rememberPathTransformation() - - fun setValue(newText: String) { - value = TextFieldValue(newText, selection = TextRange(newText.length)) + fun update(newText: String) { + text = newText data.strProp.set(newText) } @@ -74,7 +51,7 @@ fun FileOption(data: FileOptionData) { var dialogOpen by remember { mutableStateOf(false) } fun openDialog() { - if (dialogOpen) return + if (dialogOpen || !enabled) return dialogOpen = true scope.launch { try { @@ -87,57 +64,41 @@ fun FileOption(data: FileOptionData) { tinyFd.openFileSelector(data.dialogTitle, current, data.filterPatterns, data.filterName) } } ?: return@launch // cancelled so leave the existing value untouched - setValue(selected.toString()) + update(selected.toString()) } finally { dialogOpen = false } } } - // TextFieldState needs its own migration, as it changes how every field hoists its state - @Suppress("DEPRECATION") - BasicTextField( - value = value, - onValueChange = { value = it; data.strProp.set(it.text) }, - singleLine = true, - textStyle = TextStyle( - color = theme.textColor, - fontSize = 14.sp, - fontFamily = theme.typography.family, - ), - interactionSource = interactionSource, - cursorBrush = SolidColor(theme.textColor), - visualTransformation = pathTransformation, - modifier = Modifier.trackTextInputFocus() - .width(LocalOptionWidth.current) - .heightIn(min = OptionControlHeight) - .graphicsLayer {} - .animatedFrame(theme.sideBarNavigationEntryShape, fill = { theme.modCardBackground }, border = { borderColor.value }) - .padding(start = 12.dp, end = 6.dp, top = 4.dp, bottom = 4.dp), - decorationBox = { innerTextField -> + TextField( + text, + ::update, + Modifier.width(LocalOptionWidth.current).heightIn(min = OptionControlHeight), + placeholder = data.placeholder, + enabled = enabled, + fontSize = 14.sp, + visualTransformation = rememberPathTransformation(), + trailing = { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), ) { - EditableValue( - modifier = Modifier.weight(1f), - showPlaceholder = text.isEmpty() && data.placeholder != null && !isFocused, - placeholder = data.placeholder, - inner = innerTextField, - ) if (text.isNotEmpty()) { IconButton( "close", modifier = Modifier.size(16.dp), foreground = theme.textColorSecondary, hoveredForeground = theme.textColor, - ) { setValue("") } + enabled = enabled, + ) { update("") } } IconButton( "folder", - modifier = Modifier.size(18.dp).pointerHoverIcon(PointerIcon.Hand), + modifier = Modifier.size(18.dp), foreground = theme.textColorSecondary, hoveredForeground = theme.textColor, + enabled = enabled, onClick = ::openDialog, ) } @@ -145,7 +106,6 @@ fun FileOption(data: FileOptionData) { ) } -// only consumed by a value-based BasicTextField, see the note on its call site @Suppress("DEPRECATION") @Composable internal fun rememberPathTransformation(): VisualTransformation { @@ -165,19 +125,3 @@ internal fun rememberPathTransformation(): VisualTransformation { } } } - -@Composable -private fun EditableValue( - modifier: Modifier, - showPlaceholder: Boolean, - placeholder: String?, - inner: @Composable () -> Unit, -) { - val theme = LocalTheme.current - Box(modifier) { - if (showPlaceholder && placeholder != null) { - Text(placeholder, color = theme.textColorSecondary, fontSize = 14.sp) - } - inner() - } -} diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/InfoOption.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/InfoOption.kt index 939f947aa..7cd8d18a8 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/InfoOption.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/InfoOption.kt @@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -18,18 +17,14 @@ import org.polyfrost.oneconfig.internal.ui.components.Text import org.polyfrost.oneconfig.internal.ui.themes.Accent import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme -private val SuccessColor = Color(0xFF239A60) -private val WarningColor = Color(0xFFFFAB1D) -private val ErrorColor = Color(0xFFFF4444) - @Composable fun InfoOption(data: InfoOptionData) { val theme = LocalTheme.current val (iconName, iconColor) = when (data.type) { InfoOptionData.Type.Info -> "info" to Accent - InfoOptionData.Type.Success -> "check-circle" to SuccessColor - InfoOptionData.Type.Warning -> "alert-circle" to WarningColor - InfoOptionData.Type.Error -> "x-circle" to ErrorColor + InfoOptionData.Type.Success -> "check-circle" to theme.success + InfoOptionData.Type.Warning -> "alert-circle" to theme.warning + InfoOptionData.Type.Error -> "x-circle" to theme.error } Row( diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/InheritableSliderOption.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/InheritableSliderOption.kt index 9bc2de324..2d50ff0e6 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/InheritableSliderOption.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/InheritableSliderOption.kt @@ -1,19 +1,15 @@ package org.polyfrost.oneconfig.internal.ui.components.settings -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.foundation.background import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable @@ -26,22 +22,17 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.PointerIcon -import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import kotlin.math.roundToInt +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.api.ui.v1.components.ControlSize +import org.polyfrost.oneconfig.api.ui.v1.components.SegmentedChips import org.polyfrost.oneconfig.internal.ui.api.settings.InheritableSliderOptionData -import org.polyfrost.oneconfig.internal.ui.components.ReducedMotion -import org.polyfrost.oneconfig.internal.ui.components.Text import org.polyfrost.oneconfig.internal.ui.components.animatedFrame -import org.polyfrost.oneconfig.internal.ui.components.liquidGlass -import org.polyfrost.oneconfig.internal.ui.components.onClick -import org.polyfrost.oneconfig.internal.ui.components.rememberInteractionSource import org.polyfrost.oneconfig.internal.ui.sound.UiSoundEvent import org.polyfrost.oneconfig.internal.ui.sound.UiSounds import org.polyfrost.oneconfig.internal.ui.themes.Accent @@ -65,12 +56,10 @@ fun InheritableSliderOption(data: InheritableSliderOptionData) { val thumbDp = 19.dp val span = (data.max - data.min).takeIf { it != 0f } ?: 1f - val fraction = animateFloatAsState(((value - data.min) / span).coerceIn(0f, 1f), animationSpec = ReducedMotion.spec(spring())) + val fraction = Motion.animateFloatAsState(((value - data.min) / span).coerceIn(0f, 1f), spring()) val filledColor = animateAccentColorAsState(inherited, if (inherited) theme.textColorSecondary.copy(alpha = 0.4f) else Accent) - val thumbColor = animateColorAsState( - if (inherited) theme.controlThumbColor.copy(alpha = 0.5f) else theme.controlThumbColor - ) + val thumbColor = Motion.animateColorAsState(if (inherited) theme.controlThumbColor.copy(alpha = 0.5f) else theme.controlThumbColor) Row( verticalAlignment = Alignment.CenterVertically, @@ -164,37 +153,17 @@ fun InheritableSliderOption(data: InheritableSliderOptionData) { width = 80.dp, format = data.format, ) - InheritChip(label = data.inheritLabel, active = inherited) { - if (!inherited) { - data.inherit() - revision++ - } - } - } -} - -@Composable -private fun InheritChip(label: String, active: Boolean, onClick: () -> Unit) { - val theme = LocalTheme.current - val interactionSource = rememberInteractionSource() - val isHovered by interactionSource.collectIsHoveredAsState() - val background = animateAccentColorAsState( - active to isHovered, - if (active) Accent.copy(alpha = 0.2f) else if (isHovered) theme.componentBackground else theme.chipBackground - ) - val border = animateAccentColorAsState(active, if (active) Accent else theme.borderColor) - val textColor = animateAccentColorAsState(active, if (active) Accent else theme.textColorSecondary) - - Box( - modifier = Modifier - .graphicsLayer {} - .liquidGlass(theme.sideBarNavigationEntryShape) - .animatedFrame(theme.sideBarNavigationEntryShape, fill = { background.value }, border = { border.value }) - .onClick(interactionSource, onClick) - .pointerHoverIcon(PointerIcon.Hand) - .padding(horizontal = 10.dp, vertical = 6.dp), - contentAlignment = Alignment.Center, - ) { - Text(label, color = Color.Unspecified, fontSize = 12.sp, colorProducer = { textColor.value }) + SegmentedChips( + options = listOf(true), + selected = inherited, + onSelect = { + if (!inherited) { + data.inherit() + revision++ + } + }, + label = { data.inheritLabel }, + size = ControlSize.Compact, + ) } } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/KeybindOption.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/KeybindOption.kt index 9e6fcb753..177971a6b 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/KeybindOption.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/KeybindOption.kt @@ -1,76 +1,19 @@ package org.polyfrost.oneconfig.internal.ui.components.settings -import androidx.compose.foundation.focusable -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.neverEqualPolicy import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.KeyEvent -import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.onKeyEvent -import androidx.compose.ui.input.key.type -import androidx.compose.ui.input.key.utf16CodePoint -import androidx.compose.ui.input.pointer.PointerIcon -import androidx.compose.ui.input.pointer.pointerHoverIcon -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import org.polyfrost.oneconfig.api.config.v1.Property -import org.polyfrost.oneconfig.api.platform.v1.Platform +import org.polyfrost.oneconfig.api.ui.v1.components.KeybindField import org.polyfrost.oneconfig.api.ui.v1.keybind.KeyModifiers import org.polyfrost.oneconfig.api.ui.v1.keybind.KeybindManager import org.polyfrost.oneconfig.api.ui.v1.keybind.OneConfigKeybind import org.polyfrost.oneconfig.internal.ui.api.settings.KeybindOptionData -import org.polyfrost.oneconfig.internal.ui.components.Icon -import org.polyfrost.oneconfig.internal.ui.components.Text -import org.polyfrost.oneconfig.internal.ui.components.animatedFrame -import org.polyfrost.oneconfig.internal.ui.components.onClick -import org.polyfrost.oneconfig.internal.ui.components.rememberInteractionSource -import org.polyfrost.oneconfig.internal.ui.keybind.KeybindRecordingBus -import org.polyfrost.oneconfig.internal.ui.themes.Accent -import org.polyfrost.oneconfig.internal.ui.themes.animateAccentColorAsState -import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme - -private val KeybindShape @Composable get() = LocalTheme.current.sideBarNavigationEntryShape - -private val CONFLICT_COLOR = Color(0xFFE0524F) - -/** Human-readable name for a platform key code */ -private fun keyCodeToName(code: Int): String = OneConfigKeybind.keyName(code) - -/** Human-readable name for a mouse button */ -private fun mouseButtonToName(button: Int): String = Platform.compatibility().keys().mouseName(button) - -private fun splitModifiers(codes: List): Pair { - var mods = KeyModifiers.NONE - val keys = ArrayList(codes.size) - for (code in codes) { - val bit = KeyModifiers.of(code) - if (bit != KeyModifiers.NONE) mods = (mods.toInt() or bit.toInt()).toByte() - else keys += code - } - return mods to keys.toIntArray() -} - -private fun keybindDisplayName(keybind: OneConfigKeybind?): String = keybind?.displayName() ?: "None" @Suppress("UNCHECKED_CAST") private fun writeKeybind(prop: Property<*>, keys: IntArray?, mouse: IntArray?, mods: Byte): OneConfigKeybind { @@ -87,9 +30,15 @@ private fun writeKeybind(prop: Property<*>, keys: IntArray?, mouse: IntArray?, m return applied } +@Suppress("UNCHECKED_CAST") fun resetKeybindOption(prop: Property<*>) { val def = prop.getMetadata("default") as? OneConfigKeybind - if (def == null) { + if (def == null && prop.getMetadata("defaultNull") == true) { + val old = prop.get() as? OneConfigKeybind + (prop as Property).setAsReferential(null) + if (old != null) KeybindManager.unregister(old) + KeybindConflicts.revision.intValue++ + } else if (def == null) { writeKeybind(prop, null, null, KeyModifiers.NONE) } else { writeKeybind(prop, def.keyCodes?.clone(), def.mouseBtns?.clone(), def.mods) @@ -102,193 +51,27 @@ fun unbindKeybindOption(prop: Property<*>) { bumpResetEpoch(prop) } -private fun KeyEvent.awtKeyEventId(): Int? = runCatching { - val internal = nativeKeyEvent - val field = internal.javaClass.getDeclaredField("nativeEvent").apply { isAccessible = true } - (field.get(internal) as? java.awt.event.KeyEvent)?.id -}.getOrNull() - @Composable fun KeybindOption(data: KeybindOptionData) { - val theme = LocalTheme.current - val singleKey = data.prop.getMetadata("singleKey") == true - val interactionSource = rememberInteractionSource() - val isHovered by interactionSource.collectIsHoveredAsState() - var recording by remember(data.prop) { mutableStateOf(false) } - val focusRequester = remember(data.prop) { FocusRequester() } + KeybindOption(data, true) +} +@Composable +fun KeybindOption(data: KeybindOptionData, enabled: Boolean) { + val singleKey = data.prop.getMetadata("singleKey") == true var currentKeybind by remember(data.prop) { - mutableStateOf(data.prop.get() as? OneConfigKeybind) + mutableStateOf(data.prop.get() as? OneConfigKeybind, neverEqualPolicy()) } - var displayName by remember(data.prop) { mutableStateOf(keybindDisplayName(currentKeybind)) } - - val recordedKeys = remember(data.prop) { mutableStateListOf() } - val heldKeys = remember(data.prop) { HashSet() } - val recordedMouse = remember(data.prop) { mutableStateListOf() } - val heldMouse = remember(data.prop) { HashSet() } - - val hasConflict = !recording && data.prop in KeybindConflicts.conflictingProps() - - // setting the property may mutate the keybind in place or swap in a fresh instance and - // KeybindManager.replace handles both so the bind keeps firing without a mod change callback - fun applyKeybind(keys: IntArray?, mouse: IntArray?, mods: Byte = KeyModifiers.NONE) { - val applied = writeKeybind(data.prop, keys, mouse, mods) - currentKeybind = applied - displayName = keybindDisplayName(applied) - } - - fun commitRecording() { - val mouse = if (recordedMouse.isEmpty()) null else recordedMouse.toIntArray() - val (mods, keys) = splitModifiers(recordedKeys.toList()) - if (keys.isEmpty() && mouse == null && recordedKeys.isNotEmpty()) { - // a combo of only modifier keys binds them as literal keys - applyKeybind(recordedKeys.toIntArray(), null) - } else { - applyKeybind(keys.takeIf { it.isNotEmpty() }, mouse, mods) - } - recording = false - } - - val bgColor = animateAccentColorAsState( - recording to isHovered, - when { - recording -> Accent.copy(alpha = 0.2f) - isHovered -> theme.componentBackground.copy(alpha = 0.8f) - else -> theme.componentBackground - } - ) - val borderColor = animateAccentColorAsState( - listOf(recording, hasConflict, isHovered), - when { - recording -> Accent - hasConflict -> CONFLICT_COLOR - isHovered -> theme.textColor.copy(0.2f) - else -> theme.borderColor - } + val conflict = data.prop in KeybindConflicts.conflictingProps() + + KeybindField( + keybind = currentKeybind, + onChange = { next -> + currentKeybind = writeKeybind(data.prop, next?.keyCodes, next?.mouseBtns, next?.mods ?: KeyModifiers.NONE) + }, + modifier = Modifier.widthIn(max = LocalOptionWidth.current), + enabled = enabled, + conflict = conflict, + singleKey = singleKey, ) - val textColor = animateAccentColorAsState( - recording to hasConflict, - when { - recording -> Accent - hasConflict -> CONFLICT_COLOR - else -> theme.textColor - } - ) - - LaunchedEffect(recording) { - if (recording) { - recordedKeys.clear() - heldKeys.clear() - recordedMouse.clear() - heldMouse.clear() - focusRequester.requestFocus() - } - } - - DisposableEffect(recording) { - if (recording) { - val handler = { - applyKeybind(null, null) - recording = false - } - val mouseHandler: (Int, Boolean) -> Unit = { button, pressed -> - if (pressed) { - if (singleKey) { - applyKeybind(null, intArrayOf(button)) - recording = false - } else { - if (button !in recordedMouse) recordedMouse.add(button) - heldMouse.add(button) - } - } else { - heldMouse.remove(button) - if (heldKeys.isEmpty() && heldMouse.isEmpty() && (recordedKeys.isNotEmpty() || recordedMouse.isNotEmpty())) { - commitRecording() - } - } - } - KeybindRecordingBus.setEscapeHandler(handler) - KeybindRecordingBus.setMouseHandler(mouseHandler) - onDispose { - KeybindRecordingBus.clearEscapeHandler(handler) - KeybindRecordingBus.clearMouseHandler(mouseHandler) - } - } else { - onDispose { } - } - } - - Row( - modifier = Modifier - // long key combos wrap instead of squeezing the setting's label - .widthIn(max = LocalOptionWidth.current) - .heightIn(min = OptionControlHeight) - .onKeyEvent { event -> - if (!recording) return@onKeyEvent false - if (event.awtKeyEventId() == java.awt.event.KeyEvent.KEY_TYPED) return@onKeyEvent true - when (event.type) { - KeyEventType.KeyDown -> { - if (event.key == Key.Escape) { - applyKeybind(null, null) - recording = false - return@onKeyEvent true - } - if (event.key == Key.Backspace || event.key == Key.Delete) { - if (recordedKeys.isEmpty()) { - applyKeybind(null, null) - recording = false - return@onKeyEvent true - } - } - // ComposeScreen carries the raw platform key code in the event's codePoint which avoids a - // lossy AWT round-trip - // a code <= 0 is an unknown key or a character event and would match nothing or everything - val keyCode = event.utf16CodePoint - if (keyCode <= 0) return@onKeyEvent true - if (singleKey) { - applyKeybind(intArrayOf(keyCode), null) - recording = false - return@onKeyEvent true - } - if (keyCode !in recordedKeys) recordedKeys.add(keyCode) - heldKeys.add(keyCode) - return@onKeyEvent true - } - KeyEventType.KeyUp -> { - heldKeys.remove(event.utf16CodePoint) - if (heldKeys.isEmpty() && heldMouse.isEmpty() && (recordedKeys.isNotEmpty() || recordedMouse.isNotEmpty())) { - commitRecording() - } - return@onKeyEvent true - } - else -> return@onKeyEvent false - } - } - .focusRequester(focusRequester) - .focusable() - .graphicsLayer {} - .animatedFrame(KeybindShape, fill = { bgColor.value }, border = { borderColor.value }) - .onClick(interactionSource) { if (!recording) recording = true } - .pointerHoverIcon(PointerIcon.Hand) - .padding(horizontal = 12.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon( - "keyboard", - tint = { textColor.value }, - modifier = Modifier.size(16.dp) - ) - Text( - when { - recording && recordedKeys.isEmpty() && recordedMouse.isEmpty() -> "Press keys..." - recording -> (recordedKeys.map { keyCodeToName(it) } + recordedMouse.map { mouseButtonToName(it) }) - .joinToString(" + ") - else -> displayName - }, - color = Color.Unspecified, - fontSize = 13.sp, - colorProducer = { textColor.value }, - ) - } } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/ListOptionScaffold.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/ListOptionScaffold.kt index 9252315af..059c80008 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/ListOptionScaffold.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/ListOptionScaffold.kt @@ -1,13 +1,10 @@ package org.polyfrost.oneconfig.internal.ui.components.settings -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.snap import androidx.compose.animation.core.spring import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.draggable -import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState @@ -25,7 +22,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.getValue @@ -38,25 +34,38 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isAltPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.zIndex import java.util.ArrayDeque import kotlin.math.roundToInt +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.api.ui.v1.components.ControlSize +import org.polyfrost.oneconfig.api.ui.v1.components.IconButton +import org.polyfrost.oneconfig.api.ui.v1.components.MenuAction +import org.polyfrost.oneconfig.api.ui.v1.components.MenuEntry +import org.polyfrost.oneconfig.api.ui.v1.components.MenuSeparator +import org.polyfrost.oneconfig.api.ui.v1.components.ScrollContainer +import org.polyfrost.oneconfig.api.ui.v1.components.TextButton +import org.polyfrost.oneconfig.api.ui.v1.components.contextMenu +import org.polyfrost.oneconfig.api.ui.v1.components.rememberReorderState +import org.polyfrost.oneconfig.api.ui.v1.components.reorderHandle +import org.polyfrost.oneconfig.api.ui.v1.components.reorderItem import org.polyfrost.oneconfig.internal.ui.components.Icon -import org.polyfrost.oneconfig.internal.ui.components.ReducedMotion -import org.polyfrost.oneconfig.internal.ui.components.Text import org.polyfrost.oneconfig.internal.ui.components.animatedFrame import org.polyfrost.oneconfig.internal.ui.components.containVerticalScroll -import org.polyfrost.oneconfig.internal.ui.components.fadingEdges -import org.polyfrost.oneconfig.internal.ui.components.onClick import org.polyfrost.oneconfig.internal.ui.components.rememberInteractionSource +import org.polyfrost.oneconfig.internal.ui.components.translated import org.polyfrost.oneconfig.internal.ui.components.wheelScroll import org.polyfrost.oneconfig.internal.ui.sound.UiSoundEvent import org.polyfrost.oneconfig.internal.ui.sound.UiSounds @@ -72,7 +81,6 @@ import org.polyfrost.oneconfig.internal.ui.themes.concentric internal val ListEntryHeight = 34.dp internal val ListEntrySpacing = 8.dp -internal val ListInvalidColor = Color(0xFFFF4444) private val ContainerPadding = 8.dp private val ContainerShape @Composable get() = LocalTheme.current.modCardShape @@ -96,19 +104,85 @@ internal class ListEntriesState(initial: List, private val onCommit: (List val size: Int get() = entries.size - fun add(value: T) = apply(entries + ListRowEntry(nextId++, value)) + private var insertAt = -1 + + private var pending = false + + fun add(value: T) { + val at = insertAt + if (at in 0..entries.size) { + insertAt = at + 1 + val list = entries.toMutableList() + list.add(at, ListRowEntry(nextId++, value)) + apply(list) + } else { + apply(entries + ListRowEntry(nextId++, value)) + } + } fun update(id: Int, value: T) = apply(entries.map { if (it.id == id) it.copy(value = value) else it }) - fun remove(id: Int) = apply(entries.filterNot { it.id == id }) + fun remove(id: Int) { + insertAt = -1 + apply(entries.filterNot { it.id == id }) + } fun move(from: Int, to: Int) { + insertAt = -1 + val list = entries.toMutableList() + list.add(to, list.removeAt(from)) + apply(list) + } + + fun reorder(from: Int, to: Int) { + if (from !in entries.indices || to !in entries.indices) return + insertAt = -1 val list = entries.toMutableList() list.add(to, list.removeAt(from)) + entries = list + pending = true + } + + fun commitReorder() { + if (!pending) return + pending = false + apply(entries) + } + + fun indexOf(id: Int): Int = entries.indexOfFirst { it.id == id } + + fun moveBy(id: Int, delta: Int): Boolean { + val from = indexOf(id) + val to = from + delta + if (from < 0 || to !in entries.indices) return false + move(from, to) + return true + } + + fun duplicate(id: Int) { + val index = indexOf(id) + if (index < 0) return + val list = entries.toMutableList() + list.add(index + 1, ListRowEntry(nextId++, entries[index].value)) apply(list) } + fun addAtEnd(onAdd: () -> Unit) { + insertAt = -1 + onAdd() + } + + fun insertAbove(id: Int, onAdd: () -> Unit) { + val index = indexOf(id) + if (index < 0) return + val before = entries.size + insertAt = index + onAdd() + if (entries.size != before) insertAt = -1 + } + fun replace(values: List) { + insertAt = -1 val existing = HashMap>>() entries.forEach { entry -> existing.getOrPut(entry.value) { ArrayDeque() }.addLast(entry) @@ -129,6 +203,7 @@ internal class ListRowContext( private val color: State, val fieldInteraction: MutableInteractionSource, val focused: Boolean, + val rowEntries: () -> List = { emptyList() }, ) { val contentColor: Color get() = color.value } @@ -145,6 +220,7 @@ internal fun ListOptionContainer( addText: Any, onAdd: () -> Unit, showAddWhenFull: Boolean = false, + rowInsertActions: Boolean = true, entryHeight: Dp = ListEntryHeight, maxHeight: Dp = 280.dp, containScroll: Boolean = false, @@ -155,27 +231,52 @@ internal fun ListOptionContainer( val density = LocalDensity.current val entries = state.entries - var draggingIndex by remember { mutableStateOf(-1) } - var dragAccum by remember { mutableStateOf(0f) } - var lastTickIndex by remember { mutableStateOf(-1) } val stridePx = with(density) { (entryHeight + ListEntrySpacing).toPx() } - val dragTargetIndex = if (draggingIndex == -1) { - -1 - } else { - (draggingIndex + (dragAccum / stridePx).roundToInt()).coerceIn(0, entries.lastIndex) - } val contentHeight = entryHeight * entries.size + ListEntrySpacing * (entries.size - 1).coerceAtLeast(0) - val draggableState = rememberDraggableState { delta -> - if (draggingIndex == -1) return@rememberDraggableState - dragAccum = (dragAccum + delta).coerceIn( - -draggingIndex * stridePx, - (entries.lastIndex - draggingIndex) * stridePx, - ) - val target = (draggingIndex + (dragAccum / stridePx).roundToInt()).coerceIn(0, entries.lastIndex) - if (target != lastTickIndex) { - lastTickIndex = target + val reorder = rememberReorderState( + Orientation.Vertical, + { from, to -> + state.reorder(from, to) UiSounds.play(UiSoundEvent.SLIDER_TICK) + }, + { state.commitReorder() }, + ) + + val full = maxEntries > 0 && entries.size >= maxEntries + + fun rowMenu(entry: ListRowEntry): List { + val index = state.indexOf(entry.id) + if (index < 0) return emptyList() + val canAdd = maxEntries <= 0 || state.size < maxEntries + return buildList { + if (reorderable) { + add( + MenuAction( + translated("oneconfig.list.move_up", "Move up"), + { state.moveBy(entry.id, -1) }, + icon = "up", + enabled = index > 0, + shortcutHint = translated("oneconfig.list.move_up.shortcut", "Alt+Up"), + ), + ) + add( + MenuAction( + translated("oneconfig.list.move_down", "Move down"), + { state.moveBy(entry.id, 1) }, + icon = "down", + enabled = index < state.size - 1, + shortcutHint = translated("oneconfig.list.move_down.shortcut", "Alt+Down"), + ), + ) + add(MenuSeparator) + } + if (rowInsertActions) { + add(MenuAction(translated("oneconfig.list.duplicate", "Duplicate"), { state.duplicate(entry.id) }, icon = "copy-plus", enabled = canAdd)) + add(MenuAction(translated("oneconfig.list.insert_above", "Insert above"), { state.insertAbove(entry.id, onAdd) }, icon = "plus", enabled = canAdd)) + add(MenuSeparator) + } + add(MenuAction(translated("oneconfig.list.remove", "Remove"), { state.remove(entry.id) }, icon = "trash", danger = true)) } } @@ -190,19 +291,21 @@ internal fun ListOptionContainer( ) { if (entries.isNotEmpty()) { val scrollState = rememberScrollState() - val containmentModifier = if (containScroll) { - Modifier.containVerticalScroll(scrollState) + val containment = if (containScroll) { + Modifier + .containVerticalScroll(scrollState) + .wheelScroll(scrollState, contain = { scrollState.maxValue > 0 }) } else { Modifier } - Box( + ScrollContainer( + state = scrollState, modifier = Modifier .fillMaxWidth() .heightIn(max = maxHeight) - .fadingEdges(scrollState, theme.componentBackground) - .then(containmentModifier) - .wheelScroll(scrollState, contain = { containScroll && scrollState.maxValue > 0 }) - .verticalScroll(scrollState), + .then(containment), + scrollbar = scrollState.maxValue > 0, + fadingEdges = true, ) { Box( modifier = Modifier @@ -211,16 +314,9 @@ internal fun ListOptionContainer( ) { entries.forEachIndexed { index, entry -> key(entry.id) { - val isDragging = draggingIndex == index + val isDragging = reorder.draggingKey == entry.id val baseOffset = index * stridePx - val targetOffset = when { - isDragging -> baseOffset + dragAccum - draggingIndex == -1 -> baseOffset - dragTargetIndex > draggingIndex && index in (draggingIndex + 1)..dragTargetIndex -> baseOffset - stridePx - dragTargetIndex < draggingIndex && index in dragTargetIndex until draggingIndex -> baseOffset + stridePx - else -> baseOffset - } - val animatedOffset = animateFloatAsState(targetOffset, ReducedMotion.spec(spring())) + val animatedOffset = Motion.animateFloatAsState(baseOffset, if (isDragging) snap() else spring(), label = "listEntryOffset") EntryRow( valid = !invalid(entry.value), @@ -228,21 +324,21 @@ internal fun ListOptionContainer( modifier = Modifier .fillMaxWidth() .height(entryHeight) - .offset { IntOffset(0, (if (isDragging) targetOffset else animatedOffset.value).roundToInt()) } - .zIndex(if (isDragging) 1f else 0f), + .offset { IntOffset(0, baseOffset.roundToInt()) } + .reorderItem(reorder, entry.id, index, false) + .graphicsLayer { translationY = animatedOffset.value - baseOffset } + .contextMenu { rowMenu(entry) } + .onPreviewKeyEvent { event -> + val delta = event.reorderDelta() + reorderable && delta != 0 && state.moveBy(entry.id, delta).also { moved -> + if (moved) UiSounds.play(UiSoundEvent.SLIDER_TICK) + } + }, dragHandle = if (!reorderable) null else { handleModifier -> - handleModifier.draggable( - orientation = Orientation.Vertical, - state = draggableState, - onDragStarted = { draggingIndex = index; dragAccum = 0f; lastTickIndex = index }, - onDragStopped = { - if (dragTargetIndex != draggingIndex) state.move(draggingIndex, dragTargetIndex) - draggingIndex = -1 - dragAccum = 0f - }, - ) + handleModifier.reorderHandle(reorder, entry.id) }, onRemove = { state.remove(entry.id) }, + rowEntries = { rowMenu(entry) }, content = { ctx -> row(entry, ctx) }, ) } @@ -251,12 +347,21 @@ internal fun ListOptionContainer( } } - if (showAddWhenFull || maxEntries <= 0 || entries.size < maxEntries) { - AddEntryButton(addText, entryHeight, onAdd) + if (showAddWhenFull || !full) { + AddEntryButton(addText, entryHeight) { state.addAtEnd(onAdd) } } } } +internal fun KeyEvent.reorderDelta(): Int { + if (type != KeyEventType.KeyDown || !isAltPressed) return 0 + return when (key) { + Key.DirectionUp -> -1 + Key.DirectionDown -> 1 + else -> 0 + } +} + @Composable private fun EntryRow( valid: Boolean, @@ -264,6 +369,7 @@ private fun EntryRow( modifier: Modifier, dragHandle: ((Modifier) -> Modifier)?, onRemove: () -> Unit, + rowEntries: () -> List, content: @Composable RowScope.(ListRowContext) -> Unit, ) { val theme = LocalTheme.current @@ -272,10 +378,10 @@ private fun EntryRow( val fieldInteraction = rememberInteractionSource() val isFocused by fieldInteraction.collectIsFocusedAsState() - val contentColor = animateColorAsState( + val contentColor = Motion.animateColorAsState( if (dragging || isHovered || isFocused) theme.textColor else theme.textColorSecondary ) - val borderColor = animateColorAsState(if (valid) theme.borderColor else ListInvalidColor) + val borderColor = Motion.animateColorAsState(if (valid) theme.borderColor else theme.error) Row( modifier = modifier @@ -299,49 +405,26 @@ private fun EntryRow( ) } - content(ListRowContext(contentColor, fieldInteraction, isFocused)) + content(ListRowContext(contentColor, fieldInteraction, isFocused, rowEntries)) - Icon( + IconButton( "trash", - modifier = Modifier - .size(16.dp) - .pointerHoverIcon(PointerIcon.Hand) - .onClick(rememberInteractionSource(), onRemove), - tint = { contentColor.value }, + translated("oneconfig.list.remove", "Remove"), + onRemove, + size = ControlSize.Compact, + danger = true, ) } } @Composable private fun AddEntryButton(label: Any, entryHeight: Dp, onClick: () -> Unit) { - val theme = LocalTheme.current - val interactionSource = rememberInteractionSource() - val isHovered by interactionSource.collectIsHoveredAsState() - val contentColor = animateColorAsState(if (isHovered) theme.textColor else theme.textColorSecondary) - - Row( - modifier = Modifier - .fillMaxWidth() - .height(entryHeight) - .clip(EntryShape) - .graphicsLayer {} - .border(1.dp, theme.borderColor, EntryShape) - .pointerHoverIcon(PointerIcon.Hand) - .hoverable(interactionSource) - .onClick(interactionSource, onClick) - .padding(horizontal = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Icon("plus", modifier = Modifier.size(16.dp), tint = { contentColor.value }) - Text( - label, - modifier = Modifier.weight(1f, fill = false), - color = Color.Unspecified, - colorProducer = { contentColor.value }, - fontSize = 13.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } + TextButton( + label.asStyledLabel(), + "plus", + onClick, + Modifier.height(entryHeight), + size = ControlSize.Compact, + fillWidth = true, + ) } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/MultiSelectDropdownOption.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/MultiSelectDropdownOption.kt index db2d8f961..013454907 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/MultiSelectDropdownOption.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/MultiSelectDropdownOption.kt @@ -1,105 +1,89 @@ package org.polyfrost.oneconfig.internal.ui.components.settings -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp +import androidx.compose.ui.text.AnnotatedString import org.polyfrost.oneconfig.api.config.v1.Property +import org.polyfrost.oneconfig.api.ui.v1.components.MultiSelect +import org.polyfrost.oneconfig.api.ui.v1.components.Select import org.polyfrost.oneconfig.internal.ui.api.settings.MultiSelectDropdownOptionData import org.polyfrost.oneconfig.internal.ui.api.shown -import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme +import org.polyfrost.oneconfig.internal.ui.components.translated + +private const val ToggleAll = -1 -@Suppress("UNCHECKED_CAST") @Composable fun MultiSelectDropdownOption(data: MultiSelectDropdownOptionData) { - val theme = LocalTheme.current + MultiSelectDropdownOption(data, true) +} - val options: List = remember(data.prop) { +@Suppress("UNCHECKED_CAST") +@Composable +fun MultiSelectDropdownOption(data: MultiSelectDropdownOptionData, enabled: Boolean) { + val stamp = languageStamp() + val rawLabels: List = remember(data.prop, stamp) { data.optionLabels ?: data.options?.toList() ?: emptyList() } + val labels: List = remember(rawLabels) { rawLabels.map { it.asPlainLabel() } } + val styled = remember(rawLabels) { rawLabels.styledLabelsOrNull() } + val fieldModifier = Modifier.width(LocalOptionWidth.current).heightIn(min = OptionControlHeight) - // checkable means the property is an Array indexed by option - // otherwise it is an Int holding the selected index where -1 is none - var selectedFlags by remember(data.prop) { - if (data.checkable) { - val v = data.prop.shown() - val arr: BooleanArray = when (v) { - is BooleanArray -> v - is Array<*> -> BooleanArray(options.size) { i -> v.getOrNull(i) == true } - else -> BooleanArray(options.size) - } - mutableStateOf(arr.copyOf()) - } else { - mutableStateOf(BooleanArray(0)) - } + if (!data.checkable) { + var selectedIdx by remember(data.prop) { mutableStateOf(data.prop.shown() as? Int ?: -1) } + Select( + selected = selectedIdx, + options = labels.indices.toList(), + onSelect = { index -> + selectedIdx = index + (data.prop as Property).set(index) + }, + modifier = fieldModifier, + label = { labels[it] }, + enabled = enabled, + placeholder = "—", + styledLabel = styled?.let { list -> { list[it] } }, + ) + return } - var selectedIdx by remember(data.prop) { - if (!data.checkable) { - val v = data.prop.shown() - mutableStateOf(if (v is Int) v else -1) - } else { - mutableStateOf(-1) + var selectedFlags by remember(data.prop) { + val arr: BooleanArray = when (val v = data.prop.shown()) { + is BooleanArray -> v + is Array<*> -> BooleanArray(labels.size) { i -> v.getOrNull(i) == true } + else -> BooleanArray(labels.size) } + mutableStateOf(arr.copyOf()) } - - var expanded by remember { mutableStateOf(false) } - - val triggerLabel = when { - !data.checkable -> options.getOrElse(selectedIdx) { "—" } - else -> { - val count = selectedFlags.count { it } - when { - count == 0 -> "None selected" - count == options.size -> "All selected" - else -> "$count / ${options.size} selected" - } - } + val allSelected = labels.isNotEmpty() && selectedFlags.count { it } == labels.size + val selected = labels.indices.filterTo(mutableSetOf()) { selectedFlags.getOrElse(it) { false } } + val options = if (labels.size > 1) listOf(ToggleAll) + labels.indices else labels.indices.toList() + val toggleAllLabel = if (allSelected) { + translated("oneconfig.option.multi_select.deselect_all", "Deselect all") + } else { + translated("oneconfig.menu.text.select_all", "Select all") } - DropdownFrame(triggerLabel, expanded, { expanded = it }) { - if (data.checkable && options.size > 1) { - val allSelected = selectedFlags.count { it } == options.size - val toggleAll: () -> Unit = { - val updated = BooleanArray(options.size) { !allSelected } - selectedFlags = updated - (data.prop as Property).set(updated) - } - DropdownItem( - if (allSelected) "Deselect all" else "Select all", - leading = { CheckboxIndicator(checked = allSelected, onToggle = toggleAll) }, - onClick = toggleAll, - ) - Box(Modifier.fillMaxWidth().height(1.dp).background(theme.borderColor)) - } - options.forEachIndexed { index, option -> - if (data.checkable) { - val toggle: () -> Unit = { - val updated = selectedFlags.copyOf() - updated[index] = !updated[index] - selectedFlags = updated - (data.prop as Property).set(updated) - } - DropdownItem( - option, - icon = data.icon, - leading = { CheckboxIndicator(checked = selectedFlags.getOrElse(index) { false }, onToggle = toggle) }, - onClick = toggle, - ) + MultiSelect( + selected = selected, + options = options, + onChange = { next -> + val updated = if (ToggleAll in next) { + BooleanArray(labels.size) { !allSelected } } else { - DropdownItem(option, selected = selectedIdx == index, icon = data.icon) { - selectedIdx = index - (data.prop as Property).set(index) - expanded = false - } + BooleanArray(labels.size) { it in next } } - } - } + selectedFlags = updated + (data.prop as Property).set(updated) + }, + modifier = fieldModifier, + label = { if (it == ToggleAll) toggleAllLabel else labels[it] }, + enabled = enabled, + styledLabel = styled?.let { list -> { if (it == ToggleAll) AnnotatedString(toggleAllLabel) else list[it] } }, + ) } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/NumberChainOption.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/NumberChainOption.kt index ba75e32e2..3b81cee59 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/NumberChainOption.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/NumberChainOption.kt @@ -1,7 +1,5 @@ package org.polyfrost.oneconfig.internal.ui.components.settings -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -21,9 +19,6 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -35,31 +30,29 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import kotlin.math.roundToInt -import org.polyfrost.oneconfig.api.ui.v1.keybind.trackTextInputFocus +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.api.ui.v1.components.ControlSize +import org.polyfrost.oneconfig.api.ui.v1.components.TextButton +import org.polyfrost.oneconfig.api.ui.v1.components.TextField import org.polyfrost.oneconfig.internal.ui.api.settings.NumberChainOptionData import org.polyfrost.oneconfig.internal.ui.components.Icon -import org.polyfrost.oneconfig.internal.ui.components.ReducedMotion import org.polyfrost.oneconfig.internal.ui.components.Text import org.polyfrost.oneconfig.internal.ui.components.animatedFrame import org.polyfrost.oneconfig.internal.ui.components.onClick import org.polyfrost.oneconfig.internal.ui.components.rememberInteractionSource +import org.polyfrost.oneconfig.internal.ui.components.translated import org.polyfrost.oneconfig.internal.ui.sound.UiSoundEvent import org.polyfrost.oneconfig.internal.ui.sound.UiSounds import org.polyfrost.oneconfig.internal.ui.themes.Accent @@ -148,9 +141,9 @@ fun NumberChainOption(data: NumberChainOptionData) { dragTargetIndex < draggingIndex && index in dragTargetIndex until draggingIndex -> baseOffset + stridePx else -> baseOffset } - val animatedOffset = animateFloatAsState(targetOffset, ReducedMotion.spec(spring())) + val animatedOffset = Motion.animateFloatAsState(targetOffset, spring()) val bgColor = animateAccentColorAsState(isDragging, if (isDragging) Accent else theme.chipBackground) - val contentColor = animateColorAsState( + val contentColor = Motion.animateColorAsState( when { !movable -> theme.textColorSecondary isDragging || isHovered -> theme.textColor @@ -251,27 +244,16 @@ fun NumberChainOption(data: NumberChainOptionData) { } if (entries.size < data.maxEntries) { - val addInteraction = rememberInteractionSource() - val addHovered by addInteraction.collectIsHoveredAsState() - val addColor = animateColorAsState(if (addHovered) theme.textColor else theme.textColorSecondary) - Row( - modifier = Modifier - .fillMaxWidth() - .clip(ChainShape) - .graphicsLayer {} - .border(1.dp, theme.borderColor, ChainShape) - .onClick(addInteraction) { - val values = entries.map { it.value } - commit(entries + ChainEntry(idGen[0]++, data.nextValue(values))) - } - .pointerHoverIcon(PointerIcon.Hand) - .padding(horizontal = 10.dp, vertical = 7.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Icon("plus", modifier = Modifier.size(14.dp), tint = { addColor.value }) - Text("Add", color = Color.Unspecified, colorProducer = { addColor.value }, fontSize = 13.sp) - } + TextButton( + translated("oneconfig.list.add", "Add"), + "plus", + { + val values = entries.map { it.value } + commit(entries + ChainEntry(idGen[0]++, data.nextValue(values))) + }, + size = ControlSize.Compact, + fillWidth = true, + ) } } } @@ -285,29 +267,23 @@ private fun ChainValueEditor( modifier: Modifier, onCommit: (Float) -> Unit, ) { - val theme = LocalTheme.current val focusRequester = remember { FocusRequester() } var text by remember { mutableStateOf(formatSpinnerValue(initial)) } var focused by remember { mutableStateOf(false) } fun commit() = onCommit(text.toFloatOrNull()?.coerceIn(min, max) ?: initial) - // TextFieldState needs its own migration, as it changes how every field hoists its state - @Suppress("DEPRECATION") - BasicTextField( - value = text, - onValueChange = { text = filterNumberInput(it) }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { commit() }), - textStyle = TextStyle(color = theme.textColor, fontSize = 13.sp, fontFamily = theme.typography.family), - cursorBrush = SolidColor(theme.textColor), + TextField( + text, + { text = filterNumberInput(it) }, + modifier.onFocusChanged { state -> + if (state.isFocused) focused = true else if (focused) commit() + }, + framed = false, + fontSize = 13.sp, visualTransformation = rememberUnitSuffix(unit), - modifier = modifier.trackTextInputFocus() - .focusRequester(focusRequester) - .onFocusChanged { state -> - if (state.isFocused) focused = true else if (focused) commit() - }, + focusRequester = focusRequester, + onSubmit = { commit() }, ) LaunchedEffect(Unit) { focusRequester.requestFocus() } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/NumberOption.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/NumberOption.kt index 1440636d3..adaaa3184 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/NumberOption.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/NumberOption.kt @@ -1,6 +1,5 @@ package org.polyfrost.oneconfig.internal.ui.components.settings -import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.background @@ -11,10 +10,10 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue @@ -24,9 +23,15 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key @@ -37,13 +42,6 @@ import androidx.compose.ui.input.pointer.isShiftPressed import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.OffsetMapping -import androidx.compose.ui.text.input.TransformedText -import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -55,13 +53,19 @@ import java.math.BigDecimal import java.util.Locale import kotlin.math.abs import kotlin.math.roundToInt -import org.polyfrost.oneconfig.api.ui.v1.keybind.trackTextInputFocus +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.api.ui.v1.components.ControlSize +import org.polyfrost.oneconfig.api.ui.v1.components.NumberField +import org.polyfrost.oneconfig.api.ui.v1.components.TextField +import org.polyfrost.oneconfig.api.ui.v1.components.TextFieldDefaults +import org.polyfrost.oneconfig.api.ui.v1.components.oneConfigClickable +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme import org.polyfrost.oneconfig.internal.ui.api.settings.NumberOptionData import org.polyfrost.oneconfig.internal.ui.api.shown import org.polyfrost.oneconfig.internal.ui.components.Icon import org.polyfrost.oneconfig.internal.ui.components.Text -import org.polyfrost.oneconfig.internal.ui.components.onClick import org.polyfrost.oneconfig.internal.ui.components.rememberInteractionSource +import org.polyfrost.oneconfig.internal.ui.components.translated import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme fun Float.toNumberType(type: Class<*>): Number = when (type) { @@ -209,34 +213,14 @@ fun formatSpinnerValue(v: Float, step: Float = 0f): String { return String.format(Locale.ROOT, "%.${maxOf(2, stepDecimals)}f", v) } -// for value-based BasicTextFields, see the TextFieldState note where they're used -@Suppress("DEPRECATION") -@Composable -internal fun rememberUnitSuffix(unit: String?): VisualTransformation { - val color = LocalTheme.current.textColorSecondary - return remember(unit, color) { - if (unit == null) VisualTransformation.None else VisualTransformation { typed -> - val shown = AnnotatedString.Builder(typed).apply { - pushStyle(SpanStyle(color = color)) - append(unit) - pop() - }.toAnnotatedString() - TransformedText(shown, object : OffsetMapping { - override fun originalToTransformed(offset: Int) = offset - override fun transformedToOriginal(offset: Int) = offset.coerceAtMost(typed.length) - }) - } - } -} - @Composable -private fun SpinnerArrow(icon: String, enabled: Boolean, onClick: () -> Unit) { +private fun SpinnerArrow(icon: String, label: String, enabled: Boolean, onClick: () -> Unit) { val theme = LocalTheme.current val hoverInteraction = rememberInteractionSource() val hovered by hoverInteraction.collectIsHoveredAsState() - val color = animateColorAsState( + val color = Motion.animateColorAsState( when { - !enabled -> theme.textColorSecondary.copy(alpha = 0.4f) + !enabled -> theme.textColorSecondary.copy(alpha = theme.disabledAlpha) hovered -> theme.textColor else -> theme.textColorSecondary } @@ -246,7 +230,7 @@ private fun SpinnerArrow(icon: String, enabled: Boolean, onClick: () -> Unit) { modifier = Modifier .size(18.dp, 13.dp) .hoverable(hoverInteraction) - .onClick(rememberInteractionSource(), enabled, onClick), + .oneConfigClickable(label, onClick, enabled = enabled, indication = false, interactionSource = rememberInteractionSource()), contentAlignment = Alignment.Center, ) { Icon(icon, modifier = Modifier.size(14.dp), tint = { color.value }) @@ -255,9 +239,14 @@ private fun SpinnerArrow(icon: String, enabled: Boolean, onClick: () -> Unit) { @Composable fun SpinnerArrows(value: Float, min: Float, max: Float, step: Float, onStep: (Float) -> Unit) { + SpinnerArrows(value, min, max, step, true, onStep) +} + +@Composable +fun SpinnerArrows(value: Float, min: Float, max: Float, step: Float, enabled: Boolean, onStep: (Float) -> Unit) { Column(modifier = Modifier.padding(end = 2.dp)) { - SpinnerArrow("up", value < max) { onStep((value + step).coerceIn(min, max)) } - SpinnerArrow("down", value > min) { onStep((value - step).coerceIn(min, max)) } + SpinnerArrow("up", translated("oneconfig.ui.number.increase", "Increase"), enabled && value < max) { onStep((value + step).coerceIn(min, max)) } + SpinnerArrow("down", translated("oneconfig.ui.number.decrease", "Decrease"), enabled && value > min) { onStep((value - step).coerceIn(min, max)) } } } @@ -366,91 +355,119 @@ fun NumberSpinner( Modifier } + val disabledAlpha = OneConfigTheme.current.disabledAlpha + val frameShape = TextFieldDefaults.shape + val frameBorder = Motion.animateColorAsState(if (focused) TextFieldDefaults.focusBorder else TextFieldDefaults.border) + Row( modifier = Modifier - .alpha(if (enabled) 1f else 0.5f) + .graphicsLayer { alpha = if (enabled) 1f else disabledAlpha } .size(width, OptionControlHeight) - .background(theme.componentBackground, theme.sideBarNavigationEntryShape) - .border(1.dp, theme.borderColor, theme.sideBarNavigationEntryShape), + .background(theme.componentBackground, frameShape) + .border(1.dp, frameBorder.value, frameShape), verticalAlignment = Alignment.CenterVertically, ) { - // TextFieldState needs its own migration, as it changes how every field hoists its state - @Suppress("DEPRECATION") - BasicTextField( - value = text, - onValueChange = { input -> - val filtered = if (math) filterExpressionInput(input) else filterNumberInput(input) - if (filtered != text) edited = true - text = filtered - if (!math) filtered.toFloatOrNull()?.coerceIn(min, max)?.let(onValueChange) - }, - enabled = enabled, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - textStyle = TextStyle( - color = theme.textColor, - fontSize = 13.sp, - fontFamily = theme.typography.family, - ), - cursorBrush = SolidColor(theme.textColor), - visualTransformation = rememberUnitSuffix(unit), - // the field itself stays in the tree while the formatted text covers it so clicking it still focuses it for editing - decorationBox = { innerTextField -> - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (label != null) Text(label, modifier = scrubModifier, color = theme.textColor, fontSize = 13.sp) - if (icon != null) Icon(icon, color = theme.textColor, modifier = scrubModifier.size(12.dp)) - Box { - Box(Modifier.alpha(if (formatted == null) 1f else 0f)) { innerTextField() } - if (formatted != null) { - Text(formatted, color = theme.textColor, fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + Row( + modifier = Modifier.weight(1f).padding(start = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (label != null) Text(label, modifier = scrubModifier, color = theme.textColor, fontSize = 13.sp) + if (icon != null) Icon(icon, color = theme.textColor, modifier = scrubModifier.size(12.dp)) + Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { + TextField( + text, + { input -> + if (!enabled) return@TextField + val filtered = if (math) filterExpressionInput(input) else filterNumberInput(input) + if (filtered != text) edited = true + text = filtered + if (!math) filtered.toFloatOrNull()?.coerceIn(min, max)?.let(onValueChange) + }, + Modifier + .fillMaxWidth() + .alpha(if (formatted == null) 1f else 0f) + .focusProperties { canFocus = enabled } + .onPreviewKeyEvent { + if (!math || it.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + if (it.key != Key.Enter && it.key != Key.NumPadEnter) return@onPreviewKeyEvent false + commitTyped() + focusManager.clearFocus() + true } - } - } - }, - modifier = Modifier.trackTextInputFocus() - .onPreviewKeyEvent { - if (!math || it.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false - if (it.key != Key.Enter && it.key != Key.NumPadEnter) return@onPreviewKeyEvent false - commitTyped() - focusManager.clearFocus() - true - } - .onFocusChanged { - if (focused && !it.isFocused) { - if (math) commitTyped() - onEditEnd?.invoke() - } - if (!focused && it.isFocused) edited = false - focused = it.isFocused + .onFocusChanged { + if (focused && !it.isFocused) { + if (math) commitTyped() + onEditEnd?.invoke() + } + if (!focused && it.isFocused) edited = false + focused = it.isFocused + }, + framed = false, + fontSize = 13.sp, + visualTransformation = rememberUnitSuffix(unit), + ) + if (formatted != null) { + Text(formatted, color = theme.textColor, fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) } - .weight(1f) - .padding(start = 8.dp, top = 5.dp, bottom = 5.dp), - ) + } + } - SpinnerArrows(value, arrowLo, arrowHi, step) { if (enabled) commit(it) } + SpinnerArrows(value, arrowLo, arrowHi, step, enabled) { commit(it) } } } @Composable fun NumberOption(data: NumberOptionData) { + NumberOption(data, true) +} + +@Composable +fun NumberOption(data: NumberOptionData, enabled: Boolean) { + val integral = isIntegralType(data.prop.type) val step = remember(data.step, data.min, data.max) { - spinnerStep(data.step, data.min, data.max, isIntegralType(data.prop.type)) + spinnerStep(data.step, data.min, data.max, integral) } var value by remember(data.prop) { mutableStateOf(data.numProp.shown()?.toFloat()?.coerceIn(data.min, data.max) ?: data.min) } - NumberSpinner( + NumberField( value = value, onValueChange = { value = it; data.numProp.set(it.toNumberType(data.prop.type)) }, - min = data.min, - max = data.max, + modifier = Modifier.height(OptionControlHeight), + range = data.min..data.max, step = step, - width = 87.dp, - format = data.format, + roundToDecimals = integral, unit = data.unit, + format = data.format, + enabled = enabled, + size = ControlSize.Compact, ) } + +@Suppress("DEPRECATION") +@Composable +internal fun rememberUnitSuffix(unit: String?): VisualTransformation { + val color = LocalTheme.current.textColorSecondary + return remember(unit, color) { + if (unit == null) { + VisualTransformation.None + } else { + VisualTransformation { typed -> + val shown = AnnotatedString.Builder(typed).apply { + pushStyle(SpanStyle(color = color)) + append(unit) + pop() + }.toAnnotatedString() + TransformedText( + shown, + object : OffsetMapping { + override fun originalToTransformed(offset: Int): Int = offset + override fun transformedToOriginal(offset: Int): Int = offset.coerceAtMost(typed.length) + }, + ) + } + } + } +} diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/Options.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/Options.kt index e4c609923..bba257e95 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/Options.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/Options.kt @@ -1,51 +1,93 @@ package org.polyfrost.oneconfig.internal.ui.components.settings -import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Stable import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.getValue import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.neverEqualPolicy import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.input.pointer.PointerIcon -import androidx.compose.ui.input.pointer.pointerHoverIcon -import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import java.util.Collections import java.util.IdentityHashMap +import java.util.concurrent.ConcurrentHashMap import java.util.function.Consumer import java.util.function.Predicate +import androidx.compose.ui.text.AnnotatedString +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.ComponentLike import org.polyfrost.compose.render.PolyColor import org.polyfrost.oneconfig.api.config.v1.Config +import org.polyfrost.oneconfig.api.config.v1.MetaKeys import org.polyfrost.oneconfig.api.config.v1.Property import org.polyfrost.oneconfig.api.config.v1.Visualizer -import org.polyfrost.oneconfig.api.ui.v1.components.ContextMenu -import org.polyfrost.oneconfig.api.ui.v1.components.MenuItem -import org.polyfrost.oneconfig.api.ui.v1.components.MenuItemSize +import org.polyfrost.oneconfig.api.config.v1.get +import org.polyfrost.oneconfig.api.platform.v1.Platform +import org.polyfrost.oneconfig.api.ui.v1.components.MenuAction +import org.polyfrost.oneconfig.api.ui.v1.components.MenuCustomItem +import org.polyfrost.oneconfig.api.ui.v1.components.MenuEntry +import org.polyfrost.oneconfig.api.ui.v1.components.MenuSeparator +import org.polyfrost.oneconfig.api.ui.v1.components.MoreButton +import org.polyfrost.oneconfig.api.ui.v1.components.OptionMenuContributors +import org.polyfrost.oneconfig.api.ui.v1.components.contextMenu +import org.polyfrost.oneconfig.api.ui.v1.components.hiddenUntilHover import org.polyfrost.oneconfig.api.ui.v1.keybind.OneConfigKeybind -import org.polyfrost.oneconfig.internal.ui.api.Tooltip +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme import org.polyfrost.oneconfig.internal.ui.api.settings.BooleanOptionData import org.polyfrost.oneconfig.internal.ui.api.shown -import org.polyfrost.oneconfig.internal.ui.components.Icon import org.polyfrost.oneconfig.internal.ui.components.Text -import org.polyfrost.oneconfig.internal.ui.components.onClick -import org.polyfrost.oneconfig.internal.ui.components.rememberInteractionSource +import org.polyfrost.oneconfig.internal.ui.components.asRenderText +import org.polyfrost.oneconfig.internal.ui.components.componentText +import org.polyfrost.oneconfig.internal.ui.components.legacyText +import org.polyfrost.oneconfig.internal.ui.components.blockInteraction +import org.polyfrost.oneconfig.internal.ui.components.translated import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme import org.polyfrost.oneconfig.internal.ui.themes.updateAccent val LocalOptionWidth = compositionLocalOf { 300.dp } + +val LocalOptionWidthOrNull = compositionLocalOf { null } internal val OptionControlHeight = 32.dp +internal val LocalOptionEnabled = compositionLocalOf { true } + +private val LegacyCodePattern = Regex("§[0-9a-fk-orA-FK-OR]") + +internal fun Any.asPlainLabel(): String = asRenderText().replace(LegacyCodePattern, "") + +internal fun Any.hasStyledText(): Boolean = when (val wrapped = Platform.compatibility().wrapPlatformComponent(this)) { + is String -> wrapped.indexOf('§') >= 0 + is Component -> wrapped.hasStyle() + is ComponentLike -> wrapped.asComponent().hasStyle() + else -> false +} + +private fun Component.hasStyle(): Boolean = !style().isEmpty || children().any { it.hasStyle() } + +internal fun Any.asStyledLabel(): AnnotatedString = when (val wrapped = Platform.compatibility().wrapPlatformComponent(this)) { + is String -> legacyText(wrapped) + is Component -> componentText(wrapped) + is ComponentLike -> componentText(wrapped.asComponent()) + else -> legacyText(wrapped.toString()) +} + +internal fun List.styledLabelsOrNull(): List? = + if (any { it.hasStyledText() }) map { it.asStyledLabel() } else null + +internal fun languageStamp(): String = Platform.i18n().translateString("language.code") + +internal val enabledAwareVisualizers: MutableSet = ConcurrentHashMap.newKeySet() + /** * Per-property reset counters * @@ -56,7 +98,7 @@ private val resetEpochs = mutableStateMapOf() /** True if [prop] has a captured default value that can be restored */ fun optionHasDefault(prop: Property<*>, seen: MutableSet> = identitySet()): Boolean = - seen.add(prop) && (prop.getMetadata("default") != null || + seen.add(prop) && (prop.getMetadata("default") != null || prop.getMetadata("defaultNull") == true || prop.getMetadata>>("resetWith")?.any { optionHasDefault(it, seen) } == true) internal fun identitySet(): MutableSet> = Collections.newSetFromMap(IdentityHashMap()) @@ -74,19 +116,22 @@ fun bumpResetEpoch(prop: Property<*>) { fun resetOption(prop: Property<*>, seen: MutableSet> = identitySet()) { if (!seen.add(prop)) return val def = prop.getMetadata("default") + val nullDefault = def == null && prop.getMetadata("defaultNull") == true if (def != null) { (prop as Property).setAsReferential(Config.copyDefault(prop.type, def)) if (prop.type == PolyColor::class.java) updateAccent() + } else if (nullDefault) { + (prop as Property).setAsReferential(null) } val linked = prop.getMetadata>>("resetWith") linked?.forEach { resetOption(it, seen) } - if (def != null || linked != null) bumpResetEpoch(prop) + if (def != null || nullDefault || linked != null) bumpResetEpoch(prop) } @Composable fun Option(prop: Property<*>) { val vis = remember(System.identityHashCode(prop)) { - when (val raw = prop.getMetadata("visualizer")) { + when (val raw = prop[MetaKeys.VISUALIZER]) { is Visualizer -> raw is Class<*> -> (raw.getDeclaredConstructor().newInstance() as? Visualizer) else -> null @@ -94,15 +139,28 @@ fun Option(prop: Property<*>) { } if (vis != null) { val epoch = resetEpochs[System.identityHashCode(prop)] ?: 0 + val aware = remember(vis) { vis in enabledAwareVisualizers || Visualizer[vis.javaClass]?.let { it in enabledAwareVisualizers } == true } key(System.identityHashCode(prop), epoch) { - vis.visualize(prop) + if (aware) { + vis.visualize(prop) + } else { + val enabled = LocalOptionEnabled.current + val disabledAlpha = OneConfigTheme.current.disabledAlpha + Box( + (if (enabled) Modifier else Modifier.graphicsLayer { alpha = disabledAlpha }) + .blockInteraction(!enabled, keyboard = true), + propagateMinConstraints = true, + ) { + vis.visualize(prop) + } + } } return } val value = prop.shown() Text( when (value) { - is Boolean -> if (value) "On" else "Off" + is Boolean -> if (value) translated("oneconfig.control.on", "On") else translated("oneconfig.control.off", "Off") null -> "—" else -> value.toString() }, @@ -113,6 +171,11 @@ fun Option(prop: Property<*>) { @Composable fun BooleanOption(data: BooleanOptionData) { + BooleanOption(data, true) +} + +@Composable +fun BooleanOption(data: BooleanOptionData, enabled: Boolean) { var checked by remember(data.prop) { mutableStateOf(data.boolProp.shown() == true) } // the same option can be flipped from elsewhere on screen, a mod's card for its master switch for one DisposableEffect(data.prop) { @@ -127,76 +190,116 @@ fun BooleanOption(data: BooleanOptionData) { } } when (data.style) { - BooleanOptionData.Style.Switch -> SwitchControl(checked) { checked = it; data.boolProp.set(it) } - BooleanOptionData.Style.Checkbox -> CheckboxControl(checked) { checked = it; data.boolProp.set(it) } + BooleanOptionData.Style.Switch -> SwitchControl(checked, enabled) { checked = it; data.boolProp.set(it) } + BooleanOptionData.Style.Checkbox -> CheckboxControl(checked, enabled) { checked = it; data.boolProp.set(it) } } } -@Composable -fun OptionActionButton( - visible: Boolean, - modifier: Modifier = Modifier, - onClick: () -> Unit, -) { - val theme = LocalTheme.current - val interactionSource = rememberInteractionSource() - val isHovered by interactionSource.collectIsHoveredAsState() - val color = if (isHovered) theme.textColor else theme.textColorSecondary - - if (!visible) { - Box(modifier = modifier.size(24.dp).alpha(0f)) - return +fun optionMenuEntries( + prop: Property<*>, + onReset: (() -> Unit)? = null, + resetEnabled: Boolean? = null, + onUnbind: (() -> Unit)? = null, +): List { + val isKeybind = runCatching { prop.get() is OneConfigKeybind }.getOrDefault(false) + val reset = onReset ?: { if (prop.get() is OneConfigKeybind) resetKeybindOption(prop) else resetOption(prop) } + val builtIn = OptionMenuContributors.builtInEntries(prop, onReset = reset) + val canReset = resetEnabled ?: optionHasDefault(prop) + val entries = ArrayList(builtIn.size + 4) + builtIn.forEachIndexed { index, entry -> + entries += if (index == 0 && !canReset && entry is MenuAction) entry.disabled() else entry } - - Tooltip( - text = { - Text("More actions. Right-click option as shortcut.", color = theme.textColor, fontSize = 12.sp) - }, - modifier = Modifier, - anchor = Alignment.TopCenter, - ) { - Box( - modifier = modifier - .size(24.dp) - .clip(theme.sideBarNavigationEntryShape) - .onClick(interactionSource, onClick) - .pointerHoverIcon(PointerIcon.Hand), - contentAlignment = Alignment.Center, - ) { - Icon("dots-vertical", color = color, modifier = Modifier.size(16.dp)) - } + if (isKeybind || onUnbind != null) { + entries += MenuAction( + translated("oneconfig.menu.option.unbind", "Unbind"), + onUnbind ?: { unbindKeybindOption(prop) }, + icon = "close", + enabled = prop.display != Property.Display.DISABLED, + ) } + val contributed = OptionMenuContributors.entriesFor(prop) + if (contributed.isNotEmpty()) { + entries += MenuSeparator + entries += contributed + } + return entries } -/** - * Right-click context menu for a single option anchored at [offset] in pixels relative to the anchoring - * layout - */ -@Composable -fun OptionContextMenu( - prop: Property<*>, - expanded: Boolean, - offset: IntOffset, - onDismiss: () -> Unit, - resetEnabled: Boolean = optionHasDefault(prop), - onReset: (() -> Unit)? = null, - onUnbind: (() -> Unit)? = null, -) { - if (!expanded) return - val isKeybind = remember(System.identityHashCode(prop)) { prop.get() is OneConfigKeybind } - val effectiveReset = onReset ?: if (isKeybind) ({ resetKeybindOption(prop) }) else null - val effectiveUnbind = onUnbind ?: if (isKeybind) ({ unbindKeybindOption(prop) }) else null - ContextMenu(expanded, offset, onDismiss) { - MenuItem("Reset to default", "refresh", null, MenuItemSize.Compact, false, false, resetEnabled, null, Modifier, null) { - if (effectiveReset != null) effectiveReset() else resetOption(prop) - onDismiss() - } - if (effectiveUnbind != null) { - MenuItem("Unbind", "close") { - effectiveUnbind() - onDismiss() +private fun MenuAction.disabled(): MenuAction = MenuAction( + label, + onClick, + icon = icon, + painter = painter, + detail = detail, + style = style, + enabled = false, + disabledReason = disabledReason, + checked = checked, + shortcutHint = shortcutHint, + badge = badge, + size = size, + leading = leading, + children = children, +) + +@Stable +class OptionMenuState internal constructor(private val prop: Property<*>) { + private var openCount by mutableIntStateOf(0) + + internal var onReset: (() -> Unit)? = null + internal var resetEnabled: Boolean? = null + + private val tracker = MenuCustomItem { TrackOpen() } + + internal var buttonEntries: List by mutableStateOf(deferredEntries(), neverEqualPolicy()) + private set + + val isOpen: Boolean get() = openCount > 0 + + fun entries(): List = optionMenuEntries(prop, onReset, resetEnabled) + tracker + + private fun deferredEntries(): List = DeferredMenuEntries(::entries) + + @Composable + private fun TrackOpen() { + DisposableEffect(Unit) { + openCount++ + onDispose { + openCount-- + buttonEntries = deferredEntries() } } } } +private class DeferredMenuEntries(compute: () -> List) : AbstractList() { + private val entries by lazy(LazyThreadSafetyMode.NONE, compute) + + override val size: Int get() = entries.size + + override fun get(index: Int): MenuEntry = entries[index] + + override fun equals(other: Any?): Boolean = other === this + + override fun hashCode(): Int = System.identityHashCode(this) +} + +@Composable +fun rememberOptionMenu(prop: Property<*>, onReset: (() -> Unit)? = null, resetEnabled: Boolean? = null): OptionMenuState { + val state = remember(System.identityHashCode(prop)) { OptionMenuState(prop) } + state.onReset = onReset + state.resetEnabled = resetEnabled + return state +} + +fun Modifier.optionContextMenu(menu: OptionMenuState, enabled: Boolean = true): Modifier = + contextMenu(enabled) { menu.entries() } + +@Composable +fun OptionMoreButton(menu: OptionMenuState, visible: Boolean, modifier: Modifier = Modifier) { + MoreButton( + menu.buttonEntries, + modifier.hiddenUntilHover(visible || menu.isOpen), + contentDescription = translated("oneconfig.option.more_actions", "More actions. Right-click option as shortcut."), + ) +} diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/RadioButtonOption.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/RadioButtonOption.kt index 8096509be..366d2347f 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/RadioButtonOption.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/RadioButtonOption.kt @@ -1,16 +1,16 @@ package org.polyfrost.oneconfig.internal.ui.components.settings -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.spring import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.PointerIcon @@ -23,13 +23,18 @@ import androidx.compose.ui.unit.coerceAtLeast import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.polyfrost.oneconfig.api.config.v1.Property +import org.polyfrost.oneconfig.api.ui.v1.a11y.A11yRole +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.api.ui.v1.components.RadioGroup +import org.polyfrost.oneconfig.api.ui.v1.components.oneConfigClickable +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme import org.polyfrost.oneconfig.internal.ui.api.settings.RadioButtonOptionData import org.polyfrost.oneconfig.internal.ui.api.shown import org.polyfrost.oneconfig.internal.ui.components.Icon -import org.polyfrost.oneconfig.internal.ui.components.ReducedMotion import org.polyfrost.oneconfig.internal.ui.components.Text +import org.polyfrost.oneconfig.internal.ui.components.asRenderText +import org.polyfrost.oneconfig.internal.ui.components.blockInteraction import org.polyfrost.oneconfig.internal.ui.components.liquidGlass -import org.polyfrost.oneconfig.internal.ui.components.onClick import org.polyfrost.oneconfig.internal.ui.components.rememberInteractionSource import org.polyfrost.oneconfig.internal.ui.themes.Accent import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme @@ -42,6 +47,11 @@ private val RadioItemMinHeight = OptionControlHeight - RadioPadding * 2 @Composable fun RadioButtonOption(data: RadioButtonOptionData) { + RadioButtonOption(data, true) +} + +@Composable +fun RadioButtonOption(data: RadioButtonOptionData, enabled: Boolean) { val enumClass = when { data.prop.type.isEnum -> data.prop.type data.prop.type.superclass?.isEnum == true -> data.prop.type.superclass @@ -51,12 +61,15 @@ fun RadioButtonOption(data: RadioButtonOptionData) { enumClass != null -> enumClass.enumConstants.toList() else -> emptyList() } - val options: List = when { - enumClass != null -> values.mapIndexed { index, value -> - data.options?.getOrNull(index) ?: (value as Enum<*>).name + val languageStamp = languageStamp() + val options: List = remember(data.prop, languageStamp) { + when { + enumClass != null -> values.mapIndexed { index, value -> + data.options?.getOrNull(index) ?: (value as Enum<*>).name + } + data.options != null -> data.options!! + else -> emptyList() } - data.options != null -> data.options!! - else -> emptyList() } val initialValue = data.prop.shown() @@ -64,7 +77,7 @@ fun RadioButtonOption(data: RadioButtonOptionData) { mutableStateOf(if (initialValue is Enum<*>) initialValue.ordinal else initialValue as? Int ?: 0) } - Radio(options, selectedIdx, Modifier.widthIn(max = LocalOptionWidth.current)) { index -> + val select: (Int) -> Unit = { index -> selectedIdx = index @Suppress("UNCHECKED_CAST") if (enumClass != null) { @@ -73,6 +86,18 @@ fun RadioButtonOption(data: RadioButtonOptionData) { (data.prop as Property).set(index) } } + val labels = remember(options, languageStamp) { options.map { it.asPlainLabel() } } + val styled = remember(options, languageStamp) { options.styledLabelsOrNull() } + RadioGroup( + selected = selectedIdx, + options = labels.indices.toList(), + onSelect = select, + modifier = Modifier.widthIn(max = LocalOptionWidth.current).liquidGlass(RadioShape), + label = { labels[it] }, + orientation = Orientation.Horizontal, + enabled = enabled, + styledLabel = styled?.let { list -> { list[it] } }, + ) } /** @@ -112,18 +137,9 @@ fun Radio( modifier = fill.padding(RadioPadding) ) { if (indicatorReady) { - val indicatorOffset = animateDpAsState( - targetValue = with(density) { itemWidths.take(selected).sumOf { it.intValue }.toDp() }, - animationSpec = ReducedMotion.spec(spring()), - ) - val indicatorWidth = animateDpAsState( - targetValue = with(density) { itemWidths[selected].intValue.toDp() }, - animationSpec = ReducedMotion.spec(spring()), - ) - val indicatorHeight = animateDpAsState( - targetValue = with(density) { rowHeight.toDp() }.coerceAtLeast(RadioItemMinHeight), - animationSpec = ReducedMotion.spec(spring()), - ) + val indicatorOffset = Motion.animateDpAsState(with(density) { itemWidths.take(selected).sumOf { it.intValue }.toDp() }, spring()) + val indicatorWidth = Motion.animateDpAsState(with(density) { itemWidths[selected].intValue.toDp() }, spring()) + val indicatorHeight = Motion.animateDpAsState(with(density) { rowHeight.toDp() }.coerceAtLeast(RadioItemMinHeight), spring()) Box( modifier = Modifier .layout { measurable, _ -> @@ -145,14 +161,19 @@ fun Radio( val interactionSource = rememberInteractionSource() val isHovered by interactionSource.collectIsHoveredAsState() val isSelected = index == selected - val color = animateColorAsState( - if (isSelected || isHovered) theme.textColor else theme.textColorSecondary - ) + val color = Motion.animateColorAsState(if (isSelected || isHovered) theme.textColor else theme.textColorSecondary) Box( modifier = Modifier .weight(1f, fill = fillWidth) .onSizeChanged { itemWidths[index].intValue = it.width } - .onClick(interactionSource) { onSelect(index) } + .oneConfigClickable( + option?.asRenderText() ?: icons?.getOrNull(index), + { onSelect(index) }, + role = A11yRole.RadioButton, + selected = isSelected, + indication = false, + interactionSource = interactionSource, + ) .hoverable(interactionSource) .pointerHoverIcon(PointerIcon.Hand) .heightIn(min = RadioItemMinHeight) diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/RangeOption.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/RangeOption.kt index 50a984ba1..d59c57cb7 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/RangeOption.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/RangeOption.kt @@ -1,6 +1,5 @@ package org.polyfrost.oneconfig.internal.ui.components.settings -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.foundation.Canvas import androidx.compose.foundation.background @@ -40,7 +39,7 @@ import androidx.compose.ui.unit.sp import kotlin.math.abs import kotlin.math.roundToInt import org.polyfrost.oneconfig.internal.ui.api.settings.RangeOptionData -import org.polyfrost.oneconfig.internal.ui.components.ReducedMotion +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion import org.polyfrost.oneconfig.internal.ui.components.Text import org.polyfrost.oneconfig.internal.ui.components.accentSheen import org.polyfrost.oneconfig.internal.ui.sound.UiSoundEvent @@ -180,8 +179,8 @@ internal fun RangeSliderTrack( val thumbPx = with(LocalDensity.current) { thumbWidth.toPx() } val span = (max - min).takeIf { it != 0f } ?: 1f - val startFraction = animateFloatAsState(((minOf(start, end) - min) / span).coerceIn(0f, 1f), animationSpec = ReducedMotion.spec(spring())) - val endFraction = animateFloatAsState(((maxOf(start, end) - min) / span).coerceIn(0f, 1f), animationSpec = ReducedMotion.spec(spring())) + val startFraction = Motion.animateFloatAsState(((minOf(start, end) - min) / span).coerceIn(0f, 1f), spring()) + val endFraction = Motion.animateFloatAsState(((maxOf(start, end) - min) / span).coerceIn(0f, 1f), spring()) Box( modifier = modifier diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/SliderControl.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/SliderControl.kt index d553931b6..02fa9569a 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/SliderControl.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/SliderControl.kt @@ -1,6 +1,5 @@ package org.polyfrost.oneconfig.internal.ui.components.settings -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.foundation.background import androidx.compose.foundation.gestures.awaitEachGesture @@ -19,6 +18,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.onSizeChanged @@ -26,7 +26,9 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import kotlin.math.roundToInt -import org.polyfrost.oneconfig.internal.ui.components.ReducedMotion +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.api.ui.v1.components.Slider +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme import org.polyfrost.oneconfig.internal.ui.components.accentSheen import org.polyfrost.oneconfig.internal.ui.sound.UiSoundEvent import org.polyfrost.oneconfig.internal.ui.sound.UiSounds @@ -49,19 +51,38 @@ fun SliderControl( thumbSize: Dp = 19.dp, trackHeight: Dp = 5.dp, ) { + SliderControl(value, onValueChange, min, max, step, true, modifier, thumbSize, trackHeight) +} + +@Composable +fun SliderControl( + value: Float, + onValueChange: (Float) -> Unit, + min: Float, + max: Float, + step: Float, + enabled: Boolean, + modifier: Modifier = Modifier, + thumbSize: Dp = 19.dp, + trackHeight: Dp = 5.dp, +) { + if (thumbSize == 19.dp && trackHeight == 5.dp) { + Slider(value, onValueChange, modifier, min..max, step, enabled) + return + } val theme = LocalTheme.current + val disabledAlpha = OneConfigTheme.current.disabledAlpha var trackWidthPx by remember { mutableStateOf(0f) } val currentOnValueChange by rememberUpdatedState(onValueChange) - val fraction = animateFloatAsState( - ((value - min) / (max - min)).coerceIn(0f, 1f), - animationSpec = ReducedMotion.spec(spring()), - ) + val fraction = Motion.animateFloatAsState(((value - min) / (max - min)).coerceIn(0f, 1f), spring()) Box( modifier = modifier + .graphicsLayer { alpha = if (enabled) 1f else disabledAlpha } .height(thumbSize) .onSizeChanged { trackWidthPx = it.width.toFloat() } - .pointerInput(min, max, step) { + .pointerInput(enabled, min, max, step) { + if (!enabled) return@pointerInput val thumbPx = thumbSize.toPx() var lastTickValue = Float.NaN var lastTickAt = 0L diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/SliderOption.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/SliderOption.kt index 5e15f4516..ca8136b2c 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/SliderOption.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/SliderOption.kt @@ -16,6 +16,11 @@ import org.polyfrost.oneconfig.internal.ui.api.shown @Composable fun SliderOption(data: SliderOptionData) { + SliderOption(data, true) +} + +@Composable +fun SliderOption(data: SliderOptionData, enabled: Boolean) { var value by remember(data.prop) { mutableStateOf(data.numProp.shown()?.toFloat() ?: data.min) } fun update(newValue: Float) { @@ -35,6 +40,7 @@ fun SliderOption(data: SliderOptionData) { min = data.min, max = data.max, step = data.step, + enabled = enabled, modifier = Modifier.width(LocalOptionWidth.current), ) NumberSpinner( @@ -44,6 +50,7 @@ fun SliderOption(data: SliderOptionData) { max = data.max, step = if (data.step > 0f) data.step else 1f, format = data.format, + enabled = enabled, ) } } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/SwitchControl.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/SwitchControl.kt index b02a6cde7..03fbfbd4d 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/SwitchControl.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/SwitchControl.kt @@ -1,76 +1,14 @@ package org.polyfrost.oneconfig.internal.ui.components.settings -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.spring -import androidx.compose.foundation.background -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.PointerIcon -import androidx.compose.ui.input.pointer.pointerHoverIcon -import androidx.compose.ui.layout.layout -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import org.polyfrost.oneconfig.internal.ui.components.ReducedMotion -import org.polyfrost.oneconfig.internal.ui.components.Text -import org.polyfrost.oneconfig.internal.ui.components.animatedFrame -import org.polyfrost.oneconfig.internal.ui.components.onClick -import org.polyfrost.oneconfig.internal.ui.components.rememberInteractionSource -import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme -import kotlin.math.roundToInt +import org.polyfrost.oneconfig.api.ui.v1.components.Switch @Composable fun SwitchControl(checked: Boolean, onCheckedChange: (Boolean) -> Unit) { - val interactionSource = rememberInteractionSource() - val isHovered by interactionSource.collectIsHoveredAsState() - - val accentAlpha = animateFloatAsState(if (checked) 1f else 0f) - val thumbProgress = animateFloatAsState(if (checked) 1f else 0f, animationSpec = ReducedMotion.spec(spring())) - val borderColor = animateColorAsState( - if (isHovered) LocalTheme.current.textColor.copy(alpha = 0.15f) else Color.Transparent - ) + SwitchControl(checked, true, onCheckedChange) +} - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text(if (checked) "On" else "Off", color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) - Box( - modifier = Modifier - .size(42.dp, 21.dp) - .graphicsLayer {} - .clip(LocalTheme.current.circleShape) - .background(LocalTheme.current.controlTrackColor) - .animatedFrame(LocalTheme.current.circleShape, accentAlpha = { accentAlpha.value }, border = { borderColor.value }) - .onClick(interactionSource) { onCheckedChange(!checked) } - .pointerHoverIcon(PointerIcon.Hand) - .padding(3.dp), - ) { - Box( - modifier = Modifier - .layout { measurable, constraints -> - val thumb = measurable.measure(constraints) - layout(constraints.maxWidth, thumb.height) { - thumb.place(((constraints.maxWidth - thumb.width) * thumbProgress.value).roundToInt(), 0) - } - } - .fillMaxHeight() - .aspectRatio(1f) - .background(LocalTheme.current.controlThumbColor, LocalTheme.current.circleShape), - ) - } - } +@Composable +fun SwitchControl(checked: Boolean, enabled: Boolean, onCheckedChange: (Boolean) -> Unit) { + Switch(checked, onCheckedChange, enabled = enabled) } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/TextListOption.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/TextListOption.kt index 2a5d34ebd..2011b7ac9 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/TextListOption.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/TextListOption.kt @@ -1,17 +1,12 @@ package org.polyfrost.oneconfig.internal.ui.components.settings -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.text.BasicTextField import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.sp -import org.polyfrost.oneconfig.api.ui.v1.keybind.trackTextInputFocus +import org.polyfrost.oneconfig.api.ui.v1.components.TextField import org.polyfrost.oneconfig.internal.ui.api.settings.TextListOptionData -import org.polyfrost.oneconfig.internal.ui.components.Text import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme @Composable @@ -47,28 +42,13 @@ internal fun RowScope.EntryTextField( ctx: ListRowContext, onValueChange: (String) -> Unit, ) { - val theme = LocalTheme.current - // TextFieldState needs its own migration, as it changes how every field hoists its state - @Suppress("DEPRECATION") - BasicTextField( - value = value, - onValueChange = onValueChange, - singleLine = true, - textStyle = TextStyle( - color = theme.textColor, - fontSize = 13.sp, - fontFamily = theme.typography.family, - ), + TextField( + value, + onValueChange, + Modifier.weight(1f), + placeholder = placeholder, + framed = false, + fontSize = 13.sp, interactionSource = ctx.fieldInteraction, - cursorBrush = SolidColor(theme.textColor), - modifier = Modifier.trackTextInputFocus().weight(1f), - decorationBox = { innerTextField -> - Box { - if (value.isEmpty() && placeholder != null && !ctx.focused) { - Text(placeholder, color = theme.textColorSecondary, fontSize = 13.sp) - } - innerTextField() - } - }, ) } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/TextOption.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/TextOption.kt index 999d80d40..948ca8670 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/TextOption.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/TextOption.kt @@ -16,6 +16,11 @@ import org.polyfrost.oneconfig.internal.ui.api.shown @Composable fun TextOption(data: TextOptionData) { + TextOption(data, true) +} + +@Composable +fun TextOption(data: TextOptionData, enabled: Boolean) { var text by remember(data.prop) { mutableStateOf(data.strProp.shown() ?: "") } val regex = remember(data.regex) { data.regex?.takeIf { it.isNotBlank() }?.let { runCatching { Regex(it) }.getOrNull() } } val valid = regex == null || text.isEmpty() || regex.matches(text) @@ -24,19 +29,11 @@ fun TextOption(data: TextOptionData) { text, { text = it; if (regex == null || it.isEmpty() || regex.matches(it)) data.strProp.set(it) }, Modifier.width(LocalOptionWidth.current).heightIn(min = if (data.multiline) 96.dp else OptionControlHeight), - data.placeholder, - "text-input", - Int.MAX_VALUE, - !valid, - true, - !data.multiline, - true, - 14.sp, - null, - false, - null, - null, - null, - null, + placeholder = data.placeholder, + enabled = enabled, + invalid = !valid, + singleLine = !data.multiline, + leadingIcon = "text-input", + fontSize = 14.sp, ) } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/TileListOption.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/TileListOption.kt index 77dc1d2ce..71849c657 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/TileListOption.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/TileListOption.kt @@ -1,24 +1,37 @@ package org.polyfrost.oneconfig.internal.ui.components.settings -import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties +import org.polyfrost.oneconfig.api.ui.v1.components.EscapeCloses +import org.polyfrost.oneconfig.api.ui.v1.components.Placement +import org.polyfrost.oneconfig.api.ui.v1.components.SurfaceKind +import org.polyfrost.oneconfig.api.ui.v1.components.rememberAnchoredPosition +import org.polyfrost.oneconfig.api.ui.v1.components.themedSurface +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme import org.polyfrost.oneconfig.internal.ui.api.settings.TileListOptionData import org.polyfrost.oneconfig.internal.ui.components.Icon -import org.polyfrost.oneconfig.internal.ui.components.dropdown.SimpleDropdownMenu -import org.polyfrost.oneconfig.internal.ui.components.liquidGlass +import org.polyfrost.oneconfig.internal.ui.components.ShellPopup +import org.polyfrost.oneconfig.internal.ui.components.blockInteraction +import org.polyfrost.oneconfig.internal.ui.components.shellTransition import org.polyfrost.oneconfig.internal.ui.components.picker.PickerEntry import org.polyfrost.oneconfig.internal.ui.components.picker.PickerLabel import org.polyfrost.oneconfig.internal.ui.components.picker.PickerStrings @@ -30,6 +43,8 @@ import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme private val TileRowHeight = 44.dp private val TileRowIconSize = 30.dp private val SelectorWidth = 436.dp +private val SelectorGap = 8.dp +private val SelectorOutset = 16.dp private val SelectorShape @Composable get() = LocalTheme.current.popupShape @Composable @@ -41,25 +56,44 @@ fun TileListOption( searchPlaceholderKey: String = PickerStrings.Search, emptyKey: String = PickerStrings.Empty, unavailableKey: String = PickerStrings.Unavailable, - rowIcon: @Composable (id: String, entry: T?, modifier: Modifier) -> Unit = { _, entry, modifier -> - if (entry != null) icon(entry, modifier, 1f) - else Icon("eye-off", modifier = modifier.padding(5.dp), color = LocalTheme.current.textColorSecondary) - }, + rowIcon: @Composable (id: String, entry: T?, modifier: Modifier) -> Unit = { id, entry, modifier -> DefaultTileRowIcon(icon, id, entry, modifier) }, +) { + TileListOption(data, entries, icon, LocalOptionEnabled.current, titleKey, searchPlaceholderKey, emptyKey, unavailableKey, rowIcon) +} + +@Composable +fun TileListOption( + data: TileListOptionData, + entries: List, + icon: PickerTileIcon, + enabled: Boolean, + titleKey: String? = PickerStrings.Title, + searchPlaceholderKey: String = PickerStrings.Search, + emptyKey: String = PickerStrings.Empty, + unavailableKey: String = PickerStrings.Unavailable, + rowIcon: @Composable (id: String, entry: T?, modifier: Modifier) -> Unit = { id, entry, modifier -> DefaultTileRowIcon(icon, id, entry, modifier) }, ) { - val theme = LocalTheme.current val maxEntries = data.maxEntries val byId = remember(entries) { entries.distinctBy(PickerEntry::id).associateBy(PickerEntry::id) } val state = rememberListEntries(data.prop, { data.ids() }) { data.setIds(it) } var expanded by remember(data.prop) { mutableStateOf(false) } + val disabledAlpha = OneConfigTheme.current.disabledAlpha + + LaunchedEffect(enabled) { if (!enabled) expanded = false } - Box { + Box( + Modifier + .graphicsLayer { alpha = if (enabled) 1f else disabledAlpha } + .blockInteraction(!enabled, keyboard = true), + ) { ListOptionContainer( state = state, reorderable = data.reorderable, maxEntries = maxEntries, addText = data.addText, - onAdd = { expanded = true }, + onAdd = { if (enabled) expanded = true }, showAddWhenFull = true, + rowInsertActions = false, entryHeight = TileRowHeight, containScroll = true, ) { entry, ctx -> @@ -74,34 +108,57 @@ fun TileListOption( ) } - SimpleDropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - offset = DpOffset((-16).dp, 8.dp), - modifier = Modifier.width(SelectorWidth), - pinAnchor = true, - ) { - TilePicker( - entries = entries, - selected = state.values, - onToggle = { id -> - val next = toggleEntry(state.values, id, maxEntries) - if (next != state.values) state.replace(next) - if (maxEntries == 1) expanded = false - }, - icon = icon, - modifier = Modifier - .clip(SelectorShape) - .liquidGlass(SelectorShape, frosted = true) - .background(theme.popupBackground, SelectorShape) - .border(1.dp, theme.borderColor, SelectorShape) - .padding(vertical = 12.dp), - maxEntries = maxEntries, - onClose = { expanded = false }, - titleKey = titleKey, - searchPlaceholderKey = searchPlaceholderKey, - emptyKey = emptyKey, - ) + if (expanded && enabled) { + EscapeCloses { expanded = false } + val anchored = rememberAnchoredPosition(Placement.BelowStart, SelectorGap) + val outset = with(LocalDensity.current) { SelectorOutset.roundToPx() } + val pinned = remember { mutableStateOf(null) } + val position = remember(anchored, outset) { PinnedAnchorPosition(anchored, pinned, outset) } + ShellPopup(position, { expanded = false }, PopupProperties(focusable = true)) { + TilePicker( + entries = entries, + selected = state.values, + onToggle = { id -> + val next = toggleEntry(state.values, id, maxEntries) + if (next != state.values) state.replace(next) + if (maxEntries == 1) expanded = false + }, + icon = icon, + modifier = Modifier + .width(SelectorWidth) + .shellTransition() + .themedSurface(SurfaceKind.Popup, SelectorShape) + .padding(vertical = 12.dp), + maxEntries = maxEntries, + onClose = { expanded = false }, + titleKey = titleKey, + searchPlaceholderKey = searchPlaceholderKey, + emptyKey = emptyKey, + ) + } } } } + +@Composable +private fun DefaultTileRowIcon(icon: PickerTileIcon, id: String, entry: T?, modifier: Modifier) { + if (entry != null) icon(entry, modifier, 1f) + else Icon("eye-off", modifier = modifier.padding(5.dp), color = LocalTheme.current.textColorSecondary) +} + +private class PinnedAnchorPosition( + private val delegate: PopupPositionProvider, + private val pinned: MutableState, + private val outset: Int, +) : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize, + ): IntOffset { + val anchor = pinned.value ?: anchorBounds.also { pinned.value = it } + val widened = IntRect(anchor.left - outset, anchor.top, anchor.right + outset, anchor.bottom) + return delegate.calculatePosition(widened, windowSize, layoutDirection, popupContentSize) + } +} diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/HudSettingsReset.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/HudSettingsReset.kt index fa1c2ebf7..c0d76cd5c 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/HudSettingsReset.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/HudSettingsReset.kt @@ -1,85 +1,64 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + package org.polyfrost.oneconfig.internal.ui.hud -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.border -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.PointerIcon -import androidx.compose.ui.input.pointer.isSecondaryPressed -import androidx.compose.ui.input.pointer.pointerHoverIcon -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import org.polyfrost.oneconfig.internal.ui.components.ShellPopup import androidx.compose.ui.window.PopupProperties import kotlin.math.abs -import kotlin.math.roundToInt import org.polyfrost.oneconfig.api.config.v1.Property import org.polyfrost.oneconfig.api.config.v1.Tree import org.polyfrost.oneconfig.api.hud.v1.Hud +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.components.Icon +import org.polyfrost.oneconfig.api.ui.v1.components.MenuAction +import org.polyfrost.oneconfig.api.ui.v1.components.MenuCustomItem +import org.polyfrost.oneconfig.api.ui.v1.components.MenuEntry +import org.polyfrost.oneconfig.api.ui.v1.components.MenuIconRow +import org.polyfrost.oneconfig.api.ui.v1.components.MenuItem +import org.polyfrost.oneconfig.api.ui.v1.components.MenuList +import org.polyfrost.oneconfig.api.ui.v1.components.MenuSeparator import org.polyfrost.oneconfig.api.ui.v1.keybind.OneConfigKeybind import org.polyfrost.oneconfig.internal.OneConfigConfig import org.polyfrost.oneconfig.internal.ui.api.Tooltip -import org.polyfrost.oneconfig.internal.ui.components.Icon +import org.polyfrost.oneconfig.internal.ui.components.ShellPopup import org.polyfrost.oneconfig.internal.ui.components.Text import org.polyfrost.oneconfig.internal.ui.components.blockInteraction import org.polyfrost.oneconfig.internal.ui.components.isEmptyText -import org.polyfrost.oneconfig.internal.ui.components.liquidGlass import org.polyfrost.oneconfig.internal.ui.components.localizedDescription -import org.polyfrost.oneconfig.internal.ui.components.onClick -import org.polyfrost.oneconfig.internal.ui.components.rememberInteractionSource -import org.polyfrost.oneconfig.internal.ui.components.shellTransition -import org.polyfrost.oneconfig.internal.ui.components.settings.OptionContextMenu +import org.polyfrost.oneconfig.internal.ui.components.translated import org.polyfrost.oneconfig.internal.ui.components.settings.identitySet +import org.polyfrost.oneconfig.internal.ui.components.settings.optionContextMenu import org.polyfrost.oneconfig.internal.ui.components.settings.optionHasDefault +import org.polyfrost.oneconfig.internal.ui.components.settings.rememberOptionMenu import org.polyfrost.oneconfig.internal.ui.components.settings.resetOption import org.polyfrost.oneconfig.internal.ui.hud.screens.HudAlign import org.polyfrost.oneconfig.internal.ui.hud.screens.alignHuds import org.polyfrost.oneconfig.internal.ui.hud.screens.tourString -import org.polyfrost.oneconfig.internal.ui.keybind.KeybindRecordingBus import org.polyfrost.oneconfig.internal.ui.themes.Accent import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme -import org.polyfrost.oneconfig.internal.ui.themes.concentric -private val MenuPadding = 4.dp +private val MenuIconSize = 14.dp private val hudUiEpochs = mutableStateMapOf() private val STATIC_SIZE_OPTIONS = setOf("staticW", "staticH") private val POSITION_OPTIONS = setOf( "section", "relativeX", "relativeY", - "anchorTargetId", "anchorPoint", "selfAnchorPoint", "anchorOffsetX", "anchorOffsetY", + "anchorTargetId", "anchorPoint", "selfAnchorPoint", "anchorOffsetX", "anchorOffsetY", "movedByPlayer", ) /** Bumped when any HUD field is reset so bound [remember] state is recreated from the HUD */ @@ -248,24 +227,18 @@ fun HudSettingTarget( return } - var menuOpen by remember(System.identityHashCode(prop)) { mutableStateOf(false) } - var menuOffset by remember(System.identityHashCode(prop)) { mutableStateOf(IntOffset.Zero) } - - Box( - modifier = modifier.pointerInput(System.identityHashCode(prop)) { - awaitPointerEventScope { - while (true) { - val event = awaitPointerEvent(PointerEventPass.Initial) - if (event.type == PointerEventType.Press && event.buttons.isSecondaryPressed && !KeybindRecordingBus.isRecording) { - val pos = event.changes.first().position - menuOffset = IntOffset(pos.x.roundToInt(), pos.y.roundToInt()) - menuOpen = true - event.changes.forEach { it.consume() } - } - } - } + val menu = rememberOptionMenu( + prop, + onReset = { + performHudOptionReset(hud, prop, optionId) + for ((id, p) in linked) performHudOptionReset(hud, p, id) + finishHudReset(hud) }, - ) { + resetEnabled = canResetHudOption(hud, prop, optionId) || + linked.any { (id, p) -> canResetHudOption(hud, p, id) }, + ) + + Box(modifier = modifier.optionContextMenu(menu)) { val description = prop.localizedDescription()?.takeUnless { it.isEmptyText() } if (description != null) { Tooltip( @@ -280,19 +253,6 @@ fun HudSettingTarget( } else { content() } - OptionContextMenu( - prop = prop, - expanded = menuOpen, - offset = menuOffset, - onDismiss = { menuOpen = false }, - resetEnabled = canResetHudOption(hud, prop, optionId) || - linked.any { (id, p) -> canResetHudOption(hud, p, id) }, - onReset = { - performHudOptionReset(hud, prop, optionId) - for ((id, p) in linked) performHudOptionReset(hud, p, id) - finishHudReset(hud) - }, - ) } } @@ -311,8 +271,6 @@ fun HudSettingsContent(hud: Hud, content: @Composable () -> Unit) { } } -private val DeleteMenuColor = Color(0xFFE5484D) - /** * Right-click menu on a HUD element in the design studio canvas * @@ -326,361 +284,270 @@ fun HudCanvasResetMenu( offset: IntOffset, onDismiss: () -> Unit, targets: List = emptyList(), - onSettings: (Hud) -> Unit = {}, - settingsEnabled: Boolean = true, - onCopy: (Hud) -> Unit = {}, - onCut: (Hud) -> Unit = {}, - onPaste: (Hud) -> Unit = {}, - pasteEnabled: Boolean = false, - onDuplicate: (Hud) -> Unit = {}, - duplicateEnabled: Boolean = true, - onAnchor: (Hud) -> Unit = {}, - anchorEnabled: Boolean = true, - onDelete: (Hud) -> Unit = {}, - onUndo: () -> Unit = {}, - undoEnabled: Boolean = false, - onRedo: () -> Unit = {}, - redoEnabled: Boolean = false, preview: Boolean = false, onMeasured: (IntSize) -> Unit = {}, ) { if (hud == null || !expanded) return - val theme = LocalTheme.current - val actionTargets = targets.ifEmpty { listOf(hud) } - val anyAnchored = actionTargets.any { it.isAnchored } - val resetEnabled = actionTargets.any { hudHasResettableDefaults(it) } - val deletable = actionTargets.any { it.canDelete() } - val allHidden = actionTargets.all { it.hidden } - val allLocked = actionTargets.all { it.locked } - val showHints = OneConfigConfig.showKeybindHints - val settingsHint = if (showHints) keybindHint(OneConfigConfig.hudSettingsKeybind) else null - val visibilityHint = if (showHints) keybindHint(OneConfigConfig.hudVisibilityKeybind) else null - val lockHint = if (showHints) keybindHint(OneConfigConfig.hudLockKeybind) else null - val duplicateHint = if (showHints) keybindHint(OneConfigConfig.hudDuplicateKeybind) else null - val resetHint = if (showHints) keybindHint(OneConfigConfig.hudResetKeybind) else null - val copyHint = if (showHints) keybindHint(OneConfigConfig.hudCopyKeybind) else null - val cutHint = if (showHints) keybindHint(OneConfigConfig.hudCutKeybind) else null - val pasteHint = if (showHints) keybindHint(OneConfigConfig.hudPasteKeybind) else null - val deleteHint = if (showHints) keybindHint(OneConfigConfig.hudDeleteKeybind) else null - val undoHint = if (showHints) keybindHint(OneConfigConfig.hudUndoKeybind) else null - val redoHint = if (showHints) keybindHint(OneConfigConfig.hudRedoKeybind) else null + val entries = buildHudCanvasMenuEntries( + hud = hud, + targets = targets, + onSettings = {}, + settingsEnabled = true, + onCopy = {}, + onCut = {}, + onPaste = {}, + pasteEnabled = false, + onDuplicate = {}, + duplicateEnabled = true, + onAnchor = {}, + anchorEnabled = true, + onDelete = {}, + onUndo = {}, + undoEnabled = false, + onRedo = {}, + redoEnabled = false, + highlightAnchor = preview, + ) ShellPopup( alignment = Alignment.TopStart, offset = offset, onDismissRequest = onDismiss, properties = PopupProperties(focusable = !preview, dismissOnClickOutside = !preview), ) { - Column( - // without this the separator's fillMaxWidth stretches the menu out to the whole screen - modifier = Modifier - .onSizeChanged(onMeasured) - .blockInteraction(preview) - .widthIn(min = 190.dp, max = 240.dp) - .shellTransition() - .liquidGlass(theme.popupShape, frosted = true) - .background(theme.popupBackground, theme.popupShape) - .border(1.dp, theme.borderColor, theme.popupShape) - .padding(MenuPadding), - ) { - HudMenuRow( - icon = "settings", - text = "Settings", - enabled = settingsEnabled, - hint = settingsHint, - ) { - onSettings(hud) - onDismiss() + MenuList(entries, onDismiss, Modifier.onSizeChanged(onMeasured).blockInteraction(preview, keyboard = true)) + } +} + +internal fun hudCanvasMenuEntries( + hud: Hud, + targets: List, + onSettings: (Hud) -> Unit, + settingsEnabled: Boolean, + onCopy: (Hud) -> Unit, + onCut: (Hud) -> Unit, + onPaste: (Hud) -> Unit, + pasteEnabled: Boolean, + onDuplicate: (Hud) -> Unit, + duplicateEnabled: Boolean, + onAnchor: (Hud) -> Unit, + anchorEnabled: Boolean, + onDelete: (Hud) -> Unit, + onUndo: () -> Unit, + undoEnabled: Boolean, + onRedo: () -> Unit, + redoEnabled: Boolean, +): List = buildHudCanvasMenuEntries( + hud = hud, + targets = targets, + onSettings = onSettings, + settingsEnabled = settingsEnabled, + onCopy = onCopy, + onCut = onCut, + onPaste = onPaste, + pasteEnabled = pasteEnabled, + onDuplicate = onDuplicate, + duplicateEnabled = duplicateEnabled, + onAnchor = onAnchor, + anchorEnabled = anchorEnabled, + onDelete = onDelete, + onUndo = onUndo, + undoEnabled = undoEnabled, + onRedo = onRedo, + redoEnabled = redoEnabled, + highlightAnchor = false, +) + +private fun buildHudCanvasMenuEntries( + hud: Hud, + targets: List, + onSettings: (Hud) -> Unit, + settingsEnabled: Boolean, + onCopy: (Hud) -> Unit, + onCut: (Hud) -> Unit, + onPaste: (Hud) -> Unit, + pasteEnabled: Boolean, + onDuplicate: (Hud) -> Unit, + duplicateEnabled: Boolean, + onAnchor: (Hud) -> Unit, + anchorEnabled: Boolean, + onDelete: (Hud) -> Unit, + onUndo: () -> Unit, + undoEnabled: Boolean, + onRedo: () -> Unit, + redoEnabled: Boolean, + highlightAnchor: Boolean, +): List { + val actionTargets = targets.ifEmpty { listOf(hud) } + val allHidden = actionTargets.all { it.hidden } + val allLocked = actionTargets.all { it.locked } + val entries = ArrayList(24) + + entries += MenuAction( + translated("oneconfig.hud.menu.settings", "Settings"), + { onSettings(hud) }, + icon = "settings", + enabled = settingsEnabled, + shortcutHint = keybindHint(OneConfigConfig.hudSettingsKeybind), + ) + entries += MenuAction( + if (allHidden) translated("oneconfig.hud.menu.show", "Show") else translated("oneconfig.hud.menu.hide", "Hide"), + { + Snapshot.withMutableSnapshot { + val hide = actionTargets.any { !it.hidden } + actionTargets.forEach { it.hidden = hide } } - HudMenuRow( - icon = if (allHidden) "eye-off" else "eye", - text = if (allHidden) "Show" else "Hide", - hint = visibilityHint, - ) { - Snapshot.withMutableSnapshot { - val hide = actionTargets.any { !it.hidden } - actionTargets.forEach { it.hidden = hide } - } - onDismiss() + }, + icon = if (allHidden) "eye-off" else "eye", + shortcutHint = keybindHint(OneConfigConfig.hudVisibilityKeybind), + ) + val lockIcon = if (allLocked) "lock" else "unlock" + entries += MenuAction( + if (allLocked) translated("oneconfig.hud.menu.unlock", "Unlock") else translated("oneconfig.hud.menu.lock", "Lock"), + { + Snapshot.withMutableSnapshot { + val lock = actionTargets.any { !it.locked } + actionTargets.forEach { it.locked = lock } } - HudMenuRow( - icon = if (allLocked) "lock" else "unlock", - text = if (allLocked) "Unlock" else "Lock", - color = if (allLocked) Accent else null, - hint = lockHint, - ) { + }, + icon = lockIcon, + shortcutHint = keybindHint(OneConfigConfig.hudLockKeybind), + leading = if (allLocked) accentMenuIcon(lockIcon) else null, + ) + entries += MenuIconRow( + HudAlign.entries.map { align -> + MenuAction(alignLabel(align), { alignHuds(actionTargets, align) }, icon = align.icon) + }, + ) + val anchorLabel = translated("oneconfig.hud.menu.anchor", "Anchor") + entries += if (highlightAnchor) { + MenuCustomItem { MenuItem(anchorLabel, {}, icon = "align", enabled = anchorEnabled, selected = true) } + } else { + MenuAction(anchorLabel, { onAnchor(hud) }, icon = "align", enabled = anchorEnabled) + } + if (actionTargets.any { it.isAnchored }) { + entries += MenuAction( + translated("oneconfig.hud.menu.remove_anchor", "Remove anchor"), + { Snapshot.withMutableSnapshot { - val lock = actionTargets.any { !it.locked } - actionTargets.forEach { it.locked = lock } - } - onDismiss() - } - HudAlignRow { align -> - onDismiss() - alignHuds(actionTargets, align) - } - Box( - modifier = if (preview) { - Modifier - .background(Accent.copy(alpha = 0.18f), theme.buttonShape) - .border(1.dp, Accent, theme.buttonShape) - } else { - Modifier - } - ) { - HudMenuRow( - icon = "align", - text = "Anchor", - enabled = anchorEnabled, - ) { - onAnchor(hud) - onDismiss() - } - } - if (anyAnchored) { - HudMenuRow(icon = "x-circle", text = "Remove anchor") { - Snapshot.withMutableSnapshot { - actionTargets.forEach { it.clearAnchor() } - } - onDismiss() + actionTargets.forEach { it.clearAnchor() } } - } - HudMenuDivider() - HudMenuRow(icon = "copy", text = "Copy", hint = copyHint) { - onCopy(hud) - onDismiss() - } - HudMenuRow(icon = "scissors", text = "Cut", hint = cutHint) { - onCut(hud) - onDismiss() - } - HudMenuRow( - icon = "clipboard", - text = "Paste", - enabled = pasteEnabled, - hint = pasteHint, - ) { - onPaste(hud) - onDismiss() - } - HudMenuRow( - icon = "copy-plus", - text = "Duplicate", - enabled = duplicateEnabled, - hint = duplicateHint, - ) { - onDuplicate(hud) - onDismiss() - } - HudMenuDivider() - HudMenuRow(icon = "undo", text = "Undo", enabled = undoEnabled, hint = undoHint) { - onUndo() - onDismiss() - } - HudMenuRow(icon = "redo", text = "Redo", enabled = redoEnabled, hint = redoHint) { - onRedo() - onDismiss() - } - HudMenuDivider() - if (actionTargets.any(::hudHasPositionDefaults)) { - HudMenuRow( - icon = "move", - text = "Reset position", - enabled = actionTargets.any { hudCanResetPosition(it) }, - ) { - actionTargets.filter { hudCanResetPosition(it) }.forEach(::resetHudPosition) - onDismiss() - } - } - HudMenuRow( - icon = "refresh", - text = "Reset HUD settings", - enabled = resetEnabled, - hint = resetHint, - ) { - actionTargets.forEach { resetAllHudProperties(it) } - onDismiss() - } - if (deletable) { - HudMenuRow( - icon = "trash", - text = "Delete HUD", - color = DeleteMenuColor, - hint = deleteHint, - ) { - onDelete(hud) - onDismiss() - } - } - } + }, + icon = "x-circle", + ) } -} -@Composable -private fun HudMenuDivider() { - val theme = LocalTheme.current - Box( - modifier = Modifier - .padding(vertical = 4.dp) - .fillMaxWidth() - .height(1.dp) - .background(theme.borderColor) + entries += MenuSeparator + entries += MenuAction( + translated("oneconfig.menu.text.copy", "Copy"), + { onCopy(hud) }, + icon = "copy", + shortcutHint = keybindHint(OneConfigConfig.hudCopyKeybind), + ) + entries += MenuAction( + translated("oneconfig.menu.text.cut", "Cut"), + { onCut(hud) }, + icon = "scissors", + shortcutHint = keybindHint(OneConfigConfig.hudCutKeybind), + ) + entries += pasteAction({ onPaste(hud) }, pasteEnabled) + entries += MenuAction( + translated("oneconfig.hud.menu.duplicate", "Duplicate"), + { onDuplicate(hud) }, + icon = "copy-plus", + enabled = duplicateEnabled, + shortcutHint = keybindHint(OneConfigConfig.hudDuplicateKeybind), ) -} -private fun keybindHint(keybind: OneConfigKeybind?): String? = - keybind?.takeIf { it.isBound }?.displayName() + entries += MenuSeparator + entries += undoAction(onUndo, undoEnabled) + entries += redoAction(onRedo, redoEnabled) + + entries += MenuSeparator + if (actionTargets.any(::hudHasPositionDefaults)) { + entries += MenuAction( + translated("oneconfig.hud.menu.reset_position", "Reset position"), + { actionTargets.filter { hudCanResetPosition(it) }.forEach(::resetHudPosition) }, + icon = "move", + enabled = actionTargets.any { hudCanResetPosition(it) }, + ) + } + entries += MenuAction( + translated("oneconfig.hud.menu.reset_settings", "Reset HUD settings"), + { actionTargets.forEach { resetAllHudProperties(it) } }, + icon = "refresh", + enabled = actionTargets.any { hudHasResettableDefaults(it) }, + shortcutHint = keybindHint(OneConfigConfig.hudResetKeybind), + ) + if (actionTargets.any { it.canDelete() }) { + entries += MenuAction( + translated("oneconfig.hud.menu.delete", "Delete HUD"), + { onDelete(hud) }, + icon = "trash", + shortcutHint = keybindHint(OneConfigConfig.hudDeleteKeybind), + danger = true, + ) + } + return entries +} -/** Context popup shown on empty canvas space when there is a HUD in the clipboard */ -@Composable -fun HudCanvasPasteMenu( - expanded: Boolean, - offset: IntOffset, +internal fun hudCanvasPasteMenuEntries( pasteEnabled: Boolean, - onDismiss: () -> Unit, onPaste: () -> Unit, onSelectAll: () -> Unit, - onUndo: () -> Unit = {}, - undoEnabled: Boolean = false, - onRedo: () -> Unit = {}, - redoEnabled: Boolean = false, - onTour: (() -> Unit)? = null, -) { - if (!expanded) return - val theme = LocalTheme.current - val pasteHint = if (OneConfigConfig.showKeybindHints) keybindHint(OneConfigConfig.hudPasteKeybind) else null - val selectAllHint = if (OneConfigConfig.showKeybindHints) keybindHint(OneConfigConfig.hudSelectAllKeybind) else null - val undoHint = if (OneConfigConfig.showKeybindHints) keybindHint(OneConfigConfig.hudUndoKeybind) else null - val redoHint = if (OneConfigConfig.showKeybindHints) keybindHint(OneConfigConfig.hudRedoKeybind) else null - ShellPopup( - alignment = Alignment.TopStart, - offset = offset, - onDismissRequest = onDismiss, - properties = PopupProperties(focusable = true), - ) { - Column( - modifier = Modifier - .widthIn(min = 190.dp, max = 240.dp) - .shellTransition() - .liquidGlass(theme.popupShape, frosted = true) - .background(theme.popupBackground, theme.popupShape) - .border(1.dp, theme.borderColor, theme.popupShape) - .padding(MenuPadding), - ) { - HudMenuRow(icon = "undo", text = "Undo", enabled = undoEnabled, hint = undoHint) { - onUndo() - onDismiss() - } - HudMenuRow(icon = "redo", text = "Redo", enabled = redoEnabled, hint = redoHint) { - onRedo() - onDismiss() - } - HudMenuDivider() - HudMenuRow( - icon = "clipboard", - text = "Paste", - enabled = pasteEnabled, - hint = pasteHint, - ) { - onPaste() - onDismiss() - } - HudMenuRow( - icon = "select-all", - text = "Select All", - enabled = true, - hint = selectAllHint, - ) { - onSelectAll() - onDismiss() - } - if (onTour != null) { - HudMenuDivider() - HudMenuRow(icon = "info", text = tourString("menu")) { - onDismiss() - onTour() - } - } - } - } -} - -@Composable -private fun HudMenuRow( - icon: String, - text: String, - enabled: Boolean = true, - color: Color? = null, - hint: String? = null, - onClick: () -> Unit, -) { - val theme = LocalTheme.current - val rowColor = color ?: if (enabled) theme.textColor else theme.textColorSecondary - val hintColor = if (enabled) theme.textColorSecondary else theme.textColorSecondary.copy(alpha = 0.5f) - val interactionSource = rememberInteractionSource() - val isHovered by interactionSource.collectIsHoveredAsState() - val hoverBgColor by animateColorAsState( - targetValue = if (enabled && isHovered) { - theme.textColor.copy(alpha = 0.10f) - } else { - theme.textColor.copy(alpha = 0f) - }, - animationSpec = tween(120), - label = "hudMenuRowHover" + onUndo: () -> Unit, + undoEnabled: Boolean, + onRedo: () -> Unit, + redoEnabled: Boolean, + onTour: (() -> Unit)?, +): List { + val entries = ArrayList(8) + entries += undoAction(onUndo, undoEnabled) + entries += redoAction(onRedo, redoEnabled) + entries += MenuSeparator + entries += pasteAction(onPaste, pasteEnabled) + entries += MenuAction( + translated("oneconfig.menu.text.select_all", "Select all"), + onSelectAll, + icon = "select-all", + shortcutHint = keybindHint(OneConfigConfig.hudSelectAllKeybind), ) - val itemShape = theme.popupShape.concentric(MenuPadding) - Row( - modifier = Modifier - .clip(itemShape) - .background(hoverBgColor, itemShape) - .then( - if (enabled) { - Modifier.onClick(interactionSource, onClick = onClick) - .hoverable(interactionSource) - .pointerHoverIcon(PointerIcon.Hand) - } else { - Modifier - }, - ) - .padding(horizontal = 10.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon(icon, color = rowColor, modifier = Modifier.size(14.dp)) - Text(text, color = rowColor, fontSize = 13.sp, modifier = Modifier.weight(1f)) - if (hint != null) { - Text(hint, color = hintColor, fontSize = 10.sp) - } + if (onTour != null) { + entries += MenuSeparator + entries += MenuAction(tourString("menu"), onTour, icon = "info") } + return entries } -@Composable -private fun HudAlignRow(onAlign: (HudAlign) -> Unit) { - val theme = LocalTheme.current - Row( - modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - for (align in HudAlign.entries) { - val interactionSource = rememberInteractionSource() - val isHovered by interactionSource.collectIsHoveredAsState() - val hover by animateColorAsState(theme.textColor.copy(alpha = if (isHovered) 0.10f else 0f), tween(120)) - Tooltip( - text = { Text(align.label, color = theme.textColor, fontSize = 12.sp) }, - modifier = Modifier, - anchor = Alignment.TopCenter, - ) { - Box( - modifier = Modifier - .size(24.dp) - .background(hover, theme.buttonShape) - .pointerHoverIcon(PointerIcon.Hand) - .clickable(interactionSource, indication = null) { onAlign(align) } - .semantics { contentDescription = align.label }, - contentAlignment = Alignment.Center, - ) { - Icon(align.icon, theme.textColor, Modifier.size(14.dp)) - } - } - } - } +private fun pasteAction(onPaste: () -> Unit, enabled: Boolean): MenuAction = MenuAction( + translated("oneconfig.menu.text.paste", "Paste"), + onPaste, + icon = "clipboard", + enabled = enabled, + shortcutHint = keybindHint(OneConfigConfig.hudPasteKeybind), +) + +private fun undoAction(onUndo: () -> Unit, enabled: Boolean): MenuAction = MenuAction( + translated("oneconfig.hud.menu.undo", "Undo"), + onUndo, + icon = "undo", + enabled = enabled, + shortcutHint = keybindHint(OneConfigConfig.hudUndoKeybind), +) + +private fun redoAction(onRedo: () -> Unit, enabled: Boolean): MenuAction = MenuAction( + translated("oneconfig.hud.menu.redo", "Redo"), + onRedo, + icon = "redo", + enabled = enabled, + shortcutHint = keybindHint(OneConfigConfig.hudRedoKeybind), +) + +private fun alignLabel(align: HudAlign): String = + translated("oneconfig.hud.align.${align.name.lowercase()}", align.label) + +private fun accentMenuIcon(icon: String): @Composable () -> Unit = { + Icon(icon, null, Modifier.size(MenuIconSize), Accent) } + +private fun keybindHint(keybind: OneConfigKeybind?): String? = + if (OneConfigConfig.showKeybindHints) keybind?.takeIf { it.isBound }?.displayName() else null diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/components/AlignmentPicker.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/components/AlignmentPicker.kt index 29ae52fed..a8c299901 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/components/AlignmentPicker.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/components/AlignmentPicker.kt @@ -29,7 +29,8 @@ import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import org.polyfrost.compose.layout.PolyAlign -import org.polyfrost.oneconfig.internal.ui.components.ReducedMotion +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme import org.polyfrost.oneconfig.internal.ui.themes.Accent import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme import org.polyfrost.oneconfig.internal.ui.themes.cornerRadiusOrNull @@ -49,6 +50,9 @@ private val alignmentGrid: Map> = mapOf( private val gridAlignment: Map, PolyAlign> = alignmentGrid.entries.associate { (k, v) -> v to k } +private val CENTER_INDICATOR_RADIUS = 6.dp +private const val CELL_FADE_MILLIS = 150 + @Composable fun AlignmentPicker( alignment: PolyAlign, @@ -120,21 +124,22 @@ private fun AlignmentCell( val animatedAlpha = remember { Animatable(0f) } val animatedScale = remember { Animatable(1f) } + val hoverAlpha = LocalTheme.current.hoverOverlayAlpha LaunchedEffect(isSelected) { - if (isSelected && !ReducedMotion.enabled) { + if (isSelected && !Motion.reduced) { animatedScale.animateTo( 1.15f, spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium) ) - animatedScale.animateTo(1f, tween(150)) + animatedScale.animateTo(1f, tween(CELL_FADE_MILLIS)) } } - LaunchedEffect(isHovered) { + LaunchedEffect(isHovered, hoverAlpha) { animatedAlpha.animateTo( - if (isHovered && !isSelected) 0.08f else 0f, - tween(150) + if (isHovered && !isSelected) hoverAlpha else 0f, + Motion.spec(tween(CELL_FADE_MILLIS)) ) } @@ -180,7 +185,7 @@ private fun CellIndicator( color: Color, cellSize: Dp ) { - val indicatorRadius = LocalTheme.current.buttonShape.cornerRadiusOrNull() ?: 0.dp + val indicatorRadius = OneConfigTheme.shape(CENTER_INDICATOR_RADIUS).cornerRadiusOrNull() ?: 0.dp val isCenter = col == 1 && row == 1 val isCorner = (col == 0 || col == 2) && (row == 0 || row == 2) @@ -193,7 +198,7 @@ private fun CellIndicator( val len = (8f * scale).dp.toPx() when { - isCenter -> drawCenterPlus(color, s, strokeWidth * 1.2f, len * 1.6f, minOf(6f, indicatorRadius.toPx())) + isCenter -> drawCenterPlus(color, s, strokeWidth * 1.2f, len * 1.6f, indicatorRadius.toPx()) isCorner -> drawCornerBracket(col, row, color, s, strokeWidth, len) else -> drawEdgeTick(col, row, color, s, strokeWidth, len) } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/HudDesignStudio.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/HudDesignStudio.kt index 498527596..c26214821 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/HudDesignStudio.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/HudDesignStudio.kt @@ -18,7 +18,6 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.v2.ScrollbarAdapter import androidx.compose.runtime.* import androidx.compose.runtime.snapshots.Snapshot @@ -38,6 +37,7 @@ import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.input.key.* import androidx.compose.ui.input.pointer.* import androidx.compose.ui.layout.Layout @@ -47,9 +47,9 @@ import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp @@ -91,16 +91,37 @@ import org.polyfrost.oneconfig.api.notifications.v1.Notification import org.polyfrost.oneconfig.api.notifications.v1.Notifications import org.polyfrost.oneconfig.api.notifications.v1.NotificationsManager import org.polyfrost.oneconfig.api.platform.v1.Platform +import org.polyfrost.oneconfig.api.ui.v1.OneConfigUI +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.api.ui.v1.components.ControlSize +import org.polyfrost.oneconfig.api.ui.v1.components.ControlStyle +import org.polyfrost.oneconfig.api.ui.v1.components.EmptyState +import org.polyfrost.oneconfig.api.ui.v1.components.IconButton as KitIconButton +import org.polyfrost.oneconfig.api.ui.v1.components.IconButtonStyle +import org.polyfrost.oneconfig.api.ui.v1.components.MenuAction +import org.polyfrost.oneconfig.api.ui.v1.components.MenuEntry +import org.polyfrost.oneconfig.api.ui.v1.components.ScrollContainer +import org.polyfrost.oneconfig.api.ui.v1.components.SearchField +import org.polyfrost.oneconfig.api.ui.v1.components.SurfaceKind +import org.polyfrost.oneconfig.api.ui.v1.components.TextButton +import org.polyfrost.oneconfig.api.ui.v1.components.contextMenu +import org.polyfrost.oneconfig.api.ui.v1.components.oneConfigClickable +import org.polyfrost.oneconfig.api.ui.v1.components.rememberContextMenuState +import org.polyfrost.oneconfig.api.ui.v1.components.themedSurface +import org.polyfrost.oneconfig.api.ui.v1.page.OneConfigDestination +import org.polyfrost.oneconfig.api.ui.v1.page.OneConfigPages +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme import org.polyfrost.oneconfig.api.ui.v1.keybind.KeybindUtils import org.polyfrost.oneconfig.api.ui.v1.keybind.TextInputFocus import org.polyfrost.oneconfig.api.ui.v1.keybind.trackTextInputFocus import org.polyfrost.oneconfig.internal.OneConfigConfig -import org.polyfrost.oneconfig.internal.ui.components.ReducedMotion import org.polyfrost.oneconfig.utils.v1.NetworkUtils import org.polyfrost.oneconfig.internal.ui.api.ConfigRegistry +import org.polyfrost.oneconfig.internal.ui.api.ConfigSource import org.polyfrost.oneconfig.internal.ui.components.* -import org.polyfrost.oneconfig.internal.ui.hud.HudCanvasPasteMenu import org.polyfrost.oneconfig.internal.ui.hud.HudCanvasResetMenu +import org.polyfrost.oneconfig.internal.ui.hud.hudCanvasMenuEntries +import org.polyfrost.oneconfig.internal.ui.hud.hudCanvasPasteMenuEntries import org.polyfrost.oneconfig.internal.ui.hud.components.HudPreviewCanvas import org.polyfrost.oneconfig.internal.ui.hud.components.HudPreviewState import org.polyfrost.oneconfig.internal.ui.hud.components.rememberHudPreview @@ -133,8 +154,8 @@ private var lockedHudToast: Notification? = null internal fun notifyHudLocked() { lockedHudToast?.let { if (NotificationsManager.contains(it)) return } lockedHudToast = Notifications.info( - "HUD element locked", - "Unlock in settings to change its position.", + translated("oneconfig.hud.locked.title", "HUD element locked"), + translated("oneconfig.hud.locked.message", "Unlock in settings to change its position."), ) } @@ -210,7 +231,6 @@ private enum class ResizeCorner { val isTop get() = this == TopLeft || this == TopRight } -private const val SELECTION_BLUE_ARGB = 0xFF0D99FF.toInt() private const val HUD_SIZE_BADGE_HEIGHT = 16f private const val HUD_SIZE_BADGE_GAP = 9f /** How far the resize handles reach past a HUD's box */ @@ -218,24 +238,52 @@ private const val SELECTION_HANDLE_REACH = 4f private const val NUDGE_SETTLE_MS = 300L /** How stiffly the settings panel resizes to a new tab, which a dodge alongside it shares */ private const val PANEL_RESIZE_STIFFNESS = Spring.StiffnessMediumLow -private val selectionBlue = Color(SELECTION_BLUE_ARGB) -private val marqueeFillColor = Color(SELECTION_BLUE_ARGB).copy(alpha = 0.15f) +private const val DRAGGED_HUD_FILL_ALPHA = 0.10f +private const val MARQUEE_FILL_ALPHA = 0.15f -private val idleHudBoxColor = Color.White.copy(0.2f) -private val hoveredHudBoxColor = Color.White.copy(0.5f) -private val hiddenHudBoxColor = Color.White.copy(0.4f) private val hiddenHudDashEffect = PathEffect.dashPathEffect(floatArrayOf(4f, 3f), 0f) // locked HUDs get a dashed amber box instead of the plain white one -private val lockedHudBoxColor = Color(0xFFE0A44C) private val lockedHudDashEffect = PathEffect.dashPathEffect(floatArrayOf(2f, 4f), 0f) -private val hiddenLegacyScrimColor = Color.Black.copy(0.45f) +private const val IDLE_HUD_BOX_ALPHA = 0.2f +private const val HOVERED_HUD_BOX_ALPHA = 0.5f +private const val HIDDEN_HUD_BOX_ALPHA = 0.4f + +private class StudioCanvasColors( + val idleBox: Color, + val hoveredBox: Color, + val hiddenBox: Color, + val locked: Color, + val onLocked: Color, + val hiddenLegacyScrim: Color, + val anchorLine: Color, + val anchorPickScrim: Color, + val handleFill: Color, + val selection: Color, + val onSelection: Color, +) -// backdrop for the per-HUD action bar so the icons stay readable over any HUD content -private val hudActionBarBackground = Color.Black.copy(0.55f) -private val hudActionBarBorder = Color.White.copy(0.12f) -private val hudActionBarDeleteColor = Color(0xFFE5484D) +@Composable +private fun rememberStudioCanvasColors(): StudioCanvasColors { + val theme = LocalTheme.current + val accent = Accent + return remember(theme, accent) { + StudioCanvasColors( + idleBox = theme.onMedia.copy(alpha = IDLE_HUD_BOX_ALPHA), + hoveredBox = theme.onMedia.copy(alpha = HOVERED_HUD_BOX_ALPHA), + hiddenBox = theme.onMedia.copy(alpha = HIDDEN_HUD_BOX_ALPHA), + locked = theme.warning, + onLocked = theme.pageSurface, + hiddenLegacyScrim = theme.scrimColor, + anchorLine = theme.info, + anchorPickScrim = theme.scrimColor.copy(alpha = theme.scrimColor.alpha / 2f), + handleFill = theme.onMedia, + selection = accent, + onSelection = theme.accentTextOn(accent), + ) + } +} private const val SNAP_DISTANCE_PX = 6f @@ -425,9 +473,7 @@ private val ANCHOR_POINTS = listOf( private const val ANCHOR_DOT_RADIUS_PX = 5f private const val ANCHOR_HIT_RADIUS_PX = 10f -private val anchorLineColor = Color(0xFF0D99FF) private val anchorLineDashEffect = PathEffect.dashPathEffect(floatArrayOf(4f, 4f), 0f) -private val anchorPickScrimColor = Color.Black.copy(0.25f) private fun anchorFractions(hud: Hud, point: HudAnchor): Pair { val resolved = if (point == HudAnchor.Auto) hud.effectiveGrowthAnchor else point @@ -754,6 +800,7 @@ private fun DrawScope.drawSelectedHudBounds( mcToScreen: Float, showHandles: Boolean, badgeRadius: Dp, + colors: StudioCanvasColors, showBadge: Boolean = true, ) { val sx = bounds.x * mcToScreen @@ -762,7 +809,7 @@ private fun DrawScope.drawSelectedHudBounds( val sh = bounds.height * mcToScreen drawRect( - color = selectionBlue, + color = colors.selection, topLeft = Offset(sx, sy), size = Size(sw, sh), style = Stroke(width = 1f) @@ -777,27 +824,34 @@ private fun DrawScope.drawSelectedHudBounds( Offset(sx + sw, sy + sh), ).forEach { corner -> val topLeft = Offset(corner.x - handleSize / 2, corner.y - handleSize / 2) - drawRect(color = Color.White, topLeft = topLeft, size = Size(handleSize, handleSize)) - drawRect(color = selectionBlue, topLeft = topLeft, size = Size(handleSize, handleSize), style = Stroke(width = 1f)) + drawRect(color = colors.handleFill, topLeft = topLeft, size = Size(handleSize, handleSize)) + drawRect(color = colors.selection, topLeft = topLeft, size = Size(handleSize, handleSize), style = Stroke(width = 1f)) } } if (showBadge) { val badgeTopY = (sy - HUD_SIZE_BADGE_GAP - HUD_SIZE_BADGE_HEIGHT).coerceAtLeast(0f) - drawHudSizeBadge("${bounds.width.roundToInt()} x ${bounds.height.roundToInt()}", sx + sw / 2, badgeTopY, badgeRadius) + drawHudSizeBadge("${bounds.width.roundToInt()} x ${bounds.height.roundToInt()}", sx + sw / 2, badgeTopY, badgeRadius, colors) } } internal fun placementLabel(p: ExternalPlacement): String { - val action = when { - p.moved && p.scaled -> "Moved and scaled" - p.scaled -> "Scaled" - else -> "Moved" + val mod = p.mod ?: translated("oneconfig.hud.placement.another_mod", "another mod") + return when { + p.moved && p.scaled -> translated("oneconfig.hud.placement.moved_scaled", "Moved and scaled by %s", mod) + p.scaled -> translated("oneconfig.hud.placement.scaled", "Scaled by %s", mod) + else -> translated("oneconfig.hud.placement.moved", "Moved by %s", mod) } - return "$action by ${p.mod ?: "another mod"}" } -private fun DrawScope.drawHudPlacementBadge(label: String, centerX: Float, bottomY: Float, badgeRadius: Dp) { +private fun DrawScope.drawHudPlacementBadge( + label: String, + centerX: Float, + bottomY: Float, + badgeRadius: Dp, + fill: Color, + textColor: Color, +) { val font = PolyFont.default(10f) val metrics = font.metrics val textWidth = font.measureTextWidth(label) @@ -809,17 +863,17 @@ private fun DrawScope.drawHudPlacementBadge(label: String, centerX: Float, botto val topY = bottomY + HUD_SIZE_BADGE_GAP drawRoundRect( - color = lockedHudBoxColor, + color = fill, topLeft = Offset(badgeX, topY), size = Size(badgeWidth, badgeHeight), cornerRadius = CornerRadius(badgeRadius.toPx()) ) val textX = badgeX + horizontalPadding val textY = topY + (badgeHeight - textHeight) / 2f - metrics.ascent - drawPoly { it.renderer.text(label, textX, textY, Color.White.toArgb(), font) } + drawPoly { it.renderer.text(label, textX, textY, textColor.toArgb(), font) } } -private fun DrawScope.drawHudSizeBadge(label: String, centerX: Float, topY: Float, badgeRadius: Dp) { +private fun DrawScope.drawHudSizeBadge(label: String, centerX: Float, topY: Float, badgeRadius: Dp, colors: StudioCanvasColors) { val font = PolyFont.default(10f) val metrics = font.metrics val textWidth = font.measureTextWidth(label) @@ -830,24 +884,25 @@ private fun DrawScope.drawHudSizeBadge(label: String, centerX: Float, topY: Floa val badgeX = centerX - badgeWidth / 2 drawRoundRect( - color = selectionBlue, + color = colors.selection, topLeft = Offset(badgeX, topY), size = Size(badgeWidth, badgeHeight), cornerRadius = CornerRadius(badgeRadius.toPx()) ) val textX = badgeX + horizontalPadding val textY = topY + (badgeHeight - textHeight) / 2f - metrics.ascent - drawPoly { it.renderer.text(label, textX, textY, Color.White.toArgb(), font) } + drawPoly { it.renderer.text(label, textX, textY, colors.onSelection.toArgb(), font) } } @OptIn(ExperimentalComposeUiApi::class) @Composable private fun HudActionButton( icon: String, + label: String, size: Dp, - foreground: Color, - hoveredForeground: Color, sound: UiSoundEvent, + tint: Color = Color.Unspecified, + danger: Boolean = false, onClick: () -> Unit, ) { Box( @@ -864,12 +919,15 @@ private fun HudActionButton( } }, ) { - IconButton( + KitIconButton( icon, - modifier = Modifier.size(size), - foreground = foreground, - hoveredForeground = hoveredForeground, - onClick = onClick, + label, + onClick, + Modifier.size(size), + size = ControlSize.Large, + danger = danger, + tooltip = null, + tint = tint, ) } } @@ -889,7 +947,8 @@ private fun HudActionBar( val bounds = hudBounds(hud) ?: return if (bounds.width <= 0f || bounds.height <= 0f) return val layout = hudActionBarLayout(hud, mcToScreen, actionIconPx, actionBarGapPx) ?: return - val shape = LocalTheme.current.buttonShape + val theme = LocalTheme.current + val shape = theme.buttonShape val iconSize = (actionIconPx / densityFloat).dp val barX = (layout.x / densityFloat).coerceAtLeast(0f) val barY = (layout.y / densityFloat).coerceAtLeast(0f) @@ -900,45 +959,43 @@ private fun HudActionBar( modifier = Modifier .padding(start = barX.dp, top = barY.dp) .graphicsLayer { alpha = chromeAlpha } - .background(hudActionBarBackground, shape) - .border(1.dp, hudActionBarBorder, shape) + .themedSurface(SurfaceKind.Popup, shape) .padding(barPadding), horizontalArrangement = Arrangement.spacedBy((actionBarGapPx / densityFloat).dp), verticalAlignment = Alignment.CenterVertically, ) { HudActionButton( "settings", + translated("oneconfig.hud.menu.settings", "Settings"), iconSize, - foreground = Color.White.copy(0.7f), - hoveredForeground = Color.White, sound = UiSoundEvent.HUD_SELECT, onClick = onSettings, ) HudActionButton( if (isHidden) "eye-off" else "eye", + if (isHidden) translated("oneconfig.hud.menu.show", "Show") else translated("oneconfig.hud.menu.hide", "Hide"), iconSize, - foreground = Color.White.copy(0.7f), - hoveredForeground = Color.White, sound = UiSoundEvent.CLICK, ) { Snapshot.withMutableSnapshot { hud.hidden = !hud.hidden } } HudActionButton( if (isLocked) "lock" else "unlock", + if (isLocked) translated("oneconfig.hud.menu.unlock", "Unlock") else translated("oneconfig.hud.menu.lock", "Lock"), iconSize, - foreground = if (isLocked) Accent else Color.White.copy(0.7f), - hoveredForeground = if (isLocked) Accent else Color.White, sound = UiSoundEvent.CLICK, + tint = if (isLocked) Accent else Color.Unspecified, ) { Snapshot.withMutableSnapshot { hud.locked = !hud.locked } } if (hud.canDelete()) { HudActionButton( "trash", + translated("oneconfig.hud.menu.delete", "Delete HUD"), iconSize, - foreground = hudActionBarDeleteColor.copy(alpha = 0.7f), - hoveredForeground = hudActionBarDeleteColor, sound = UiSoundEvent.CLICK, + tint = theme.error, + danger = true, onClick = onDelete, ) } @@ -1089,8 +1146,7 @@ fun HudDesignStudio(visible: Boolean = true) { var panelBoxSize by remember { mutableStateOf(IntSize.Zero) } var panelTargetHeight by remember { mutableStateOf(null) } var rootSize by remember { mutableStateOf(IntSize.Zero) } - var hudContextMenuTarget by remember { mutableStateOf(null) } - var hudContextMenuOffset by remember { mutableStateOf(IntOffset.Zero) } + val canvasMenu = rememberContextMenuState() // a locked HUD currently being pressed so a far enough move can explain why it is not following var lockedPressHud by remember { mutableStateOf(null) } var lockedPressOrigin by remember { mutableStateOf(Offset.Zero) } @@ -1098,7 +1154,6 @@ fun HudDesignStudio(visible: Boolean = true) { val history = remember { HudHistory() } var pointerDown by remember { mutableStateOf(false) } var hudClipboard by remember { mutableStateOf>(emptyList()) } - var pasteMenuOffset by remember { mutableStateOf(null) } var marqueeStart by remember { mutableStateOf(null) } var marqueeCurrent by remember { mutableStateOf(null) } var marqueeActive by remember { mutableStateOf(false) } @@ -1136,8 +1191,7 @@ fun HudDesignStudio(visible: Boolean = true) { val panelVisible = panelHud != null fun clearTransientEditorState() { - hudContextMenuTarget = null - pasteMenuOffset = null + canvasMenu.close() anchorPickSources = emptyList() anchorPickSelf = null hoveredAnchor = null @@ -1330,7 +1384,7 @@ fun HudDesignStudio(visible: Boolean = true) { if (pasted.isNotEmpty()) { Snapshot.withMutableSnapshot { selectedHuds = pasted.toSet() - pasteMenuOffset = null + canvasMenu.close() } } UiSounds.play(UiSoundEvent.CLICK) @@ -1376,7 +1430,7 @@ fun HudDesignStudio(visible: Boolean = true) { val live = HudManager.activeInstances selectedHuds = selectedHuds.filterTo(LinkedHashSet()) { it in live } + revived if (hoveredHud !in live) hoveredHud = null - if (hudContextMenuTarget !in live) hudContextMenuTarget = null + canvasMenu.close() } UiSounds.play(UiSoundEvent.CLICK) } @@ -1409,7 +1463,7 @@ fun HudDesignStudio(visible: Boolean = true) { val librarySections = remember(groupedHuds, modNames) { groupedHuds.mapNotNull { (modId, huds) -> huds.takeIf { it.isNotEmpty() } - ?.let { HudLibrarySection(modId, modId?.let { id -> modNames[id] } ?: "Other", it) } + ?.let { HudLibrarySection(modId, modId?.let { id -> modNames[id] } ?: translated("oneconfig.hud.library.other", "Other"), it) } } } val scrolledLibraryMod by remember(librarySections, librarySectionIndexes, libraryScrollState) { @@ -1562,15 +1616,92 @@ fun HudDesignStudio(visible: Boolean = true) { UiSounds.play(UiSoundEvent.HUD_SELECT) selectedHuds = setOf(hit) } - hudContextMenuTarget = hit - hudContextMenuOffset = IntOffset(pos.x.roundToInt(), pos.y.roundToInt()) closeLibrary() - pasteMenuOffset = null - } else { - hudContextMenuTarget = null - pasteMenuOffset = IntOffset(pos.x.roundToInt(), pos.y.roundToInt()) } } + val root = rootCoords[0]?.takeIf { it.isAttached } ?: return@safePointerEvent + val toMc = Platform.screen().screenToMcScale() + val pasteAt = Offset(pos.x * toMc, pos.y * toMc) + val canUndo = history.canUndo + val canRedo = history.canRedo + val entries = if (hit != null) { + val targets = if (hit in selectedHuds) selectedHuds.toList() else listOf(hit) + hudCanvasMenuEntries( + hud = hit, + targets = targets, + onSettings = { settingsHud -> + Snapshot.withMutableSnapshot { + selectedHuds = setOf(settingsHud) + panelOpen = true + } + }, + settingsEnabled = targets.size <= 1, + onCopy = { _ -> + Snapshot.withMutableSnapshot { hudClipboard = targets } + UiSounds.play(UiSoundEvent.CLICK) + }, + onCut = { _ -> + Snapshot.withMutableSnapshot { hudClipboard = targets } + deleteHuds(targets) + }, + onPaste = { _ -> + val pasted = duplicateHudGroup(hudClipboard, pasteAt) + if (pasted.isNotEmpty()) { + Snapshot.withMutableSnapshot { selectedHuds = pasted.toSet() } + } + UiSounds.play(UiSoundEvent.CLICK) + }, + pasteEnabled = hudClipboard.any(::canDuplicateHud), + onDuplicate = { _ -> + val pasted = duplicateHudGroup(targets) + if (pasted.isNotEmpty()) { + Snapshot.withMutableSnapshot { selectedHuds = pasted.toSet() } + } + UiSounds.play(UiSoundEvent.CLICK) + }, + duplicateEnabled = targets.any(::canDuplicateHud), + onAnchor = { _ -> + Snapshot.withMutableSnapshot { + anchorPickSources = targets + // one HUD chooses which of its own points is pinned while a multi-selection has no + // single box to pick on so each keeps its growth anchor + anchorPickSelf = if (targets.size == 1) null else HudAnchor.Auto + hoveredAnchor = null + hoveredHud = null + closeLibrary() + } + }, + anchorEnabled = targets.isNotEmpty() && anchorTargetsFor(targets).isNotEmpty(), + onDelete = { _ -> deleteHuds(targets) }, + onUndo = { HudDesignSession.handleUndo(true) }, + undoEnabled = canUndo, + onRedo = { HudDesignSession.handleRedo(true) }, + redoEnabled = canRedo, + ) + } else { + hudCanvasPasteMenuEntries( + pasteEnabled = hudClipboard.any(::canDuplicateHud), + onPaste = { + val pasted = duplicateHudGroup(hudClipboard, pasteAt) + if (pasted.isNotEmpty()) { + Snapshot.withMutableSnapshot { selectedHuds = pasted.toSet() } + } + UiSounds.play(UiSoundEvent.CLICK) + }, + onSelectAll = { + Snapshot.withMutableSnapshot { + selectedHuds = HudManager.activeInstances.filter { !it.locked }.toSet() + } + UiSounds.play(UiSoundEvent.CLICK) + }, + onUndo = { HudDesignSession.handleUndo(true) }, + undoEnabled = canUndo, + onRedo = { HudDesignSession.handleRedo(true) }, + redoEnabled = canRedo, + onTour = { startTour() }, + ) + } + canvasMenu.open(root.localToWindow(pos), entries) return@safePointerEvent } pressWasSecondary = false @@ -1600,14 +1731,14 @@ fun HudDesignStudio(visible: Boolean = true) { resizeStartScale = selected.customScale hoveredHud = selected closeLibrary() - pasteMenuOffset = null + canvasMenu.close() } return@safePointerEvent } } // clicks inside the bar belong to its buttons so never select or drag the HUD behind it if (hoveredHud?.let { hitTestHudActionBar(it, pos.x, pos.y, mcToScreen, actionIconPx, actionBarGapPx) } == true) { - Snapshot.withMutableSnapshot { pasteMenuOffset = null } + canvasMenu.close() return@safePointerEvent } val s = Platform.screen().screenToMcScale() @@ -1651,7 +1782,7 @@ fun HudDesignStudio(visible: Boolean = true) { marqueeCurrent = pos marqueeActive = true marqueeAdditive = actionPressed || shift - pasteMenuOffset = null + canvasMenu.close() } } } @@ -1899,18 +2030,18 @@ fun HudDesignStudio(visible: Boolean = true) { } } - val chromeAlpha by animateFloatAsState( - targetValue = if (isDragging) (OneConfigConfig.hudDragUiOpacity / 100f).coerceIn(0f, 1f) else 1f, - animationSpec = tween(150), - label = "hudDragChromeAlpha" + val chromeAlpha by Motion.animateFloatAsState( + if (isDragging) (OneConfigConfig.hudDragUiOpacity / 100f).coerceIn(0f, 1f) else 1f, + tween(150), + label = "hudDragChromeAlpha", ) val marqueeAnimEnabled = OneConfigConfig.marqueeSelectionAnim val marqueeAnimDuration = OneConfigConfig.marqueeSelectionAnimDuration.toInt().coerceAtLeast(1) - val marqueeAlpha by animateFloatAsState( - targetValue = if (marqueeActive) 1f else 0f, - animationSpec = tween(if (marqueeAnimEnabled) marqueeAnimDuration else 0), - label = "marqueeAlpha" + val marqueeAlpha by Motion.animateFloatAsState( + if (marqueeActive) 1f else 0f, + tween(if (marqueeAnimEnabled) marqueeAnimDuration else 0), + label = "marqueeAlpha", ) var lastMarqueeStart by remember { mutableStateOf(null) } @@ -1923,14 +2054,15 @@ fun HudDesignStudio(visible: Boolean = true) { val theme = LocalTheme.current val badgeRadius = hudBadgeRadius() - val selectionCornerRadius = theme.buttonShape.cornerRadiusOrNull() ?: 0.dp + val canvasColors = rememberStudioCanvasColors() + val marqueeShape = OneConfigTheme.shape(Radii.SM) // the library and return chip stay on screen while a HUD is selected but dimmed so the settings // panel reads as the focus - val sideChromeAlpha by animateFloatAsState( - targetValue = if (selectedHuds.isNotEmpty()) SIDE_CHROME_DIM_ALPHA else 1f, - animationSpec = tween(150), - label = "sideChromeAlpha" + val sideChromeAlpha by Motion.animateFloatAsState( + if (selectedHuds.isNotEmpty()) SIDE_CHROME_DIM_ALPHA else 1f, + tween(150), + label = "sideChromeAlpha", ) fun hudScreenBox(hud: Hud): Rect? { @@ -2032,7 +2164,7 @@ fun HudDesignStudio(visible: Boolean = true) { if (target != null) { // moves with the tab resize's spring, settling to about a pixel since x is a fraction val spec = if (panelTargetHeight != null) spring(stiffness = PANEL_RESIZE_STIFFNESS, visibilityThreshold = 0.001f) else tween(200) - animate(panelPos.x, target.x, animationSpec = ReducedMotion.spec(spec)) { value, _ -> panelPos = panelPos.copy(x = value) } + animate(panelPos.x, target.x, animationSpec = Motion.spec(spec)) { value, _ -> panelPos = panelPos.copy(x = value) } } panelDropPending[0] = false } @@ -2139,7 +2271,7 @@ fun HudDesignStudio(visible: Boolean = true) { if (isSelected || isHovered) { hud.externalPlacement?.takeIf { it.moved || it.scaled }?.let { - drawHudPlacementBadge(placementLabel(it), sx + sw / 2, sy + sh, badgeRadius) + drawHudPlacementBadge(placementLabel(it), sx + sw / 2, sy + sh, badgeRadius, canvasColors.locked, canvasColors.onLocked) } } @@ -2147,7 +2279,7 @@ fun HudDesignStudio(visible: Boolean = true) { // no selection handles because a locked HUD can be neither moved nor resized if (isSelected || isHovered) { drawRect( - color = lockedHudBoxColor, + color = canvasColors.locked, topLeft = Offset(sx, sy), size = Size(sw, sh), style = Stroke(width = 1f, pathEffect = lockedHudDashEffect) @@ -2158,36 +2290,37 @@ fun HudDesignStudio(visible: Boolean = true) { if (isBeingDragged) { drawRect( - color = selectionBlue.copy(alpha = 0.10f), + color = canvasColors.selection.copy(alpha = DRAGGED_HUD_FILL_ALPHA), topLeft = Offset(sx, sy), size = Size(sw, sh), ) - drawSelectedHudBounds(bounds, mcToScreen, resizable && singleSelection, badgeRadius, showBadge = singleSelection) + drawSelectedHudBounds(bounds, mcToScreen, resizable && singleSelection, badgeRadius, canvasColors, showBadge = singleSelection) } else if (isSelected) { drawSelectedHudBounds( bounds, mcToScreen, showHandles = isPrimary && singleSelection, badgeRadius = badgeRadius, + colors = canvasColors, showBadge = singleSelection, ) } else if (hud.hidden) { if (hud is LegacyHud) { drawRect( - color = hiddenLegacyScrimColor, + color = canvasColors.hiddenLegacyScrim, topLeft = Offset(sx, sy), size = Size(sw, sh), ) } drawRect( - color = if (isHovered) hoveredHudBoxColor else hiddenHudBoxColor, + color = if (isHovered) canvasColors.hoveredBox else canvasColors.hiddenBox, topLeft = Offset(sx, sy), size = Size(sw, sh), style = Stroke(width = 1f, pathEffect = hiddenHudDashEffect) ) } else { drawRect( - color = if (isHovered) hoveredHudBoxColor else idleHudBoxColor, + color = if (isHovered) canvasColors.hoveredBox else canvasColors.idleBox, topLeft = Offset(sx, sy), size = Size(sw, sh), style = Stroke(width = 1f) @@ -2201,13 +2334,13 @@ fun HudDesignStudio(visible: Boolean = true) { val to = if (parent == null) null else anchorPointOf(parent, hovered.effectiveAnchorPoint) if (from != null && to != null) { drawLine( - color = anchorLineColor, + color = canvasColors.anchorLine, start = from * mcToScreen, end = to * mcToScreen, strokeWidth = 1.5f, pathEffect = anchorLineDashEffect, ) - drawCircle(anchorLineColor, radius = ANCHOR_DOT_RADIUS_PX, center = to * mcToScreen) + drawCircle(canvasColors.anchorLine, radius = ANCHOR_DOT_RADIUS_PX, center = to * mcToScreen) } } @@ -2217,7 +2350,7 @@ fun HudDesignStudio(visible: Boolean = true) { } if (anchorPickSources.isNotEmpty()) { - drawRect(color = anchorPickScrimColor, size = size) + drawRect(color = canvasColors.anchorPickScrim, size = size) val hotAnchor = hoveredAnchor val self = anchorPickSelf val source = anchorPickSources[0] @@ -2240,9 +2373,9 @@ fun HudDesignStudio(visible: Boolean = true) { val hot = self == point || (hotAnchor?.first === source && hotAnchor.second == point) val radius = if (hot) ANCHOR_DOT_RADIUS_PX + 2f else ANCHOR_DOT_RADIUS_PX - drawCircle(Color.White, radius = radius, center = center) + drawCircle(canvasColors.handleFill, radius = radius, center = center) drawCircle( - color = if (hot) Accent else anchorLineColor, + color = if (hot) Accent else canvasColors.anchorLine, radius = radius - 2f, center = center, ) @@ -2252,7 +2385,7 @@ fun HudDesignStudio(visible: Boolean = true) { for (target in targets) { val b = hudBounds(target) ?: continue drawRect( - color = anchorLineColor.copy(alpha = 0.5f), + color = canvasColors.anchorLine.copy(alpha = 0.5f), topLeft = Offset(b.x * mcToScreen, b.y * mcToScreen), size = Size(b.width * mcToScreen, b.height * mcToScreen), style = Stroke(width = 1f), @@ -2262,9 +2395,9 @@ fun HudDesignStudio(visible: Boolean = true) { val center = p * mcToScreen val hot = hotAnchor?.first === target && hotAnchor.second == point val radius = if (hot) ANCHOR_DOT_RADIUS_PX + 2f else ANCHOR_DOT_RADIUS_PX - drawCircle(Color.White, radius = radius, center = center) + drawCircle(canvasColors.handleFill, radius = radius, center = center) drawCircle( - color = if (hot) Accent else anchorLineColor, + color = if (hot) Accent else canvasColors.anchorLine, radius = radius - 2f, center = center, ) @@ -2280,21 +2413,18 @@ fun HudDesignStudio(visible: Boolean = true) { val w = abs(renderCurrent.x - renderStart.x) val h = abs(renderCurrent.y - renderStart.y) if (w > 0f && h > 0f) { - val cornerRadiusPx = selectionCornerRadius.toPx() - val cornerRadius = CornerRadius(cornerRadiusPx, cornerRadiusPx) - drawRoundRect( - color = marqueeFillColor.copy(alpha = marqueeFillColor.alpha * marqueeAlpha), - topLeft = Offset(left, top), - size = Size(w, h), - cornerRadius = cornerRadius, - ) - drawRoundRect( - color = selectionBlue.copy(alpha = selectionBlue.alpha * marqueeAlpha), - topLeft = Offset(left, top), - size = Size(w, h), - cornerRadius = cornerRadius, - style = Stroke(width = 1.dp.toPx()), - ) + val outline = marqueeShape.createOutline(Size(w, h), layoutDirection, this) + translate(left, top) { + drawOutline( + outline, + color = canvasColors.selection.copy(alpha = canvasColors.selection.alpha * MARQUEE_FILL_ALPHA * marqueeAlpha), + ) + drawOutline( + outline, + color = canvasColors.selection.copy(alpha = canvasColors.selection.alpha * marqueeAlpha), + style = Stroke(width = 1.dp.toPx()), + ) + } } } } @@ -2324,14 +2454,14 @@ fun HudDesignStudio(visible: Boolean = true) { // one line of guidance for the gestures the canvas cannot show on its own val hintText = when { anchorPickSources.isNotEmpty() && anchorPickSelf == null -> - "Click the point on this HUD that gets anchored · Esc to cancel" + translated("oneconfig.hud.hint.anchor_self", "Click the point on this HUD that gets anchored · Esc to cancel") anchorPickSources.isNotEmpty() -> - "Click the point to anchor it to · Right click to remove the anchor · Esc to go back" + translated("oneconfig.hud.hint.anchor_target", "Click the point to anchor it to · Right click to remove the anchor · Esc to go back") isDragging -> null lockedPressHud != null || hoveredHud?.locked == true -> - "This HUD is locked · Use the lock button above it to move or resize it" + translated("oneconfig.hud.hint.locked", "This HUD is locked · Use the lock button above it to move or resize it") hoveredHud?.let { it.bgMerged && !it.locked } == true -> - "Drag to move the merged HUDs together · Click it first, then drag, to pull it out" + translated("oneconfig.hud.hint.merged", "Drag to move the merged HUDs together · Click it first, then drag, to pull it out") else -> null } var lastHint by remember { mutableStateOf("") } @@ -2356,15 +2486,15 @@ fun HudDesignStudio(visible: Boolean = true) { obstacles.addAll(chromeRects.values) hintSlot = pickHintSlot(rootSize, hintSize, hintMargin, obstacles) } - val hintSpec = tween(durationMillis = if (hintPlaced && !ReducedMotion.enabled) 160 else 0) - val hintX by animateFloatAsState(hintSlot.x, hintSpec, label = "hintX") - val hintY by animateFloatAsState(hintSlot.y, hintSpec, label = "hintY") + val hintSpec = tween(durationMillis = if (hintPlaced) 160 else 0) + val hintX by Motion.animateFloatAsState(hintSlot.x, hintSpec, label = "hintX") + val hintY by Motion.animateFloatAsState(hintSlot.y, hintSpec, label = "hintY") AnimatedVisibility( visible = hintText != null, modifier = Modifier.align(Alignment.TopStart) .offset { IntOffset(hintX.roundToInt(), hintY.roundToInt()) }, - enter = fadeIn(), - exit = fadeOut(), + enter = Motion.enter(fadeIn()), + exit = Motion.exit(fadeOut()), ) { Box( modifier = Modifier @@ -2397,8 +2527,8 @@ fun HudDesignStudio(visible: Boolean = true) { val panelSlidePx = (if (panelPos.x < 0.5f) -1 else 1) * with(densityObj) { 24.dp.roundToPx() } AnimatedVisibility( visible = panelHud != null, - enter = ReducedMotion.enter(slideInHorizontally(initialOffsetX = { panelSlidePx }) + fadeIn()), - exit = ReducedMotion.exit(slideOutHorizontally(targetOffsetX = { panelSlidePx }) + fadeOut()), + enter = Motion.enter(slideInHorizontally(initialOffsetX = { panelSlidePx }) + fadeIn()), + exit = Motion.exit(slideOutHorizontally(targetOffsetX = { panelSlidePx }) + fadeOut()), ) { DisposableEffect(Unit) { onDispose { chromeRects.remove(CHROME_SETTINGS_PANEL) } } // inside the slide so it never clips, and hidden until first placed so it never flashes at the top left @@ -2438,8 +2568,8 @@ fun HudDesignStudio(visible: Boolean = true) { AnimatedVisibility( visible = libraryChromeVisible, modifier = Modifier.align(Alignment.CenterEnd), - enter = ReducedMotion.enter(slideInHorizontally(initialOffsetX = { it })), - exit = ReducedMotion.exit(slideOutHorizontally(targetOffsetX = { it })), + enter = Motion.enter(slideInHorizontally(initialOffsetX = { it })), + exit = Motion.exit(slideOutHorizontally(targetOffsetX = { it })), ) { DisposableEffect(Unit) { onDispose { @@ -2454,8 +2584,8 @@ fun HudDesignStudio(visible: Boolean = true) { ) { AnimatedVisibility( visible = libraryVisible, - enter = ReducedMotion.enter(slideInHorizontally(initialOffsetX = { it }) + fadeIn()), - exit = ReducedMotion.exit(slideOutHorizontally(targetOffsetX = { it }) + fadeOut()), + enter = Motion.enter(slideInHorizontally(initialOffsetX = { it }) + fadeIn()), + exit = Motion.exit(slideOutHorizontally(targetOffsetX = { it }) + fadeOut()), ) { DisposableEffect(Unit) { onDispose { chromeRects.remove(CHROME_LIBRARY) } } HudLibraryPanel( @@ -2550,113 +2680,13 @@ fun HudDesignStudio(visible: Boolean = true) { } } - val contextMenuTargets = hudContextMenuTarget?.let { hud -> - if (hud in selectedHuds) selectedHuds.toList() else listOf(hud) - } ?: emptyList() - - val menuOpen = hudContextMenuTarget != null || pasteMenuOffset != null - val canUndo = remember(menuOpen) { menuOpen && history.canUndo } - val canRedo = remember(menuOpen) { menuOpen && history.canRedo } - - HudCanvasResetMenu( - hud = hudContextMenuTarget, - expanded = hudContextMenuTarget != null, - offset = hudContextMenuOffset, - onDismiss = { hudContextMenuTarget = null }, - targets = contextMenuTargets, - settingsEnabled = contextMenuTargets.size <= 1, - onSettings = { hud -> - Snapshot.withMutableSnapshot { - hudContextMenuTarget = null - selectedHuds = setOf(hud) - panelOpen = true - } - }, - onCopy = { _ -> - Snapshot.withMutableSnapshot { hudClipboard = contextMenuTargets } - UiSounds.play(UiSoundEvent.CLICK) - }, - onCut = { _ -> - Snapshot.withMutableSnapshot { - hudClipboard = contextMenuTargets - hudContextMenuTarget = null - } - deleteHuds(contextMenuTargets) - }, - onPaste = { _ -> - val s = Platform.screen().screenToMcScale() - val pasted = duplicateHudGroup( - hudClipboard, - Offset(hudContextMenuOffset.x * s, hudContextMenuOffset.y * s), - ) - if (pasted.isNotEmpty()) { - Snapshot.withMutableSnapshot { selectedHuds = pasted.toSet() } - } - UiSounds.play(UiSoundEvent.CLICK) - }, - pasteEnabled = hudClipboard.any(::canDuplicateHud), - onDuplicate = { _ -> - val pasted = duplicateHudGroup(contextMenuTargets) - if (pasted.isNotEmpty()) { - Snapshot.withMutableSnapshot { selectedHuds = pasted.toSet() } - } - UiSounds.play(UiSoundEvent.CLICK) - }, - duplicateEnabled = contextMenuTargets.any(::canDuplicateHud), - onAnchor = { _ -> - Snapshot.withMutableSnapshot { - anchorPickSources = contextMenuTargets - // one HUD chooses which of its own points is pinned while a multi-selection has no - // single box to pick on so each keeps its growth anchor - anchorPickSelf = if (contextMenuTargets.size == 1) null else HudAnchor.Auto - hoveredAnchor = null - hoveredHud = null - closeLibrary() - } - }, - anchorEnabled = contextMenuTargets.isNotEmpty() && - anchorTargetsFor(contextMenuTargets).isNotEmpty(), - onDelete = { _ -> deleteHuds(contextMenuTargets) }, - onUndo = { HudDesignSession.handleUndo(true) }, - undoEnabled = canUndo, - onRedo = { HudDesignSession.handleRedo(true) }, - redoEnabled = canRedo, - ) - - HudCanvasPasteMenu( - expanded = pasteMenuOffset != null, - offset = pasteMenuOffset ?: IntOffset.Zero, - pasteEnabled = hudClipboard.any(::canDuplicateHud), - onDismiss = { pasteMenuOffset = null }, - onPaste = { - val off = pasteMenuOffset ?: return@HudCanvasPasteMenu - val s = Platform.screen().screenToMcScale() - val pasted = duplicateHudGroup(hudClipboard, Offset(off.x * s, off.y * s)) - if (pasted.isNotEmpty()) { - Snapshot.withMutableSnapshot { selectedHuds = pasted.toSet() } - } - UiSounds.play(UiSoundEvent.CLICK) - }, - onSelectAll = { - Snapshot.withMutableSnapshot { - selectedHuds = HudManager.activeInstances.filter { !it.locked }.toSet() - } - UiSounds.play(UiSoundEvent.CLICK) - }, - onUndo = { HudDesignSession.handleUndo(true) }, - undoEnabled = canUndo, - onRedo = { HudDesignSession.handleRedo(true) }, - redoEnabled = canRedo, - onTour = { startTour() }, - ) - var lastTourStep by remember { mutableStateOf(HudTourStep.entries.first()) } tourStep?.let { lastTourStep = it } AnimatedVisibility( visible = tourStep != null, modifier = Modifier.fillMaxSize(), - enter = fadeIn(), - exit = fadeOut(), + enter = Motion.enter(fadeIn()), + exit = Motion.exit(fadeOut()), ) { DisposableEffect(Unit) { onDispose { @@ -2862,6 +2892,7 @@ internal fun duplicateHudGroup( @Composable fun HudDragLayer(modifier: Modifier = Modifier) { val badgeRadius = hudBadgeRadius() + val canvasColors = rememberStudioCanvasColors() HudEditorViewport.observe() val shellLayout = LocalShell.current.layout var hoveredHud by remember { mutableStateOf(null) } @@ -3005,7 +3036,7 @@ fun HudDragLayer(modifier: Modifier = Modifier) { if (hud.locked) { if (isHovered) { drawRect( - color = lockedHudBoxColor, + color = canvasColors.locked, topLeft = Offset(sx, sy), size = Size(sw, sh), style = Stroke(width = 1f, pathEffect = lockedHudDashEffect) @@ -3015,28 +3046,28 @@ fun HudDragLayer(modifier: Modifier = Modifier) { } if (isBeingDragged) { drawRect( - color = selectionBlue.copy(alpha = 0.10f), + color = canvasColors.selection.copy(alpha = DRAGGED_HUD_FILL_ALPHA), topLeft = Offset(sx, sy), size = Size(sw, sh), ) - drawSelectedHudBounds(bounds, mcToScreen, showHandles = false, badgeRadius = badgeRadius) + drawSelectedHudBounds(bounds, mcToScreen, showHandles = false, badgeRadius = badgeRadius, colors = canvasColors) } else if (hud.hidden) { if (hud is LegacyHud) { drawRect( - color = hiddenLegacyScrimColor, + color = canvasColors.hiddenLegacyScrim, topLeft = Offset(sx, sy), size = Size(sw, sh), ) } drawRect( - color = if (isHovered) hoveredHudBoxColor else hiddenHudBoxColor, + color = if (isHovered) canvasColors.hoveredBox else canvasColors.hiddenBox, topLeft = Offset(sx, sy), size = Size(sw, sh), style = Stroke(width = 1f, pathEffect = hiddenHudDashEffect) ) } else { drawRect( - color = if (isHovered) hoveredHudBoxColor else idleHudBoxColor, + color = if (isHovered) canvasColors.hoveredBox else canvasColors.idleBox, topLeft = Offset(sx, sy), size = Size(sw, sh), style = Stroke(width = 1f) @@ -3129,7 +3160,7 @@ private fun DesignStudioPanel( val titleInteraction = remember { MutableInteractionSource() } val titleHovered by titleInteraction.collectIsHoveredAsState() var titleDragging by remember { mutableStateOf(false) } - val handleAlpha by animateFloatAsState(if (titleHovered || titleDragging) 1f else 0.65f, tween(150)) + val handleAlpha by Motion.animateFloatAsState(if (titleHovered || titleDragging) 1f else 0.65f, tween(150), label = "panelHandleAlpha") Row( modifier = Modifier .fillMaxWidth() @@ -3168,12 +3199,12 @@ private fun DesignStudioPanel( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp), ) { - Text(title ?: "HUD", color = theme.textColor, fontSize = 18.sp, fontWeight = FontWeight.Medium) + Text(title ?: translated("oneconfig.hud", "HUD"), color = theme.textColor, fontSize = 18.sp, fontWeight = FontWeight.Medium) subtitle?.let { Text(it, color = theme.textColorSecondary, fontSize = 12.sp) } } - IconButton("close", modifier = Modifier.size(32.dp), hoverBorder = true) { onBack() } + KitIconButton("close", translated("oneconfig.button.close", "Close"), onBack, Modifier.size(32.dp)) } var searchOpen by remember { mutableStateOf(false) } var focusSearch by remember { mutableStateOf(false) } @@ -3183,7 +3214,7 @@ private fun DesignStudioPanel( // 0 shows the tabs and 1 the whole field, which grows out of the search button in between val searchProgress by animateFloatAsState( if (searchOpen) 1f else 0f, - tween(if (ReducedMotion.enabled) 150 else 220, easing = FastOutSlowInEasing), + tween(if (Motion.reduced) 150 else 220, easing = FastOutSlowInEasing), ) Box(modifier = Modifier.fillMaxWidth().padding(start = StudioPanelGutter, end = StudioPanelGutter, bottom = 16.dp)) { Row( @@ -3192,13 +3223,13 @@ private fun DesignStudioPanel( .height(IntrinsicSize.Min) // gone before the growing field reaches them and back once the shrinking one has passed .graphicsLayer { - alpha = if (ReducedMotion.enabled) 1f - searchProgress + alpha = if (Motion.reduced) 1f - searchProgress else ((0.3f - searchProgress) / 0.25f).coerceIn(0f, 1f) }, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { if (categories.size > 1) categories.forEach { - Chip(it.title, it == shownCategory, it.icon) { onCategoryChange(it) } + Chip(translated("oneconfig.hud.tab.${it.name.lowercase()}", it.title), it == shownCategory, it.icon) { onCategoryChange(it) } } Spacer(Modifier.weight(1f)) SearchToggle(Modifier.fillMaxHeight().aspectRatio(1f).graphicsLayer { alpha = if (searchProgress > 0f) 0f else 1f }) { @@ -3210,17 +3241,17 @@ private fun DesignStudioPanel( SearchBar( searchText, onSearchChange, - "Search settings...", + translated("oneconfig.hud.panel.search", "Search settings..."), focusRequested = focusSearch || searchFocusRequested, onFocused = { focusSearch = false onSearchFocused() }, modifier = Modifier - .width(if (ReducedMotion.enabled) maxWidth else lerp(maxHeight, maxWidth, searchProgress)) + .width(if (Motion.reduced) maxWidth else lerp(maxHeight, maxWidth, searchProgress)) .fillMaxHeight() .clip(theme.sideBarNavigationEntryShape) - .graphicsLayer { if (ReducedMotion.enabled) alpha = searchProgress }, + .graphicsLayer { if (Motion.reduced) alpha = searchProgress }, onDismiss = { onSearchChange("") searchOpen = false @@ -3237,18 +3268,13 @@ private fun DesignStudioPanel( transitionSpec = { ContentSlide.tabSwitch(forward = targetState.ordinal > initialState.ordinal) using SizeTransform { from, to -> onTabResize(from, to) - spring(stiffness = PANEL_RESIZE_STIFFNESS, visibilityThreshold = IntSize.VisibilityThreshold) + Motion.finiteSpec(spring(stiffness = PANEL_RESIZE_STIFFNESS, visibilityThreshold = IntSize.VisibilityThreshold)) } }, ) { category -> val panelScrollState = rememberScrollState() - ScrollbarBox(panelScrollState, StudioPanelGutter) { - SectionStack( - Modifier.fillMaxWidth() - .wheelScroll(panelScrollState) - .verticalScroll(panelScrollState) - .padding(horizontal = StudioPanelGutter), - ) { + ScrollContainer(panelScrollState) { + SectionStack(Modifier.fillMaxWidth().padding(start = StudioPanelGutter)) { // like the main config screen, a search spans every category rather than only the open one categories.forEach { if (query.isNotEmpty() || it == category) when (it) { @@ -3267,20 +3293,14 @@ private fun DesignStudioPanel( @Composable private fun SearchToggle(modifier: Modifier, onClick: () -> Unit) { - val theme = LocalTheme.current - val interactionSource = rememberInteractionSource() - val hovered by interactionSource.collectIsHoveredAsState() - val iconColor by animateColorAsState(theme.textColor.copy(if (hovered) 1f else 0.7f)) - Box( - modifier = modifier - .background(theme.chipBackground, theme.sideBarNavigationEntryShape) - .border(1.dp, theme.borderColor, theme.sideBarNavigationEntryShape) - .pointerHoverIcon(PointerIcon.Hand) - .onClick(interactionSource) { onClick() }, - contentAlignment = Alignment.Center, - ) { - Icon("search", color = iconColor, modifier = Modifier.size(16.dp)) - } + KitIconButton( + "search", + translated("oneconfig.hud.panel.search_button", "Search settings"), + onClick, + modifier, + style = IconButtonStyle.Framed, + tooltip = null, + ) } @Composable @@ -3305,6 +3325,7 @@ private val Hud.addableToLibrary: Boolean get() = multipleInstancesAllowed() || !HudManager.hasHudOfType(this::class.java) private val StudioPanelGutter = 16.dp +private val FILTER_BAR_RADIUS = 2.dp /** The blurred page tinted card the editor's panel, HUD library and mod list float on, like the main screen's page */ @Composable @@ -3411,9 +3432,10 @@ private class HudLibraryScrollbarAdapter( @Composable private fun HudLibraryHeader(section: HudLibrarySection, active: Boolean) { val theme = LocalTheme.current - val textColor by animateColorAsState( + val textColor by Motion.animateColorAsState( if (active) theme.textColor else theme.textColorSecondary, - animationSpec = tween(150), + tween(150), + label = "libraryHeaderText", ) Row( modifier = Modifier.fillMaxWidth().height(LIBRARY_HEADER_HEIGHT), @@ -3554,10 +3576,10 @@ private fun HudLibraryPanel( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { - Text("HUDs", color = theme.textColor, fontSize = 18.sp, fontWeight = FontWeight.Medium) + Text(translated("oneconfig.hud.library.title", "HUDs"), color = theme.textColor, fontSize = 18.sp, fontWeight = FontWeight.Medium) if (!suggestEvergreenHud) { - SearchBar(searchText, onSearchChange, "Search HUDs...", searchFocusRequested, onSearchFocused) - IconButton("close", modifier = Modifier.size(32.dp), hoverBorder = true, onClick = onClose) + SearchBar(searchText, onSearchChange, translated("oneconfig.hud.library.search", "Search HUDs..."), searchFocusRequested, onSearchFocused) + KitIconButton("close", translated("oneconfig.button.close", "Close"), onClose, Modifier.size(32.dp)) } } if (suggestEvergreenHud) { @@ -3565,7 +3587,7 @@ private fun HudLibraryPanel( return@Column } Text( - "Click to add · Drag to place it yourself", + translated("oneconfig.hud.library.hint", "Click to add · Drag to place it yourself"), modifier = Modifier.padding(horizontal = StudioPanelGutter), color = theme.textColorSecondary, fontSize = 11.sp, @@ -3598,7 +3620,7 @@ private fun HudLibraryPanel( val maxOffset = (metrics.contentHeight - constraints.maxHeight).coerceAtLeast(0) val targetOffset = metrics.itemOffsets[index].coerceAtMost(maxOffset) val distance = targetOffset - scrollbarAdapter.scrollOffset - if (ReducedMotion.enabled) scrollbarAdapter.scrollTo(targetOffset.toDouble()) + if (Motion.reduced) scrollbarAdapter.scrollTo(targetOffset.toDouble()) else scrollState.animateScrollBy(distance.toFloat()) } catch (cancelled: CancellationException) { if (currentCoroutineContext().isActive) completeScroll(target, true) @@ -3615,10 +3637,10 @@ private fun HudLibraryPanel( ) { if (sections.isEmpty()) { item(key = "empty") { - Text( - "No HUDs found", - color = theme.textColorSecondary, - fontSize = 14.sp, + EmptyState( + title = translated("oneconfig.hud.library.empty", "No HUDs found"), + modifier = Modifier.fillParentMaxWidth(), + icon = "search", ) } } @@ -3658,34 +3680,24 @@ private fun HudLibraryPanel( @Composable private fun EvergreenHudSuggestion(modifier: Modifier) { - val theme = LocalTheme.current - val source = rememberInteractionSource() - val isHovered by source.collectIsHoveredAsState() - val background by animateAccentColorAsState(isHovered, if (isHovered) Color.White.copy(0.12f).compositeOver(Accent) else Accent) - Column( + EmptyState( + title = translated("oneconfig.hud.library.none_installed", "No HUDs installed"), modifier = modifier, - verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text("No HUDs installed", color = theme.textColor, fontSize = 14.sp, fontWeight = FontWeight.Medium) - Text( + icon = "hud", + text = translated( + "oneconfig.hud.library.evergreen_body", "Install EvergreenHUD for FPS, CPS, coordinates, ping, and many more HUDs.", - color = theme.textColorSecondary, - fontSize = 12.sp, - textAlign = TextAlign.Center, - modifier = Modifier.padding(horizontal = 24.dp), - ) - Box( - modifier = Modifier - .clip(theme.buttonShape) - .background(background, theme.buttonShape) - .onClick(source) { NetworkUtils.browseLink("https://modrinth.com/mod/evergreenhud") } - .pointerHoverIcon(PointerIcon.Hand) - .padding(horizontal = 12.dp, vertical = 6.dp), - ) { - Text("Get EvergreenHUD", color = theme.accentTextColor, fontSize = 12.sp, fontWeight = FontWeight.Medium) - } - } + ), + action = { + TextButton( + text = translated("oneconfig.hud.library.evergreen_action", "Get EvergreenHUD"), + icon = "external-link", + onClick = { NetworkUtils.browseLink("https://modrinth.com/mod/evergreenhud") }, + style = ControlStyle.Primary, + size = ControlSize.Compact, + ) + }, + ) } @Composable @@ -3706,6 +3718,7 @@ private fun ModIconColumn( modIds.forEach { modId -> ModFilterIcon( iconName = libraryIconFor(modId), + label = remember(modId) { modNameFor(modId) ?: modId }, selected = activeModId == modId && libraryVisible, ) { onModClick(modId) } } @@ -3713,21 +3726,36 @@ private fun ModIconColumn( } private const val PREVIEW_SCALE = 2f -private val hudCardOverlayScrim = Color.Black.copy(0.4f) + +private fun libraryCardMenu(hud: Hud, onAdd: (Hud) -> Unit): List = buildList { + add(MenuAction(translated("oneconfig.hud.library.add", "Add to screen"), { onAdd(hud) }, icon = "plus")) + val modId = hud.configId ?: return@buildList + val config = ConfigRegistry.findById(modId) ?: return@buildList + val onOpen = config.onOpen + if (onOpen == null && config.source != ConfigSource.OC) return@buildList + add( + MenuAction( + translated("oneconfig.hud.library.mod_settings", "Open mod settings"), + { if (onOpen != null) onOpen() else OneConfigUI.open(OneConfigDestination.ModConfig(OneConfigPages.modConfig(modId))) }, + icon = "settings", + ), + ) +} @Composable private fun BoxScope.HudCardAddOverlay(visible: Boolean) { + val theme = LocalTheme.current AnimatedVisibility( visible = visible, modifier = Modifier.matchParentSize(), - enter = fadeIn(tween(150)), - exit = fadeOut(tween(150)), + enter = Motion.enter(fadeIn(tween(150))), + exit = Motion.exit(fadeOut(tween(150))), ) { Box( - modifier = Modifier.fillMaxSize().background(hudCardOverlayScrim), + modifier = Modifier.fillMaxSize().background(theme.scrimColor), contentAlignment = Alignment.Center, ) { - Icon("plus", modifier = Modifier.size(20.dp), color = Color.White) + Icon("plus", modifier = Modifier.size(20.dp), color = theme.onMedia) } } } @@ -3787,12 +3815,14 @@ private fun LegacyHudPreviewCard( .background(backgroundColor, theme.buttonShape) .border(1.dp, theme.borderColor, theme.buttonShape) .clip(theme.buttonShape) + .contextMenu { libraryCardMenu(hud, onCardClick) } .safePointerEvent(PointerEventType.Enter) { isHovered = true } .safePointerEvent(PointerEventType.Exit) { isHovered = false if (!dragStarted) pressPos = null } .safePointerEvent(PointerEventType.Press) { event -> + if (event.buttons.isSecondaryPressed) return@safePointerEvent val pos = event.changes.firstOrNull()?.position if (pos != null) { pressPos = pos @@ -3830,7 +3860,7 @@ private fun LegacyHudPreviewCard( contentAlignment = Alignment.Center, ) { Text( - localizedLabel(hud.title) ?: "Legacy HUD", + localizedLabel(hud.title) ?: translated("oneconfig.hud.legacy", "Legacy HUD"), color = theme.textColor.copy(0.85f), fontSize = 12.sp, modifier = Modifier.padding(cardPadding), @@ -3871,12 +3901,14 @@ private fun ComposeHudPreviewCard( .background(backgroundColor, theme.buttonShape) .border(1.dp, theme.borderColor, theme.buttonShape) .clip(theme.buttonShape) + .contextMenu { libraryCardMenu(hud, onCardClick) } .safePointerEvent(PointerEventType.Enter) { isHovered = true } .safePointerEvent(PointerEventType.Exit) { isHovered = false if (!dragStarted) pressPos = null } .safePointerEvent(PointerEventType.Press) { event -> + if (event.buttons.isSecondaryPressed) return@safePointerEvent val pos = event.changes.firstOrNull()?.position if (pos != null) { pressPos = pos @@ -3935,13 +3967,14 @@ private fun ComposeHudPreviewCard( } @Composable -private fun ModFilterIcon(iconName: String, selected: Boolean, onClick: () -> Unit) { +private fun ModFilterIcon(iconName: String, label: String, selected: Boolean, onClick: () -> Unit) { val interactionSource = rememberInteractionSource() val isHovered by interactionSource.collectIsHoveredAsState() val theme = LocalTheme.current - val barRadius = minOf(2.dp, theme.buttonShape.cornerRadiusOrNull() ?: 0.dp) - val iconAlpha by animateFloatAsState( - if (selected) 1f else if (isHovered) 0.8f else 0.7f + val barShape = OneConfigTheme.shape(FILTER_BAR_RADIUS) + val iconAlpha by Motion.animateFloatAsState( + if (selected) 1f else if (isHovered) 0.8f else 0.7f, + label = "modFilterIconAlpha", ) Box( @@ -3951,15 +3984,19 @@ private fun ModFilterIcon(iconName: String, selected: Boolean, onClick: () -> Un drawContent() if (selected) { val barHeight = 24.dp.toPx() - drawRoundRect( - color = Accent, - topLeft = Offset(-8.dp.toPx(), (size.height - barHeight) / 2f), - size = Size(4.dp.toPx(), barHeight), - cornerRadius = CornerRadius(barRadius.toPx()), - ) + val outline = barShape.createOutline(Size(4.dp.toPx(), barHeight), layoutDirection, this) + translate(-8.dp.toPx(), (size.height - barHeight) / 2f) { + drawOutline(outline, color = Accent) + } } } - .onClick(interactionSource) { onClick() }, + .oneConfigClickable( + label = label, + onClick = onClick, + selected = selected, + indication = false, + interactionSource = interactionSource, + ), contentAlignment = Alignment.Center ) { Icon( @@ -3977,11 +4014,11 @@ private fun SearchBar( placeholder: String, focusRequested: Boolean = false, onFocused: () -> Unit = {}, - modifier: Modifier = Modifier.size(181.dp, 32.dp), + modifier: Modifier = Modifier.width(181.dp), onDismiss: (() -> Unit)? = null, showClear: Boolean = true, ) { - val interactionSource = rememberInteractionSource() + val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() // a dismissable field closes once it is left empty and unfocused var hadFocus by remember { mutableStateOf(false) } @@ -3990,10 +4027,8 @@ private fun SearchBar( else if (hadFocus && value.isEmpty()) onDismiss?.invoke() } val focusRequester = remember { FocusRequester() } - // holds the selection alongside the caller's text so a focus request can select the query var field by remember { mutableStateOf(TextFieldValue(value)) } val current = field.copy(text = value) - LaunchedEffect(focusRequested) { if (!focusRequested) return@LaunchedEffect field = current.copy(selection = TextRange(0, value.length)) @@ -4003,44 +4038,27 @@ private fun SearchBar( } onFocused() } - val theme = LocalTheme.current - - // TextFieldState needs its own migration, as it changes how every field hoists its state - @Suppress("DEPRECATION") - BasicTextField( - current, - { + SearchField( + value = current, + onValueChange = { field = it if (it.text != value) onValueChange(it.text) }, - singleLine = true, - modifier = modifier - .trackTextInputFocus() - .focusRequester(focusRequester) - // clears the text first then dismisses the field so escape only closes the studio after both - .onPreviewKeyEvent { event -> - if (event.type != KeyEventType.KeyDown || event.key != Key.Escape) return@onPreviewKeyEvent false - when { - value.isNotEmpty() -> onValueChange("") - onDismiss != null -> onDismiss() - else -> return@onPreviewKeyEvent false - } - true - }, + // clears the text first then dismisses the field so escape only closes the studio after both + modifier = modifier.onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyDown || event.key != Key.Escape) return@onPreviewKeyEvent false + when { + value.isNotEmpty() -> onValueChange("") + onDismiss != null -> onDismiss() + else -> return@onPreviewKeyEvent false + } + true + }, + placeholder = placeholder, + clearable = showClear, + size = ControlSize.Regular, + focusRequester = focusRequester, interactionSource = interactionSource, - textStyle = searchFieldTextStyle(), - cursorBrush = SolidColor(theme.textColorSecondary) - ) { innerTextField -> - SearchFieldFrame( - placeholder, - showPlaceholder = !isFocused && value.isEmpty(), - // a dismissable field keeps its button so it can always be closed, not only cleared - onClear = if (showClear && (value.isNotEmpty() || onDismiss != null)) ({ - onValueChange("") - onDismiss?.invoke() - }) else null, - modifier = Modifier.fillMaxSize(), - innerTextField = innerTextField, - ) - } + onDismiss = onDismiss, + ) } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/HudMeasure.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/HudMeasure.kt index fda5f40f8..61b495514 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/HudMeasure.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/HudMeasure.kt @@ -16,15 +16,17 @@ import kotlin.math.abs import kotlin.math.roundToInt import org.polyfrost.compose.backend.drawPoly import org.polyfrost.compose.render.PolyFont +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme +import org.polyfrost.oneconfig.internal.ui.themes.PolyfrostedDark import org.polyfrost.oneconfig.internal.ui.themes.ThemeRegistry +import org.polyfrost.oneconfig.internal.ui.themes.UITheme import org.polyfrost.oneconfig.internal.ui.themes.cornerRadiusOrNull -internal val hudMeasureColor = Color(0xFFF24822) -internal val hudSpacingColor = Color(0xFFFF3EA5) -private val hudSideGapLineColor = Color.White.copy(alpha = 0.45f) -private val hudSideGapBadgeColor = Color(0xE6202020) -private val hudGridColor = Color.White.copy(alpha = 0.07f) private val hudMeasureDashEffect = PathEffect.dashPathEffect(floatArrayOf(3f, 3f), 0f) +private val MEASURE_BADGE_RADIUS = 3.dp +private const val SIDE_GAP_LINE_ALPHA = 0.45f +private const val SIDE_GAP_BADGE_ALPHA = 0.9f +private const val GRID_LINE_ALPHA = 0.07f private const val MEASURE_EPSILON = 0.05f private const val MEASURE_TICK_PX = 3f @@ -120,10 +122,13 @@ internal fun hudMeasurement(a: Rect, b: Rect): HudMeasurement { return HudMeasurement(b, lines, extensions) } +private val measureTheme: UITheme + get() = ThemeRegistry.activeTheme ?: PolyfrostedDark + @Composable -internal fun hudBadgeRadius(): Dp = minOf(3.dp, ThemeRegistry.activeTheme?.buttonShape?.cornerRadiusOrNull() ?: 3.dp) +internal fun hudBadgeRadius(): Dp = OneConfigTheme.shape(MEASURE_BADGE_RADIUS).cornerRadiusOrNull() ?: 0.dp -private fun DrawScope.drawHudPxBadge(label: String, center: Offset, color: Color, badgeRadius: Dp) { +private fun DrawScope.drawHudPxBadge(label: String, center: Offset, color: Color, textColor: Color, badgeRadius: Dp) { val font = PolyFont.default(9f) val metrics = font.metrics val textWidth = font.measureTextWidth(label) @@ -139,7 +144,8 @@ private fun DrawScope.drawHudPxBadge(label: String, center: Offset, color: Color ) val textX = left + MEASURE_BADGE_PADDING val textY = top + (MEASURE_BADGE_HEIGHT - textHeight) / 2f - metrics.ascent - drawPoly { it.renderer.text(label, textX, textY, Color.White.toArgb(), font) } + val argb = textColor.toArgb() + drawPoly { it.renderer.text(label, textX, textY, argb, font) } } private fun DrawScope.drawHudDimension( @@ -148,6 +154,7 @@ private fun DrawScope.drawHudDimension( value: Float, lineColor: Color, badgeColor: Color, + badgeTextColor: Color, mcToScreen: Float, badgeRadius: Dp, ) { @@ -158,47 +165,58 @@ private fun DrawScope.drawHudDimension( val tick = if (horizontal) Offset(0f, MEASURE_TICK_PX) else Offset(MEASURE_TICK_PX, 0f) drawLine(lineColor, start - tick, start + tick, strokeWidth = 1f) drawLine(lineColor, end - tick, end + tick, strokeWidth = 1f) - drawHudPxBadge(formatHudPx(value), (start + end) / 2f, badgeColor, badgeRadius) + drawHudPxBadge(formatHudPx(value), (start + end) / 2f, badgeColor, badgeTextColor, badgeRadius) } internal fun DrawScope.drawHudMeasurement(measurement: HudMeasurement, mcToScreen: Float, badgeRadius: Dp) { + val theme = measureTheme + val measureColor = theme.error + val measureTextColor = theme.onError val t = measurement.target drawRect( - color = hudMeasureColor, + color = measureColor, topLeft = Offset(t.left * mcToScreen, t.top * mcToScreen), size = Size(t.width * mcToScreen, t.height * mcToScreen), style = Stroke(width = 1f), ) for ((from, to) in measurement.extensions) { - drawLine(hudMeasureColor, from * mcToScreen, to * mcToScreen, strokeWidth = 1f, pathEffect = hudMeasureDashEffect) + drawLine(measureColor, from * mcToScreen, to * mcToScreen, strokeWidth = 1f, pathEffect = hudMeasureDashEffect) } for (line in measurement.lines) { - drawHudDimension(line.from, line.to, line.value, hudMeasureColor, hudMeasureColor, mcToScreen, badgeRadius) + drawHudDimension(line.from, line.to, line.value, measureColor, measureColor, measureTextColor, mcToScreen, badgeRadius) } } internal fun DrawScope.drawHudDragFeedback(result: HudSnapResult, mcToScreen: Float, badgeRadius: Dp) { + val theme = measureTheme + val measureColor = theme.error + val spacingColor = theme.success + val spacingTextColor = theme.onSuccess + val sideGapLineColor = theme.onMedia.copy(alpha = SIDE_GAP_LINE_ALPHA) + val sideGapBadgeColor = theme.pageSurface.copy(alpha = SIDE_GAP_BADGE_ALPHA) + val sideGapTextColor = theme.textColor for (guide in result.guides) { val p = guide.position * mcToScreen val a = guide.start * mcToScreen val b = guide.end * mcToScreen - if (guide.vertical) drawLine(hudMeasureColor, Offset(p, a), Offset(p, b), strokeWidth = 1f) - else drawLine(hudMeasureColor, Offset(a, p), Offset(b, p), strokeWidth = 1f) + if (guide.vertical) drawLine(measureColor, Offset(p, a), Offset(p, b), strokeWidth = 1f) + else drawLine(measureColor, Offset(a, p), Offset(b, p), strokeWidth = 1f) } for (line in result.sideGaps) { val start = line.from * mcToScreen val end = line.to * mcToScreen - drawLine(hudSideGapLineColor, start, end, strokeWidth = 1f, pathEffect = hudMeasureDashEffect) - drawHudPxBadge(formatHudPx(line.value), (start + end) / 2f, hudSideGapBadgeColor, badgeRadius) + drawLine(sideGapLineColor, start, end, strokeWidth = 1f, pathEffect = hudMeasureDashEffect) + drawHudPxBadge(formatHudPx(line.value), (start + end) / 2f, sideGapBadgeColor, sideGapTextColor, badgeRadius) } for (gap in result.gaps) { - drawHudDimension(gap.from, gap.to, gap.size, hudSpacingColor, hudSpacingColor, mcToScreen, badgeRadius) + drawHudDimension(gap.from, gap.to, gap.size, spacingColor, spacingColor, spacingTextColor, mcToScreen, badgeRadius) } } internal fun DrawScope.drawHudGrid(step: Float, mcToScreen: Float) { val px = step * mcToScreen if (px < 2f) return + val hudGridColor = measureTheme.onMedia.copy(alpha = GRID_LINE_ALPHA) var x = px while (x < size.width) { drawLine(hudGridColor, Offset(x, 0f), Offset(x, size.height), strokeWidth = 1f) diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/HudTour.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/HudTour.kt index bc97f6d7b..ef26a6eba 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/HudTour.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/HudTour.kt @@ -1,36 +1,25 @@ package org.polyfrost.oneconfig.internal.ui.hud.screens import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.keyframes -import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.RoundRect import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.PathFillType -import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.PointerIcon -import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight @@ -40,14 +29,15 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlin.math.roundToInt import org.polyfrost.oneconfig.api.platform.v1.Platform +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.api.ui.v1.components.ControlSize +import org.polyfrost.oneconfig.api.ui.v1.components.ControlStyle +import org.polyfrost.oneconfig.api.ui.v1.components.ModalPanel +import org.polyfrost.oneconfig.api.ui.v1.components.TextButton +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme import org.polyfrost.oneconfig.internal.ui.components.Icon -import org.polyfrost.oneconfig.internal.ui.components.ReducedMotion import org.polyfrost.oneconfig.internal.ui.components.Text -import org.polyfrost.oneconfig.internal.ui.components.liquidGlass -import org.polyfrost.oneconfig.internal.ui.components.onClick -import org.polyfrost.oneconfig.internal.ui.components.rememberInteractionSource import org.polyfrost.oneconfig.internal.ui.themes.Accent -import org.polyfrost.oneconfig.internal.ui.themes.animateAccentColorAsState import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme import org.polyfrost.oneconfig.internal.ui.themes.cornerRadiusOrNull @@ -86,8 +76,10 @@ internal enum class HudTourStep( internal fun tourString(key: String): String = Platform.i18n().translateString("oneconfig.hud.tour.$key") -private val tourScrimColor = Color.Black.copy(0.6f) private val TOUR_CARD_WIDTH = 280.dp +private val SPOTLIGHT_RADIUS = 8.dp +private val GHOST_RADIUS = 4.dp +private const val TOUR_MOTION_MILLIS = 220 private fun placeTourCard( root: IntSize, @@ -144,13 +136,14 @@ internal fun HudTourOverlay( val hole = focus?.inflate(with(density) { 6.dp.toPx() }) val target = hole ?: Rect(Offset(rootSize.width / 2f, rootSize.height / 2f), Size.Zero) - val holeSpec = tween(if (ReducedMotion.enabled) 0 else 220) - val holeLeft by animateFloatAsState(target.left, holeSpec, label = "tourHoleLeft") - val holeTop by animateFloatAsState(target.top, holeSpec, label = "tourHoleTop") - val holeRight by animateFloatAsState(target.right, holeSpec, label = "tourHoleRight") - val holeBottom by animateFloatAsState(target.bottom, holeSpec, label = "tourHoleBottom") - val ringAlpha by animateFloatAsState(if (hole != null) 1f else 0f, holeSpec, label = "tourRingAlpha") - val scrimAlpha by animateFloatAsState(if (step.interactive) 0f else 1f, tween(220), label = "tourScrimAlpha") + val holeSpec = tween(TOUR_MOTION_MILLIS) + val holeLeft by Motion.animateFloatAsState(target.left, holeSpec, label = "tourHoleLeft") + val holeTop by Motion.animateFloatAsState(target.top, holeSpec, label = "tourHoleTop") + val holeRight by Motion.animateFloatAsState(target.right, holeSpec, label = "tourHoleRight") + val holeBottom by Motion.animateFloatAsState(target.bottom, holeSpec, label = "tourHoleBottom") + val ringAlpha by Motion.animateFloatAsState(if (hole != null) 1f else 0f, holeSpec, label = "tourRingAlpha") + val scrimAlpha by Motion.animateFloatAsState(if (step.interactive) 0f else 1f, holeSpec, label = "tourScrimAlpha") + val scrimColor = theme.scrimColor val measured = rootSize.width > 0 && cardSize.width > 0 var cardPlaced by remember { mutableStateOf(false) } @@ -167,9 +160,9 @@ internal fun HudTourOverlay( } else { Offset.Zero } - val cardSpec = tween(durationMillis = if (cardPlaced && !ReducedMotion.enabled) 220 else 0) - val cardX by animateFloatAsState(cardSlot.x, cardSpec, label = "tourCardX") - val cardY by animateFloatAsState(cardSlot.y, cardSpec, label = "tourCardY") + val cardSpec = tween(durationMillis = if (cardPlaced) TOUR_MOTION_MILLIS else 0) + val cardX by Motion.animateFloatAsState(cardSlot.x, cardSpec, label = "tourCardX") + val cardY by Motion.animateFloatAsState(cardSlot.y, cardSpec, label = "tourCardY") LaunchedEffect(measured) { if (measured) cardPlaced = true } Box( @@ -177,9 +170,9 @@ internal fun HudTourOverlay( .fillMaxSize() .onSizeChanged { rootSize = it } ) { - val spotlightRadius = theme.modCardShape.cornerRadiusOrNull() ?: 0.dp + val spotlightRadius = OneConfigTheme.shape(SPOTLIGHT_RADIUS).cornerRadiusOrNull() ?: 0.dp Canvas(Modifier.fillMaxSize()) { - val radius = CornerRadius(minOf(8.dp, spotlightRadius).toPx()) + val radius = CornerRadius(spotlightRadius.toPx()) val cutout = Path().apply { fillType = PathFillType.EvenOdd val margin = 2.dp.toPx() @@ -193,7 +186,7 @@ internal fun HudTourOverlay( ) addRoundRect(RoundRect(holeLeft, holeTop, holeRight, holeBottom, radius)) } - drawPath(cutout, tourScrimColor.copy(alpha = tourScrimColor.alpha * scrimAlpha)) + drawPath(cutout, scrimColor.copy(alpha = scrimColor.alpha * scrimAlpha)) if (ringAlpha > 0f) { drawRoundRect( color = Accent.copy(alpha = ringAlpha), @@ -207,7 +200,7 @@ internal fun HudTourOverlay( if (dragHint != null) TourDragHint(dragHint.first, dragHint.second) - Column( + ModalPanel( modifier = Modifier .align(Alignment.TopStart) .offset { @@ -217,20 +210,17 @@ internal fun HudTourOverlay( } .graphicsLayer { alpha = if (measured) 1f else 0f } .then(cardModifier) - .onSizeChanged { cardSize = it } - .width(TOUR_CARD_WIDTH) - .liquidGlass(theme.popupShape, frosted = true) - .background(theme.popupBackground, theme.popupShape) - .border(1.dp, theme.borderColor, theme.popupShape) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + .onSizeChanged { cardSize = it }, + width = TOUR_CARD_WIDTH, + padding = PaddingValues(16.dp), + spacing = 8.dp, ) { Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { for (entry in HudTourStep.entries) { Box( Modifier .size(6.dp) - .background(if (entry.ordinal <= step.ordinal) Accent else theme.borderColor, CircleShape) + .background(if (entry.ordinal <= step.ordinal) Accent else theme.borderColor, theme.shapes.circle) ) } } @@ -270,10 +260,11 @@ internal fun HudTourOverlay( @Composable private fun TourDragHint(from: Rect, to: Rect) { - val progress = if (ReducedMotion.enabled) 0f else tourDragProgress() - val ghostRadius = LocalTheme.current.buttonShape.cornerRadiusOrNull() ?: 0.dp + val progress = tourDragProgress() + val ghostRadius = OneConfigTheme.shape(GHOST_RADIUS).cornerRadiusOrNull() ?: 0.dp + val handleRing = LocalTheme.current.onMedia Canvas(Modifier.fillMaxSize()) { - val radius = CornerRadius(minOf(4.dp, ghostRadius).toPx()) + val radius = CornerRadius(ghostRadius.toPx()) val stroke = 1.5.dp.toPx() val dash = PathEffect.dashPathEffect(floatArrayOf(6.dp.toPx(), 4.dp.toPx())) drawRoundRect( @@ -287,18 +278,17 @@ private fun TourDragHint(from: Rect, to: Rect) { val ghost = from.translate((to.topLeft - from.topLeft) * progress) drawRoundRect(Accent.copy(alpha = 0.2f), ghost.topLeft, ghost.size, radius) drawRoundRect(Accent, ghost.topLeft, ghost.size, radius, style = Stroke(width = stroke)) - drawCircle(Color.White, radius = 7.dp.toPx(), center = ghost.center) + drawCircle(handleRing, radius = 7.dp.toPx(), center = ghost.center) drawCircle(Accent, radius = 5.dp.toPx(), center = ghost.center) } } @Composable private fun tourDragProgress(): Float { - val transition = rememberInfiniteTransition(label = "tourDragHint") - val progress by transition.animateFloat( - initialValue = 0f, - targetValue = 1f, - animationSpec = infiniteRepeatable( + val progress by Motion.animateInfiniteFloat( + initial = 0f, + target = 1f, + spec = infiniteRepeatable( keyframes { durationMillis = 2200 0f at 0 @@ -314,29 +304,11 @@ private fun tourDragProgress(): Float { @Composable private fun TourButton(text: String, primary: Boolean, enabled: Boolean = true, onClick: () -> Unit) { - val theme = LocalTheme.current - val source = rememberInteractionSource() - val isHovered by source.collectIsHoveredAsState() - val base = if (primary) Accent else Color.Transparent - val background by animateAccentColorAsState( - listOf(isHovered, enabled, primary), - if (isHovered && enabled) (if (primary) Color.White else theme.textColor).copy(0.12f).compositeOver(base) else base + TextButton( + text, + onClick, + style = if (primary) ControlStyle.Primary else ControlStyle.Ghost, + size = ControlSize.Compact, + enabled = enabled, ) - val alpha by animateFloatAsState(if (enabled) 1f else 0.4f, label = "tourButtonAlpha") - Box( - modifier = Modifier - .graphicsLayer { this.alpha = alpha } - .clip(theme.buttonShape) - .background(background, theme.buttonShape) - .onClick(source, enabled, onClick) - .pointerHoverIcon(if (enabled) PointerIcon.Hand else PointerIcon.Default) - .padding(horizontal = 12.dp, vertical = 6.dp), - ) { - Text( - text, - color = if (primary) theme.accentTextColor else theme.textColorSecondary, - fontSize = 12.sp, - fontWeight = FontWeight.Medium, - ) - } } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/sections/Cards.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/sections/Cards.kt index 6f992fcaa..db3416656 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/sections/Cards.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/sections/Cards.kt @@ -29,11 +29,13 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.polyfrost.oneconfig.api.config.v1.Property -import org.polyfrost.oneconfig.internal.ui.api.Tooltip -import org.polyfrost.oneconfig.internal.ui.components.IconButton -import org.polyfrost.oneconfig.internal.ui.components.ReducedMotion +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.api.ui.v1.components.ControlSize +import org.polyfrost.oneconfig.api.ui.v1.components.IconButton +import org.polyfrost.oneconfig.api.ui.v1.components.TooltipPlacement import org.polyfrost.oneconfig.internal.ui.components.Text import org.polyfrost.oneconfig.internal.ui.components.blockInteraction +import org.polyfrost.oneconfig.internal.ui.components.translated import org.polyfrost.oneconfig.internal.ui.components.settings.SwitchControl import org.polyfrost.oneconfig.internal.ui.screens.Accordion import org.polyfrost.oneconfig.internal.ui.screens.displayAlpha @@ -47,6 +49,8 @@ internal val GridCol3 = 96.dp private val CardRowGap = 12.dp +internal fun hudPanelText(key: String, fallback: String): String = translated("oneconfig.hud.panel.$key", fallback) + /** Whether a HUD panel block described by [labels] is kept by the panel's search [query] */ internal fun hudSearchMatches(query: String, vararg labels: String) = query.isEmpty() || labels.any { searchMatches(it, query) } @@ -88,20 +92,15 @@ internal fun CardSection( }, titleActions = { if (onReset != null && canReset) { - val theme = LocalTheme.current - Tooltip( - text = { Text("Reset $title", color = theme.textColor, fontSize = 12.sp) }, - modifier = Modifier, - anchor = Alignment.TopCenter, - ) { - IconButton( - "refresh", - Modifier.size(24.dp, 20.dp).padding(horizontal = 4.dp, vertical = 2.dp), - foreground = theme.textColorSecondary, - hoveredForeground = theme.textColor, - onClick = onReset, - ) - } + val label = translated("oneconfig.hud.panel.reset_card", "Reset %s", title) + IconButton( + "refresh", + label, + onReset, + Modifier.size(24.dp, 20.dp), + size = ControlSize.Compact, + tooltipPlacement = TooltipPlacement.AboveCenter, + ) } }, ) { enabled -> @@ -175,8 +174,8 @@ internal fun RowWithFold(unfolded: Boolean, row: @Composable () -> Unit, folded: row() AnimatedVisibility( visible = unfolded, - enter = ReducedMotion.enter(expandVertically(spring(stiffness = Spring.StiffnessMediumLow)) + fadeIn()), - exit = ReducedMotion.exit(shrinkVertically(spring(stiffness = Spring.StiffnessMediumLow)) + fadeOut()), + enter = Motion.enter(expandVertically(spring(stiffness = Spring.StiffnessMediumLow)) + fadeIn()), + exit = Motion.exit(shrinkVertically(spring(stiffness = Spring.StiffnessMediumLow)) + fadeOut()), ) { Column(Modifier.padding(top = CardRowGap), verticalArrangement = Arrangement.spacedBy(CardRowGap)) { folded() } } @@ -186,5 +185,5 @@ internal fun RowWithFold(unfolded: Boolean, row: @Composable () -> Unit, folded: /** A grid cell whose [content] fades in and out with [visible] while the cell keeps its place */ @Composable internal fun FadingCell(visible: Boolean, content: @Composable () -> Unit) { - AnimatedVisibility(visible, enter = ReducedMotion.enter(fadeIn()), exit = ReducedMotion.exit(fadeOut())) { content() } + AnimatedVisibility(visible, enter = Motion.enter(fadeIn()), exit = Motion.exit(fadeOut())) { content() } } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/sections/Layout.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/sections/Layout.kt index f859441e7..91a7bdaff 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/sections/Layout.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/sections/Layout.kt @@ -74,16 +74,16 @@ fun Layout(hud: Hud?, query: String) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { val scalable = hud !is LegacyHudMarker || hud.supportsScale CardSection( - if (scalable) "Position & Scale" else "Position", query, - "X", "Y", "Lock position", "Reset to default position", - *if (scalable) arrayOf("Scale", "Follow GUI scale", "Use Minecraft GUI Scale") else emptyArray(), + if (scalable) hudPanelText("position_scale", "Position & Scale") else hudPanelText("position", "Position"), query, + "X", "Y", hudPanelText("lock_position", "Lock position"), "Reset to default position", + *if (scalable) arrayOf(hudPanelText("scale", "Scale"), hudPanelText("follow_gui_scale", "Follow GUI scale"), "Use Minecraft GUI Scale") else emptyArray(), onReset = { resetHudOptions(hud, POSITION_SCALE_OPTIONS, position = true) }, canReset = hudOptionsCanReset(hud, POSITION_SCALE_OPTIONS, position = true), ) { col1 -> HudPositionFields(hud, col1) { if (scalable) { HudSettingTarget(hud, "customScale") { - LabeledControl("Scale") { + LabeledControl(hudPanelText("scale", "Scale")) { NumberSpinner( hud.customScale, { Snapshot.withMutableSnapshot { hud.customScale = it } }, 0.25f, 4f, 0.25f, width = GridCol3, unit = "x" @@ -96,17 +96,17 @@ fun Layout(hud: Hud?, query: String) { val secondary = LocalTheme.current.textColorSecondary Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text(placementLabel(it), color = secondary, fontSize = 12.sp) - if (!it.moved && it.scaled) Text("Reset position to follow it", color = secondary, fontSize = 12.sp) + if (!it.moved && it.scaled) Text(hudPanelText("reset_to_follow", "Reset position to follow it"), color = secondary, fontSize = 12.sp) } } HudSettingTarget(hud, "locked") { - SwitchRow("Lock position", locked) { + SwitchRow(hudPanelText("lock_position", "Lock position"), locked) { Snapshot.withMutableSnapshot { locked = it; hud.locked = it } } } if (hud !is LegacyHudMarker) { HudSettingTarget(hud, "useGuiScale") { - SwitchRow("Follow GUI scale", hud.useGuiScale) { + SwitchRow(hudPanelText("follow_gui_scale", "Follow GUI scale"), hud.useGuiScale) { Snapshot.withMutableSnapshot { setFollowGuiScale(hud, it) } } } @@ -115,7 +115,7 @@ fun Layout(hud: Hud?, query: String) { if (hud !is LegacyHudMarker) { CardSection( - "Size & Alignment", query, "Static size", "Width", "Height", "Anchor", "Content alignment", + hudPanelText("size_alignment", "Size & Alignment"), query, hudPanelText("static_size", "Static size"), hudPanelText("width", "Width"), hudPanelText("height", "Height"), hudPanelText("anchor", "Anchor"), hudPanelText("content_alignment", "Content alignment"), "Alignment", "Growth anchor", onReset = { resetHudOptions(hud, SIZE_OPTIONS) }, canReset = hudOptionsCanReset(hud, SIZE_OPTIONS), @@ -124,7 +124,7 @@ fun Layout(hud: Hud?, query: String) { staticWidth, row = { HudSettingTarget(hud, "staticWidth") { - SwitchRow("Static size", staticWidth) { + SwitchRow(hudPanelText("static_size", "Static size"), staticWidth) { Snapshot.withMutableSnapshot { staticWidth = it; hud.staticWidth = it if (it) { @@ -142,7 +142,7 @@ fun Layout(hud: Hud?, query: String) { col1, first = { HudSettingTarget(hud, "staticW") { - LabeledControl("Width") { + LabeledControl(hudPanelText("width", "Width")) { NumberSpinner( staticW, { Snapshot.withMutableSnapshot { staticW = it; hud.staticW = it } }, 20f, 2000f, 1f, width = col1, unit = "px", scrub = true, math = true, @@ -152,7 +152,7 @@ fun Layout(hud: Hud?, query: String) { }, second = { HudSettingTarget(hud, "staticH") { - LabeledControl("Height") { + LabeledControl(hudPanelText("height", "Height")) { NumberSpinner( staticH, { Snapshot.withMutableSnapshot { staticH = it; hud.staticH = it } }, 8f, 2000f, 1f, width = GridCol2, unit = "px", scrub = true, math = true, @@ -165,7 +165,7 @@ fun Layout(hud: Hud?, query: String) { ) HudSettingTarget(hud, if (staticWidth) "alignment" else "growthAnchor") { - LabeledControl(if (staticWidth) "Content alignment" else "Anchor") { + LabeledControl(if (staticWidth) hudPanelText("content_alignment", "Content alignment") else hudPanelText("anchor", "Anchor")) { Row( horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically, @@ -182,8 +182,8 @@ fun Layout(hud: Hud?, query: String) { } } Text( - if (staticWidth) "Aligns the content inside the fixed box" - else "Pins this corner or edge in place;\nthe HUD only grows away from it", + if (staticWidth) hudPanelText("content_alignment.hint", "Aligns the content inside the fixed box") + else hudPanelText("anchor.hint", "Pins this corner or edge in place;\nthe HUD only grows away from it"), color = LocalTheme.current.textColorSecondary, fontSize = 12.sp ) @@ -193,12 +193,12 @@ fun Layout(hud: Hud?, query: String) { } CardSection( - "Padding", query, "Horizontal", "Vertical", "Per side", "Left", "Right", "Top", "Bottom", + hudPanelText("padding", "Padding"), query, hudPanelText("horizontal", "Horizontal"), hudPanelText("vertical", "Vertical"), hudPanelText("per_side", "Per side"), hudPanelText("left", "Left"), hudPanelText("right", "Right"), hudPanelText("top", "Top"), hudPanelText("bottom", "Bottom"), onReset = { resetHudOptions(hud, PADDING_OPTIONS) }, canReset = hudOptionsCanReset(hud, PADDING_OPTIONS), ) { col1 -> val perSideSwitch: @Composable () -> Unit = { - LabeledControl("Per side") { + LabeledControl(hudPanelText("per_side", "Per side")) { SwitchControl(perSide) { perSide = it // leaving per side mode evens the sides out so the two fields show what is applied @@ -217,9 +217,9 @@ fun Layout(hud: Hud?, query: String) { col1, first = { if (perSide) { - HudSettingTarget(hud, "padLeft") { PadSpinner("Left", padLeft, col1) { padLeft = it; hud.padLeft = it } } + HudSettingTarget(hud, "padLeft") { PadSpinner(hudPanelText("left", "Left"), padLeft, col1) { padLeft = it; hud.padLeft = it } } } else { - PadSpinner("Horizontal", padLeft, col1) { + PadSpinner(hudPanelText("horizontal", "Horizontal"), padLeft, col1) { padLeft = it; hud.padLeft = it padRight = it; hud.padRight = it } @@ -227,9 +227,9 @@ fun Layout(hud: Hud?, query: String) { }, second = { if (perSide) { - HudSettingTarget(hud, "padTop") { PadSpinner("Top", padTop, GridCol2) { padTop = it; hud.padTop = it } } + HudSettingTarget(hud, "padTop") { PadSpinner(hudPanelText("top", "Top"), padTop, GridCol2) { padTop = it; hud.padTop = it } } } else { - PadSpinner("Vertical", padTop, GridCol2) { + PadSpinner(hudPanelText("vertical", "Vertical"), padTop, GridCol2) { padTop = it; hud.padTop = it padBottom = it; hud.padBottom = it } @@ -241,8 +241,8 @@ fun Layout(hud: Hud?, query: String) { folded = { GridRow( col1, - first = { HudSettingTarget(hud, "padRight") { PadSpinner("Right", padRight, col1) { padRight = it; hud.padRight = it } } }, - second = { HudSettingTarget(hud, "padBottom") { PadSpinner("Bottom", padBottom, GridCol2) { padBottom = it; hud.padBottom = it } } }, + first = { HudSettingTarget(hud, "padRight") { PadSpinner(hudPanelText("right", "Right"), padRight, col1) { padRight = it; hud.padRight = it } } }, + second = { HudSettingTarget(hud, "padBottom") { PadSpinner(hudPanelText("bottom", "Bottom"), padBottom, GridCol2) { padBottom = it; hud.padBottom = it } } }, ) }, ) diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/sections/Settings.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/sections/Settings.kt index 5c74f036f..bc3089db1 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/sections/Settings.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/sections/Settings.kt @@ -38,28 +38,28 @@ fun Settings(hud: Hud?, query: String) { hudOptionsCanReset(hud, VISIBILITY_OPTIONS) } CardSection( - "Visibility", query, "Show in F3 Screen", "Show in Tab List", "Show in GUIs", "Show in Chat", + hudPanelText("visibility", "Visibility"), query, hudPanelText("show_in_f3", "Show in F3 Screen"), hudPanelText("show_in_tab", "Show in Tab List"), hudPanelText("show_in_screens", "Show in GUIs"), hudPanelText("show_in_chat", "Show in Chat"), onReset = { resetHudOptions(hud, VISIBILITY_OPTIONS) }, canReset = canReset, collapsedByDefault = !canReset, ) { HudSettingTarget(hud, "showInF3") { - SwitchRow("Show in F3 Screen", showInF3) { + SwitchRow(hudPanelText("show_in_f3", "Show in F3 Screen"), showInF3) { Snapshot.withMutableSnapshot { showInF3 = it; hud.showInF3 = it } } } HudSettingTarget(hud, "showInTab") { - SwitchRow("Show in Tab List", showInTab) { + SwitchRow(hudPanelText("show_in_tab", "Show in Tab List"), showInTab) { Snapshot.withMutableSnapshot { showInTab = it; hud.showInTab = it } } } HudSettingTarget(hud, "showInScreens") { - SwitchRow("Show in GUIs", showInScreens) { + SwitchRow(hudPanelText("show_in_screens", "Show in GUIs"), showInScreens) { Snapshot.withMutableSnapshot { showInScreens = it; hud.showInScreens = it } } } HudSettingTarget(hud, "showInChat") { - SwitchRow("Show in Chat", showInChat) { + SwitchRow(hudPanelText("show_in_chat", "Show in Chat"), showInChat) { Snapshot.withMutableSnapshot { showInChat = it; hud.showInChat = it } } } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/sections/Style.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/sections/Style.kt index b9e59342b..adeaec07a 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/sections/Style.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/screens/sections/Style.kt @@ -1,3 +1,5 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + package org.polyfrost.oneconfig.internal.ui.hud.screens.sections import androidx.compose.foundation.background @@ -25,12 +27,12 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.polyfrost.oneconfig.api.hud.v1.Font import org.polyfrost.oneconfig.api.hud.v1.Hud +import org.polyfrost.compose.render.PolyColor import org.polyfrost.oneconfig.api.hud.v1.Weight -import org.polyfrost.oneconfig.internal.ui.components.SelectableIconButton -import org.polyfrost.oneconfig.internal.ui.components.Text -import org.polyfrost.oneconfig.internal.ui.components.settings.ChromaColorAnimation -import org.polyfrost.oneconfig.internal.ui.components.settings.ColorPicker -import org.polyfrost.oneconfig.internal.ui.components.settings.ColorPickerModel +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.components.ColorSwatchButton +import org.polyfrost.oneconfig.api.ui.v1.components.ControlSize +import org.polyfrost.oneconfig.api.ui.v1.components.IconButton import org.polyfrost.oneconfig.internal.ui.components.settings.Dropdown import org.polyfrost.oneconfig.internal.ui.components.settings.NumberSpinner import org.polyfrost.oneconfig.internal.ui.components.settings.Radio @@ -48,7 +50,7 @@ private val TEXT_OPTIONS = listOf( ) private val BACKGROUND_OPTIONS = listOf( "showBackground", "bgRadius", "bgColor", "bgChroma", "bgChromaSpeed", "mergeBackground", "mergeDiagonally", - "keepBgWhenHidden", + "keepBgWhenHidden", "borderEnabled", "borderColor", "borderChroma", "borderChromaSpeed", "borderSize", ) enum class CaseType(val icon: String) { @@ -110,11 +112,17 @@ fun Style(hud: Hud?, query: String) { var mergeBackground by remember { mutableStateOf(hud.mergeBackground) } var mergeDiagonally by remember { mutableStateOf(hud.mergeDiagonally) } var keepBgWhenHidden by remember { mutableStateOf(hud.keepBgWhenHidden) } + var borderEnabled by remember { mutableStateOf(hud.borderEnabled) } + var borderSize by remember { mutableStateOf(hud.borderSize) } + var borderColor by remember { mutableStateOf(Color(hud.borderColor)) } + var borderChroma by remember { mutableStateOf(hud.borderChroma) } + var borderChromaSpeed by remember { mutableStateOf(hud.borderChromaSpeed) } + val hasBorderOptions = hud.hasBuiltInBorderOptions && hud.tree?.getProp("borderEnabled") != null Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { CardSection( - "Text", query, "Font", "Size", "Font size", "Modifiers", "Bold", "Italic", "Underline", "Weight", "Case", "Case Type", - "Color", "Text Color", "Shadow", "Normal", "Full", "Shadow color", + hudPanelText("text", "Text"), query, hudPanelText("font", "Font"), hudPanelText("size", "Size"), "Font size", hudPanelText("modifiers", "Modifiers"), "Bold", "Italic", "Underline", hudPanelText("weight", "Weight"), hudPanelText("case", "Case"), "Case Type", + hudPanelText("color", "Color"), "Text Color", hudPanelText("shadow", "Shadow"), hudPanelText("mode.normal", "Normal"), hudPanelText("mode.full", "Full"), hudPanelText("shadow_color", "Shadow color"), onReset = { resetHudOptions(hud, TEXT_OPTIONS) }, canReset = hudOptionsCanReset(hud, TEXT_OPTIONS), ) { col1 -> @@ -122,7 +130,7 @@ fun Style(hud: Hud?, query: String) { col1, first = { HudSettingTarget(hud, "font") { - LabeledControl("Font") { + LabeledControl(hudPanelText("font", "Font")) { Dropdown(Font.entries.map { it.name }, font.ordinal, onSelect = { Snapshot.withMutableSnapshot { font = Font.entries[it]; hud.font = font } }, width = col1) @@ -131,7 +139,7 @@ fun Style(hud: Hud?, query: String) { }, second = { HudSettingTarget(hud, "textScale") { - LabeledControl("Size") { + LabeledControl(hudPanelText("size", "Size")) { NumberSpinner( hud.textScale * 100f, { Snapshot.withMutableSnapshot { hud.textScale = it / 100f } }, 25f, 500f, 5f, width = GridCol2, unit = "%" @@ -141,7 +149,7 @@ fun Style(hud: Hud?, query: String) { }, third = { HudSettingTarget(hud, "textBold") { - LabeledControl("Modifiers") { + LabeledControl(hudPanelText("modifiers", "Modifiers")) { Box( modifier = Modifier .fillMaxWidth() @@ -149,34 +157,40 @@ fun Style(hud: Hud?, query: String) { .border(1.dp, LocalTheme.current.borderColor, LocalTheme.current.buttonShape) ) { Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically, ) { - SelectableIconButton( + IconButton( Modifiers.Bold.icon, + hudPanelText("bold", "Bold"), + modifier = Modifier.size(28.dp), + size = ControlSize.Large, selected = textBold, - modifier = Modifier.size(18.dp), onClick = { Snapshot.withMutableSnapshot { textBold = !textBold; hud.textBold = textBold } } ) - SelectableIconButton( + IconButton( Modifiers.Italic.icon, + hudPanelText("italic", "Italic"), + modifier = Modifier.size(28.dp), + size = ControlSize.Large, selected = textItalic, - modifier = Modifier.size(18.dp), onClick = { Snapshot.withMutableSnapshot { textItalic = !textItalic; hud.textItalic = textItalic } } ) - SelectableIconButton( + IconButton( Modifiers.Underline.icon, + hudPanelText("underline", "Underline"), + modifier = Modifier.size(28.dp), + size = ControlSize.Large, selected = textUnderline, - modifier = Modifier.size(18.dp), onClick = { Snapshot.withMutableSnapshot { textUnderline = !textUnderline; hud.textUnderline = textUnderline @@ -193,7 +207,7 @@ fun Style(hud: Hud?, query: String) { col1, first = { HudSettingTarget(hud, "textWeight") { - LabeledControl("Weight") { + LabeledControl(hudPanelText("weight", "Weight")) { Dropdown(Weight.entries.map { it.name }, textWeight.ordinal, onSelect = { Snapshot.withMutableSnapshot { textWeight = Weight.entries[it]; hud.textWeight = textWeight } }, width = col1) @@ -202,7 +216,7 @@ fun Style(hud: Hud?, query: String) { }, second = { HudSettingTarget(hud, "caseType") { - LabeledControl("Case") { + LabeledControl(hudPanelText("case", "Case")) { Radio( CaseType.entries.map { null }, caseType.ordinal, fillWidth = true, icons = CaseType.entries.map { it.icon }, @@ -214,7 +228,7 @@ fun Style(hud: Hud?, query: String) { }, third = { HudSettingTarget(hud, "textColor") { - LabeledControl("Text color") { + LabeledControl(hudPanelText("text_color", "Text color")) { ColorSwatch( textColor, chroma = textChroma, chromaSpeed = textChromaSpeed, @@ -235,14 +249,14 @@ fun Style(hud: Hud?, query: String) { GridRow( col1, wide = { - LabeledControl("Shadow") { + LabeledControl(hudPanelText("shadow", "Shadow")) { val shadowMode = when { !showShadow -> ShadowMode.Off fullShadow -> ShadowMode.Full else -> ShadowMode.Normal } HudSettingTarget(hud, "showShadow", "fullShadow") { - Radio(ShadowMode.entries.map { it.title }, shadowMode.ordinal, fillWidth = true) { + Radio(ShadowMode.entries.map { hudPanelText("mode.${it.name.lowercase()}", it.title) }, shadowMode.ordinal, fillWidth = true) { val mode = ShadowMode.entries[it] Snapshot.withMutableSnapshot { showShadow = mode != ShadowMode.Off @@ -259,7 +273,7 @@ fun Style(hud: Hud?, query: String) { third = { FadingCell(showShadow) { HudSettingTarget(hud, "shadowColor") { - LabeledControl("Shadow color") { + LabeledControl(hudPanelText("shadow_color", "Shadow color")) { ColorSwatch( shadowColor, chroma = shadowChroma, chromaSpeed = shadowChromaSpeed, @@ -281,8 +295,9 @@ fun Style(hud: Hud?, query: String) { } CardSection( - "Background", query, "Show Background", "Radius", "Color", "Background Color", "Merge", - "Merge with neighbours", "Diagonal", "Keep hidden", "Keep Background When Hidden", + hudPanelText("background", "Background"), query, "Show Background", hudPanelText("radius", "Radius"), hudPanelText("color", "Color"), "Background Color", "Merge", + hudPanelText("merge", "Merge with neighbours"), hudPanelText("mode.diagonal", "Diagonal"), hudPanelText("keep_hidden", "Keep hidden"), "Keep Background When Hidden", hudPanelText("border", "Border"), hudPanelText("border_size", "Border size"), + hudPanelText("border_color", "Border color"), checked = showBackground, onCheckedChange = { Snapshot.withMutableSnapshot { showBackground = it; hud.showBackground = it } }, onReset = { resetHudOptions(hud, BACKGROUND_OPTIONS) }, @@ -292,7 +307,7 @@ fun Style(hud: Hud?, query: String) { col1, first = { HudSettingTarget(hud, "bgRadius") { - LabeledControl("Radius") { + LabeledControl(hudPanelText("radius", "Radius")) { NumberSpinner( bgRadius, { Snapshot.withMutableSnapshot { bgRadius = it; hud.bgRadius = it } }, 0f, 32f, 1f, width = col1, unit = "px" @@ -302,7 +317,7 @@ fun Style(hud: Hud?, query: String) { }, third = { HudSettingTarget(hud, "bgColor") { - LabeledControl("Color") { + LabeledControl(hudPanelText("color", "Color")) { ColorSwatch( bgColor, chroma = bgChroma, chromaSpeed = bgChromaSpeed, @@ -320,18 +335,65 @@ fun Style(hud: Hud?, query: String) { } }, ) + if (hasBorderOptions) { + GridRow( + col1, + first = { + HudSettingTarget(hud, "borderEnabled") { + LabeledControl(hudPanelText("border", "Border")) { + SwitchControl(borderEnabled) { + Snapshot.withMutableSnapshot { borderEnabled = it; hud.borderEnabled = it } + } + } + } + }, + second = { + FadingCell(borderEnabled) { + HudSettingTarget(hud, "borderSize") { + LabeledControl(hudPanelText("border_size", "Border size")) { + NumberSpinner( + borderSize, { Snapshot.withMutableSnapshot { borderSize = it; hud.borderSize = it } }, + 0f, 10f, 0.5f, width = GridCol2, unit = "px" + ) + } + } + } + }, + third = { + FadingCell(borderEnabled) { + HudSettingTarget(hud, "borderColor", "borderChroma", "borderChromaSpeed") { + LabeledControl(hudPanelText("border_color", "Border color")) { + ColorSwatch( + borderColor, + chroma = borderChroma, chromaSpeed = borderChromaSpeed, + onColorChanged = { + Snapshot.withMutableSnapshot { borderColor = it; hud.borderColor = it.toArgb() } + }, + onChromaChanged = { en, sp -> + Snapshot.withMutableSnapshot { + borderChroma = en; hud.borderChroma = en + borderChromaSpeed = sp; hud.borderChromaSpeed = sp + } + }, + ) + } + } + } + }, + ) + } if (hud.canMergeBackground()) { GridRow( col1, wide = { - LabeledControl("Merge with neighbours") { + LabeledControl(hudPanelText("merge", "Merge with neighbours")) { val mergeMode = when { !mergeBackground -> MergeMode.Off mergeDiagonally -> MergeMode.Diagonal else -> MergeMode.Normal } HudSettingTarget(hud, "mergeBackground", "mergeDiagonally") { - Radio(MergeMode.entries.map { it.title }, mergeMode.ordinal, fillWidth = true) { + Radio(MergeMode.entries.map { hudPanelText("mode.${it.name.lowercase()}", it.title) }, mergeMode.ordinal, fillWidth = true) { val mode = MergeMode.entries[it] Snapshot.withMutableSnapshot { mergeBackground = mode != MergeMode.Off @@ -348,7 +410,7 @@ fun Style(hud: Hud?, query: String) { third = { FadingCell(mergeBackground) { HudSettingTarget(hud, "keepBgWhenHidden") { - LabeledControl("Keep hidden") { + LabeledControl(hudPanelText("keep_hidden", "Keep hidden")) { SwitchControl(keepBgWhenHidden) { Snapshot.withMutableSnapshot { keepBgWhenHidden = it; hud.keepBgWhenHidden = it } } @@ -372,23 +434,12 @@ private fun ColorSwatch( onColorChanged: (Color) -> Unit, onChromaChanged: (Boolean, Float) -> Unit = { _, _ -> }, ) { - val pickerModel = remember { - ColorPickerModel(color).apply { - chromaEnabled = chroma - this.chromaSpeed = chromaSpeed - } - } - - var chromaColor by remember { mutableStateOf(color) } - ChromaColorAnimation(pickerModel) { chromaColor = it } - - ColorPicker( - pickerModel, - if (pickerModel.chromaEnabled) chromaColor else color, - onColorChanged = { c -> - onColorChanged(c) - onChromaChanged(pickerModel.chromaEnabled, pickerModel.chromaSpeed) + ColorSwatchButton( + PolyColor(color.toArgb(), chroma, chromaSpeed), + onColorChanged = { picked -> + onColorChanged(Color(picked.rawArgb)) + onChromaChanged(picked.chroma, picked.chromaSpeed) }, - swatch = true, + chromaCapable = true, ) } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/keybind/MinecraftKeybindRegistrar.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/keybind/MinecraftKeybindRegistrar.kt index ed9315b0f..4e5037522 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/keybind/MinecraftKeybindRegistrar.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/keybind/MinecraftKeybindRegistrar.kt @@ -4,6 +4,7 @@ import java.util.WeakHashMap import org.polyfrost.oneconfig.api.config.v1.Property import org.polyfrost.oneconfig.api.config.v1.Tree import org.polyfrost.oneconfig.api.ui.v1.keybind.KeybindManager +import org.polyfrost.oneconfig.api.ui.v1.keybind.KeybindMetadata import org.polyfrost.oneconfig.api.ui.v1.keybind.OneConfigKeybind import org.polyfrost.oneconfig.internal.ui.components.localizedString import org.polyfrost.oneconfig.internal.ui.components.localizedTitle @@ -15,7 +16,9 @@ object MinecraftKeybindRegistrar { * Use it when the property is a view onto a [net.minecraft.client.KeyMapping] the owning mod already * registered since mirroring it would splice in a duplicate mapping */ - const val NO_MIRROR_METADATA = "oc_no_mc_mirror" + const val NO_MIRROR_METADATA = KeybindMetadata.NO_MIRROR + + private const val MIRROR_TO_VANILLA_METADATA = "mirrorToVanilla" private val propByBind = WeakHashMap>() private val wiredProps = WeakHashMap, Boolean>() @@ -64,6 +67,7 @@ object MinecraftKeybindRegistrar { bind.name = resolveName(prop) bind.category = category bind.defaultKeybind = prop.getMetadata("default") as? OneConfigKeybind + if (prop.getMetadata(MIRROR_TO_VANILLA_METADATA) == false) bind.mirrorToVanilla = false if (KeybindManager.isRegistered(bind)) KeybindManager.refreshMinecraftBinding(bind) else KeybindManager.register(bind) propByBind[bind] = prop diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/keybind/ProfileKeybinds.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/keybind/ProfileKeybinds.kt index 7e5615769..19a52b320 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/keybind/ProfileKeybinds.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/keybind/ProfileKeybinds.kt @@ -14,6 +14,7 @@ import org.polyfrost.oneconfig.api.ui.v1.keybind.BindNotInScreen import org.polyfrost.oneconfig.api.ui.v1.keybind.KeybindManager import org.polyfrost.oneconfig.api.ui.v1.keybind.OneConfigKeybind import org.polyfrost.oneconfig.internal.OneConfigConfig +import org.polyfrost.oneconfig.internal.ui.components.translated import org.slf4j.LoggerFactory object ProfileKeybinds { @@ -126,11 +127,15 @@ object ProfileKeybinds { try { ConfigManager.openProfile(profile) if (OneConfigConfig.profileSwitchToast) { - Notifications.info("Profile switched", "Now using ${profile.ifEmpty { "Default" }}.") + val name = profile.ifEmpty { translated("oneconfig.profiles.default", "Default") } + Notifications.info( + translated("oneconfig.profiles.switched.title", "Profile switched"), + translated("oneconfig.profiles.switched.text", "Now using %s.", name), + ) } } catch (t: Throwable) { LOGGER.error("Failed to switch to profile {}", profile, t) - Notifications.error("Could not switch profile", t.message ?: t::class.java.simpleName) + Notifications.error(translated("oneconfig.profiles.switch_failed", "Could not switch profile"), t.message ?: t::class.java.simpleName) } finally { switching.set(false) } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/navigation/Pages.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/navigation/Pages.kt index 3ac07f03a..067b0e583 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/navigation/Pages.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/navigation/Pages.kt @@ -1,7 +1,14 @@ package org.polyfrost.oneconfig.internal.ui.navigation import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot import androidx.navigation.NavBackStackEntry import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.NavDestination.Companion.hierarchy @@ -9,6 +16,7 @@ import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import androidx.navigation.toRoute import java.util.Locale +import java.util.concurrent.ConcurrentHashMap import kotlinx.serialization.Serializable import org.apache.logging.log4j.LogManager import org.polyfrost.oneconfig.api.ui.v1.page.ModConfigTarget @@ -120,12 +128,62 @@ fun resolveOpenTarget(target: Any?): Any? = when (target) { else -> target } +object PageArguments { + private class Delivery(val pageId: String, val argument: Any) + + private var revision by mutableIntStateOf(0) + + @Volatile + private var pending: Delivery? = null + + private val retained = ConcurrentHashMap() + private val live = ConcurrentHashMap() + private val scope = ThreadLocal() + + fun offer(pageId: String, argument: Any) { + pending = Delivery(pageId, argument) + Snapshot.withMutableSnapshot { revision++ } + } + + fun take(): Any? { + val pageId = scope.get() ?: live.keys.singleOrNull() ?: return null + return retained[pageId]?.argument + } + + private fun claim(pageId: String): Delivery? { + val delivery = pending?.takeIf { it.pageId == pageId } ?: return null + pending = null + retained[pageId] = delivery + return delivery + } + + private class Latest(var delivery: Delivery?) + + @Composable + fun Deliver(pageId: String, content: @Composable () -> Unit) { + val round = revision + val delivered = remember(pageId, round) { claim(pageId) } + val latest = remember(pageId) { Latest(null) } + if (delivered != null) latest.delivery = delivered + DisposableEffect(pageId) { + live.merge(pageId, 1, Int::plus) + onDispose { live.compute(pageId) { _, count -> if (count == null || count <= 1) null else count - 1 } } + } + key(latest.delivery) { + val previous = scope.get() + scope.set(pageId) + content() + scope.set(previous) + } + } +} + fun NavGraphBuilder.pageDestinations() { composable { entry -> val id = entry.toRoute().pageId val page = OneConfigPages.byId(id) if (page != null) { - page.Content() + PageArguments.Deliver(id) { page.Content() } return@composable } val shell = LocalShell.current diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Changelog.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Changelog.kt index 8cadf6069..12dc96cfc 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Changelog.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Changelog.kt @@ -1,7 +1,5 @@ package org.polyfrost.oneconfig.internal.ui.screens -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background import androidx.compose.foundation.gestures.Orientation @@ -21,7 +19,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable @@ -68,6 +65,9 @@ import org.commonmark.node.StrongEmphasis import org.commonmark.node.Text as CmText import org.polyfrost.oneconfig.api.ui.v1.ChangelogSource import org.polyfrost.oneconfig.api.ui.v1.ChangelogSources +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.api.ui.v1.components.EmptyState +import org.polyfrost.oneconfig.api.ui.v1.components.LoadState import org.polyfrost.oneconfig.internal.ui.api.ChangeKind import org.polyfrost.oneconfig.internal.ui.api.ChangelogData import org.polyfrost.oneconfig.internal.ui.api.ChangelogEntry @@ -133,7 +133,6 @@ private fun seriesOf(version: String): String = version.split('.', '-').take(2). @Composable fun Changelog() { - val theme = LocalTheme.current val query = pageSearchQuery().trim() val source = ChangelogSources.active() DisposableEffect(Unit) { onDispose { openedVersion = null } } @@ -145,7 +144,7 @@ fun Changelog() { val current = view if (current == null) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text(translated("oneconfig.changelog.loading", "Loading the changelog"), color = theme.textColorSecondary, fontSize = 12.sp) + EmptyState(translated("oneconfig.changelog.loading", "Loading the changelog"), state = LoadState.Loading) } return } @@ -156,9 +155,11 @@ fun Changelog() { if (visible.isEmpty()) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - val message = if (releases.isEmpty()) translated("oneconfig.changelog.empty", "No changelog was bundled with this build") - else translated("oneconfig.changelog.nomatch", "No releases match \"%s\"", query) - Text(message, color = theme.textColorSecondary, fontSize = 12.sp) + if (releases.isEmpty()) { + EmptyState(translated("oneconfig.changelog.empty", "No changelog was bundled with this build")) + } else { + EmptyState(translated("oneconfig.changelog.nomatch", "No releases match \"%s\"", query), icon = "search") + } } return } @@ -197,8 +198,8 @@ private fun VersionList(releases: List, selected: ChangelogRel } } - val topFade by animateFloatAsState(if (state.canScrollBackward) 1f else 0f) - val bottomFade by animateFloatAsState(if (state.canScrollForward) 1f else 0f) + val topFade by Motion.animateFloatAsState(if (state.canScrollBackward) 1f else 0f) + val bottomFade by Motion.animateFloatAsState(if (state.canScrollForward) 1f else 0f) Box(Modifier.width(VersionListWidth).fillMaxHeight().wheelScroll(state).scrollable(state, Orientation.Vertical, reverseDirection = true)) { LazyColumn( state = state, @@ -246,16 +247,16 @@ private fun VersionEntry(release: ChangelogRelease, isSelected: Boolean, onClick val source = rememberInteractionSource() val hovered by source.collectIsHoveredAsState() val translucent = theme.translucentSelection - val selectedAlpha by animateFloatAsState(if (isSelected) 1f else 0f) - val foreground by animateColorAsState( + val selectedAlpha by Motion.animateFloatAsState(if (isSelected) 1f else 0f) + val foreground by Motion.animateColorAsState( if (isSelected && !translucent) theme.accentTextColor else if (isSelected || hovered) theme.textColor else theme.textColor.copy(0.8f) ) - val secondary by animateColorAsState( + val secondary by Motion.animateColorAsState( if (isSelected && !translucent) theme.accentTextColor.copy(0.75f) else theme.textColorSecondary ) - val border by animateColorAsState( + val border by Motion.animateColorAsState( if (isSelected && translucent) theme.borderColor.copy(0f) else if (hovered || isSelected) theme.borderColor else theme.borderColor.copy(0f) @@ -281,7 +282,7 @@ private fun VersionEntry(release: ChangelogRelease, isSelected: Boolean, onClick ) { Text(release.version, color = Color.Unspecified, colorProducer = { foreground }, fontSize = 14.sp, maxLines = 1) if (installed) { - Box(Modifier.size(5.dp).background(if (isSelected && !translucent) theme.accentTextColor else Accent, CircleShape)) + Box(Modifier.size(5.dp).background(if (isSelected && !translucent) theme.accentTextColor else Accent, theme.shapes.circle)) } Spacer(Modifier.weight(1f)) formatDate(release.date, FormatStyle.SHORT)?.let { @@ -361,7 +362,7 @@ private fun EntryList(entries: List, depth: Int) { Box( Modifier.padding(start = 4.dp, top = if (depth == 0) 8.dp else 7.dp) .size(4.dp) - .background(theme.textColorSecondary.copy(if (depth == 0) 0.7f else 0.5f), CircleShape) + .background(theme.textColorSecondary.copy(if (depth == 0) 0.7f else 0.5f), theme.shapes.circle) ) BasicText(entry.paragraph?.let { inline(it, styles) } ?: AnnotatedString(entry.text), style = body) } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/ChangelogDialog.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/ChangelogDialog.kt index 26a589faf..1d9b2cf44 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/ChangelogDialog.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/ChangelogDialog.kt @@ -2,26 +2,19 @@ package org.polyfrost.oneconfig.internal.ui.screens import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.BasicText -import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -30,19 +23,11 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.compositeOver -import androidx.compose.ui.input.pointer.PointerIcon -import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntRect -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.PopupPositionProvider -import androidx.compose.ui.window.PopupProperties import org.commonmark.node.BlockQuote import org.commonmark.node.BulletList import org.commonmark.node.FencedCodeBlock @@ -54,30 +39,26 @@ import org.commonmark.node.Node import org.commonmark.node.OrderedList import org.commonmark.node.Paragraph import org.commonmark.node.ThematicBreak -import org.polyfrost.oneconfig.api.ui.v1.components.EscapeCloses +import org.polyfrost.oneconfig.api.ui.v1.components.ControlSize +import org.polyfrost.oneconfig.api.ui.v1.components.DialogTitle +import org.polyfrost.oneconfig.api.ui.v1.components.Divider +import org.polyfrost.oneconfig.api.ui.v1.components.EmptyState +import org.polyfrost.oneconfig.api.ui.v1.components.IconButton +import org.polyfrost.oneconfig.api.ui.v1.components.LoadState +import org.polyfrost.oneconfig.api.ui.v1.components.ModalDialog +import org.polyfrost.oneconfig.api.ui.v1.components.ScrollContainer +import org.polyfrost.oneconfig.api.ui.v1.components.TextButton import org.polyfrost.oneconfig.internal.ui.api.ModrinthChangelog import org.polyfrost.oneconfig.internal.ui.api.ModrinthChangelogs import org.polyfrost.oneconfig.internal.ui.api.ModrinthVersionLink -import org.polyfrost.oneconfig.internal.ui.components.Icon -import org.polyfrost.oneconfig.internal.ui.components.IconButton -import org.polyfrost.oneconfig.internal.ui.components.ScrollbarBox -import org.polyfrost.oneconfig.internal.ui.components.ShellPopup -import org.polyfrost.oneconfig.internal.ui.components.Text -import org.polyfrost.oneconfig.internal.ui.components.onClick -import org.polyfrost.oneconfig.internal.ui.components.rememberInteractionSource import org.polyfrost.oneconfig.internal.ui.components.shellTransition import org.polyfrost.oneconfig.internal.ui.components.translated -import org.polyfrost.oneconfig.internal.ui.themes.Accent -import org.polyfrost.oneconfig.internal.ui.themes.animateAccentColorAsState import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme import org.polyfrost.oneconfig.utils.v1.NetworkUtils -import androidx.compose.ui.graphics.BlendMode -import androidx.compose.ui.graphics.shadow.Shadow -import androidx.compose.ui.unit.DpOffset -import org.polyfrost.oneconfig.internal.ui.util.cachedDropShadow private val DialogMaxWidth = 680.dp -private val DialogPadding = 20.dp +private const val DialogWidthFraction = 0.9f +private const val DialogHeightFraction = 0.85f private sealed interface DialogState { data object Loading : DialogState @@ -85,18 +66,8 @@ private sealed interface DialogState { data object Failed : DialogState } -private object WindowOrigin : PopupPositionProvider { - override fun calculatePosition( - anchorBounds: IntRect, - windowSize: IntSize, - layoutDirection: LayoutDirection, - popupContentSize: IntSize, - ): IntOffset = IntOffset.Zero -} - @Composable internal fun ModrinthChangelogDialog(link: ModrinthVersionLink, onDismiss: () -> Unit) { - val theme = LocalTheme.current var attempt by remember(link) { mutableIntStateOf(0) } val state by produceState(ModrinthChangelogs.cached(link)?.let(DialogState::Loaded) ?: DialogState.Loading, link, attempt) { val cached = ModrinthChangelogs.cached(link) @@ -109,104 +80,63 @@ internal fun ModrinthChangelogDialog(link: ModrinthVersionLink, onDismiss: () -> } val current = state val title = (current as? DialogState.Loaded)?.changelog?.title ?: link.version - val popupShadow = remember(theme.shadowColor) { Shadow(24.dp, theme.shadowColor, 0.dp, DpOffset(0.dp, 8.dp), .3f, BlendMode.SrcOver) } + val window = LocalWindowInfo.current.containerSize + val density = LocalDensity.current + val width = with(density) { (window.width.toDp() * DialogWidthFraction).coerceAtMost(DialogMaxWidth) } + val maxHeight = with(density) { window.height.toDp() * DialogHeightFraction } - EscapeCloses(onDismiss) - ShellPopup( - popupPositionProvider = WindowOrigin, - onDismissRequest = onDismiss, - properties = PopupProperties(focusable = true, dismissOnClickOutside = false), - ) { - BoxWithConstraints( - Modifier.fillMaxSize() - .background(theme.shadowColor.copy(alpha = 0.7f)) - .onClick(rememberInteractionSource(), onDismiss), - contentAlignment = Alignment.Center, - ) { - Column( - Modifier.widthIn(max = DialogMaxWidth) - .fillMaxWidth(0.9f) - .heightIn(max = maxHeight * 0.85f) - .shellTransition() - .then(if (theme.shadowEnabled) Modifier.cachedDropShadow(theme.popupShape, popupShadow) else Modifier) - .background(theme.popupBackground.compositeOver(theme.pageBackground.copy(alpha = 1f)), theme.popupShape) - .border(1.dp, theme.borderColor, theme.popupShape) - .onClick(rememberInteractionSource()) {}, + ModalDialog( + onDismiss, + Modifier.shellTransition().heightIn(max = maxHeight), + width = width, + title = { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - Row( - Modifier.fillMaxWidth().padding(start = DialogPadding, end = 14.dp, top = 16.dp, bottom = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text(title, color = theme.textColor, fontSize = 18.sp, fontWeight = FontWeight.Medium, maxLines = 2, modifier = Modifier.weight(1f)) - IconButton("close", Modifier.size(16.dp), onClick = onDismiss) - } - when (current) { - DialogState.Loading -> DialogMessage(translated("oneconfig.changelog.modrinth.loading", "Loading the changelog")) - DialogState.Failed -> Column( - Modifier.fillMaxWidth().padding(horizontal = DialogPadding, vertical = 24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text(translated("oneconfig.changelog.modrinth.failed", "Couldn't load this changelog"), color = theme.textColorSecondary, fontSize = 12.sp) - DialogButton(translated("oneconfig.changelog.modrinth.retry", "Try again"), primary = true) { attempt++ } - } - is DialogState.Loaded -> { - val document = current.changelog.document - if (document.firstChild == null) { - DialogMessage(translated("oneconfig.changelog.modrinth.empty", "This version has no changelog")) - } else { - val scroll = remember(link) { ScrollState(0) } - ScrollbarBox(scroll, 12.dp, Modifier.weight(1f, fill = false)) { - Column(Modifier.fillMaxWidth().verticalScroll(scroll).padding(start = DialogPadding, end = DialogPadding, bottom = 8.dp)) { - MarkdownBlocks(document, lists = 0) - } - } + DialogTitle(title, Modifier.weight(1f)) + IconButton("close", translated("oneconfig.changelog.modrinth.close", "Close"), onDismiss, size = ControlSize.Compact) + } + }, + buttons = { + TextButton( + translated("oneconfig.changelog.modrinth.open", "Open on Modrinth"), + "external-link", + { runCatching { NetworkUtils.browseLink(link.pageUrl) } }, + size = ControlSize.Compact, + ) + }, + ) { + when (current) { + DialogState.Loading -> EmptyState( + translated("oneconfig.changelog.modrinth.loading", "Loading the changelog"), + Modifier.fillMaxWidth(), + state = LoadState.Loading, + ) + DialogState.Failed -> EmptyState( + translated("oneconfig.changelog.modrinth.failed", "Couldn't load this changelog"), + Modifier.fillMaxWidth(), + state = LoadState.Error(""), + onRetry = { attempt++ }, + ) + is DialogState.Loaded -> { + val document = current.changelog.document + if (document.firstChild == null) { + EmptyState(translated("oneconfig.changelog.modrinth.empty", "This version has no changelog"), Modifier.fillMaxWidth()) + } else { + val scroll = remember(link) { ScrollState(0) } + ScrollContainer(scroll, Modifier.weight(1f, fill = false)) { + Column(Modifier.fillMaxWidth().padding(bottom = 8.dp)) { + MarkdownBlocks(document, lists = 0) } } } - Row( - Modifier.fillMaxWidth().padding(horizontal = DialogPadding, vertical = 14.dp), - horizontalArrangement = Arrangement.End, - ) { - DialogButton(translated("oneconfig.changelog.modrinth.open", "Open on Modrinth"), primary = false, icon = "external-link") { - runCatching { NetworkUtils.browseLink(link.pageUrl) } - } - } } } } } -@Composable -private fun DialogMessage(text: String) { - Box(Modifier.fillMaxWidth().height(96.dp), contentAlignment = Alignment.Center) { - Text(text, color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) - } -} - -@Composable -private fun DialogButton(text: String, primary: Boolean, icon: String? = null, onClick: () -> Unit) { - val theme = LocalTheme.current - val source = rememberInteractionSource() - val hovered by source.collectIsHoveredAsState() - val base = if (primary) Accent else theme.textColor.copy(0.08f) - val background by animateAccentColorAsState(hovered to primary, if (hovered) theme.textColor.copy(0.12f).compositeOver(base) else base) - val ink = if (primary) theme.accentTextColor else theme.textColor - Row( - Modifier.clip(theme.buttonShape) - .background(background, theme.buttonShape) - .onClick(source, onClick) - .pointerHoverIcon(PointerIcon.Hand) - .padding(horizontal = 12.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - if (icon != null) Icon(icon, color = ink, modifier = Modifier.size(12.dp)) - Text(text, color = ink, fontSize = 12.sp, fontWeight = FontWeight.Medium) - } -} - private fun childrenOf(node: Node): List = buildList { var c = node.firstChild while (c != null) { add(c); c = c.next } @@ -254,7 +184,7 @@ private fun MarkdownBlock(node: Node, lists: Int) { Box( Modifier.padding(start = 4.dp, top = if (itemDepth == 0) 8.dp else 7.dp) .size(4.dp) - .background(theme.textColorSecondary.copy(if (itemDepth == 0) 0.7f else 0.5f), CircleShape) + .background(theme.textColorSecondary.copy(if (itemDepth == 0) 0.7f else 0.5f), theme.shapes.circle) ) } Column(Modifier.weight(1f)) { MarkdownBlocks(item, nested) } @@ -268,7 +198,7 @@ private fun MarkdownBlock(node: Node, lists: Int) { } is FencedCodeBlock -> CodeBlock(node.literal) is IndentedCodeBlock -> CodeBlock(node.literal) - is ThematicBreak -> Box(Modifier.fillMaxWidth().padding(vertical = 4.dp).height(1.dp).background(theme.borderColor)) + is ThematicBreak -> Divider(Modifier.padding(vertical = 4.dp)) is HtmlBlock -> { val text = htmlText(node.literal.orEmpty()) if (text.isNotBlank()) BasicText(text, style = changelogBodyStyle(depth)) diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/ConfigScreen.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/ConfigScreen.kt index ce951b422..d1192d1d2 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/ConfigScreen.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/ConfigScreen.kt @@ -8,12 +8,12 @@ import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.Spring import androidx.compose.animation.core.VisibilityThreshold import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.animation.core.updateTransition import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically import androidx.compose.animation.togetherWith +import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.hoverable @@ -42,6 +42,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf @@ -50,19 +51,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.isSecondaryPressed -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.Placeable import androidx.compose.ui.layout.layout -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.TextMeasurer import androidx.compose.ui.text.TextStyle @@ -75,43 +70,62 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import java.io.ByteArrayInputStream +import java.io.File +import java.util.concurrent.ConcurrentHashMap import java.util.function.Consumer import java.util.function.Predicate +import javax.imageio.ImageIO +import kotlin.math.min import kotlin.math.roundToInt import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.polyfrost.compose.backend.asComposeImageBitmap +import org.polyfrost.compose.render.PolyBitmap +import org.polyfrost.oneconfig.api.config.v1.MetaKeys import org.polyfrost.oneconfig.api.config.v1.Node import org.polyfrost.oneconfig.api.config.v1.Property import org.polyfrost.oneconfig.api.config.v1.Tree import org.polyfrost.oneconfig.api.config.v1.Visualizer +import org.polyfrost.oneconfig.api.config.v1.enableToggle +import org.polyfrost.oneconfig.api.config.v1.get +import org.polyfrost.oneconfig.api.ui.v1.IconSpec import org.polyfrost.oneconfig.api.ui.v1.SettingRowDecorator import org.polyfrost.oneconfig.api.ui.v1.SettingRowDecorators +import org.polyfrost.oneconfig.api.ui.v1.TreeHeaderDecorator +import org.polyfrost.oneconfig.api.ui.v1.TreeHeaderDecorators +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.api.ui.v1.components.ControlSize +import org.polyfrost.oneconfig.api.ui.v1.components.EmptyState +import org.polyfrost.oneconfig.api.ui.v1.components.LoadState import org.polyfrost.oneconfig.internal.OneConfigConfig import org.polyfrost.oneconfig.internal.ui.api.Tooltip import org.polyfrost.oneconfig.internal.ui.api.shown import org.polyfrost.oneconfig.internal.ui.components.Chip +import org.polyfrost.oneconfig.internal.ui.components.CollapseChevron +import org.polyfrost.oneconfig.internal.ui.components.CollapsibleHeader import org.polyfrost.oneconfig.internal.ui.components.ContentSlide import org.polyfrost.oneconfig.internal.ui.components.ContentSlide.tabSlide import org.polyfrost.oneconfig.internal.ui.components.Icon -import org.polyfrost.oneconfig.internal.ui.components.ReducedMotion import org.polyfrost.oneconfig.internal.ui.components.ScrollbarBox import org.polyfrost.oneconfig.internal.ui.components.Text import org.polyfrost.oneconfig.internal.ui.components.asRenderText -import org.polyfrost.oneconfig.internal.ui.components.blockInteraction +import org.polyfrost.oneconfig.internal.ui.components.collapsibleToggle import org.polyfrost.oneconfig.internal.ui.components.hoverGlow import org.polyfrost.oneconfig.internal.ui.components.isEmptyText import org.polyfrost.oneconfig.internal.ui.components.localizedDescription import org.polyfrost.oneconfig.internal.ui.components.localizedTitle -import org.polyfrost.oneconfig.internal.ui.components.onClick import org.polyfrost.oneconfig.internal.ui.components.rememberInteractionSource +import org.polyfrost.oneconfig.internal.ui.components.settings.LocalOptionEnabled import org.polyfrost.oneconfig.internal.ui.components.settings.LocalOptionWidth +import org.polyfrost.oneconfig.internal.ui.components.settings.LocalOptionWidthOrNull import org.polyfrost.oneconfig.internal.ui.components.settings.Option -import org.polyfrost.oneconfig.internal.ui.components.settings.OptionActionButton -import org.polyfrost.oneconfig.internal.ui.components.settings.OptionContextMenu import org.polyfrost.oneconfig.internal.ui.components.settings.OptionControlHeight +import org.polyfrost.oneconfig.internal.ui.components.settings.OptionMoreButton import org.polyfrost.oneconfig.internal.ui.components.settings.SwitchControl +import org.polyfrost.oneconfig.internal.ui.components.settings.optionContextMenu +import org.polyfrost.oneconfig.internal.ui.components.settings.rememberOptionMenu import org.polyfrost.oneconfig.internal.ui.components.translated -import org.polyfrost.oneconfig.internal.ui.keybind.KeybindRecordingBus import org.polyfrost.oneconfig.internal.ui.navigation.graph.ModConfigRoute import org.polyfrost.oneconfig.internal.ui.search.CategoryGroup import org.polyfrost.oneconfig.internal.ui.search.ConfigListEntry @@ -138,7 +152,6 @@ import org.polyfrost.oneconfig.internal.ui.shell.PageGutter import org.polyfrost.oneconfig.internal.ui.shell.pageSearchQuery import org.polyfrost.oneconfig.internal.ui.shell.rememberRestorableLazyListState import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme -import org.polyfrost.oneconfig.internal.ui.util.LayoutRef /** The id of the config whose page is showing or null where a submenu row has no page of its config to open */ internal val LocalConfigId = staticCompositionLocalOf { null } @@ -181,6 +194,7 @@ private fun ConfigScreenContent( } val localSearchQuery = pageSearchQuery().trim() val navigation = LocalShell.current.navigation + val groupOff = rememberSwitchValue(tree.takeIf { it.isSubmenu() }?.enableToggle) == false val savedCategory = navigation.selectedCategory(pageKey) ?: initialCategory val selectedCategory = categories.firstOrNull { it.name.equals(savedCategory, ignoreCase = true) } @@ -250,16 +264,16 @@ private fun ConfigScreenContent( search.query, ) - val emptyMessage = when { + val empty = when { entries.isNotEmpty() -> null - localSearchQuery.isBlank() -> translated("oneconfig.search.no_settings", "No settings available.") - results == null -> translated("oneconfig.search.searching", "Searching...") - else -> translated("oneconfig.search.no_settings_match", "No settings match \"%s\"", localSearchQuery) + localSearchQuery.isBlank() -> ConfigEmpty(translated("oneconfig.search.no_settings", "No settings available."), "settings") + results == null -> ConfigEmpty(translated("oneconfig.search.searching", "Searching..."), null, LoadState.Loading) + else -> ConfigEmpty(translated("oneconfig.search.no_settings_match", "No settings match \"%s\"", localSearchQuery), "search") } - if (emptyMessage != null && header == null) { + if (empty != null && header == null) { Box(Modifier.fillMaxSize().then(slide), contentAlignment = Alignment.Center) { - Text(emptyMessage, color = LocalTheme.current.textColorSecondary) + EmptyState(empty.title, icon = empty.icon, state = empty.state) } return@AnimatedContent } @@ -289,17 +303,19 @@ private fun ConfigScreenContent( ) { header() } } } - if (emptyMessage != null) { + if (empty != null) { item(key = "empty") { - Box(Modifier.fillMaxWidth().padding(vertical = 24.dp).then(slide), contentAlignment = Alignment.Center) { - Text(emptyMessage, color = LocalTheme.current.textColorSecondary) + Box(Modifier.fillMaxWidth().then(slide), contentAlignment = Alignment.Center) { + EmptyState(empty.title, icon = empty.icon, state = empty.state) } } } items(entries) { entry -> val option = ((entry as? ConfigListEntry.Item)?.node as? SettingNode.Leaf)?.prop val lock = locked && (option == null || option !== lockExempt) - Box(slide.alpha(if (lock) 0.45f else 1f).blockInteraction(lock, keyboard = true)) { ConfigListRow(entry) } + CompositionLocalProvider(LocalOptionEnabled provides !(lock || groupOff)) { + Box(slide) { ConfigListRow(entry) } + } } } } @@ -307,6 +323,8 @@ private fun ConfigScreenContent( } } +private class ConfigEmpty(val title: String, val icon: String?, val state: LoadState = LoadState.Idle) + private fun categoryProperties(categories: List): List> { return categories.flatMap { category -> category.subcategories.flatMap { subcategory -> @@ -430,7 +448,7 @@ private fun Tree.matchesLocalSearch(category: String, subcategory: String, query /** Width a slider-style control needs beside its track, or null when the control is compact */ private fun sliderTrailingWidth(prop: Property<*>): Dp? { - return when (prop.getMetadata("visualizer")) { + return when (prop[MetaKeys.VISUALIZER]) { Visualizer.SliderVisualizer::class.java, is Visualizer.SliderVisualizer -> SLIDER_SPINNER_WIDTH // two spinners, or a spinner plus the inherit chip Visualizer.RangeSliderVisualizer::class.java, is Visualizer.RangeSliderVisualizer, @@ -439,7 +457,15 @@ private fun sliderTrailingWidth(prop: Property<*>): Dp? { } } -private fun isFullWidth(prop: Property<*>): Boolean = prop.getMetadata("fullWidth") == true +private fun isFullWidth(prop: Property<*>): Boolean = prop[MetaKeys.FULL_WIDTH] == true || hidesLabel(prop) + +private fun hidesLabel(prop: Property<*>): Boolean = prop[MetaKeys.HIDE_LABEL] == true + +private fun Node.nodeIcon(): Any? = when (val icon = this[MetaKeys.ICON]) { + is IconSpec -> icon + is String -> icon.takeIf { it.isNotBlank() } + else -> null +} private fun isWideControl(prop: Property<*>): Boolean = sliderTrailingWidth(prop) != null || isFullWidth(prop) @@ -450,9 +476,10 @@ private fun isWideControl(prop: Property<*>): Boolean = sliderTrailingWidth(prop */ @Composable internal fun ConfigListRow(entry: ConfigListEntry, compact: (SettingNode) -> Boolean = { false }) { + val headerModifier = if (LocalOptionEnabled.current) Modifier else Modifier.alpha(LocalTheme.current.disabledAlpha) when (entry) { - is ConfigListEntry.CategoryHeader -> CategoryHeader(entry.title) - is ConfigListEntry.SubcategoryHeader -> SubcategoryHeader(entry.title) + is ConfigListEntry.CategoryHeader -> CategoryHeader(entry.title, headerModifier) + is ConfigListEntry.SubcategoryHeader -> SubcategoryHeader(entry.title, headerModifier) is ConfigListEntry.Item -> SettingEntryRow(entry.node, compact(entry.node)) } } @@ -471,43 +498,44 @@ internal fun SettingEntryRow(node: SettingNode, compact: Boolean = false) { } @Composable -private fun CategoryHeader(title: String) { +private fun CategoryHeader(title: String, modifier: Modifier = Modifier) { Text( title, color = LocalTheme.current.textColorSecondary, fontSize = 11.sp, fontWeight = FontWeight.Medium, - modifier = Modifier.padding(top = 8.dp, bottom = 2.dp) + modifier = modifier.padding(top = 8.dp, bottom = 2.dp) ) } @Composable -private fun SubcategoryHeader(title: String) { +private fun SubcategoryHeader(title: String, modifier: Modifier = Modifier) { Text( title, color = LocalTheme.current.textColor, fontSize = 22.sp, fontWeight = FontWeight.Medium, - modifier = Modifier.padding(top = 8.dp, bottom = 2.dp) + modifier = modifier.padding(top = 8.dp, bottom = 2.dp) ) } @Composable private fun AccordionRow(node: SettingNode.Accordion, compact: Boolean = false) { + val head = node.head + val toggle = head?.takeIf { it === node.tree.enableToggle } var expanded by remember(node) { mutableStateOf( - if (node.tree.getMetadata("collapsed") == true) false - else node.head?.shown() != false + if (node.tree[MetaKeys.COLLAPSED] == true) false + else toggle != null || node.head?.shown() != false ) } - val head = node.head var headOn by remember(node) { mutableStateOf(head?.shown() == true) } if (head != null && head.isBooleanHead()) { DisposableEffect(head) { val boolHead = Property.recast(head) val listener = Predicate { value -> headOn = value == true - if (head.hasVisualizer()) expanded = value == true + if (head.hasVisualizer() && toggle == null) expanded = value == true false } boolHead.addCallback(listener) @@ -515,18 +543,17 @@ private fun AccordionRow(node: SettingNode.Accordion, compact: Boolean = false) } } val headDisplay = head?.let { rememberDisplay(it) } ?: Property.Display.SHOWN - val switchHead = head != null && !head.hasVisualizer() + val switchHead = head != null && isSwitchHead(node.tree, head) if (switchHead && headDisplay == Property.Display.HIDDEN) return val enabled = !switchHead || (headOn && headDisplay != Property.Display.DISABLED) - val chevronRotation by animateFloatAsState(if (expanded) 180f else 90f) GroupCard { GroupHeader( tree = node.tree, head = head, - chevronRotation = chevronRotation, + expanded = expanded, compact = compact, - onHeadChange = { expanded = it; headOn = it }, + onHeadChange = { if (toggle == null) expanded = it; headOn = it }, onClick = { expanded = !expanded }, ) @@ -540,11 +567,11 @@ private fun AccordionRow(node: SettingNode.Accordion, compact: Boolean = false) private const val FOLD_STIFFNESS = Spring.StiffnessMediumLow -private fun foldEnter() = ReducedMotion.enter( +private fun foldEnter() = Motion.enter( expandVertically(spring(stiffness = FOLD_STIFFNESS, visibilityThreshold = IntSize.VisibilityThreshold)) ) -private fun foldExit() = ReducedMotion.exit( +private fun foldExit() = Motion.exit( shrinkVertically(spring(stiffness = FOLD_STIFFNESS, visibilityThreshold = IntSize.VisibilityThreshold)) ) @@ -567,29 +594,17 @@ internal fun Accordion( titleActions: @Composable RowScope.() -> Unit = {}, body: @Composable (enabled: Boolean) -> Unit, ) { - val theme = LocalTheme.current - val chevronRotation by animateFloatAsState(if (expanded) 180f else 90f) Column(Modifier.optionCard()) { CardHeader( title, + expanded, + onExpandedChange?.let { change -> { change(!expanded) } }, description, icon, titleActions, - Modifier - .then(if (onExpandedChange != null) Modifier.onClick(rememberInteractionSource()) { onExpandedChange(!expanded) } else Modifier) - .alpha(if (headEnabled) 1f else displayAlpha(Property.Display.DISABLED)), + dimmed = !headEnabled, ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - if (checked != null) { - Box(Modifier.blockInteraction(!headEnabled)) { SwitchControl(checked, onCheckedChange) } - } - if (onExpandedChange != null) { - Icon("up", color = theme.textColorSecondary, modifier = Modifier.size(12.dp).rotate(chevronRotation)) - } - } + if (checked != null) SwitchControl(checked, headEnabled, onCheckedChange) } AnimatedVisibility(visible = expanded, enter = foldEnter(), exit = foldExit()) { Box(Modifier.fillMaxWidth().padding(bottom = 12.dp)) { body(checked != false && headEnabled) } @@ -600,33 +615,60 @@ internal fun Accordion( @Composable private fun CardHeader( title: Any, + expanded: Boolean, + onToggle: (() -> Unit)?, description: Any? = null, icon: String? = null, titleActions: @Composable RowScope.() -> Unit = {}, modifier: Modifier = Modifier, + dimmed: Boolean = false, trailing: @Composable () -> Unit = {}, ) { - val theme = LocalTheme.current - Row( - modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, + val labelAlpha = if (dimmed) LocalTheme.current.disabledAlpha else 1f + CollapsibleHeader( + label = title.asRenderText(), + expanded = expanded, + onToggle = onToggle, + modifier = modifier, + chevron = false, + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), ) { + HeaderLabel(title, description, Modifier.weight(1f).alpha(labelAlpha), titleActions) { + icon?.let { Icon(it, color = LocalTheme.current.textColor, modifier = Modifier.size(32.dp)) } + } Row( - modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - icon?.let { Icon(it, color = theme.textColor, modifier = Modifier.size(32.dp)) } - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Text(title, color = theme.textColor, fontSize = 15.sp, fontWeight = FontWeight.Medium) - titleActions() - } - description?.let { Text(it, color = theme.textColorSecondary, fontSize = 13.sp) } + trailing() + if (onToggle != null) CollapseChevron(expanded) + } + } +} + +@Composable +private fun HeaderLabel( + title: Any, + description: Any?, + modifier: Modifier, + titleActions: @Composable RowScope.() -> Unit = {}, + icon: @Composable () -> Unit, +) { + val theme = LocalTheme.current + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + icon() + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(title, color = theme.textColor, fontSize = 15.sp, fontWeight = FontWeight.Medium) + titleActions() } + description?.let { Text(it, color = theme.textColorSecondary, fontSize = 13.sp) } } - trailing() } } @@ -643,7 +685,7 @@ private fun SubmenuRow(node: SettingNode.Submenu, compact: Boolean = false) { GroupHeader( tree = node.tree, head = node.head, - chevronRotation = 90f, + expanded = false, compact = compact, onHeadChange = {}, onClick = { navigation.navigate(ModConfigRoute(configId, submenu = node.path)) }, @@ -654,8 +696,9 @@ private fun SubmenuRow(node: SettingNode.Submenu, compact: Boolean = false) { /** A submenu without a config page to open, which expands in place with everything nested inside it */ @Composable private fun SubmenuAccordionRow(node: SettingNode.Submenu, compact: Boolean) { - var expanded by remember(node) { mutableStateOf(node.head?.shown() != false) } - val chevronRotation by animateFloatAsState(if (expanded) 180f else 90f) + val toggle = node.head?.takeIf { it === node.tree.enableToggle } + var expanded by remember(node) { mutableStateOf(toggle != null || node.head?.shown() != false) } + val groupOff = rememberSwitchValue(toggle) == false val revision = rememberDisplayRevision(node.content) val entries = remember(node, revision) { node.content.mapNotNull(::filterHiddenNodes).flatMap(::flattenEntries) @@ -665,7 +708,7 @@ private fun SubmenuAccordionRow(node: SettingNode.Submenu, compact: Boolean) { GroupHeader( tree = node.tree, head = node.head, - chevronRotation = chevronRotation, + expanded = expanded, compact = compact, onHeadChange = {}, onClick = { expanded = !expanded }, @@ -673,10 +716,14 @@ private fun SubmenuAccordionRow(node: SettingNode.Submenu, compact: Boolean) { AnimatedVisibility(visible = expanded, enter = foldEnter(), exit = foldExit()) { Column( - modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, bottom = 12.dp), + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 12.dp), verticalArrangement = Arrangement.spacedBy(OPTION_ROW_GAP), ) { - entries.forEach { ConfigListRow(it) { compact } } + CompositionLocalProvider(LocalOptionEnabled provides (LocalOptionEnabled.current && !groupOff)) { + entries.forEach { ConfigListRow(it) { compact } } + } } } } @@ -696,12 +743,49 @@ private fun GroupCard(content: @Composable ColumnScope.() -> Unit) { private fun GroupHeader( tree: Tree, head: Property<*>?, - chevronRotation: Float, + expanded: Boolean, + compact: Boolean, + onHeadChange: (Boolean) -> Unit, + onClick: () -> Unit, +) { + DecoratedGroupHeader(TreeHeaderDecorators.all(), 0, tree) { + GroupHeaderContent(tree, head, expanded, compact, onHeadChange, onClick) + } +} + +@Composable +private fun DecoratedGroupHeader(decorators: List, index: Int, tree: Tree, content: @Composable () -> Unit) { + val decorator = decorators.getOrNull(index) ?: return content() + decorator.Decorate(tree) { DecoratedGroupHeader(decorators, index + 1, tree, content) } +} + +private fun isSwitchHead(tree: Tree, head: Property<*>): Boolean = + !head.hasVisualizer() || head === tree.enableToggle + +@Composable +private fun rememberSwitchValue(prop: Property<*>?): Boolean? { + if (prop == null || !prop.isBooleanHead()) return null + return key(System.identityHashCode(prop)) { + var on by remember { mutableStateOf(prop.shown() == true) } + DisposableEffect(Unit) { + val bool = Property.recast(prop) + val listener = Predicate { value -> on = value == true; false } + bool.addCallback(listener) + onDispose { bool.removeCallback(listener) } + } + on + } +} + +@Composable +private fun GroupHeaderContent( + tree: Tree, + head: Property<*>?, + expanded: Boolean, compact: Boolean, onHeadChange: (Boolean) -> Unit, onClick: () -> Unit, ) { - val theme = LocalTheme.current val interaction = rememberInteractionSource() val headDisplay = head?.let { rememberDisplay(it) } ?: Property.Display.SHOWN val density = LocalDensity.current @@ -709,67 +793,35 @@ private fun GroupHeader( val optionWidth = LocalOptionWidth.current val titleStyle = rememberTitleStyle() - var menuOpen by remember(tree) { mutableStateOf(false) } - var menuOffset by remember(tree) { mutableStateOf(IntOffset.Zero) } - val origin = remember(tree) { LayoutRef(Offset.Zero) } - val actionMenuOffset = remember(tree) { LayoutRef(IntOffset.Zero) } val isHovered by interaction.collectIsHoveredAsState() - val showActionButton = OneConfigConfig.showOptionActionButtons && headDisplay != Property.Display.DISABLED - LaunchedEffect(headDisplay) { if (headDisplay == Property.Display.DISABLED) menuOpen = false } + val groupEnabled = LocalOptionEnabled.current + val headLocked = headDisplay == Property.Display.DISABLED || !groupEnabled + val labelModifier = if (groupEnabled) Modifier else Modifier.alpha(LocalTheme.current.disabledAlpha) + val showActionButton = OneConfigConfig.showOptionActionButtons && !headLocked + val menu = head?.let { rememberOptionMenu(it) } BoxWithConstraints( modifier = Modifier .fillMaxWidth() .hoverable(interaction) - .onGloballyPositioned { origin.value = it.positionInRoot() } - .pointerInput(head, headDisplay) { - if (head == null || headDisplay == Property.Display.DISABLED) return@pointerInput - awaitPointerEventScope { - while (true) { - val event = awaitPointerEvent(PointerEventPass.Initial) - if (event.type == PointerEventType.Press && event.buttons.isSecondaryPressed && !KeybindRecordingBus.isRecording) { - val pos = event.changes.first().position - menuOffset = IntOffset(pos.x.roundToInt(), pos.y.roundToInt()) - menuOpen = true - event.changes.forEach { it.consume() } - } - } - } - } - .onClick(interaction, onClick) + .then(if (menu != null) Modifier.optionContextMenu(menu, enabled = !headLocked) else Modifier) + .collapsibleToggle(tree.localizedTitle().asRenderText(), onClick, interaction) ) { - val control = head?.takeIf { !it.hasVisualizer() || headDisplay != Property.Display.HIDDEN } - val stacked = control != null && control.hasVisualizer() && (compact || isFullWidth(control) || run { - val iconWidth = if (tree.getMetadata("icon") != null) OPTION_ICON_WIDTH else 0.dp + val switch = head != null && isSwitchHead(tree, head) + val control = head?.takeIf { switch || headDisplay != Property.Display.HIDDEN } + val stacked = control != null && !switch && (compact || isFullWidth(control) || run { + val iconWidth = if (tree.nodeIcon() != null) OPTION_ICON_WIDTH else 0.dp val reserved = optionReservedWidth(control, optionWidth) + iconWidth + GROUP_CHEVRON_WIDTH measurer.titleWidth(tree, titleStyle, density) + reserved > maxWidth }) val headControl = @Composable { if (control != null) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { - if (showActionButton) { - OptionActionButton( - visible = isHovered || menuOpen, - modifier = Modifier.onGloballyPositioned { - val pos = it.positionInRoot() - origin.value - actionMenuOffset.value = IntOffset(pos.x.roundToInt(), (pos.y + it.size.height).roundToInt()) - }, - onClick = { menuOffset = actionMenuOffset.value; menuOpen = true }, - ) - } - Box(Modifier.alpha(displayAlpha(headDisplay))) { HeadControl(control, headDisplay, onHeadChange) } + if (showActionButton && menu != null) OptionMoreButton(menu, visible = isHovered) + HeadControl(control, !headLocked, switch, onHeadChange) } } } - val chevron = @Composable { - Icon( - "up", - color = theme.textColorSecondary, - modifier = Modifier - .size(12.dp) - .rotate(chevronRotation), - ) - } if (stacked) { Column( @@ -783,8 +835,8 @@ private fun GroupHeader( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp), ) { - GroupLabel(tree, Modifier.weight(1f)) - chevron() + GroupLabel(tree, Modifier.weight(1f).then(labelModifier)) + CollapseChevron(expanded) } Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { headControl() } } @@ -796,52 +848,95 @@ private fun GroupHeader( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp), ) { - GroupLabel(tree, Modifier.weight(1f)) + GroupLabel(tree, Modifier.weight(1f).then(labelModifier)) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { headControl() - chevron() + CollapseChevron(expanded) } } } - - if (head != null) OptionContextMenu(head, menuOpen, menuOffset, onDismiss = { menuOpen = false }) } } @Composable private fun GroupLabel(tree: Tree, modifier: Modifier) { - val theme = LocalTheme.current - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - tree.getMetadata("icon")?.let { Icon(it, color = theme.textColor, modifier = Modifier.size(32.dp)) } - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text( - tree.localizedTitle(), - color = theme.textColor, - fontSize = 15.sp, - fontWeight = FontWeight.Medium, - ) - tree.localizedDescription()?.let { - Text(it, color = theme.textColorSecondary, fontSize = 13.sp) - } - } + HeaderLabel(tree.localizedTitle(), tree.localizedDescription(), modifier) { + tree.nodeIcon()?.let { NodeIcon(it, Modifier.size(32.dp)) } + } +} + +@Composable +private fun NodeIcon(icon: Any, modifier: Modifier) { + if (icon is IconSpec) SpriteIcon(icon, modifier) + else Icon(icon.toString(), color = LocalTheme.current.textColor, modifier = modifier) +} + +private object SpriteSheets { + private class Entry(val bitmap: ImageBitmap?) + + private val cache = ConcurrentHashMap() + + fun peek(path: String): ImageBitmap? = cache[path]?.bitmap + + fun load(path: String, loader: ClassLoader?): ImageBitmap? = cache.getOrPut(path) { + Entry(runCatching { read(path, loader)?.let(::decode) }.getOrNull()) + }.bitmap + + private fun read(path: String, loader: ClassLoader?): ByteArray? { + File(path).takeIf { it.isAbsolute && it.isFile }?.let { return it.readBytes() } + val stream = loader?.getResourceAsStream(path.removePrefix("/")) + ?: SpriteSheets::class.java.getResourceAsStream(if (path.startsWith("/")) path else "/$path") + return stream?.use { it.readBytes() } + } + + private fun decode(bytes: ByteArray): ImageBitmap? { + val buffered = runCatching { ImageIO.read(ByteArrayInputStream(bytes)) }.getOrNull() + ?: return PolyBitmap.decode(bytes)?.asComposeImageBitmap() + return PolyBitmap.fromAwt(buffered).asComposeImageBitmap() + } +} + +@Composable +private fun SpriteIcon(spec: IconSpec, modifier: Modifier) { + val loader = remember { Thread.currentThread().contextClassLoader ?: SpriteSheets::class.java.classLoader } + val bitmap by produceState(SpriteSheets.peek(spec.path), spec.path) { + value = SpriteSheets.peek(spec.path) + if (value == null) value = withContext(Dispatchers.IO) { SpriteSheets.load(spec.path, loader) } + } + Canvas(modifier) { + val image = bitmap ?: return@Canvas + val scaleX = if (spec.sheetWidth > 0) image.width.toFloat() / spec.sheetWidth else 1f + val scaleY = if (spec.sheetHeight > 0) image.height.toFloat() / spec.sheetHeight else 1f + val srcX = (spec.x * scaleX).roundToInt() + val srcY = (spec.y * scaleY).roundToInt() + val srcW = min((spec.width * scaleX).roundToInt(), image.width - srcX) + val srcH = min((spec.height * scaleY).roundToInt(), image.height - srcY) + if (srcW <= 0 || srcH <= 0) return@Canvas + val fit = min(size.width / srcW, size.height / srcH) + val dstW = (srcW * fit).roundToInt() + val dstH = (srcH * fit).roundToInt() + drawImage( + image, + srcOffset = IntOffset(srcX, srcY), + srcSize = IntSize(srcW, srcH), + dstOffset = IntOffset(((size.width - dstW) / 2).roundToInt(), ((size.height - dstH) / 2).roundToInt()), + dstSize = IntSize(dstW, dstH), + filterQuality = if (spec.pixelated) FilterQuality.None else FilterQuality.Medium, + ) } } @Composable -private fun HeadControl(head: Property<*>, display: Property.Display, onHeadChange: (Boolean) -> Unit) { - Box(Modifier.blockInteraction(display == Property.Display.DISABLED)) { +private fun HeadControl(head: Property<*>, enabled: Boolean, switch: Boolean, onHeadChange: (Boolean) -> Unit) { + CompositionLocalProvider(LocalOptionEnabled provides enabled) { when { - !head.hasVisualizer() -> HeadSwitch(Property.recast(head), onHeadChange) + switch -> HeadSwitch(Property.recast(head), enabled, onHeadChange) isFullWidth(head) -> BoxWithConstraints(Modifier.fillMaxWidth()) { - CompositionLocalProvider(LocalOptionWidth provides maxWidth) { Option(head) } + CompositionLocalProvider(LocalOptionWidth provides maxWidth, LocalOptionWidthOrNull provides maxWidth) { Option(head) } } else -> Option(head) } @@ -849,14 +944,14 @@ private fun HeadControl(head: Property<*>, display: Property.Display, onHeadChan } @Composable -private fun HeadSwitch(head: Property, onHeadChange: (Boolean) -> Unit) { +private fun HeadSwitch(head: Property, enabled: Boolean, onHeadChange: (Boolean) -> Unit) { var checked by remember(head) { mutableStateOf(head.shown() == true) } DisposableEffect(head) { val listener = Predicate { value -> checked = value == true; false } head.addCallback(listener) onDispose { head.removeCallback(listener) } } - SwitchControl(checked) { + SwitchControl(checked, enabled) { checked = it head.set(it) onHeadChange(it) @@ -882,9 +977,7 @@ private fun SettingRowContent(prop: Property<*>, compact: Boolean) { } Box(Modifier.optionCard()) { - Box(Modifier.alpha(displayAlpha(display))) { - SettingContent(prop, compact = compact, enabled = display != Property.Display.DISABLED) - } + SettingContent(prop, compact = compact, enabled = display != Property.Display.DISABLED) } } @@ -909,9 +1002,8 @@ private fun AccordionSettingRow( modifier: Modifier, compact: Boolean = false, ) { - val effectiveDisplay = if (groupEnabled) display else Property.Display.DISABLED - Box(modifier.alpha(displayAlpha(effectiveDisplay))) { - SettingContent(prop, nested = true, compact = compact, enabled = effectiveDisplay != Property.Display.DISABLED) + Box(modifier) { + SettingContent(prop, nested = true, compact = compact, enabled = groupEnabled && display != Property.Display.DISABLED) } } @@ -929,7 +1021,7 @@ private val OPTION_ROW_GAP = 8.dp private val OPTION_CHROME_WIDTH = 108.dp private val OPTION_ICON_WIDTH = 44.dp -private val OPTION_ACTION_WIDTH = 24.dp + 16.dp +private val OPTION_ACTION_WIDTH = ControlSize.Compact.height + 16.dp private val OPTION_ROW_CHROME = 32.dp + 16.dp @@ -946,7 +1038,7 @@ private fun optionReservedWidth(prop: Property<*>, optionWidth: Dp): Dp { ?: prop.getMetadata("controlWidth")?.let { it.toFloat().dp + OPTION_ROW_CHROME } ?: (if (prop.getMetadata("swatch").let { it != null && it != false }) 24.dp + OPTION_ROW_CHROME else null) ?: OPTION_CHROME_WIDTH - return control + (if (prop.getMetadata("icon") != null) OPTION_ICON_WIDTH else 0.dp) + + return control + (if (prop.nodeIcon() != null) OPTION_ICON_WIDTH else 0.dp) + (if (OneConfigConfig.showOptionActionButtons) OPTION_ACTION_WIDTH else 0.dp) } @@ -1051,34 +1143,36 @@ internal fun rememberDisplay(prop: Property<*>): Property.Display { } } +@Composable internal fun displayAlpha(display: Property.Display): Float { - return if (display == Property.Display.DISABLED) 0.65f else 1f + return if (display == Property.Display.DISABLED) LocalTheme.current.disabledAlpha else 1f } @Composable private fun SettingContent(prop: Property<*>, nested: Boolean = false, compact: Boolean = false, enabled: Boolean = true) { + val active = enabled && LocalOptionEnabled.current + CompositionLocalProvider(LocalOptionEnabled provides active) { + SettingContentBody(prop, nested, compact, active) + } +} + +@Composable +private fun SettingContentBody(prop: Property<*>, nested: Boolean, compact: Boolean, enabled: Boolean) { val verticalPadding = if (nested) 0.dp else 12.dp + val labelModifier = if (enabled) Modifier else Modifier.alpha(LocalTheme.current.disabledAlpha) - val visualizer = prop.getMetadata("visualizer") + val visualizer = prop[MetaKeys.VISUALIZER] if (visualizer == Visualizer.InfoVisualizer::class.java || visualizer is Visualizer.InfoVisualizer) { - Row(modifier = Modifier.fillMaxWidth().blockInteraction(!enabled, keyboard = true).padding(horizontal = 16.dp, vertical = verticalPadding)) { + Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = verticalPadding)) { Option(prop) } return } - var menuOpen by remember(System.identityHashCode(prop)) { mutableStateOf(false) } - var menuOffset by remember(System.identityHashCode(prop)) { mutableStateOf(IntOffset.Zero) } - val rowOrigin = remember(System.identityHashCode(prop)) { LayoutRef(Offset.Zero) } - val actionMenuOffset = remember(System.identityHashCode(prop)) { LayoutRef(IntOffset.Zero) } + val menu = rememberOptionMenu(prop) val rowInteraction = rememberInteractionSource() val isRowHovered by rowInteraction.collectIsHoveredAsState() val showActionButton = OneConfigConfig.showOptionActionButtons && enabled - LaunchedEffect(enabled) { if (!enabled) menuOpen = false } - fun openMenuFromActionButton() { - menuOffset = actionMenuOffset.value - menuOpen = true - } val measurer = rememberTextMeasurer() val titleStyle = rememberTitleStyle() @@ -1087,48 +1181,34 @@ private fun SettingContent(prop: Property<*>, nested: Boolean = false, compact: modifier = Modifier .fillMaxWidth() .hoverable(rowInteraction) - .onGloballyPositioned { rowOrigin.value = it.positionInRoot() } - .pointerInput(prop, enabled) { - if (!enabled) return@pointerInput - awaitPointerEventScope { - while (true) { - val event = awaitPointerEvent(PointerEventPass.Initial) - if (event.type == PointerEventType.Press && event.buttons.isSecondaryPressed && !KeybindRecordingBus.isRecording) { - val pos = event.changes.first().position - menuOffset = IntOffset(pos.x.roundToInt(), pos.y.roundToInt()) - menuOpen = true - event.changes.forEach { it.consume() } - } - } - } - } + .optionContextMenu(menu, enabled) ) { val actionButton = @Composable { - if (showActionButton) { - OptionActionButton( - visible = isRowHovered || menuOpen, - modifier = Modifier.onGloballyPositioned { - val pos = it.positionInRoot() - rowOrigin.value - actionMenuOffset.value = IntOffset(pos.x.roundToInt(), (pos.y + it.size.height).roundToInt()) - }, - onClick = ::openMenuFromActionButton, - ) - } + if (showActionButton) OptionMoreButton(menu, visible = isRowHovered) } if (isFullWidth(prop)) { + val fullControl = @Composable { modifier: Modifier -> + BoxWithConstraints(modifier) { + CompositionLocalProvider(LocalOptionWidth provides maxWidth, LocalOptionWidthOrNull provides maxWidth) { Option(prop) } + } + } Column( modifier = Modifier .fillMaxWidth() - .blockInteraction(!enabled, keyboard = true) .padding(start = 16.dp, end = 16.dp, top = verticalPadding, bottom = if (nested) 8.dp else verticalPadding), verticalArrangement = Arrangement.spacedBy(10.dp), ) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { - SettingLabel(prop, nested = nested, modifier = Modifier.weight(1f)) - actionButton() - } - BoxWithConstraints(Modifier.fillMaxWidth()) { - CompositionLocalProvider(LocalOptionWidth provides maxWidth) { Option(prop) } + if (hidesLabel(prop)) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + fullControl(Modifier.weight(1f)) + actionButton() + } + } else { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + SettingLabel(prop, nested = nested, modifier = Modifier.weight(1f).then(labelModifier)) + actionButton() + } + fullControl(Modifier.fillMaxWidth()) } } } else { @@ -1138,18 +1218,16 @@ private fun SettingContent(prop: Property<*>, nested: Boolean = false, compact: } LabelledOptionRow( titleWidth = titleWidth, - leading = if (prop.getMetadata("icon") != null) OPTION_ICON_WIDTH else 0.dp, + leading = if (prop.nodeIcon() != null) OPTION_ICON_WIDTH else 0.dp, stack = compact, verticalPadding = verticalPadding, bottomPadding = if (nested) 8.dp else verticalPadding, - modifier = Modifier.fillMaxWidth().blockInteraction(!enabled, keyboard = true), - label = { SettingLabel(prop, nested = nested) }, + modifier = Modifier.fillMaxWidth(), + label = { SettingLabel(prop, nested = nested, modifier = labelModifier) }, actionButton = actionButton, control = { Option(prop) }, ) } - - OptionContextMenu(prop, menuOpen, menuOffset, onDismiss = { menuOpen = false }) } } @@ -1221,9 +1299,7 @@ private fun SettingLabel( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - prop.getMetadata("icon")?.let { - Icon(it, color = theme.textColor, modifier = Modifier.size(32.dp)) - } + prop.nodeIcon()?.let { NodeIcon(it, Modifier.size(32.dp)) } Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { val title = @Composable { Text( @@ -1286,7 +1362,7 @@ private fun HudConfigScreenContent(tree: Tree, searchQuery: String) { // empty draws nothing but stays composed, so the display listeners above notice hidden options appearing if (entries.isEmpty()) return - CompositionLocalProvider(LocalOptionWidth provides 210.dp) { + CompositionLocalProvider(LocalOptionWidth provides 210.dp, LocalOptionWidthOrNull provides 210.dp) { Column(verticalArrangement = Arrangement.spacedBy(19.dp)) { if (searchQuery.isEmpty() && visibleCategories.size > 1) { FlowRow( diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Credits.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Credits.kt index db98af2ed..dbbd50a14 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Credits.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Credits.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.unit.sp import java.util.UUID import org.polyfrost.oneconfig.api.platform.v1.ModInfo import org.polyfrost.oneconfig.api.platform.v1.Platform +import org.polyfrost.oneconfig.api.ui.v1.components.EmptyState import org.polyfrost.oneconfig.api.ui.v1.components.PlayerHead import org.polyfrost.oneconfig.internal.ui.api.TranslationCredits import org.polyfrost.oneconfig.internal.ui.components.Icon @@ -49,37 +50,43 @@ private val oneConfigVersion: String? by lazy { }.getOrNull()?.trim()?.takeIf { it.isNotEmpty() } } -private class Person(val name: String, val role: String, uuid: String?) { +private class Person(val name: String, private val roleText: RoleText, uuid: String?) { + val role: String get() = translated(roleText.key, roleText.fallback) + val id: UUID = uuid?.let { UUID(java.lang.Long.parseUnsignedLong(it.substring(0, 16), 16), java.lang.Long.parseUnsignedLong(it.substring(16), 16)) } ?: UUID.nameUUIDFromBytes("OfflinePlayer:$name".toByteArray(Charsets.UTF_8)) } +private class RoleText(val key: String, val fallback: String) + +private fun role(id: String, fallback: String) = RoleText("oneconfig.credits.role.$id", fallback) + private class Thanks(val icon: String, val title: String, val detail: String) private val people = listOf( - Person("Wyvest", "CEO", "a53314040e77440e8bef24c071dac1ae"), - Person("Lynith", "CTO", "f247be7c5b8241c69148793ded77e71f"), - Person("nextdaydelivery", "OneConfig founding engineer", "0dee60d49ff840a9a79fbb415bbaa909"), - Person("Deftu", "OneConfig major engineer", "63677969c51746fbb60fa130b3d9beb9"), - Person("Lunasa", "PolyCompose founding engineer", "4614dcc468244d1c9a352e2844c6c9b2"), - Person("Mona", "CompatEngine founding engineer", "e90ea9ec080a401b8d106a53c407ac53"), - Person("DeDiamondPro", "Config backend, GUI frontend, HUD", "0b4d470ff2fb487493341eaef8ba4804"), - Person("rachel", "Lead cosmetics designer, OneClient", "22e98637ba974b6ba84ffb57a461ce43"), - Person("Qewer", "Designer", "9d139c40d3494916ba4b3d348a284e32"), - Person("Caledonian", "Designer", "e5de6a33a9e04a4e8a493201d74875a6"), - Person("Mo2men", "Designer", "bdad0d588e35422d938ae1a4c0d5d026"), - Person("Zetvue", "Sound design", "9a23f6435e334101a5f2d63f248b49c2"), - Person("Pauline", "OneConfig utilities", "56e2a469477c41f7b0bdcb94dad100f6"), - Person("xtrm", "OneConfig utilities", "7740d6e39f204381a56a060991a1c41c"), - Person("MoonTidez", "Designer, v0", "acbfaeb2bcfe49bcb605ac1ebe16c2d1"), - Person("ThinkSeal", "Major tester", "94418374e58a497cbcba29d34727eca4"), + Person("Wyvest", role("ceo", "CEO"), "a53314040e77440e8bef24c071dac1ae"), + Person("Lynith", role("cto", "CTO"), "f247be7c5b8241c69148793ded77e71f"), + Person("nextdaydelivery", role("oneconfig_founding", "OneConfig founding engineer"), "0dee60d49ff840a9a79fbb415bbaa909"), + Person("Deftu", role("oneconfig_major", "OneConfig major engineer"), "63677969c51746fbb60fa130b3d9beb9"), + Person("Lunasa", role("polycompose_founding", "PolyCompose founding engineer"), "4614dcc468244d1c9a352e2844c6c9b2"), + Person("Mona", role("compatengine_founding", "CompatEngine founding engineer"), "e90ea9ec080a401b8d106a53c407ac53"), + Person("DeDiamondPro", role("config_gui_hud", "Config backend, GUI frontend, HUD"), "0b4d470ff2fb487493341eaef8ba4804"), + Person("rachel", role("cosmetics_lead", "Lead cosmetics designer, OneClient"), "22e98637ba974b6ba84ffb57a461ce43"), + Person("Qewer", role("designer", "Designer"), "9d139c40d3494916ba4b3d348a284e32"), + Person("Caledonian", role("designer", "Designer"), "e5de6a33a9e04a4e8a493201d74875a6"), + Person("Mo2men", role("designer", "Designer"), "bdad0d588e35422d938ae1a4c0d5d026"), + Person("Zetvue", role("sound", "Sound design"), "9a23f6435e334101a5f2d63f248b49c2"), + Person("Pauline", role("utilities", "OneConfig utilities"), "56e2a469477c41f7b0bdcb94dad100f6"), + Person("xtrm", role("utilities", "OneConfig utilities"), "7740d6e39f204381a56a060991a1c41c"), + Person("MoonTidez", role("designer_v0", "Designer, v0"), "acbfaeb2bcfe49bcb605ac1ebe16c2d1"), + Person("ThinkSeal", role("tester", "Major tester"), "94418374e58a497cbcba29d34727eca4"), ) private val thankedPeople = listOf( - Person("Scherso", "For saving us", "466d70e6fe944960ac17597948f437b3"), - Person("FireStorm", "For saving us", "37f1d5edb9a14b99accb0c6b23493a62"), - Person("ImToggle", "Contributions to OneConfig and Polyfrost mods", "c8bf4768af4448cba25901e42fb7bc79"), - Person("Sk1er", "Lasting impact on Minecraft modding", "1a5c41c8a9524ddfa19c66f6bb3707a1"), + Person("Scherso", role("saving", "For saving us"), "466d70e6fe944960ac17597948f437b3"), + Person("FireStorm", role("saving", "For saving us"), "37f1d5edb9a14b99accb0c6b23493a62"), + Person("ImToggle", role("contributions", "Contributions to OneConfig and Polyfrost mods"), "c8bf4768af4448cba25901e42fb7bc79"), + Person("Sk1er", role("modding_impact", "Lasting impact on Minecraft modding"), "1a5c41c8a9524ddfa19c66f6bb3707a1"), ) private fun translationCredit(): String { @@ -102,9 +109,17 @@ private fun translationCredit(): String { } private fun specialThanks() = listOf( - Thanks("profiles", "The Polyfrost team", "For their support and contributions"), - Thanks("text", "Translators", translationCredit()), - Thanks("star", "Contributors and testers", "For helping make modding accessible to everyone"), + Thanks( + "profiles", + translated("oneconfig.credits.thanks.team", "The Polyfrost team"), + translated("oneconfig.credits.thanks.team.detail", "For their support and contributions"), + ), + Thanks("text", translated("oneconfig.credits.thanks.translators", "Translators"), translationCredit()), + Thanks( + "star", + translated("oneconfig.credits.thanks.contributors", "Contributors and testers"), + translated("oneconfig.credits.thanks.contributors.detail", "For helping make modding accessible to everyone"), + ), ) @Composable @@ -138,8 +153,8 @@ fun Credits() { } } if (shownPeople.isEmpty() && shownThankedPeople.isEmpty() && shownThanks.isEmpty()) { - Box(Modifier.fillMaxWidth().padding(top = 40.dp), contentAlignment = Alignment.Center) { - Text(translated("oneconfig.credits.nomatch", "No credits match \"%s\"", query), color = LocalTheme.current.textColorSecondary, fontSize = 12.sp) + Box(Modifier.fillMaxWidth().padding(top = 16.dp), contentAlignment = Alignment.Center) { + EmptyState(translated("oneconfig.credits.nomatch", "No credits match \"%s\"", query), icon = "search") } } if (query.isEmpty()) Legal() @@ -160,7 +175,7 @@ private fun Masthead() { ) } Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Text("by", color = theme.textColorSecondary, fontSize = 12.sp) + Text(translated("oneconfig.credits.by", "by"), color = theme.textColorSecondary, fontSize = 12.sp) rememberSvgResourcePainter("assets/oneconfig/brand/by-polyfrost.svg")?.let { poly -> Image(painter = poly, contentDescription = "Polyfrost", modifier = Modifier.size(104.dp, 18.dp)) } @@ -219,7 +234,7 @@ private fun PersonCard(person: Person, modifier: Modifier) { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - PlayerHead(person.id, 40.dp, Modifier, theme.buttonShape) + PlayerHead(person.id, 40.dp, Modifier, shape = theme.buttonShape) Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { Text(person.name, color = theme.textColor, fontSize = 14.sp, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis) Text(person.role, color = theme.textColorSecondary, fontSize = 12.sp, lineHeight = 16.sp, maxLines = 2, overflow = TextOverflow.Ellipsis) diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/CustomThemeEditor.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/CustomThemeEditor.kt index c5423f038..c5a32f104 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/CustomThemeEditor.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/CustomThemeEditor.kt @@ -4,33 +4,26 @@ import androidx.compose.foundation.Canvas import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size @@ -38,35 +31,39 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.lerp -import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.input.pointer.PointerIcon -import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.DpOffset -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.PopupProperties import kotlinx.coroutines.delay +import org.polyfrost.oneconfig.api.ui.v1.components.ArmedConfirmButton +import org.polyfrost.oneconfig.api.ui.v1.components.Chip +import org.polyfrost.oneconfig.api.ui.v1.components.ColorPickerPopup +import org.polyfrost.oneconfig.api.ui.v1.components.Confirm +import org.polyfrost.oneconfig.api.ui.v1.components.ControlSize +import org.polyfrost.oneconfig.api.ui.v1.components.ControlStyle +import org.polyfrost.oneconfig.api.ui.v1.components.DropdownMenu +import org.polyfrost.oneconfig.api.ui.v1.components.IconButton +import org.polyfrost.oneconfig.api.ui.v1.components.IconButtonStyle +import org.polyfrost.oneconfig.api.ui.v1.components.MenuAction +import org.polyfrost.oneconfig.api.ui.v1.components.MenuEntry +import org.polyfrost.oneconfig.api.ui.v1.components.MenuHeading +import org.polyfrost.oneconfig.api.ui.v1.components.MenuSeparator +import org.polyfrost.oneconfig.api.ui.v1.components.SegmentedChips +import org.polyfrost.oneconfig.api.ui.v1.components.TextButton import org.polyfrost.oneconfig.api.ui.v1.components.TextField +import org.polyfrost.oneconfig.api.ui.v1.components.TooltipBox +import org.polyfrost.oneconfig.api.ui.v1.components.TooltipPlacement +import org.polyfrost.oneconfig.api.ui.v1.components.oneConfigClickable +import org.polyfrost.oneconfig.api.ui.v1.components.rememberColorPickerModel import org.polyfrost.oneconfig.internal.ui.components.Icon -import org.polyfrost.oneconfig.internal.ui.components.ShellPopup import org.polyfrost.oneconfig.internal.ui.components.Text -import org.polyfrost.oneconfig.internal.ui.components.dropdown.SimpleDropdownMenu import org.polyfrost.oneconfig.internal.ui.components.glassHover -import org.polyfrost.oneconfig.internal.ui.components.keepSearchFocus import org.polyfrost.oneconfig.internal.ui.components.layout.FlexibleLayout import org.polyfrost.oneconfig.internal.ui.components.liquidGlass -import org.polyfrost.oneconfig.internal.ui.components.rememberInteractionSource -import org.polyfrost.oneconfig.internal.ui.components.settings.ColorPickerModel -import org.polyfrost.oneconfig.internal.ui.components.settings.ColorPickerPopup import org.polyfrost.oneconfig.internal.ui.components.settings.SliderControl import org.polyfrost.oneconfig.internal.ui.components.translated import org.polyfrost.oneconfig.internal.ui.shell.PageGutter -import org.polyfrost.oneconfig.internal.ui.sound.UiSoundEvent -import org.polyfrost.oneconfig.internal.ui.sound.UiSounds import org.polyfrost.oneconfig.internal.ui.themes.Accent import org.polyfrost.oneconfig.internal.ui.themes.CustomBase import org.polyfrost.oneconfig.internal.ui.themes.CustomCorners @@ -79,9 +76,7 @@ import org.polyfrost.oneconfig.internal.ui.themes.UITheme import org.polyfrost.oneconfig.internal.ui.themes.contrastRatio import org.polyfrost.oneconfig.internal.ui.themes.customSpec import org.polyfrost.oneconfig.internal.ui.themes.isCustom -import org.polyfrost.oneconfig.internal.ui.themes.palette import org.polyfrost.oneconfig.internal.ui.themes.previewAsset -import org.polyfrost.oneconfig.internal.ui.themes.isLight import org.polyfrost.oneconfig.utils.v1.ClipboardHelper import kotlin.math.roundToInt @@ -91,14 +86,12 @@ private const val SECONDARY_CONTRAST = 3f private val EditorLabelWidth = 110.dp -private val EditorControlHeight = 30.dp +private val EditorControlHeight = ControlSize.Compact.height private val EditorFieldWidth = 320.dp private val EditorGap = 8.dp -private val DangerColor = Color(0xFFE35B5B) - private fun baseOf(theme: UITheme): CustomBase = theme.customSpec?.base ?: when { theme.liquidGlass -> CustomBase.POLYGLASS theme.isMinecraftStyle -> CustomBase.MINECRAFT @@ -146,6 +139,76 @@ internal fun createCustomFrom(source: UITheme): Boolean { return true } +private fun duplicateCustom(spec: CustomThemeSpec): EditorNotice? { + val copy = CustomThemes.import(spec.encode()) ?: return EditorNotice.Message(fullMessage()) + val named = copy.copy(name = customCopyName(spec.name)) + CustomThemes.update(named) + activateCustom(named) + return null +} + +private fun copyCodeNotice(spec: CustomThemeSpec): EditorNotice = + if (ClipboardHelper.setString(spec.encode())) { + EditorNotice.Message(translated("oneconfig.themes.custom.copied", "Code copied")) + } else { + EditorNotice.Message(translated("oneconfig.themes.custom.copy_failed", "Couldn't reach the clipboard")) + } + +private fun deleteCustom(spec: CustomThemeSpec) { + if (ThemeRegistry.activeTheme?.customSpec?.id == spec.id) { + val fallback = ThemeRegistry.registry.firstOrNull { !it.isCustom && baseOf(it) == spec.base && it.isLight == spec.light } + ?: ThemeRegistry.registry.firstOrNull { !it.isCustom } + fallback?.let(ThemeRegistry::activate) + } + CustomThemes.remove(spec.id) +} + +private fun askDeleteCustom(spec: CustomThemeSpec) { + Confirm.ask( + translated("oneconfig.themes.custom.delete_title", "Delete \"%s\"?", spec.name), + translated("oneconfig.themes.custom.delete_text", "This custom theme will be gone for good. Copy its code first if you want to keep it."), + translated("oneconfig.themes.custom.delete", "Delete"), + { deleteCustom(spec) }, + icon = "trash", + ) +} + +private fun customThemeMenu(spec: CustomThemeSpec, active: Boolean, onNotice: (EditorNotice?) -> Unit): List { + val full = CustomThemes.specs.size >= CustomThemeSpec.MAX + return listOf( + MenuAction( + translated("oneconfig.themes.menu.activate", "Activate"), + { activateCustom(spec) }, + icon = "check-circle", + enabled = !active, + ), + MenuAction( + translated("oneconfig.themes.menu.edit", "Edit"), + { if (!active) activateCustom(spec) }, + icon = "paintbrush", + ), + MenuAction( + translated("oneconfig.themes.custom.duplicate", "Duplicate"), + { duplicateCustom(spec)?.let { onNotice(it) } }, + icon = "copy-plus", + enabled = !full, + disabledReason = if (full) fullMessage() else null, + ), + MenuAction( + translated("oneconfig.themes.custom.copy", "Copy code"), + { onNotice(copyCodeNotice(spec)) }, + icon = "copy", + ), + MenuSeparator, + MenuAction( + translated("oneconfig.themes.custom.delete", "Delete"), + { askDeleteCustom(spec) }, + icon = "trash", + danger = true, + ), + ) +} + private fun baseLabel(base: CustomBase): String = when (base) { CustomBase.POLYFROSTED -> translated("oneconfig.themes.custom.base.polyfrosted", "Polyfrosted") CustomBase.POLYGLASS -> translated("oneconfig.themes.custom.base.polyglass", "PolyGlass") @@ -170,7 +233,7 @@ private fun fixContrast(color: Color, background: Color, target: Float): Color { } @Composable -internal fun CustomThemeCard(modifier: Modifier, onPaste: () -> Unit) { +internal fun CustomThemeCard(modifier: Modifier, onNotice: (EditorNotice?) -> Unit) { val theme = LocalTheme.current val specs = CustomThemes.specs val active = theme.customSpec @@ -194,111 +257,75 @@ internal fun CustomThemeCard(modifier: Modifier, onPaste: () -> Unit) { }, controls = { if (shown != null) { - ThemeDropdown(specs, shown, { it.name }) { spec -> + ThemeSelect(specs, shown, { it.name }) { spec -> pickedId = spec.id - if (active != null) activateCustom(spec) else UiSounds.play(UiSoundEvent.CLICK) + if (active != null) activateCustom(spec) } } if (specs.size < CustomThemeSpec.MAX) StartFromButton(startOpen) { startOpen = it } if (active == null) { - SquareControlButton("clipboard", translated("oneconfig.themes.tooltip.custom_paste", "Add a theme from a code"), onPaste) + IconButton( + "clipboard", + translated("oneconfig.themes.tooltip.custom_paste", "Add a theme from a code"), + { onNotice(pastedThemeNotice()) }, + style = IconButtonStyle.Framed, + size = ControlSize.Compact, + tooltipPlacement = TooltipPlacement.AboveCenter, + ) } }, footer = { ThemeCaption(translated("oneconfig.themes.caption.custom", "Make your own from any theme")) }, + menu = { + if (shown == null) emptyList() else customThemeMenu(shown, active?.id == shown.id, onNotice) + }, ) } @Composable private fun StartFromButton(open: Boolean, onOpenChange: (Boolean) -> Unit) { - val theme = LocalTheme.current Box { - SquareControlButton("plus", translated("oneconfig.themes.tooltip.custom_new", "New custom theme")) { onOpenChange(!open) } - SimpleDropdownMenu( - expanded = open, - onDismissRequest = { onOpenChange(false) }, - offset = DpOffset(0.dp, 8.dp), - ) { - Column( - Modifier - .width(240.dp) - .clip(theme.popupShape) - .background(theme.popupBackground, theme.popupShape) - .border(1.dp, theme.borderColor, theme.popupShape) - .padding(6.dp), - ) { - StartFromHeading(translated("oneconfig.themes.custom.start_from", "Start from")) - Column( - Modifier.heightIn(max = 320.dp).verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - ThemeRegistry.registry.filterNot { it.isCustom }.forEach { source -> - StartFromRow(source.name, { - @Suppress("DEPRECATION") - Image( - painter = painterResource("/assets/oneconfig/images/${source.previewAsset}.svg"), - contentDescription = null, - modifier = Modifier.size(StartFromPreviewWidth, StartFromPreviewHeight), - ) - }) { - onOpenChange(false) - createCustomFrom(source) - } - } - if (CustomThemes.specs.isNotEmpty()) { - StartFromHeading(translated("oneconfig.themes.custom.start_from.yours", "Your custom themes")) - CustomThemes.specs.forEach { spec -> - StartFromRow(spec.name, { - CustomThemePreview(spec, Modifier.size(StartFromPreviewWidth, StartFromPreviewHeight)) - }) { - onOpenChange(false) - createCustomFrom(customTheme(spec)) - } - } - } - } - } - } + IconButton( + "plus", + translated("oneconfig.themes.tooltip.custom_new", "New custom theme"), + { onOpenChange(!open) }, + style = IconButtonStyle.Framed, + size = ControlSize.Compact, + tooltipPlacement = TooltipPlacement.AboveCenter, + ) + if (open) DropdownMenu(true, { onOpenChange(false) }, startFromEntries()) } } -@Composable -private fun StartFromHeading(text: String) { - Text( - text, - color = LocalTheme.current.textColorSecondary, - fontSize = 12.sp, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 6.dp), - ) +private fun startFromEntries(): List = buildList { + add(MenuHeading(translated("oneconfig.themes.custom.start_from", "Start from"))) + ThemeRegistry.registry.filterNot { it.isCustom }.forEach { source -> + add(MenuAction(source.name, { createCustomFrom(source) }, leading = { StartFromPreview(source.previewAsset) })) + } + val yours = CustomThemes.specs.toList() + if (yours.isNotEmpty()) { + add(MenuHeading(translated("oneconfig.themes.custom.start_from.yours", "Your custom themes"))) + yours.forEach { spec -> + add( + MenuAction( + spec.name, + { createCustomFrom(customTheme(spec)) }, + leading = { CustomThemePreview(spec, Modifier.size(StartFromPreviewWidth, StartFromPreviewHeight)) }, + ) + ) + } + } } +@Suppress("DEPRECATION") @Composable -private fun StartFromRow(name: String, preview: @Composable () -> Unit, onPick: () -> Unit) { - val theme = LocalTheme.current - val source = rememberInteractionSource() - val hovered by source.collectIsHoveredAsState() - Row( - Modifier - .fillMaxWidth() - .background(if (hovered) theme.modCardBackground else Color.Transparent, theme.buttonShape) - .hoverable(source) - .keepSearchFocus() - .clickable(source, indication = null, onClick = onPick) - .pointerHoverIcon(PointerIcon.Hand) - .padding(6.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - preview() - Text( - name, - color = theme.textColor, - fontSize = 13.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } +private fun StartFromPreview(asset: String) { + Image( + painter = painterResource("/assets/oneconfig/images/$asset.svg"), + contentDescription = null, + modifier = Modifier.size(StartFromPreviewWidth, StartFromPreviewHeight), + ) } @Composable @@ -395,16 +422,21 @@ internal fun ThemeCodeNotice(notice: EditorNotice, onNotice: (EditorNotice?) -> color = theme.textColor, fontSize = 13.sp, ) - EditorButton(translated("oneconfig.themes.custom.import", "Add"), selected = true) { - val imported = CustomThemes.import(notice.code) - if (imported == null) { - onNotice(EditorNotice.Message(fullMessage())) - } else { - onNotice(null) - activateCustom(imported) - } - } - EditorButton(translated("oneconfig.themes.custom.cancel", "Cancel")) { onNotice(null) } + TextButton( + translated("oneconfig.themes.custom.import", "Add"), + { + val imported = CustomThemes.import(notice.code) + if (imported == null) { + onNotice(EditorNotice.Message(fullMessage())) + } else { + onNotice(null) + activateCustom(imported) + } + }, + style = ControlStyle.Primary, + size = ControlSize.Compact, + ) + TextButton(translated("oneconfig.themes.custom.cancel", "Cancel"), { onNotice(null) }, size = ControlSize.Compact) } } } @@ -413,7 +445,6 @@ internal fun ThemeCodeNotice(notice: EditorNotice, onNotice: (EditorNotice?) -> internal fun CustomThemeEditor(spec: CustomThemeSpec, noticeState: MutableState) { val theme = LocalTheme.current var notice by noticeState - var confirmDelete by remember(spec.id) { mutableStateOf(false) } val update: (CustomThemeSpec) -> Unit = { CustomThemes.update(it) } Column( Modifier @@ -439,15 +470,17 @@ internal fun CustomThemeEditor(spec: CustomThemeSpec, noticeState: MutableState< } EditorRow(translated("oneconfig.themes.custom.base", "Base")) { FlexibleLayout(horizontalSpacing = EditorGap, verticalSpacing = EditorGap) { - CustomBase.entries.forEach { base -> - EditorButton(baseLabel(base), selected = spec.base == base) { - if (spec.base != base) update(spec.copy(base = base)) - } - } - SegmentGroup { - ToggleSegment("moon", lightHint(false), !spec.light) { update(spec.copy(light = false)); UiSounds.play(UiSoundEvent.CLICK) } - ToggleSegment("sun", lightHint(true), spec.light) { update(spec.copy(light = true)); UiSounds.play(UiSoundEvent.CLICK) } - } + SegmentedChips( + CustomBase.entries, + spec.base, + { if (it != spec.base) update(spec.copy(base = it)) }, + label = ::baseLabel, + size = ControlSize.Compact, + ) + IconSegments( + listOf(IconSegment(false, "moon", lightHint(false)), IconSegment(true, "sun", lightHint(true))), + spec.light, + ) { update(spec.copy(light = it)) } } } EditorRow(translated("oneconfig.themes.custom.colours", "Colours")) { @@ -484,33 +517,40 @@ internal fun CustomThemeEditor(spec: CustomThemeSpec, noticeState: MutableState< color = theme.textColorSecondary, fontSize = 13.sp, ) - EditorButton(translated("oneconfig.themes.custom.contrast.fix", "Fix")) { - update( - spec.copy( - text = if (textLow) fixContrast(spec.text, spec.background, TEXT_CONTRAST) else spec.text, - textSecondary = if (secondaryLow) fixContrast(spec.textSecondary, spec.background, SECONDARY_CONTRAST) else spec.textSecondary, + TextButton( + translated("oneconfig.themes.custom.contrast.fix", "Fix"), + { + update( + spec.copy( + text = if (textLow) fixContrast(spec.text, spec.background, TEXT_CONTRAST) else spec.text, + textSecondary = if (secondaryLow) fixContrast(spec.textSecondary, spec.background, SECONDARY_CONTRAST) else spec.textSecondary, + ) ) - ) - } + }, + size = ControlSize.Compact, + ) } } } EditorRow(translated("oneconfig.themes.custom.accent", "Accent")) { Row(horizontalArrangement = Arrangement.spacedBy(EditorGap), verticalAlignment = Alignment.CenterVertically) { ColorField(null, spec.accent ?: PlayerAccent, spec.accent != null) { update(spec.copy(accent = it)) } - EditorButton(translated("oneconfig.themes.custom.accent.player", "Use my accent"), selected = spec.accent == null) { - if (spec.accent != null) update(spec.copy(accent = null)) - } + Chip( + translated("oneconfig.themes.custom.accent.player", "Use my accent"), + spec.accent == null, + { if (spec.accent != null) update(spec.copy(accent = null)) }, + size = ControlSize.Compact, + ) } } EditorRow(translated("oneconfig.themes.custom.corners", "Corners")) { - FlexibleLayout(horizontalSpacing = EditorGap, verticalSpacing = EditorGap) { - CustomCorners.entries.forEach { corners -> - EditorButton(cornersLabel(corners), selected = spec.corners == corners) { - if (spec.corners != corners) update(spec.copy(corners = corners)) - } - } - } + SegmentedChips( + CustomCorners.entries, + spec.corners, + { if (it != spec.corners) update(spec.copy(corners = it)) }, + label = ::cornersLabel, + size = ControlSize.Compact, + ) } if (spec.base == CustomBase.POLYGLASS) { EditorRow(translated("oneconfig.themes.custom.glass", "Glass")) { @@ -533,49 +573,33 @@ internal fun CustomThemeEditor(spec: CustomThemeSpec, noticeState: MutableState< } EditorRow("") { FlexibleLayout(horizontalSpacing = EditorGap, verticalSpacing = EditorGap) { - ThemeHint(translated("oneconfig.themes.tooltip.custom_copy", "Copy a code you can share")) { - EditorButton(translated("oneconfig.themes.custom.copy", "Copy code"), icon = "copy") { - notice = if (ClipboardHelper.setString(spec.encode())) { - EditorNotice.Message(translated("oneconfig.themes.custom.copied", "Code copied")) - } else { - EditorNotice.Message(translated("oneconfig.themes.custom.copy_failed", "Couldn't reach the clipboard")) - } - } + TooltipBox(translated("oneconfig.themes.tooltip.custom_copy", "Copy a code you can share"), placement = TooltipPlacement.AboveCenter) { + TextButton(translated("oneconfig.themes.custom.copy", "Copy code"), "copy", { notice = copyCodeNotice(spec) }, size = ControlSize.Compact) } - ThemeHint(translated("oneconfig.themes.tooltip.custom_paste", "Add a theme from a code")) { - EditorButton(translated("oneconfig.themes.custom.paste", "Paste code"), icon = "clipboard") { - notice = pastedThemeNotice() - } - } - EditorButton(translated("oneconfig.themes.custom.duplicate", "Duplicate"), icon = "copy-plus") { - val copy = CustomThemes.import(spec.encode()) - if (copy == null) { - notice = EditorNotice.Message(fullMessage()) - } else { - val named = copy.copy(name = customCopyName(spec.name)) - update(named) - activateCustom(named) - } + TooltipBox(translated("oneconfig.themes.tooltip.custom_paste", "Add a theme from a code"), placement = TooltipPlacement.AboveCenter) { + TextButton(translated("oneconfig.themes.custom.paste", "Paste code"), "clipboard", { notice = pastedThemeNotice() }, size = ControlSize.Compact) } - ThemeHint(translated("oneconfig.themes.tooltip.custom_reset", "Put the colours back to the base theme's")) { - EditorButton(translated("oneconfig.themes.custom.reset", "Reset to base"), icon = "refresh") { - update(CustomThemeSpec.fromBase(spec.id, spec.name, spec.base, spec.light)) - } + TextButton( + translated("oneconfig.themes.custom.duplicate", "Duplicate"), + "copy-plus", + { duplicateCustom(spec)?.let { notice = it } }, + size = ControlSize.Compact, + ) + TooltipBox(translated("oneconfig.themes.tooltip.custom_reset", "Put the colours back to the base theme's"), placement = TooltipPlacement.AboveCenter) { + TextButton( + translated("oneconfig.themes.custom.reset", "Reset to base"), + "refresh", + { update(CustomThemeSpec.fromBase(spec.id, spec.name, spec.base, spec.light)) }, + size = ControlSize.Compact, + ) } - EditorButton( - if (confirmDelete) translated("oneconfig.themes.custom.delete_confirm", "Click again to delete") else translated("oneconfig.themes.custom.delete", "Delete"), - icon = "trash", - danger = true, - selected = confirmDelete, - ) { - if (!confirmDelete) { - confirmDelete = true - } else { - val fallback = ThemeRegistry.registry.firstOrNull { !it.isCustom && baseOf(it) == spec.base && it.isLight == spec.light } - ?: ThemeRegistry.registry.firstOrNull { !it.isCustom } - fallback?.let(ThemeRegistry::activate) - CustomThemes.remove(spec.id) - } + key(spec.id) { + ArmedConfirmButton( + translated("oneconfig.themes.custom.delete", "Delete"), + { deleteCustom(spec) }, + armedText = translated("oneconfig.themes.custom.delete_confirm", "Click again to delete"), + size = ControlSize.Compact, + ) } } } @@ -598,55 +622,15 @@ private fun EditorRow(label: String, content: @Composable () -> Unit) { } } -@Composable -private fun EditorButton( - label: String, - icon: String? = null, - selected: Boolean = false, - danger: Boolean = false, - onClick: () -> Unit, -) { - val theme = LocalTheme.current - val interactionSource = rememberInteractionSource() - val shape = theme.buttonShape - val fill = when { - selected && danger -> DangerColor - selected -> Accent - else -> theme.componentBackground - } - val ink = when { - selected && danger -> Color.White - selected -> theme.accentTextColor - danger -> DangerColor - else -> theme.textColor - } - Row( - Modifier - .height(EditorControlHeight) - .liquidGlass(shape) - .background(fill, shape) - .border(1.dp, if (danger) DangerColor.copy(alpha = 0.6f) else if (selected) Color.Transparent else theme.borderColor, shape) - .glassHover(shape, pull = 0.75.dp) - .keepSearchFocus() - .clickable(interactionSource, indication = null, onClick = onClick) - .pointerHoverIcon(PointerIcon.Hand) - .padding(horizontal = 12.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (icon != null) Icon(icon, color = ink, modifier = Modifier.size(14.dp)) - Text(label, color = ink, fontSize = 13.sp, maxLines = 1) - } -} - private fun opaqueHex(color: Color): String = "#%06X".format(color.toArgb() and 0xFFFFFF) @Composable private fun ColorField(label: String?, color: Color, selected: Boolean, onChange: (Color) -> Unit) { val theme = LocalTheme.current var expanded by remember { mutableStateOf(false) } - val interactionSource = rememberInteractionSource() val shape = theme.buttonShape + val circle = theme.shapes.circle + val hex = opaqueHex(color) Box { Row( Modifier @@ -655,9 +639,12 @@ private fun ColorField(label: String?, color: Color, selected: Boolean, onChange .background(theme.componentBackground, shape) .border(if (selected) 2.dp else 1.dp, if (selected) Accent else theme.borderColor, shape) .glassHover(shape, pull = 0.75.dp) - .keepSearchFocus() - .clickable(interactionSource, indication = null) { expanded = !expanded } - .pointerHoverIcon(PointerIcon.Hand) + .oneConfigClickable( + if (label == null) hex else "$label $hex", + { expanded = !expanded }, + shape = shape, + indication = false, + ) .padding(horizontal = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, @@ -665,27 +652,19 @@ private fun ColorField(label: String?, color: Color, selected: Boolean, onChange Box( Modifier .size(14.dp) - .background(color.copy(alpha = 1f), CircleShape) - .border(1.dp, theme.borderColor, CircleShape) + .background(color.copy(alpha = 1f), circle) + .border(1.dp, theme.borderColor, circle) ) if (label != null) Text(label, color = theme.textColor, fontSize = 13.sp, maxLines = 1) - Text(opaqueHex(color), color = theme.textColorSecondary, fontSize = 13.sp, maxLines = 1) + Text(hex, color = theme.textColorSecondary, fontSize = 13.sp, maxLines = 1) } if (expanded) { - ShellPopup( - alignment = Alignment.TopStart, - offset = IntOffset(0, 38), - onDismissRequest = { expanded = false }, - properties = PopupProperties(focusable = true), - ) { - val model = remember { ColorPickerModel(color.copy(alpha = 1f), false) } - ColorPickerPopup( - model = model, - onColorChanged = { onChange(it.copy(alpha = color.alpha)) }, - onClose = { expanded = false }, - chromaCapable = false, - ) - } + val model = rememberColorPickerModel(color.copy(alpha = 1f), hasAlpha = false) + ColorPickerPopup( + model, + { onChange(it.copy(alpha = color.alpha)) }, + { expanded = false }, + ) } } } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Keybinds.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Keybinds.kt index cf3152ba4..443ecd27c 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Keybinds.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Keybinds.kt @@ -1,6 +1,5 @@ package org.polyfrost.oneconfig.internal.ui.screens -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.hoverable @@ -27,28 +26,20 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.center import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.isSecondaryPressed -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import java.util.IdentityHashMap -import kotlin.math.roundToInt import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.polyfrost.oneconfig.api.config.v1.Property +import org.polyfrost.oneconfig.api.ui.v1.components.EmptyState +import org.polyfrost.oneconfig.api.ui.v1.components.LoadState import org.polyfrost.oneconfig.internal.OneConfigConfig import org.polyfrost.oneconfig.internal.ui.api.ConfigRegistry +import org.polyfrost.oneconfig.internal.ui.components.CollapsibleHeader import org.polyfrost.oneconfig.internal.ui.components.Icon import org.polyfrost.oneconfig.internal.ui.components.ScrollbarBox import org.polyfrost.oneconfig.internal.ui.components.Text @@ -58,18 +49,17 @@ import org.polyfrost.oneconfig.internal.ui.components.hoverGlow import org.polyfrost.oneconfig.internal.ui.components.isEmptyText import org.polyfrost.oneconfig.internal.ui.components.localizedDescription import org.polyfrost.oneconfig.internal.ui.components.localizedTitle -import org.polyfrost.oneconfig.internal.ui.components.onClick import org.polyfrost.oneconfig.internal.ui.components.rememberInteractionSource import org.polyfrost.oneconfig.internal.ui.components.settings.KeybindConflicts import org.polyfrost.oneconfig.internal.ui.components.settings.Option -import org.polyfrost.oneconfig.internal.ui.components.settings.OptionActionButton -import org.polyfrost.oneconfig.internal.ui.components.settings.OptionContextMenu +import org.polyfrost.oneconfig.internal.ui.components.settings.OptionMoreButton +import org.polyfrost.oneconfig.internal.ui.components.settings.optionContextMenu +import org.polyfrost.oneconfig.internal.ui.components.settings.rememberOptionMenu import org.polyfrost.oneconfig.internal.ui.components.translated import org.polyfrost.oneconfig.internal.ui.keybind.KeybindEntry import org.polyfrost.oneconfig.internal.ui.keybind.KeybindGroup import org.polyfrost.oneconfig.internal.ui.keybind.KeybindGroupCollapseStore import org.polyfrost.oneconfig.internal.ui.keybind.KeybindProviderRegistry -import org.polyfrost.oneconfig.internal.ui.keybind.KeybindRecordingBus import org.polyfrost.oneconfig.internal.ui.keybind.collectAllKeybindGroups import org.polyfrost.oneconfig.internal.ui.search.SearchCorpus import org.polyfrost.oneconfig.internal.ui.search.SearchDocument @@ -79,9 +69,6 @@ import org.polyfrost.oneconfig.internal.ui.shell.PageGutter import org.polyfrost.oneconfig.internal.ui.shell.pageSearchQuery import org.polyfrost.oneconfig.internal.ui.shell.rememberRestorableLazyListState import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme -import org.polyfrost.oneconfig.internal.ui.util.LayoutRef - -private val CONFLICT_COLOR = Color(0xFFE0524F) @Composable fun Keybinds() { @@ -99,13 +86,15 @@ fun Keybinds() { if (visibleGroups.isEmpty()) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - val message = when { - localSearchQuery.isBlank() -> translated("oneconfig.search.no_keybinds", "No keybinds available.") + when { + localSearchQuery.isBlank() -> + EmptyState(translated("oneconfig.search.no_keybinds", "No keybinds available."), icon = "keyboard") // nothing to say until the first search comes back - searchResults == null -> translated("oneconfig.search.searching", "Searching...") - else -> translated("oneconfig.search.no_keybinds_match", "No keybinds match \"%s\"", localSearchQuery) + searchResults == null -> + EmptyState(translated("oneconfig.search.searching", "Searching..."), state = LoadState.Loading) + else -> + EmptyState(translated("oneconfig.search.no_keybinds_match", "No keybinds match \"%s\"", localSearchQuery), icon = "search") } - Text(message, color = LocalTheme.current.textColorSecondary, fontSize = 15.sp) } return } @@ -185,15 +174,11 @@ private fun searchKeybindGroups(groups: List, query: String): List @Composable private fun KeybindGroupHeader(group: KeybindGroup, expanded: Boolean, onToggle: () -> Unit) { val theme = LocalTheme.current - val interaction = rememberInteractionSource() - val chevronRotation by animateFloatAsState(if (expanded) 180f else 90f) - Row( - modifier = Modifier - .fillMaxWidth() - .onClick(interaction, onClick = onToggle) - .padding(top = 4.dp, bottom = 2.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), + CollapsibleHeader( + label = group.modTitle.asRenderText(), + expanded = expanded, + onToggle = onToggle, + contentPadding = PaddingValues(top = 4.dp, bottom = 2.dp), ) { group.modIcon?.let { icon -> Icon(icon, color = theme.textColorSecondary, modifier = Modifier.size(18.dp)) @@ -204,11 +189,6 @@ private fun KeybindGroupHeader(group: KeybindGroup, expanded: Boolean, onToggle: fontSize = 11.sp, fontWeight = FontWeight.Medium, ) - Icon( - "up", - color = theme.textColorSecondary, - modifier = Modifier.size(12.dp).rotate(chevronRotation), - ) } } @@ -220,37 +200,17 @@ private fun KeybindRow(entry: KeybindEntry, conflictsWith: List>) { val display = rememberDisplay(prop) if (display == Property.Display.HIDDEN) return val enabled = display != Property.Display.DISABLED - var menuOpen by remember(System.identityHashCode(prop)) { mutableStateOf(false) } - var menuOffset by remember(System.identityHashCode(prop)) { mutableStateOf(IntOffset.Zero) } - val rowOrigin = remember(System.identityHashCode(prop)) { LayoutRef(Offset.Zero) } - val actionMenuOffset = remember(System.identityHashCode(prop)) { LayoutRef(IntOffset.Zero) } + val menu = rememberOptionMenu(prop) val rowInteraction = rememberInteractionSource() val isRowHovered by rowInteraction.collectIsHoveredAsState() val showActionButton = OneConfigConfig.showOptionActionButtons - fun openMenuFromActionButton() { - menuOffset = actionMenuOffset.value - menuOpen = true - } Box( modifier = Modifier .fillMaxWidth() .alpha(displayAlpha(display)) .hoverable(rowInteraction) - .onGloballyPositioned { rowOrigin.value = it.positionInRoot() } - .pointerInput(System.identityHashCode(prop)) { - awaitPointerEventScope { - while (true) { - val event = awaitPointerEvent(PointerEventPass.Initial) - if (event.type == PointerEventType.Press && event.buttons.isSecondaryPressed && !KeybindRecordingBus.isRecording) { - val pos = event.changes.first().position - menuOffset = IntOffset(pos.x.roundToInt(), pos.y.roundToInt()) - menuOpen = true - event.changes.forEach { it.consume() } - } - } - } - } + .optionContextMenu(menu) .hoverGlow(shape) .background(theme.modCardBackground, shape) .border( @@ -284,7 +244,7 @@ private fun KeybindRow(entry: KeybindEntry, conflictsWith: List>) { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - prop.getMetadata("icon")?.let { + (prop.getMetadata("icon") as? String)?.let { Icon(it, color = theme.textColor, modifier = Modifier.size(32.dp)) } Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { @@ -308,31 +268,20 @@ private fun KeybindRow(entry: KeybindEntry, conflictsWith: List>) { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - Icon("alert-circle", color = CONFLICT_COLOR, modifier = Modifier.size(12.dp)) + Icon("alert-circle", color = theme.error, modifier = Modifier.size(12.dp)) Text( - "Conflicts with $names", - color = CONFLICT_COLOR, + translated("oneconfig.keybind.conflicts", "Conflicts with %s", names), + color = theme.error, fontSize = 11.sp, ) } } } } - if (showActionButton) { - OptionActionButton( - visible = isRowHovered || menuOpen, - modifier = Modifier.onGloballyPositioned { - val pos = it.positionInRoot() - rowOrigin.value - actionMenuOffset.value = IntOffset(pos.x.roundToInt(), (pos.y + it.size.height).roundToInt()) - }, - onClick = ::openMenuFromActionButton, - ) - } + if (showActionButton) OptionMoreButton(menu, visible = isRowHovered) Box(Modifier.blockInteraction(!enabled)) { Option(prop) } } - - OptionContextMenu(prop, menuOpen, menuOffset, onDismiss = { menuOpen = false }) } } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/ModConfig.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/ModConfig.kt index e97820d42..a6757ff57 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/ModConfig.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/ModConfig.kt @@ -4,22 +4,27 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import java.util.function.Predicate +import kotlinx.coroutines.delay import org.apache.logging.log4j.LogManager import org.polyfrost.oneconfig.api.ui.v1.ModToggles +import org.polyfrost.oneconfig.api.ui.v1.components.ControlStyle +import org.polyfrost.oneconfig.api.ui.v1.components.EmptyState +import org.polyfrost.oneconfig.api.ui.v1.components.TextButton import org.polyfrost.oneconfig.internal.ui.api.ConfigRegistry import org.polyfrost.oneconfig.internal.ui.api.PropertyModToggle -import org.polyfrost.oneconfig.internal.ui.components.Text +import org.polyfrost.oneconfig.internal.ui.components.translated import org.polyfrost.oneconfig.internal.ui.navigation.graph.ModConfigRoute import org.polyfrost.oneconfig.internal.ui.search.submenuTrail -import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme private val LOGGER = LogManager.getLogger("OneConfig/UI") @@ -33,16 +38,34 @@ fun ModConfig(id: String, initialCategory: String? = null, submenu: String? = nu when { config == null -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("Unknown mod: $id", color = LocalTheme.current.textColorSecondary) + EmptyState( + translated("oneconfig.modconfig.unknown", "Mod not found"), + icon = "alert-circle", + text = translated("oneconfig.modconfig.unknown.text", "No mod with the id %s is loaded.", id), + ) } tree?.getMetadata("ui_only") == true && externalOpen != null -> Box( Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { - Text("This mod opens its own settings screen.", color = LocalTheme.current.textColorSecondary) + EmptyState( + translated("oneconfig.modconfig.external", "This mod opens its own settings screen."), + icon = "external-link", + action = { + TextButton( + translated("oneconfig.modconfig.external.open", "Open settings"), + "external-link", + externalOpen, + style = ControlStyle.Primary, + ) + }, + ) } tree == null -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("This mod manages its own configuration.", color = LocalTheme.current.textColorSecondary) + EmptyState( + translated("oneconfig.modconfig.own", "This mod manages its own configuration."), + icon = "settings", + ) } else -> { val toggleRevision = ModToggles.revision @@ -54,7 +77,13 @@ fun ModConfig(id: String, initialCategory: String? = null, submenu: String? = nu ownSwitch?.addCallback(listener) onDispose { ownSwitch?.removeCallback(listener) } } - val locked = remember(toggle, ownSwitchFlips) { toggle?.isEnabled() == false } + var locked by remember(toggle, ownSwitchFlips) { mutableStateOf(toggle?.isEnabled() == false) } + LaunchedEffect(toggle, ownSwitchFlips) { + while (toggle != null) { + locked = toggle.isEnabled() == false + delay(250) + } + } // switching a mod can swap the values of its options so the page is rebuilt afterwards key(revision, tree) { diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/ModSortControl.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/ModSortControl.kt index 1cd2f984e..c479669c9 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/ModSortControl.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/ModSortControl.kt @@ -1,128 +1,65 @@ package org.polyfrost.oneconfig.internal.ui.screens -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.PopupProperties import org.polyfrost.oneconfig.api.ui.v1.ModSortMode import org.polyfrost.oneconfig.api.ui.v1.ModSortModes +import org.polyfrost.oneconfig.api.ui.v1.components.Confirm +import org.polyfrost.oneconfig.api.ui.v1.components.ControlSize +import org.polyfrost.oneconfig.api.ui.v1.components.ControlStyle +import org.polyfrost.oneconfig.api.ui.v1.components.TabItem +import org.polyfrost.oneconfig.api.ui.v1.components.TabRow +import org.polyfrost.oneconfig.api.ui.v1.components.TabStyle +import org.polyfrost.oneconfig.api.ui.v1.components.TooltipPlacement import org.polyfrost.oneconfig.internal.ui.api.ModSortStore -import org.polyfrost.oneconfig.internal.ui.api.Tooltip -import org.polyfrost.oneconfig.internal.ui.components.Chip -import org.polyfrost.oneconfig.internal.ui.components.Icon -import org.polyfrost.oneconfig.internal.ui.components.ShellPopup -import org.polyfrost.oneconfig.internal.ui.components.Text -import org.polyfrost.oneconfig.internal.ui.components.glassHover -import org.polyfrost.oneconfig.internal.ui.components.liquidGlass -import org.polyfrost.oneconfig.internal.ui.components.onClick -import org.polyfrost.oneconfig.internal.ui.components.rememberInteractionSource +import org.polyfrost.oneconfig.internal.ui.components.translated import org.polyfrost.oneconfig.internal.ui.shell.PageGutter -import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme -import org.polyfrost.oneconfig.internal.ui.themes.concentric private val customMode = ModSortMode(ModSortStore.CUSTOM, "dots-grid", "Custom order") {} private val alphabeticalMode = ModSortMode(ModSortStore.ALPHABETICAL, "capitalized", "A to Z") {} +private fun ModSortMode.label(): String = when (id) { + ModSortStore.CUSTOM -> translated("oneconfig.mods.sort.custom", title) + ModSortStore.ALPHABETICAL -> translated("oneconfig.mods.sort.alphabetical", title) + else -> title +} + @Composable fun ModSortControl(modifier: Modifier = Modifier) { val modes = listOf(customMode, alphabeticalMode) + ModSortModes.all() - val selectedId = ModSortStore.selectedId.takeIf { id -> modes.any { it.id == id } } ?: ModSortStore.CUSTOM - var pending by remember { mutableStateOf(null) } - val theme = LocalTheme.current + val selected = modes.firstOrNull { it.id == ModSortStore.selectedId } ?: customMode fun apply(mode: ModSortMode) { ModSortStore.select(mode.id) mode.onSelect() } - Box(modifier.padding(end = PageGutter)) { - Row( - Modifier - .height(36.dp) - .liquidGlass(theme.buttonShape) - .background(theme.componentBackground, theme.buttonShape) - .border(1.dp, theme.borderColor, theme.buttonShape) - .padding(3.dp), - horizontalArrangement = Arrangement.spacedBy(2.dp), - ) { - modes.forEach { mode -> - SortSegment(mode, mode.id == selectedId) { - if (mode.confirmation != null) pending = mode else apply(mode) - } - } - } - pending?.let { mode -> - ShellPopup( - alignment = Alignment.TopEnd, - offset = IntOffset(0, with(LocalDensity.current) { 42.dp.roundToPx() }), - onDismissRequest = { pending = null }, - properties = PopupProperties(focusable = true), - ) { - Column( - Modifier - .width(280.dp) - .liquidGlass(theme.popupShape, frosted = true) - .background(theme.popupBackground, theme.popupShape) - .border(1.dp, theme.borderColor, theme.popupShape) - .padding(12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text(mode.confirmation.orEmpty(), color = theme.textColor, fontSize = 13.sp) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Chip("Cancel", selected = false) { pending = null } - Chip("Switch", selected = true) { - pending = null - apply(mode) - } - } - } - } + fun pick(mode: ModSortMode) { + if (mode.id == selected.id) return + val confirmation = mode.confirmation + if (confirmation == null) { + apply(mode) + return } + Confirm.ask( + title = translated("oneconfig.mods.sort.confirm.title", "Sort by %s?", mode.label()), + text = confirmation, + confirmLabel = translated("oneconfig.mods.sort.confirm", "Switch"), + onConfirm = { apply(mode) }, + style = ControlStyle.Primary, + icon = mode.icon, + ) } -} -@Composable -private fun SortSegment(mode: ModSortMode, selected: Boolean, onSwitch: () -> Unit) { - val theme = LocalTheme.current - val interactionSource = rememberInteractionSource() - Tooltip( - text = { Text(mode.title, color = theme.textColor, fontSize = 12.sp) }, - modifier = Modifier, - anchor = Alignment.BottomCenter, - ) { - Box( - Modifier - .fillMaxHeight() - .glassHover(theme.buttonShape.concentric(3.dp), pull = 0.75.dp) - .background( - if (selected) theme.textColor.copy(alpha = 0.08f) else Color.Transparent, - theme.buttonShape.concentric(3.dp), - ) - .onClick(interactionSource, !selected, onSwitch) - .padding(horizontal = 12.dp), - contentAlignment = Alignment.Center, - ) { - Icon(mode.icon, if (selected) theme.textColor else theme.textColorSecondary, Modifier.size(16.dp)) - } - } + TabRow( + tabs = modes.map { TabItem(it, "", icon = it.icon, tooltip = it.label()) }, + selected = selected, + onSelect = ::pick, + modifier = modifier.padding(end = PageGutter), + style = TabStyle.Segmented, + size = ControlSize.Large, + fill = false, + tooltipPlacement = TooltipPlacement.BelowCenter, + ) } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Mods.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Mods.kt index 94b920c9b..458f93b8d 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Mods.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Mods.kt @@ -1,16 +1,12 @@ package org.polyfrost.oneconfig.internal.ui.screens -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.VisibilityThreshold -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.spring import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -27,20 +23,21 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.toMutableStateList +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.draw.rotate import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size @@ -52,13 +49,12 @@ import androidx.compose.ui.graphics.CompositeShader import androidx.compose.ui.graphics.LinearGradientShader import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.ShaderBrush +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.drawOutline import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.lerp -import androidx.compose.ui.input.pointer.PointerIcon -import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity @@ -66,15 +62,41 @@ import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay import org.polyfrost.oneconfig.api.config.v1.Config +import org.polyfrost.oneconfig.api.config.v1.Property +import org.polyfrost.oneconfig.api.config.v1.Tree import org.polyfrost.oneconfig.api.hud.v1.HudManager import org.polyfrost.oneconfig.api.ui.v1.ModCardTypes import org.polyfrost.oneconfig.api.ui.v1.ModToggles import org.polyfrost.oneconfig.api.ui.v1.ModsPageDecorator import org.polyfrost.oneconfig.api.ui.v1.ModsPageDecorators +import org.polyfrost.oneconfig.api.ui.v1.ReadOnlyModToggle +import org.polyfrost.oneconfig.api.ui.v1.a11y.A11yRole +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.api.ui.v1.components.Confirm +import org.polyfrost.oneconfig.api.ui.v1.components.ControlSize +import org.polyfrost.oneconfig.api.ui.v1.components.EmptyState +import org.polyfrost.oneconfig.api.ui.v1.components.IconButton +import org.polyfrost.oneconfig.api.ui.v1.components.IconButtonDefaults +import org.polyfrost.oneconfig.api.ui.v1.components.IconButtonStyle +import org.polyfrost.oneconfig.api.ui.v1.components.MenuAction +import org.polyfrost.oneconfig.api.ui.v1.components.MenuEntry +import org.polyfrost.oneconfig.api.ui.v1.components.MenuSeparator +import org.polyfrost.oneconfig.api.ui.v1.components.ModCardMenuContributors +import org.polyfrost.oneconfig.api.ui.v1.components.TooltipBox +import org.polyfrost.oneconfig.api.ui.v1.components.TooltipPlacement +import org.polyfrost.oneconfig.api.ui.v1.components.contextMenu +import org.polyfrost.oneconfig.api.ui.v1.components.hiddenUntilHover +import org.polyfrost.oneconfig.api.ui.v1.components.oneConfigClickable +import org.polyfrost.oneconfig.api.ui.v1.components.rememberGridReorderState +import org.polyfrost.oneconfig.api.ui.v1.components.reorderAutoScroll +import org.polyfrost.oneconfig.api.ui.v1.components.reorderContainer +import org.polyfrost.oneconfig.api.ui.v1.components.reorderOverlay +import org.polyfrost.oneconfig.api.ui.v1.components.reorderableItem +import org.polyfrost.oneconfig.api.ui.v1.keybind.OneConfigKeybind import org.polyfrost.oneconfig.internal.ui.api.ConfigData import org.polyfrost.oneconfig.internal.ui.api.ConfigRegistry import org.polyfrost.oneconfig.internal.ui.api.ConfigSource @@ -84,15 +106,15 @@ import org.polyfrost.oneconfig.internal.ui.api.ModGridEntry import org.polyfrost.oneconfig.internal.ui.api.ModHidden import org.polyfrost.oneconfig.internal.ui.api.ModOrder import org.polyfrost.oneconfig.internal.ui.api.ModSortStore +import org.polyfrost.oneconfig.internal.ui.api.PropertyModToggle import org.polyfrost.oneconfig.internal.ui.api.ThirdPartyModCategories -import org.polyfrost.oneconfig.internal.ui.api.Tooltip import org.polyfrost.oneconfig.internal.ui.api.buildModGridEntries import org.polyfrost.oneconfig.internal.ui.api.modCardOrder import org.polyfrost.oneconfig.internal.ui.api.modFavoriteAt import org.polyfrost.oneconfig.internal.ui.api.modGroupBounds import org.polyfrost.oneconfig.internal.ui.components.Chip +import org.polyfrost.oneconfig.internal.ui.components.CollapsibleHeader import org.polyfrost.oneconfig.internal.ui.components.Icon -import org.polyfrost.oneconfig.internal.ui.components.ReducedMotion import org.polyfrost.oneconfig.internal.ui.components.ScrollbarBox import org.polyfrost.oneconfig.internal.ui.components.Text import org.polyfrost.oneconfig.internal.ui.components.accentSheen @@ -102,13 +124,13 @@ import org.polyfrost.oneconfig.internal.ui.components.glassHover import org.polyfrost.oneconfig.internal.ui.components.hoverGlow import org.polyfrost.oneconfig.internal.ui.components.liquidGlass import org.polyfrost.oneconfig.internal.ui.components.localizedLabel -import org.polyfrost.oneconfig.internal.ui.components.onClick -import org.polyfrost.oneconfig.internal.ui.components.rememberGridReorderState import org.polyfrost.oneconfig.internal.ui.components.rememberInteractionSource +import org.polyfrost.oneconfig.internal.ui.components.ModToggleFlips import org.polyfrost.oneconfig.internal.ui.components.rememberModEnabled -import org.polyfrost.oneconfig.internal.ui.components.reorderContainer -import org.polyfrost.oneconfig.internal.ui.components.reorderOverlay -import org.polyfrost.oneconfig.internal.ui.components.reorderableItem +import org.polyfrost.oneconfig.internal.ui.components.settings.identitySet +import org.polyfrost.oneconfig.internal.ui.components.settings.resetKeybindOption +import org.polyfrost.oneconfig.internal.ui.components.settings.resetOption +import org.polyfrost.oneconfig.internal.ui.components.translated import org.polyfrost.oneconfig.internal.ui.navigation.graph.ModConfigRoute import org.polyfrost.oneconfig.internal.ui.shell.LocalShell import org.polyfrost.oneconfig.internal.ui.shell.PageGutter @@ -119,7 +141,9 @@ import org.polyfrost.oneconfig.internal.ui.themes.AccentBrush import org.polyfrost.oneconfig.internal.ui.themes.AccentEnd import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme import org.polyfrost.oneconfig.internal.ui.themes.concentric +import org.polyfrost.oneconfig.internal.ui.themes.cornerRadiusOrNull import org.polyfrost.oneconfig.internal.ui.themes.contrastRatio +import org.polyfrost.oneconfig.utils.v1.ClipboardHelper enum class ModCategory( val title: String, @@ -138,6 +162,8 @@ enum class ModCategory( QoL("Quality of Life", "qol", Config.Category.QOL), Other("Other", null, Config.Category.OTHER), Hidden("Hidden", "eye-off", null, hiddenOnly = true); + + val label: String get() = translated("oneconfig.mods.category.${name.lowercase()}", title) } @Composable @@ -174,7 +200,7 @@ private fun ModsPage() { } if (shown) { Chip( - label = it.title, + label = it.label, selected = activeCategory == it, icon = it.icon, onClick = { @@ -221,10 +247,19 @@ fun ColumnScope.ModsGrid(category: ModCategory) { } if (filtered.isEmpty() && (category.favoritesOnly || category.hiddenOnly)) { Box(Modifier.weight(1f).fillMaxSize(), contentAlignment = Alignment.Center) { - Text( - if (category.hiddenOnly) "No hidden mods." else "No favorite mods.", - color = LocalTheme.current.textColorSecondary, - ) + if (category.hiddenOnly) { + EmptyState( + translated("oneconfig.mods.empty.hidden", "No hidden mods"), + icon = "eye-off", + text = translated("oneconfig.mods.empty.hidden.text", "Mods you hide from their card show up here."), + ) + } else { + EmptyState( + translated("oneconfig.mods.empty.favorites", "No favorite mods"), + icon = "star", + text = translated("oneconfig.mods.empty.favorites.text", "Star a mod on its card to keep it here."), + ) + } } return } @@ -241,13 +276,21 @@ fun ColumnScope.ModsGrid(category: ModCategory) { val windowSize = LocalWindowInfo.current.containerSize // Only enable placement animations after the grid has been laid out for the current size var animateItems by remember(windowSize, density) { mutableStateOf(false) } + val movedKey = remember { arrayOfNulls(1) } val reorderState = rememberGridReorderState( - gridState = gridState, - onMove = { from, to -> entries.add(to, entries.removeAt(from)) }, - onDrop = { index -> commitDrop(entries, index) }, - dragBounds = { index -> modGroupBounds(entries, index) }, + lazyState = gridState, + onMove = { from, to -> + movedKey[0] = entries[from].key + entries.add(to, entries.removeAt(from)) + }, + onDragEnd = { + val index = entries.indexOfFirst { it.key == movedKey[0] } + movedKey[0] = null + if (index >= 0) commitDrop(entries, index) + }, + dropRange = { index -> modGroupBounds(entries, index) }, onClick = { key -> entries.cardData(key)?.let { openModCard(it, navigation) } }, - horizontalPadding = PageGutter, + crossAxisPadding = PageGutter, ) val dropFavorite = reorderState.draggingKey ?.let { key -> entries.indexOfFirst { it.key == key } } @@ -255,7 +298,10 @@ fun ColumnScope.ModsGrid(category: ModCategory) { ?.let { index -> modFavoriteAt(entries, index, ModFavorites::isFavorite) } // the dragged card's index no longer applies once the list is rebuilt DisposableEffect(entries) { - onDispose { reorderState.cancel() } + onDispose { + movedKey[0] = null + reorderState.cancel() + } } // The overlay card is drawn as a sibling of the grid, but the page pane that wraps the nav host @@ -289,7 +335,8 @@ fun ColumnScope.ModsGrid(category: ModCategory) { if (waitForUpOrCancellation() != null) focusManager.clearFocus() } } - .reorderContainer(reorderState), + .reorderContainer(reorderState) + .reorderAutoScroll(reorderState), ) { LazyVerticalGrid( state = gridState, @@ -301,23 +348,23 @@ fun ColumnScope.ModsGrid(category: ModCategory) { animateItems = true }, ) { - items( + itemsIndexed( entries, - key = { it.key }, - contentType = { if (it is ModGridEntry.Header) HEADER_CONTENT_TYPE else CARD_CONTENT_TYPE }, - span = { if (it is ModGridEntry.Header) GridItemSpan(maxLineSpan) else GridItemSpan(1) }, - ) { entry -> + key = { _, entry -> entry.key }, + contentType = { _, entry -> if (entry is ModGridEntry.Header) HEADER_CONTENT_TYPE else CARD_CONTENT_TYPE }, + span = { _, entry -> if (entry is ModGridEntry.Header) GridItemSpan(maxLineSpan) else GridItemSpan(1) }, + ) { index, entry -> when (entry) { is ModGridEntry.Header -> ModTypeHeader( entry, - modifier = if (animateItems) Modifier.animateItem(placementSpec = modCardPlacementSpec()) else Modifier, + modifier = if (animateItems) Modifier.animateItem(placementSpec = Motion.itemPlacementSpec()) else Modifier, onToggle = { ModCardTypeCollapseStore.toggle(entry.type.id) }, ) is ModGridEntry.Card -> { val mod = entry.data val dragging = reorderState.draggingKey == mod.id - val outlineAlpha = animateFloatAsState(if (dragging) 1f else 0f) + val outlineAlpha = Motion.animateFloatAsState(if (dragging) 1f else 0f) val favoriteSlot = if (dragging) dropFavorite == true else ModFavorites.isFavorite(mod.id) ModCard( mod, @@ -325,14 +372,14 @@ fun ColumnScope.ModsGrid(category: ModCategory) { .then( if (animateItems) { Modifier.animateItem( - placementSpec = if (dragging) null else modCardPlacementSpec(), + placementSpec = if (dragging) null else Motion.itemPlacementSpec(), ) } else { Modifier }, ) .dropSlotOutline(favoriteSlot) { outlineAlpha.value } - .reorderableItem(reorderState, mod.id), + .reorderableItem(reorderState, mod.id, index), hoverHint = reorderState.overlayKey == null, ) } @@ -356,23 +403,21 @@ fun ColumnScope.ModsGrid(category: ModCategory) { @Composable private fun ModTypeHeader(entry: ModGridEntry.Header, modifier: Modifier = Modifier, onToggle: () -> Unit) { val theme = LocalTheme.current - val interactionSource = rememberInteractionSource() - val chevronRotation by animateFloatAsState(if (entry.expanded) 180f else 90f) - - Row( - modifier = modifier - .fillMaxWidth() - .onClick(interactionSource, onClick = onToggle) - .pointerHoverIcon(PointerIcon.Hand), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), + val title = localizedLabel(entry.type.title) ?: entry.type.title + + CollapsibleHeader( + label = title, + expanded = entry.expanded, + onToggle = onToggle, + modifier = modifier, + trailing = { Rule(Modifier.weight(1f)) }, ) { Rule(Modifier.weight(1f)) remember(entry.type.icon) { entry.type.icon?.takeIf(::canRenderIcon) }?.let { icon -> Icon(icon, color = theme.textColorSecondary, modifier = Modifier.size(14.dp)) } Text( - localizedLabel(entry.type.title) ?: entry.type.title, + title, color = theme.textColorSecondary, fontSize = 11.sp, fontWeight = FontWeight.Medium, @@ -380,12 +425,6 @@ private fun ModTypeHeader(entry: ModGridEntry.Header, modifier: Modifier = Modif if (!entry.expanded && entry.cardCount > 0) { Text("(${entry.cardCount})", color = theme.textColorSecondary, fontSize = 11.sp) } - Icon( - "up", - color = theme.textColorSecondary, - modifier = Modifier.size(12.dp).rotate(chevronRotation), - ) - Rule(Modifier.weight(1f)) } } @@ -394,14 +433,6 @@ private fun Rule(modifier: Modifier) { Box(modifier.height(1.dp).background(LocalTheme.current.borderColor)) } -private fun modCardPlacementSpec() = if (ReducedMotion.enabled) null else ModCardPlacementSpec - -private val ModCardPlacementSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMediumLow, - visibilityThreshold = IntOffset.VisibilityThreshold, -) - private fun List.cardData(key: Any): ConfigData? = firstNotNullOfOrNull { (it as? ModGridEntry.Card)?.data?.takeIf { mod -> mod.id == key } } @@ -413,6 +444,105 @@ private fun openModCard(mod: ConfigData, navigation: ShellNavigation) { } } +private fun modCardMenu(mod: ConfigData, navigation: ShellNavigation): List { + val toggle = mod.toggle + val favorite = ModFavorites.isFavorite(mod.id) + val hidden = ModHidden.isHidden(mod.id) + val tree = ConfigRegistry.findTree(mod.id) + val resettable = mod.source == ConfigSource.OC && tree != null && tree.getMetadata("ui_only") != true + val hasHud = HudManager.providers().any { it.configId == mod.id } || HudManager.instances().any { it.configId == mod.id } + val contributed = ModCardMenuContributors.entriesFor(mod.id) + return buildList { + add( + MenuAction( + translated("oneconfig.mods.menu.open", "Open settings"), + { openModCard(mod, navigation) }, + icon = "settings", + enabled = mod.onOpen != null || mod.source == ConfigSource.OC, + ), + ) + add( + MenuAction( + if (favorite) translated("oneconfig.mods.menu.unfavorite", "Remove from favorites") + else translated("oneconfig.mods.menu.favorite", "Add to favorites"), + { ModFavorites.toggle(mod.id) }, + icon = if (favorite) "star-filled" else "star", + ), + ) + add( + MenuAction( + if (hidden) translated("oneconfig.mods.menu.show", "Show mod") else translated("oneconfig.mods.menu.hide", "Hide mod"), + { ModHidden.toggle(mod.id) }, + icon = if (hidden) "eye" else "eye-off", + ), + ) + if (toggle != null) { + val on = toggle.isEnabled() + add( + MenuAction( + if (on) translated("oneconfig.mods.menu.disable", "Disable") else translated("oneconfig.mods.menu.enable", "Enable"), + { + if (!on && toggle.needsSetup()) { + openModCard(mod, navigation) + } else { + toggle.setEnabled(!on) + ModToggleFlips.count++ + } + }, + icon = if (on) "x-circle" else "check-circle", + enabled = toggle !is ReadOnlyModToggle, + disabledReason = (toggle as? ReadOnlyModToggle)?.lockedReason?.takeIf { it.isNotBlank() } + ?: translated("oneconfig.mods.toggle.locked", "This mod is switched on and off somewhere else."), + ), + ) + } + if (hasHud || resettable) add(MenuSeparator) + if (hasHud) { + add(MenuAction(translated("oneconfig.mods.menu.hud_editor", "Open HUD editor"), { HudManager.openEditor() }, icon = "hud")) + } + if (resettable) { + add( + MenuAction( + translated("oneconfig.mods.menu.reset", "Reset to defaults"), + { confirmReset(mod, tree) }, + icon = "refresh", + danger = true, + ), + ) + } + add(MenuSeparator) + add(MenuAction(translated("oneconfig.mods.menu.copy_id", "Copy mod id"), { ClipboardHelper.setString(mod.id) }, icon = "copy", detail = mod.id)) + if (contributed.isNotEmpty()) { + add(MenuSeparator) + addAll(contributed) + } + } +} + +private fun confirmReset(mod: ConfigData, tree: Tree) { + val ownSwitch = (mod.toggle as? PropertyModToggle)?.property + Confirm.ask( + title = translated("oneconfig.mods.menu.reset.title", "Reset %s?", mod.title.asRenderText()), + text = translated("oneconfig.mods.menu.reset.text", "Every setting of this mod goes back to its default. This can't be undone."), + confirmLabel = translated("oneconfig.mods.menu.reset.confirm", "Reset settings"), + onConfirm = { resetTree(tree, identitySet().also { seen -> ownSwitch?.let(seen::add) }) }, + icon = "refresh", + ) +} + +private fun resetTree(tree: Tree, seen: MutableSet>) { + for (node in tree.map.values) { + when (node) { + is Property<*> -> if (node.get() is OneConfigKeybind) { + if (seen.add(node)) resetKeybindOption(node) + } else { + resetOption(node, seen) + } + is Tree -> resetTree(node, seen) + } + } +} + /** Persists the arrangement and the favorite state after a card is dropped at [index] */ private fun commitDrop(entries: List, index: Int) { val dropped = (entries.getOrNull(index) as? ModGridEntry.Card)?.data ?: return @@ -455,16 +585,15 @@ private val ModCardGlowHeight = 50.dp private val ModCardInsetFooterInset = 6.dp -private val FavoriteStarColor = Color(0xFFFFD700) - private val ModCardToggleDot = 6.dp private val ModCardToggleRing = 12.dp private val ModCardToggleGlow = 5.dp -private val ModCardToggleUnavailable = Color(0xFF4F505B) -// the accent is darkened towards this for the ring, which for the default accent gives the ring of the design +private const val ModCardToggleRingDarken = 0.42f private val ModCardToggleRingShade = Color(0xFF181600) +private const val ReadOnlyTogglePollMs = 250L + /** * [hoverHint] faintly shows an empty star on hover, and a card that is not [interactive] lets clicks and scrolling * pass through @@ -484,13 +613,22 @@ fun ModCard( val toggleRevision = ModToggles.revision val toggle = remember(mod, toggleRevision) { mod.toggle } var enabled by rememberModEnabled(toggle) - val footerAccent by animateFloatAsState(if (enabled) 1f else 0f) + val locked = toggle is ReadOnlyModToggle + if (toggle is ReadOnlyModToggle) { + LaunchedEffect(toggle) { + while (true) { + enabled = toggle.isEnabled() + delay(ReadOnlyTogglePollMs) + } + } + } + val footerAccent by Motion.animateFloatAsState(if (enabled) 1f else 0f) val insetFooter = theme.insetModFooter val insetFooterShape = theme.modCardShape.concentric(ModCardInsetFooterInset) - val footerTextColor by animateColorAsState( + val footerTextColor by Motion.animateColorAsState( if (enabled) (if (insetFooter && contrastRatio(theme.textColor, Accent) >= 3f) theme.textColor else theme.accentTextColor) else theme.textColor.copy(alpha = 0.75f) ) - val glowStrength by animateFloatAsState(if (enabled) 0.5f else 0f) + val glowStrength by Motion.animateFloatAsState(if (enabled) 0.5f else 0f) val navigation = LocalShell.current.navigation val open = { val onOpen = mod.onOpen @@ -512,8 +650,8 @@ fun ModCard( // cards never take focus, otherwise they slide in from off screen when scrolled back into view Modifier .focusProperties { canFocus = false } - .onClick(interactionSource) { openModCard(mod, navigation) } - .pointerHoverIcon(PointerIcon.Hand) + .contextMenu { modCardMenu(mod, navigation) } + .oneConfigClickable(mod.title.asRenderText(), { openModCard(mod, navigation) }, indication = false, interactionSource = interactionSource) } else { Modifier }, @@ -561,18 +699,24 @@ fun ModCard( .background(AccentBrush, alpha = footerAccent) .accentSheen(alpha = footerAccent) .then( - if (toggle != null && interactive) { + if (toggle != null && interactive && !locked) { Modifier .focusProperties { canFocus = false } - .onClick(footerInteractions) { - if (!enabled && toggle.needsSetup()) { - open() - } else { - toggle.setEnabled(!enabled) - enabled = toggle.isEnabled() - } - } - .pointerHoverIcon(PointerIcon.Hand) + .oneConfigClickable( + mod.title.asRenderText(), + { + if (!enabled && toggle.needsSetup()) { + open() + } else { + toggle.setEnabled(!enabled) + enabled = toggle.isEnabled() + } + }, + role = A11yRole.Switch, + checked = enabled, + indication = false, + interactionSource = footerInteractions, + ) } else { Modifier }, @@ -611,7 +755,7 @@ fun ModCard( LinearGradientShader(Offset.Zero, Offset(size.width, 0f), listOf(Accent, AccentEnd)), LinearGradientShader( Offset(0f, glowTop), Offset(0f, size.height), - listOf(Color.Black.copy(0f), Color.Black.copy(0.2f), Color.Black.copy(0.4f)), + listOf(Accent.copy(0f), Accent.copy(0.2f), Accent.copy(0.4f)), listOf(0f, 0.4f, 1f), ), BlendMode.DstIn, @@ -629,7 +773,7 @@ fun ModCard( } ) } - Row(Modifier.padding(4.dp)) { + Row(Modifier.padding(CardToggleInset)) { FavoriteStar( mod = mod, favorite = favorite, @@ -640,25 +784,32 @@ fun ModCard( HideToggle( mod = mod, cardInteractions = interactionSource, + interactive = interactive, ) } Box(Modifier.align(Alignment.TopEnd).padding(11.dp)) { - Tooltip( - text = { - Text( - if (toggle != null) "This mod can be toggled on and off." else "This mod doesn't support being toggled.", - color = theme.textColor, - fontSize = 12.sp, - ) + TooltipBox( + when { + toggle is ReadOnlyModToggle -> toggle.lockedReason?.takeIf { it.isNotBlank() } + ?: translated("oneconfig.mods.toggle.locked", "This mod is switched on and off somewhere else.") + toggle != null -> translated("oneconfig.mods.toggle.available", "This mod can be toggled on and off.") + else -> translated("oneconfig.mods.toggle.unavailable", "This mod doesn't support being toggled.") }, - modifier = Modifier, - anchor = Alignment.TopCenter, + placement = TooltipPlacement.AboveCenter, ) { - Box(Modifier.size(ModCardToggleRing), contentAlignment = Alignment.Center) { - val ringStart = if (toggle != null) lerp(Accent, ModCardToggleRingShade, 0.42f) else ModCardToggleUnavailable.copy(alpha = 0.5f) - val ringEnd = if (toggle != null) lerp(AccentEnd, ModCardToggleRingShade, 0.42f) else ringStart + if (locked) { + Icon( + "lock", + color = if (enabled) Accent else theme.textColor.copy(alpha = 0.6f), + modifier = Modifier.size(ModCardToggleRing), + ) + } else Box(Modifier.size(ModCardToggleRing), contentAlignment = Alignment.Center) { + val shade = ModCardToggleRingShade + val unavailable = theme.mutedText + val ringStart = if (toggle != null) lerp(Accent, shade, ModCardToggleRingDarken) else unavailable.copy(alpha = 0.5f) + val ringEnd = if (toggle != null) lerp(AccentEnd, shade, ModCardToggleRingDarken) else ringStart val ring = lerp(ringStart, ringEnd, 0.5f) - val dot = ModCardToggleUnavailable + val dot = unavailable // drawn around one centre, since laying the dot out inside the ring lands it on a pixel boundary off-centre Box( Modifier.fillMaxSize().drawWithCache { @@ -706,66 +857,92 @@ private fun FavoriteStar( modifier: Modifier = Modifier, ) { val theme = LocalTheme.current - val interactionSource = rememberInteractionSource() - val hovered by interactionSource.collectIsHoveredAsState() val cardHovered by cardInteractions.collectIsHoveredAsState() - val alpha = animateFloatAsState( - when { - favorite -> 1f - !hoverHint -> 0f - hovered -> 1f - cardHovered -> 0.6f - else -> 0f - } - ) - val color by animateColorAsState(if (favorite) theme.favoriteColor else theme.textColor) - - Box( - modifier = modifier - .size(24.dp) - .graphicsLayer { this.alpha = alpha.value } - .then( - if (interactive) { - Modifier - .focusProperties { canFocus = false } - .onClick(interactionSource) { ModFavorites.toggle(mod.id) } - .pointerHoverIcon(PointerIcon.Hand) - } else { - Modifier - }, - ), - contentAlignment = Alignment.Center, - ) { - Icon(if (favorite) "star-filled" else "star", color = color, modifier = Modifier.size(18.dp)) - } + val color by Motion.animateColorAsState(if (favorite) theme.favoriteColor else theme.textColor) + CardCornerToggle( + icon = if (favorite) "star-filled" else "star", + label = if (favorite) translated("oneconfig.mods.menu.unfavorite", "Remove from favorites") + else translated("oneconfig.mods.menu.favorite", "Add to favorites"), + color = color, + shown = favorite || hoverHint && cardHovered, + emphasized = favorite, + interactive = interactive, + modifier = modifier, + shape = cardCornerShape(), + ) { ModFavorites.toggle(mod.id) } } @Composable private fun HideToggle( mod: ConfigData, cardInteractions: InteractionSource, + interactive: Boolean, modifier: Modifier = Modifier, ) { val hidden = ModHidden.isHidden(mod.id) - val interactionSource = rememberInteractionSource() - val hovered by interactionSource.collectIsHoveredAsState() val cardHovered by cardInteractions.collectIsHoveredAsState() - val alpha = animateFloatAsState( - when { - hidden || hovered -> 1f - cardHovered -> 0.6f - else -> 0f + CardCornerToggle( + icon = if (hidden) "eye-off" else "eye", + label = if (hidden) translated("oneconfig.mods.menu.show", "Show mod") else translated("oneconfig.mods.menu.hide", "Hide mod"), + color = LocalTheme.current.textColor, + shown = hidden || cardHovered, + emphasized = hidden, + interactive = interactive, + modifier = modifier, + ) { ModHidden.toggle(mod.id) } +} + +@Composable +private fun CardCornerToggle( + icon: String, + label: String, + color: Color, + shown: Boolean, + emphasized: Boolean, + interactive: Boolean, + modifier: Modifier = Modifier, + shape: Shape = IconButtonDefaults.shape(IconButtonStyle.Ghost), + onClick: () -> Unit, +) { + val source = remember { MutableInteractionSource() } + val hovered by source.collectIsHoveredAsState() + val emphasis by Motion.animateFloatAsState(if (emphasized || hovered) 1f else CardToggleHintAlpha) + val toggleModifier = modifier + .size(CardToggleSize) + .hiddenUntilHover(shown) + .focusProperties { canFocus = false } + .graphicsLayer { alpha = emphasis } + if (!interactive) { + Box(toggleModifier, contentAlignment = Alignment.Center) { + Icon(icon, color = color, modifier = Modifier.size(CardToggleIconSize)) } + return + } + IconButton( + icon, + label, + onClick, + toggleModifier, + size = ControlSize.Large, + tooltip = null, + tint = color, + interactionSource = source, + shape = shape, ) +} - Box( - modifier = modifier - .size(24.dp) - .graphicsLayer { this.alpha = alpha.value } - .onClick(interactionSource) { ModHidden.toggle(mod.id) } - .pointerHoverIcon(PointerIcon.Hand), - contentAlignment = Alignment.Center, - ) { - Icon(if (hidden) "eye-off" else "eye", color = LocalTheme.current.textColor, modifier = Modifier.size(18.dp)) - } +@Composable +private fun cardCornerShape(): Shape { + val theme = LocalTheme.current + val base = IconButtonDefaults.shape(IconButtonStyle.Ghost) + val outer = theme.modCardShape.cornerRadiusOrNull() ?: return base + val button = theme.buttonShape.cornerRadiusOrNull() ?: return base + val inner = minOf(button, CardToggleSize / 4) + val corner = (outer - CardToggleInset).coerceAtLeast(inner) + return remember(corner, inner) { RoundedCornerShape(topStart = corner, topEnd = inner, bottomEnd = inner, bottomStart = inner) } } + +private val CardToggleInset = 4.dp +private val CardToggleSize = 24.dp +private val CardToggleIconSize = 18.dp +private const val CardToggleHintAlpha = 0.6f diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Preferences.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Preferences.kt index 79bf0a813..0500bc678 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Preferences.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Preferences.kt @@ -6,9 +6,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.key import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import org.polyfrost.oneconfig.api.ui.v1.components.EmptyState import org.polyfrost.oneconfig.internal.ui.api.ConfigRegistry -import org.polyfrost.oneconfig.internal.ui.components.Text -import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme +import org.polyfrost.oneconfig.internal.ui.components.translated const val PREFERENCES_ID = "oneconfig.json" @@ -19,7 +19,7 @@ fun Preferences() { if (tree == null) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("No preferences available.", color = LocalTheme.current.textColorSecondary) + EmptyState(translated("oneconfig.preferences.empty", "No preferences available"), icon = "settings") } return } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Profiles.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Profiles.kt index b3e74fdbb..2a59c3a6a 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Profiles.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Profiles.kt @@ -1,6 +1,5 @@ package org.polyfrost.oneconfig.internal.ui.screens -import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.interaction.collectIsHoveredAsState @@ -10,9 +9,11 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -20,10 +21,10 @@ import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -35,32 +36,24 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.center import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.key.key import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import org.polyfrost.oneconfig.internal.ui.components.ShellPopup -import androidx.compose.ui.window.PopupProperties -import androidx.compose.ui.window.PopupPositionProvider -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntRect -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.LayoutDirection import java.nio.file.Files +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull @@ -68,21 +61,32 @@ import org.polyfrost.oneconfig.api.config.v1.ConfigManager import org.polyfrost.oneconfig.api.notifications.v1.Notifications import org.polyfrost.oneconfig.api.platform.v1.DesktopHelper import org.polyfrost.oneconfig.api.platform.v1.Platform +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion import org.polyfrost.oneconfig.api.ui.v1.api.TinyFdApi -import org.polyfrost.oneconfig.api.ui.v1.components.MenuItem -import org.polyfrost.oneconfig.api.ui.v1.components.MenuItemSize -import org.polyfrost.oneconfig.api.ui.v1.components.MenuSurface +import org.polyfrost.oneconfig.api.ui.v1.components.Confirm +import org.polyfrost.oneconfig.api.ui.v1.components.DropdownMenu +import org.polyfrost.oneconfig.api.ui.v1.components.EmptyState +import org.polyfrost.oneconfig.api.ui.v1.components.LoadState +import org.polyfrost.oneconfig.api.ui.v1.components.MenuAction +import org.polyfrost.oneconfig.api.ui.v1.components.MenuEntry +import org.polyfrost.oneconfig.api.ui.v1.components.MenuSeparator +import org.polyfrost.oneconfig.api.ui.v1.components.ModalDialog +import org.polyfrost.oneconfig.api.ui.v1.components.Placement +import org.polyfrost.oneconfig.api.ui.v1.components.ProgressBar +import org.polyfrost.oneconfig.api.ui.v1.components.Scrollbar +import org.polyfrost.oneconfig.api.ui.v1.components.Text import org.polyfrost.oneconfig.api.ui.v1.components.TextField +import org.polyfrost.oneconfig.api.ui.v1.components.TextPromptDialog +import org.polyfrost.oneconfig.api.ui.v1.components.contextMenu +import org.polyfrost.oneconfig.api.ui.v1.components.smoothWheelScroll import org.polyfrost.oneconfig.internal.ui.api.settings.KeybindOptionData import org.polyfrost.oneconfig.internal.ui.components.Chip import org.polyfrost.oneconfig.internal.ui.components.Icon -import org.polyfrost.oneconfig.internal.ui.components.Text import org.polyfrost.oneconfig.internal.ui.components.hoverGlow -import org.polyfrost.oneconfig.internal.ui.components.liquidGlass import org.polyfrost.oneconfig.internal.ui.components.onClick import org.polyfrost.oneconfig.internal.ui.components.rememberInteractionSource import org.polyfrost.oneconfig.internal.ui.components.settings.KeybindOption -import org.polyfrost.oneconfig.internal.ui.components.wheelScroll +import org.polyfrost.oneconfig.internal.ui.components.translated import org.polyfrost.oneconfig.internal.ui.keybind.ProfileKeybinds import org.polyfrost.oneconfig.internal.ui.search.searchMatches import org.polyfrost.oneconfig.internal.ui.shell.PageGutter @@ -112,9 +116,10 @@ private data class ProfileActionResult( ) private val ProfileCardHeight = 180.dp +private val ProfileKeybindClearance = 12.dp +private const val BusyOverlayDelayMs = 400L private const val OverlayFrameTimeoutMs = 500L -private val DangerColor = Color(0xFFE35B5B) -private enum class ProfileEditor { Rename, Clone, Icon, Delete } +private enum class ProfileEditor { Rename, Clone, Icon } private val ProfileIconOptions = listOf( "profiles", "star", @@ -136,26 +141,39 @@ fun Profiles() { var createError by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() var busy by remember { mutableStateOf(false) } + var busyInstant by remember { mutableStateOf(false) } var createTick by remember { mutableIntStateOf(0) } var profileSpecificControls by remember { mutableStateOf(true) } + var loadState by remember { mutableStateOf(LoadState.Loading) } + var loadAttempt by remember { mutableIntStateOf(0) } - LaunchedEffect(Unit) { - val loaded = withContext(Dispatchers.IO) { - loadProfiles() to ConfigManager.profileSpecificControls() + LaunchedEffect(loadAttempt) { + loadState = LoadState.Loading + try { + val loaded = withContext(Dispatchers.IO) { + loadProfiles() to ConfigManager.profileSpecificControls() + } + profiles = loaded.first + profileSpecificControls = loaded.second + loadState = LoadState.Idle + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Throwable) { + loadState = LoadState.Error(failure.messageOrType()) } - profiles = loaded.first - profileSpecificControls = loaded.second } fun runProfileAction( onSuccess: () -> Unit = {}, - onError: (String) -> Unit = { Notifications.error("Profile action failed", it) }, + onError: (String) -> Unit = { Notifications.error(actionFailedTitle(), it) }, holdsBusy: Boolean = false, + instant: Boolean = false, action: () -> Unit, ) { Platform.screen().runOnUiThread { if (busy && !holdsBusy) return@runOnUiThread busy = true + busyInstant = instant // A profile operation must finish refreshing the global registry even if this page is // closed while its blocking IO is still running. Enter the non-cancellable section // before the first suspension so disposal of the composition cannot strand the UI on @@ -163,7 +181,7 @@ fun Profiles() { val ownerJob = scope.coroutineContext[Job]!! scope.launch(start = CoroutineStart.UNDISPATCHED) { withContext(NonCancellable) { - awaitOverlayDrawn() + if (instant) awaitOverlayDrawn() val result = withContext(Dispatchers.IO) { try { action() @@ -182,7 +200,7 @@ fun Profiles() { if (ownerJob.isCancelled) { result.failure?.let { failure -> Notifications.error( - "Profile action failed", + actionFailedTitle(), failure.messageOrType(), ) } @@ -195,11 +213,12 @@ fun Profiles() { else onError(failure.messageOrType()) } catch (failure: Throwable) { Notifications.error( - "Profile action failed", + actionFailedTitle(), failure.messageOrType(), ) } finally { busy = false + busyInstant = false } } } @@ -213,13 +232,12 @@ fun Profiles() { busy = true scope.launch(start = CoroutineStart.UNDISPATCHED) { withContext(NonCancellable) { - awaitOverlayDrawn() val choice = try { withContext(Dispatchers.IO) { prompt() } } catch (failure: Throwable) { Platform.screen().runOnUiThread { busy = false - Notifications.error("Profile action failed", failure.messageOrType()) + Notifications.error(actionFailedTitle(), failure.messageOrType()) } return@withContext } @@ -256,7 +274,7 @@ fun Profiles() { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { ProfileCategory.entries.forEach { Chip( - label = it.title, + label = translated("oneconfig.profiles.category.${it.name.lowercase()}", it.title), selected = activeCategory == it, icon = it.icon, onClick = { activeCategory = it } @@ -265,7 +283,7 @@ fun Profiles() { } Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Chip( - label = "Separate controls", + label = translated("oneconfig.profiles.separate_controls", "Separate controls"), selected = profileSpecificControls, icon = "keyboard", onClick = { @@ -276,7 +294,7 @@ fun Profiles() { }, ) Chip( - label = "Open folder", + label = translated("oneconfig.profiles.open_folder", "Open folder"), selected = false, icon = "folder", onClick = { @@ -285,7 +303,10 @@ fun Profiles() { Files.createDirectories(ConfigManager.PROFILES_DIR) DesktopHelper.open(ConfigManager.PROFILES_DIR.toFile()) }.onFailure { - Notifications.error("Could not open the profiles folder", it.messageOrType()) + Notifications.error( + translated("oneconfig.profiles.open_folder.failed", "Could not open the profiles folder"), + it.messageOrType(), + ) } } }, @@ -293,12 +314,23 @@ fun Profiles() { } } - ProfilesGrid( + val state = loadState + if (state != LoadState.Idle) { + Box(Modifier.weight(1f).fillMaxSize(), contentAlignment = Alignment.Center) { + EmptyState( + if (state is LoadState.Error) translated("oneconfig.profiles.load_failed", "Couldn't load profiles") + else translated("oneconfig.profiles.loading", "Loading profiles"), + state = state, + onRetry = { loadAttempt++ }, + ) + } + } else ProfilesGrid( profiles = visibleProfiles, existingProfileIds = existingProfileIds, showCreateProfile = activeCategory == ProfileCategory.All && localSearchQuery.isBlank(), - emptyMessage = if (localSearchQuery.isBlank()) "No favorite profiles." - else "No profiles match \"$localSearchQuery\"", + emptyMessage = if (localSearchQuery.isBlank()) translated("oneconfig.profiles.no_favorites", "No favorite profiles.") + else translated("oneconfig.profiles.nomatch", "No profiles match \"%s\"", localSearchQuery), + emptyIcon = if (localSearchQuery.isBlank()) "star" else "search", newProfileName = newProfileName, createError = createError, busy = busy, @@ -319,7 +351,7 @@ fun Profiles() { }, onOpen = { profile -> if (!profile.active) { - runProfileAction { ConfigManager.openProfile(profile.id) } + runProfileAction(instant = true) { ConfigManager.openProfile(profile.id) } } }, onFavorite = { profile -> @@ -339,17 +371,23 @@ fun Profiles() { runProfileAction(onSuccess, onError) { ConfigManager.setProfileIcon(profile.id, icon) } }, onDelete = { profile -> - runProfileAction { ConfigManager.deleteProfile(profile.id) } + Confirm.ask( + translated("oneconfig.profiles.delete.title", "Delete profile?"), + translated("oneconfig.profiles.delete.text", "The profile \"%s\" and all its settings will be deleted.", profile.name), + translated("oneconfig.profiles.delete.confirm", "Delete profile"), + onConfirm = { runProfileAction(instant = profile.active) { ConfigManager.deleteProfile(profile.id) } }, + icon = "trash", + ) }, onExport = { profile -> runProfileDialog( prompt = { val defaultName = profile.name.replace(Regex("[^a-zA-Z0-9._-]"), "_") + ".zip" TinyFdApi.getInstance().openSaveSelector( - "Export profile", + translated("oneconfig.profiles.export.title", "Export profile"), defaultName, arrayOf("*.zip"), - "Zip archive", + translated("oneconfig.profiles.export.filter", "Zip archive"), ) }, action = { destination -> @@ -364,7 +402,7 @@ fun Profiles() { } ) } - if (busy) ProfileBusyOverlay() + if (busy) ProfileBusyOverlay(busyInstant) } } @@ -376,48 +414,35 @@ private suspend fun awaitOverlayDrawn() { } @Composable -private fun ProfileBusyOverlay() { +private fun ProfileBusyOverlay(instant: Boolean) { + var shown by remember { mutableStateOf(instant) } + LaunchedEffect(Unit) { + if (shown) return@LaunchedEffect + delay(BusyOverlayDelayMs) + shown = true + } + if (!shown) return val theme = LocalTheme.current - ShellPopup( - popupPositionProvider = WindowOriginPositionProvider, - properties = PopupProperties(focusable = true, dismissOnBackPress = false, dismissOnClickOutside = false), - ) { - Box( - modifier = Modifier.fillMaxSize().background(theme.shadowColor.copy(alpha = 0.55f)), - contentAlignment = Alignment.Center, - ) { - Column( - modifier = Modifier - .liquidGlass(theme.modCardShape, frosted = true) - .background(theme.modCardBackground, theme.modCardShape) - .padding(horizontal = 32.dp, vertical = 24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - Text( - "Applying profile changes\u2026", - color = theme.textColor, - fontSize = 18.sp, - fontWeight = FontWeight.Medium, - ) - Text( - "The game will stop responding until it finishes.", - color = theme.textColorSecondary, - fontSize = 14.sp, - ) - } - } + ModalDialog(onDismiss = {}, dismissOnScrim = false, dismissOnEscape = false) { + Text( + translated("oneconfig.profiles.busy.title", "Applying profile changes\u2026"), + Modifier.fillMaxWidth(), + theme.textColor, + theme.typography.title, + textAlign = TextAlign.Center, + ) + Text( + translated("oneconfig.profiles.busy.text", "The game will stop responding until it finishes."), + Modifier.fillMaxWidth(), + theme.textColorSecondary, + theme.typography.body, + textAlign = TextAlign.Center, + ) + ProgressBar(null, Modifier.fillMaxWidth().padding(top = theme.spacing.xs)) } } -private object WindowOriginPositionProvider : PopupPositionProvider { - override fun calculatePosition( - anchorBounds: IntRect, - windowSize: IntSize, - layoutDirection: LayoutDirection, - popupContentSize: IntSize, - ): IntOffset = IntOffset.Zero -} +private fun actionFailedTitle(): String = translated("oneconfig.profiles.action_failed", "Profile action failed") private fun Throwable.messageOrType(): String = message ?: this::class.java.simpleName @@ -434,7 +459,7 @@ private fun loadProfiles(): List { return ConfigManager.profiles().map { id -> UiProfile( id = id, - name = if (id.isEmpty()) "Default" else id, + name = if (id.isEmpty()) translated("oneconfig.profiles.default", "Default") else id, icon = icons[id] ?: "profiles", active = id == active, favorite = id in favorites, @@ -448,6 +473,7 @@ private fun ColumnScope.ProfilesGrid( existingProfileIds: Set, showCreateProfile: Boolean, emptyMessage: String, + emptyIcon: String, newProfileName: String, createError: String?, busy: Boolean, @@ -479,85 +505,88 @@ private fun ColumnScope.ProfilesGrid( if (profiles.isEmpty() && !showCreateProfile) { Box(Modifier.weight(1f).fillMaxSize(), contentAlignment = Alignment.Center) { - Text(emptyMessage, color = LocalTheme.current.textColorSecondary) + EmptyState(emptyMessage, icon = emptyIcon) } return } val gridState = rememberLazyGridState() - LazyVerticalGrid( - state = gridState, - modifier = Modifier.weight(1f).wheelScroll(gridState), - columns = GridCells.Fixed(5), - verticalArrangement = Arrangement.spacedBy(19.dp), - horizontalArrangement = Arrangement.spacedBy(19.dp), - ) { - if (showCreateProfile) { - item(key = "create-profile") { - CreateProfileCard( - value = newProfileName, - error = createError, + Box(Modifier.weight(1f)) { + LazyVerticalGrid( + state = gridState, + modifier = Modifier.fillMaxSize().padding(end = LocalTheme.current.spacing.l).smoothWheelScroll(gridState), + columns = GridCells.Fixed(5), + verticalArrangement = Arrangement.spacedBy(19.dp), + horizontalArrangement = Arrangement.spacedBy(19.dp), + ) { + if (showCreateProfile) { + item(key = "create-profile") { + CreateProfileCard( + value = newProfileName, + error = createError, + busy = busy, + creating = creatingProfile, + createTick = createTick, + onValueChange = onNewProfileNameChange, + onCreate = onCreateProfile, + onCreatingChange = { creating -> + creatingProfile = creating + if (creating) { + editingProfileId = null + activeEditor = null + menuProfileId = null + } + }, + ) + } + } + items(profiles, key = { "profile:${it.id}" }) { profile -> + ProfileCard( + profile = profile, busy = busy, - creating = creatingProfile, - createTick = createTick, - onValueChange = onNewProfileNameChange, - onCreate = onCreateProfile, - onCreatingChange = { creating -> - creatingProfile = creating - if (creating) { + editor = activeEditor.takeIf { editingProfileId == profile.id }, + menuOpen = menuProfileId == profile.id, + suggestedCloneName = nextCloneName(profile.name, existingProfileIds), + onEditorChange = { editor -> + if (editor == null) { + if (editingProfileId == profile.id) { + editingProfileId = null + activeEditor = null + } + } else { + creatingProfile = false + editingProfileId = profile.id + activeEditor = editor + menuProfileId = null + } + }, + onMenuOpenChange = { open -> + if (open) { + creatingProfile = false + menuProfileId = profile.id editingProfileId = null activeEditor = null + } else if (menuProfileId == profile.id) { menuProfileId = null } }, + onOpen = { onOpen(profile) }, + onFavorite = { onFavorite(profile) }, + onRename = { newName, onSuccess, onError -> + onRename(profile, newName, onSuccess, onError) + }, + onClone = { newName, onSuccess, onError -> + onClone(profile, newName, onSuccess, onError) + }, + onIconChange = { icon, onSuccess, onError -> + onIconChange(profile, icon, onSuccess, onError) + }, + onDelete = { onDelete(profile) }, + onExport = { onExport(profile) }, ) } } - items(profiles, key = { "profile:${it.id}" }) { profile -> - ProfileCard( - profile = profile, - busy = busy, - editor = activeEditor.takeIf { editingProfileId == profile.id }, - menuOpen = menuProfileId == profile.id, - suggestedCloneName = nextCloneName(profile.name, existingProfileIds), - onEditorChange = { editor -> - if (editor == null) { - if (editingProfileId == profile.id) { - editingProfileId = null - activeEditor = null - } - } else { - creatingProfile = false - editingProfileId = profile.id - activeEditor = editor - menuProfileId = null - } - }, - onMenuOpenChange = { open -> - if (open) { - creatingProfile = false - menuProfileId = profile.id - editingProfileId = null - activeEditor = null - } else if (menuProfileId == profile.id) { - menuProfileId = null - } - }, - onOpen = { onOpen(profile) }, - onFavorite = { onFavorite(profile) }, - onRename = { newName, onSuccess, onError -> - onRename(profile, newName, onSuccess, onError) - }, - onClone = { newName, onSuccess, onError -> - onClone(profile, newName, onSuccess, onError) - }, - onIconChange = { icon, onSuccess, onError -> - onIconChange(profile, icon, onSuccess, onError) - }, - onDelete = { onDelete(profile) }, - onExport = { onExport(profile) }, - ) - } + Scrollbar(gridState, Modifier.align(Alignment.CenterEnd).fillMaxHeight()) } } @@ -677,7 +706,7 @@ private fun CreateProfileCard( if (creating) { ProfileTextField( value = value, - placeholder = "Profile name", + placeholder = translated("oneconfig.profiles.name_placeholder", "Profile name"), error = error, enabled = !busy, width = 150.dp, @@ -686,10 +715,9 @@ private fun CreateProfileCard( ) } else { Text( - "New profile", + translated("oneconfig.profiles.new", "New profile"), color = theme.textColor.copy(alpha = 0.86f), - fontSize = 18.sp, - fontWeight = FontWeight.Medium, + style = theme.typography.title.copy(fontWeight = FontWeight.Medium), ) } } @@ -717,28 +745,44 @@ private fun ProfileCard( val isHovered by interactionSource.collectIsHoveredAsState() val theme = LocalTheme.current val shape = theme.modCardShape - var editName by remember(profile.id) { mutableStateOf(profile.name) } - var editError by remember(profile.id) { mutableStateOf(null) } + var iconError by remember(profile.id) { mutableStateOf(null) } + var nameError by remember(profile.id) { mutableStateOf?>(null) } val keybindProp = remember(profile.id) { ProfileKeybinds.property(profile.id) } + fun openEditor(next: ProfileEditor) { + iconError = null + nameError = null + onEditorChange(next) + } + fun closeEditor() { onEditorChange(null) - editName = profile.name - editError = null + iconError = null + nameError = null } - fun submitName() { - if (busy) return - editError = null - val onSuccess = { - onEditorChange(null) - editError = null + fun menuEntries(): List = buildList { + add(MenuAction(translated("oneconfig.profiles.menu.apply", "Apply"), onOpen, icon = "tick", enabled = !busy && !profile.active)) + if (profile.editable) { + add(MenuAction(translated("oneconfig.profiles.menu.rename", "Rename"), { openEditor(ProfileEditor.Rename) }, icon = "text-input", enabled = !busy)) + } + add(MenuAction(translated("oneconfig.profiles.menu.clone", "Clone"), { openEditor(ProfileEditor.Clone) }, icon = "copy", enabled = !busy)) + if (profile.editable) { + add(MenuAction(translated("oneconfig.profiles.menu.icon", "Change icon"), { openEditor(ProfileEditor.Icon) }, icon = "paintbrush", enabled = !busy)) } - val onError = { message: String -> editError = message } - when (editor) { - ProfileEditor.Rename -> onRename(editName, onSuccess, onError) - ProfileEditor.Clone -> onClone(editName, onSuccess, onError) - else -> Unit + add( + MenuAction( + if (profile.favorite) translated("oneconfig.profiles.menu.unfavorite", "Remove favorite") + else translated("oneconfig.profiles.menu.favorite", "Favorite"), + onFavorite, + icon = if (profile.favorite) "star-filled" else "star", + enabled = !busy, + ), + ) + add(MenuAction(translated("oneconfig.profiles.menu.export", "Export / share"), onExport, icon = "down", enabled = !busy)) + if (profile.editable) { + add(MenuSeparator) + add(MenuAction(translated("oneconfig.profiles.menu.delete", "Delete"), onDelete, icon = "trash", enabled = !busy, danger = true)) } } @@ -759,6 +803,7 @@ private fun ProfileCard( listOf(theme.borderColor, theme.borderColor.copy(0f)) ), shape ) + .contextMenu(enabled = editor == null) { menuEntries() } .onClick(interactionSource) { if (editor == null && !menuOpen) onOpen() } @@ -792,114 +837,59 @@ private fun ProfileCard( horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically, ) { - if (editor != null) { - when (editor) { - ProfileEditor.Rename, ProfileEditor.Clone -> - ActionIcon("tick", enabled = !busy, tint = Accent, onClick = ::submitName) - ProfileEditor.Delete -> ActionIcon("tick", enabled = !busy, tint = DangerColor) { - onEditorChange(null) - onDelete() - } - ProfileEditor.Icon -> Unit - } + if (editor == ProfileEditor.Icon) { ActionIcon("close", enabled = !busy, tint = theme.textColorSecondary, onClick = ::closeEditor) } else { if (profile.favorite) { ActionIcon("star-filled", enabled = !busy, tint = theme.favoriteColor) { onFavorite() } } if (profile.active || isHovered || menuOpen) { - ActionIcon("settings", enabled = !busy, tint = theme.textColorSecondary, hoveredTint = Accent) { - onMenuOpenChange(true) + Box { + ActionIcon("settings", enabled = !busy, tint = theme.textColorSecondary, hoveredTint = Accent) { + onMenuOpenChange(true) + } + DropdownMenu(menuOpen, { onMenuOpenChange(false) }, menuEntries(), placement = Placement.BelowEnd) } } } } - ProfileActionsMenu( - profile = profile, - expanded = menuOpen, - enabled = !busy, - onDismiss = { onMenuOpenChange(false) }, - onClone = { - editName = suggestedCloneName - editError = null - onEditorChange(ProfileEditor.Clone) - }, - onRename = { - editName = profile.name - editError = null - onEditorChange(ProfileEditor.Rename) - }, - onIconChange = { - editError = null - onEditorChange(ProfileEditor.Icon) - }, - onFavorite = onFavorite, - onExport = onExport, - onDelete = { - editError = null - onEditorChange(ProfileEditor.Delete) - }, - ) - Column( - modifier = Modifier.align(Alignment.Center), + modifier = Modifier.align(Alignment.Center).offset(y = if (editor == null) -ProfileKeybindClearance else 0.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - val compact = editor != null && editor != ProfileEditor.Delete + val compact = editor == ProfileEditor.Icon Icon(profile.icon, modifier = Modifier.size(if (compact) 42.dp else 64.dp), color = theme.textColor) Spacer(Modifier.height(if (compact) 14.dp else 24.dp)) - when (editor) { - ProfileEditor.Rename, ProfileEditor.Clone -> ProfileTextField( - value = editName, - placeholder = "Profile name", - width = 150.dp, - error = editError, - enabled = !busy, - onValueChange = { - editName = it - editError = null - }, - onSubmit = ::submitName, - ) - ProfileEditor.Icon -> ProfileIconPicker( + if (editor == ProfileEditor.Icon) { + ProfileIconPicker( selectedIcon = profile.icon, enabled = !busy, onIconChange = { - editError = null + iconError = null onIconChange( it, { onEditorChange(null) - editError = null + iconError = null }, - { message -> editError = message }, + { message -> iconError = message }, ) }, ) - ProfileEditor.Delete, null -> { - BasicText( - profile.name, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 18.dp), - style = TextStyle( - color = theme.textColor, - fontSize = 18.sp, - fontFamily = theme.typography.family, - fontWeight = FontWeight.Medium, - textAlign = TextAlign.Center, - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - if (editor == ProfileEditor.Icon && editError != null) { - ProfileError(editError!!, 150.dp) - } - if (editor == ProfileEditor.Delete) { - Text("Delete this profile?", modifier = Modifier.padding(top = 4.dp), color = DangerColor, fontSize = 13.sp) + iconError?.let { ProfileError(it, 150.dp) } + } else { + Text( + profile.name, + Modifier + .fillMaxWidth() + .padding(horizontal = 18.dp), + theme.textColor, + theme.typography.title.copy(fontWeight = FontWeight.Medium), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + ) } } @@ -909,76 +899,48 @@ private fun ProfileCard( } } } -} -@Composable -private fun ProfileActionsMenu( - profile: UiProfile, - expanded: Boolean, - enabled: Boolean, - onDismiss: () -> Unit, - onClone: () -> Unit, - onRename: () -> Unit, - onIconChange: () -> Unit, - onFavorite: () -> Unit, - onExport: () -> Unit, - onDelete: () -> Unit, -) { - if (!expanded) return - ShellPopup( - alignment = Alignment.TopEnd, - onDismissRequest = onDismiss, - properties = PopupProperties(focusable = true), - ) { - MenuSurface(Modifier) { - ProfileMenuItem("copy", "Clone", enabled = enabled) { - onDismiss() - onClone() - } - if (profile.editable) { - ProfileMenuItem("text-input", "Rename", enabled = enabled) { - onDismiss() - onRename() - } - ProfileMenuItem("paintbrush", "Change icon", enabled = enabled) { - onDismiss() - onIconChange() - } - } - ProfileMenuItem( - if (profile.favorite) "star-filled" else "star", - if (profile.favorite) "Remove favorite" else "Favorite", - enabled = enabled, - ) { - onDismiss() - onFavorite() - } - ProfileMenuItem("down", "Export / share", enabled = enabled) { - onDismiss() - onExport() - } - if (profile.editable) { - Spacer(Modifier.height(4.dp)) - ProfileMenuItem("trash", "Delete", danger = true, enabled = enabled) { - onDismiss() - onDelete() - } - } + if (editor == ProfileEditor.Rename || editor == ProfileEditor.Clone) { + val renaming = editor == ProfileEditor.Rename + val initial = if (renaming) profile.name else suggestedCloneName + val title = if (renaming) translated("oneconfig.profiles.rename.title", "Rename profile") + else translated("oneconfig.profiles.clone.title", "Clone profile") + key(editor) { + TextPromptDialog( + title = title, + initial = initial, + onConfirm = { name -> + if (!busy) { + val submit = if (renaming) onRename else onClone + submit( + name, + { + nameError = null + onEditorChange(null) + }, + { message -> + nameError = name to message + if (name == initial) Notifications.error(title, message) + }, + ) + } + }, + onDismiss = { if (!busy) closeEditor() }, + confirmLabel = if (renaming) translated("oneconfig.profiles.menu.rename", "Rename") + else translated("oneconfig.profiles.menu.clone", "Clone"), + validate = { value -> + val failed = nameError + when { + value.isBlank() -> translated("oneconfig.profiles.name_required", "Enter a name") + failed != null && failed.first == value -> failed.second + else -> null + } + }, + ) } } } -@Composable -private fun ProfileMenuItem( - icon: String, - label: String, - danger: Boolean = false, - enabled: Boolean = true, - onClick: () -> Unit, -) { - MenuItem(label, icon, null, MenuItemSize.Compact, danger, false, enabled, null, Modifier, null, onClick) -} - @Composable private fun ProfileIconPicker( selectedIcon: String, @@ -1024,7 +986,7 @@ private fun ProfileIconChoice( selected && enabled, if (selected && enabled) Accent else theme.borderColor.copy(alpha = 0f) ) - val iconColor by animateColorAsState( + val iconColor by Motion.animateColorAsState( if (enabled && (selected || isHovered)) theme.textColor else theme.textColorSecondary ) @@ -1055,7 +1017,7 @@ private fun ActionIcon( ) { val interactionSource = rememberInteractionSource() val isHovered by interactionSource.collectIsHoveredAsState() - val iconColor by animateColorAsState( + val iconColor by Motion.animateColorAsState( if (enabled && isHovered) hoveredTint else tint ) @@ -1094,20 +1056,12 @@ private fun ProfileTextField( value, onValueChange, Modifier.fillMaxWidth(), - placeholder, - null, - Int.MAX_VALUE, - error != null, - enabled, - true, - true, - 14.sp, - null, - false, - focusRequester, - null, - null, - onSubmit, + placeholder = placeholder, + enabled = enabled, + invalid = error != null, + fontSize = 14.sp, + focusRequester = focusRequester, + onSubmit = onSubmit, ) if (error != null) ProfileError(error, width) } @@ -1119,16 +1073,14 @@ private fun ProfileTextField( @Composable private fun ProfileError(message: String, width: Dp) { - BasicText( - text = message, - modifier = Modifier.width(width).padding(top = 4.dp), - style = TextStyle( - color = DangerColor, - fontSize = 10.sp, - fontFamily = LocalTheme.current.typography.family, - textAlign = TextAlign.Center, - ), + val theme = LocalTheme.current + Text( + message, + Modifier.width(width).padding(top = 4.dp), + theme.error, + theme.typography.caption, maxLines = 2, overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, ) } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/SearchResultsScreen.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/SearchResultsScreen.kt index 9c3bbbe13..05831f8d4 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/SearchResultsScreen.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/SearchResultsScreen.kt @@ -21,6 +21,8 @@ import androidx.compose.ui.unit.sp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.polyfrost.oneconfig.api.ui.v1.ModToggles +import org.polyfrost.oneconfig.api.ui.v1.components.EmptyState +import org.polyfrost.oneconfig.api.ui.v1.components.LoadState import org.polyfrost.oneconfig.internal.ui.api.ConfigData import org.polyfrost.oneconfig.internal.ui.api.PropertyModToggle import org.polyfrost.oneconfig.internal.ui.components.ScrollbarBox @@ -69,7 +71,7 @@ fun SearchResultsScreen(query: String) { results.forEach { (row, documents) -> if (row == null || documents.isEmpty()) return@forEach val node = searchNode(row.node, documents) ?: return@forEach - val mod = row.modTitle ?: "Other" + val mod = row.modTitle ?: translated("oneconfig.search.other", "Other") byGroup.getOrPut(row.groupLabel?.let { "$mod / $it" } ?: mod) { ArrayList() } += (node to row.mod) } byGroup @@ -82,9 +84,12 @@ fun SearchResultsScreen(query: String) { if (matchingMods.isEmpty() && groupedOptions.isEmpty()) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { // nothing to say until the first search comes back - searchedQuery?.also { - Text(translated("oneconfig.search.no_results", "No results for \"%s\"", it), color = theme.textColorSecondary, fontSize = 15.sp) - } ?: Text(translated("oneconfig.search.searching", "Searching..."), color = theme.textColorSecondary, fontSize = 15.sp) + val searched = searchedQuery + if (searched != null) { + EmptyState(translated("oneconfig.search.no_results", "No results for \"%s\"", searched), icon = "search") + } else { + EmptyState(translated("oneconfig.search.searching", "Searching..."), state = LoadState.Loading) + } } return } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Themes.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Themes.kt index 21560b8f0..f6066d11f 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Themes.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Themes.kt @@ -1,12 +1,11 @@ package org.polyfrost.oneconfig.internal.ui.screens -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -14,16 +13,13 @@ import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -34,31 +30,37 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.input.pointer.PointerIcon -import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.polyfrost.oneconfig.api.platform.v1.Platform +import org.polyfrost.oneconfig.api.ui.v1.a11y.A11yRole +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.api.ui.v1.components.ControlSize +import org.polyfrost.oneconfig.api.ui.v1.components.EmptyState +import org.polyfrost.oneconfig.api.ui.v1.components.IconButton +import org.polyfrost.oneconfig.api.ui.v1.components.IconButtonStyle +import org.polyfrost.oneconfig.api.ui.v1.components.MenuAction +import org.polyfrost.oneconfig.api.ui.v1.components.MenuEntry +import org.polyfrost.oneconfig.api.ui.v1.components.Select +import org.polyfrost.oneconfig.api.ui.v1.components.TabItem +import org.polyfrost.oneconfig.api.ui.v1.components.TabRow +import org.polyfrost.oneconfig.api.ui.v1.components.TabStyle +import org.polyfrost.oneconfig.api.ui.v1.components.TooltipBox +import org.polyfrost.oneconfig.api.ui.v1.components.TooltipPlacement +import org.polyfrost.oneconfig.api.ui.v1.components.contextMenu +import org.polyfrost.oneconfig.api.ui.v1.components.hiddenUntilHover +import org.polyfrost.oneconfig.api.ui.v1.components.oneConfigClickable import org.polyfrost.oneconfig.internal.ThemeConfig import org.polyfrost.oneconfig.internal.ui.api.ConfigRegistry -import org.polyfrost.oneconfig.internal.ui.api.Tooltip import org.polyfrost.oneconfig.internal.ui.components.Icon import org.polyfrost.oneconfig.internal.ui.components.Text -import org.polyfrost.oneconfig.internal.ui.components.dropdown.SimpleDropdownMenu -import org.polyfrost.oneconfig.internal.ui.components.glassHover import org.polyfrost.oneconfig.internal.ui.components.hoverGlow -import org.polyfrost.oneconfig.internal.ui.components.keepSearchFocus import org.polyfrost.oneconfig.internal.ui.components.layout.FlexibleLayout -import org.polyfrost.oneconfig.internal.ui.components.liquidGlass import org.polyfrost.oneconfig.internal.ui.components.rememberInteractionSource import org.polyfrost.oneconfig.internal.ui.components.translated import org.polyfrost.oneconfig.internal.ui.shell.PageGutter @@ -76,13 +78,11 @@ import org.polyfrost.oneconfig.internal.ui.themes.PaletteAccent import org.polyfrost.oneconfig.internal.ui.themes.PaletteInfo import org.polyfrost.oneconfig.internal.ui.themes.ThemeRegistry import org.polyfrost.oneconfig.internal.ui.themes.UITheme -import org.polyfrost.oneconfig.internal.ui.themes.concentric import org.polyfrost.oneconfig.internal.ui.themes.customSpec import org.polyfrost.oneconfig.internal.ui.themes.isCustom import org.polyfrost.oneconfig.internal.ui.themes.palette import org.polyfrost.oneconfig.internal.ui.themes.syncNotificationTheme import org.polyfrost.oneconfig.internal.ui.themes.previewAsset -import org.polyfrost.oneconfig.internal.ui.themes.isLight const val THEMES_ID = "themes.json" @@ -90,11 +90,9 @@ private val ThemeCardGap = 20.dp private const val ThemeColumns = 3 -private val VariantToggleHeight = 30.dp - private val CardActionSize = 22.dp -private val ToggleSegmentWidth = 28.dp +private val ThemeSelectMaxWidth = 150.dp internal val ThemePreviewWidth = 112.dp @@ -124,7 +122,7 @@ fun Themes() { Column(verticalArrangement = Arrangement.spacedBy(20.dp)) { ThemesHeader() Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("No preferences available.", color = LocalTheme.current.textColorSecondary) + EmptyState(translated("oneconfig.themes.empty", "No theme settings available"), icon = "paintbrush") } } return @@ -150,7 +148,7 @@ private fun ThemesHeader() { cards.chunked(ThemeColumns).forEach { row -> Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(ThemeCardGap)) { row.forEach { card -> - if (card == null) CustomThemeCard(Modifier.weight(1f)) { codeNotice.value = pastedThemeNotice() } else ThemeCard(card.first, card.second, Modifier.weight(1f)) + if (card == null) CustomThemeCard(Modifier.weight(1f)) { codeNotice.value = it } else ThemeCard(card.first, card.second, Modifier.weight(1f)) } repeat(ThemeColumns - row.size) { Spacer(Modifier.weight(1f)) } } @@ -166,7 +164,10 @@ private fun ThemesHeader() { } if (!Platform.screen().supportsBackdropGlass()) { Text( - "PolyGlass shows without frosted glass while VulkanMod is installed on this Minecraft version.", + translated( + "oneconfig.themes.no_backdrop_glass", + "PolyGlass shows without frosted glass while VulkanMod is installed on this Minecraft version.", + ), color = LocalTheme.current.textColorSecondary, modifier = Modifier.padding(horizontal = PageGutter), ) @@ -189,7 +190,7 @@ private fun ThemeCard(family: String, variants: List, modifier: Modifie val isSelected = active != null val onVariant: (UITheme) -> Unit = { variant -> pickedName = variant.name - if (isSelected) ThemeRegistry.activate(variant) else UiSounds.play(UiSoundEvent.CLICK) + if (isSelected) ThemeRegistry.activate(variant) } val palette = shown.palette ThemeCardShell( @@ -208,7 +209,7 @@ private fun ThemeCard(family: String, variants: List, modifier: Modifie }, controls = { if (palette != null && variants.size > 2) { - ThemeDropdown(variants, shown, { it.palette?.label ?: it.name }, onVariant) + ThemeSelect(variants, shown, { it.palette?.label ?: it.name }, onVariant) } else { if (shown.liquidGlass) TintToggle() if (variants.size > 1) VariantToggle(variants, shown, onVariant) @@ -221,40 +222,39 @@ private fun ThemeCard(family: String, variants: List, modifier: Modifie ThemeCaption(familyCaption(family)) } }, - action = { + action = { actionModifier -> val full = CustomThemes.specs.size >= CustomThemeSpec.MAX - CardActionButton( + IconButton( "copy-plus", if (full) fullMessage() else translated("oneconfig.themes.tooltip.make_copy", "Make a custom copy"), - ) { - if (!createCustomFrom(shown)) UiSounds.play(UiSoundEvent.CLICK) - } + { createCustomFrom(shown) }, + actionModifier.size(CardActionSize), + style = IconButtonStyle.Framed, + size = ControlSize.Compact, + tooltipPlacement = TooltipPlacement.AboveCenter, + ) + }, + menu = { + val full = CustomThemes.specs.size >= CustomThemeSpec.MAX + listOf( + MenuAction( + translated("oneconfig.themes.menu.activate", "Activate"), + { ThemeRegistry.activate(shown) }, + icon = "check-circle", + enabled = !isSelected, + ), + MenuAction( + translated("oneconfig.themes.tooltip.make_copy", "Make a custom copy"), + { createCustomFrom(shown) }, + icon = "copy-plus", + enabled = !full, + disabledReason = if (full) fullMessage() else null, + ), + ) }, ) } -@Composable -private fun CardActionButton(icon: String, hint: String, onClick: () -> Unit) { - ThemeHint(hint) { - val theme = LocalTheme.current - val source = rememberInteractionSource() - Box( - Modifier - .size(CardActionSize) - .liquidGlass(theme.buttonShape) - .background(theme.componentBackground, theme.buttonShape) - .border(1.dp, theme.borderColor, theme.buttonShape) - .glassHover(theme.buttonShape, pull = 0.5.dp) - .keepSearchFocus() - .clickable(source, indication = null, onClick = onClick) - .pointerHoverIcon(PointerIcon.Hand), - contentAlignment = Alignment.Center, - ) { - Icon(icon, color = theme.textColorSecondary, modifier = Modifier.size(12.dp)) - } - } -} - private val FamilyCaptions = mapOf( "polyfrosted" to "Frosted panels, the OneConfig look", "polyglass" to "See-through glass over your game", @@ -287,18 +287,20 @@ internal fun ThemeCardShell( preview: @Composable () -> Unit, controls: @Composable () -> Unit, footer: @Composable () -> Unit, - action: (@Composable () -> Unit)? = null, + action: (@Composable (Modifier) -> Unit)? = null, + menu: (() -> List)? = null, ) { val interactionSource = rememberInteractionSource() val theme = LocalTheme.current val hovered by interactionSource.collectIsHoveredAsState() + val focused by interactionSource.collectIsFocusedAsState() var actionFocused by remember { mutableStateOf(false) } - val actionAlpha by animateFloatAsState(if (hovered || actionFocused) 1f else 0f) val borderColor by animateAccentColorAsState( selected, - if (selected) Accent else theme.borderColor + if (selected) Accent else theme.borderColor, + Motion.spec(spring()), ) - val borderWidth by animateDpAsState( + val borderWidth by Motion.animateDpAsState( if (selected) 3.dp else 1.dp ) Column( @@ -306,13 +308,20 @@ internal fun ThemeCardShell( .hoverGlow(theme.backgroundShape) .background(theme.modCardBackground, theme.backgroundShape) .border(borderWidth, borderColor, theme.backgroundShape) - .keepSearchFocus() + .then(if (menu != null) Modifier.contextMenu(entries = menu) else Modifier) .hoverable(interactionSource) - .clickable(interactionSource, indication = null) { - // Use clickable because onClick always plays CLICK. ThemeRegistry.activate chooses CLICK or OPEN - onClick() - } - .pointerHoverIcon(PointerIcon.Hand) + .oneConfigClickable( + title, + { + // Use clickable because onClick always plays CLICK. ThemeRegistry.activate chooses CLICK or OPEN + onClick() + }, + selected = selected, + shape = theme.backgroundShape, + indication = false, + sound = null, + interactionSource = interactionSource, + ) .padding(12.dp), verticalArrangement = Arrangement.spacedBy(ThemeFooterGap), ) { @@ -337,12 +346,8 @@ internal fun ThemeCardShell( modifier = Modifier.weight(1f), ) if (action != null) { - Box( - Modifier - .graphicsLayer { alpha = actionAlpha } - .onFocusChanged { actionFocused = it.hasFocus } - ) { - action() + Box(Modifier.onFocusChanged { actionFocused = it.hasFocus }) { + action(Modifier.hiddenUntilHover(hovered || focused || actionFocused)) } } } @@ -357,29 +362,28 @@ internal fun ThemeCardShell( } } +internal class IconSegment(val value: T, val icon: String, val hint: String) + @Composable -private fun VariantToggle(variants: List, shown: UITheme, onPick: (UITheme) -> Unit) { - SegmentGroup { - variants.forEach { variant -> - ToggleSegment(if (variant.isLight) "sun" else "moon", lightHint(variant.isLight), variant == shown) { onPick(variant) } - } - } +internal fun IconSegments(segments: List>, selected: T, onSelect: (T) -> Unit) { + TabRow( + segments.map { TabItem(it.value, "", icon = it.icon, tooltip = it.hint) }, + selected, + { if (it != selected) onSelect(it) }, + style = TabStyle.Segmented, + size = ControlSize.Compact, + fill = false, + tooltipPlacement = TooltipPlacement.AboveCenter, + ) } @Composable -internal fun SegmentGroup(content: @Composable () -> Unit) { - val theme = LocalTheme.current - Row( - Modifier - .height(VariantToggleHeight) - .liquidGlass(theme.buttonShape) - .background(theme.componentBackground, theme.buttonShape) - .border(1.dp, theme.borderColor, theme.buttonShape) - .padding(3.dp), - horizontalArrangement = Arrangement.spacedBy(2.dp), - ) { - content() - } +private fun VariantToggle(variants: List, shown: UITheme, onPick: (UITheme) -> Unit) { + IconSegments( + variants.map { IconSegment(it, if (it.isLight) "sun" else "moon", lightHint(it.isLight)) }, + shown, + onPick, + ) } private val TintIcons = listOf("layers", "paintbrush", "cloud") @@ -391,137 +395,28 @@ internal fun lightHint(light: Boolean): String = @Composable private fun TintToggle() { - val selected = GlassTint.mode - SegmentGroup { - TintIcons.forEachIndexed { index, icon -> - val (key, fallback) = TintHints[index] - ToggleSegment(icon, translated("oneconfig.themes.tooltip.tint.$key", fallback), index == selected) { - GlassTint.mode = index - ThemeConfig.selectGlassTint(index) - UiSounds.play(UiSoundEvent.CLICK) - } - } + val segments = TintIcons.mapIndexed { index, icon -> + val (key, fallback) = TintHints[index] + IconSegment(index, icon, translated("oneconfig.themes.tooltip.tint.$key", fallback)) } -} - -@Composable -internal fun SquareControlButton(icon: String, hint: String, onClick: () -> Unit) { - ThemeHint(hint) { SquareControlButtonBody(icon, onClick) } -} - -@Composable -private fun SquareControlButtonBody(icon: String, onClick: () -> Unit) { - val theme = LocalTheme.current - val interactionSource = rememberInteractionSource() - Box( - Modifier - .size(VariantToggleHeight) - .liquidGlass(theme.buttonShape) - .background(theme.componentBackground, theme.buttonShape) - .border(1.dp, theme.borderColor, theme.buttonShape) - .glassHover(theme.buttonShape, pull = 0.75.dp) - .keepSearchFocus() - .clickable(interactionSource, indication = null, onClick = onClick) - .pointerHoverIcon(PointerIcon.Hand), - contentAlignment = Alignment.Center, - ) { - Icon(icon, color = theme.textColorSecondary, modifier = Modifier.size(14.dp)) + IconSegments(segments, GlassTint.mode) { index -> + GlassTint.mode = index + ThemeConfig.selectGlassTint(index) } } @Composable -internal fun ThemeHint(text: String, content: @Composable () -> Unit) { - Tooltip( - text = { Text(text, color = LocalTheme.current.textColor, fontSize = 12.sp) }, - modifier = Modifier.widthIn(max = 220.dp), - anchor = Alignment.TopCenter, - content = content, +internal fun ThemeSelect(items: List, shown: T, label: (T) -> String, onPick: (T) -> Unit) { + Select( + shown, + items, + { if (it != shown) onPick(it) }, + Modifier.widthIn(max = ThemeSelectMaxWidth), + label = label, + size = ControlSize.Compact, ) } -@Composable -internal fun ThemeDropdown(items: List, shown: T, label: (T) -> String, onPick: (T) -> Unit) { - val theme = LocalTheme.current - var expanded by remember { mutableStateOf(false) } - val interactionSource = rememberInteractionSource() - Box { - Row( - Modifier - .height(VariantToggleHeight) - .widthIn(max = 150.dp) - .liquidGlass(theme.buttonShape) - .background(theme.componentBackground, theme.buttonShape) - .border(1.dp, theme.borderColor, theme.buttonShape) - .padding(horizontal = 10.dp) - .keepSearchFocus() - .clickable(interactionSource, indication = null) { expanded = !expanded } - .pointerHoverIcon(PointerIcon.Hand), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - label(shown), - color = theme.textColor, - fontSize = 13.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false), - ) - Icon( - "down", - color = theme.textColorSecondary, - modifier = Modifier.size(14.dp).graphicsLayer { rotationZ = if (expanded) 180f else 0f }, - ) - } - SimpleDropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - offset = DpOffset(0.dp, 8.dp), - ) { - Column( - Modifier - .width(200.dp) - .clip(theme.popupShape) - .background(theme.popupBackground, theme.popupShape) - .border(1.dp, theme.borderColor, theme.popupShape) - .padding(6.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - items.forEach { item -> - val itemSource = rememberInteractionSource() - val hovered by itemSource.collectIsHoveredAsState() - val selected = item == shown - val shape = theme.buttonShape - Text( - label(item), - color = if (selected) theme.accentTextColor else theme.textColor, - fontSize = 13.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .fillMaxWidth() - .background( - when { - selected -> Accent - hovered -> theme.modCardBackground - else -> Color.Transparent - }, - shape, - ) - .hoverable(itemSource) - .clickable(itemSource, indication = null) { - expanded = false - if (!selected) onPick(item) - } - .pointerHoverIcon(PointerIcon.Hand) - .padding(horizontal = 10.dp, vertical = 7.dp), - ) - } - } - } - } -} - @Composable private fun PaletteAccentRow(palette: PaletteInfo) { val choice = PaletteAccent.choice(palette.group) @@ -529,18 +424,18 @@ private fun PaletteAccentRow(palette: PaletteInfo) { PaletteAccent.set(palette.group, value) ThemeConfig.savePaletteAccents() ThemeRegistry.activeTheme?.let(::syncNotificationTheme) - UiSounds.play(UiSoundEvent.CLICK) } + val circle = LocalTheme.current.shapes.circle FlexibleLayout(horizontalSpacing = AccentSwatchGap, verticalSpacing = AccentSwatchGap) { palette.accents.forEachIndexed { index, accent -> val selected = choice == index || (choice == PaletteAccent.RECOMMENDED && index == palette.defaultAccent) AccentSwatch(accent.name, selected, { pick(index) }) { - Box(Modifier.fillMaxSize().background(accent.color, CircleShape)) + Box(Modifier.fillMaxSize().background(accent.color, circle)) } } AccentSwatch(translated("oneconfig.themes.tooltip.player_accent", "Use my accent"), choice == PaletteAccent.PLAYER, { pick(PaletteAccent.PLAYER) }) { Box( - Modifier.fillMaxSize().background(LocalTheme.current.componentBackground, CircleShape), + Modifier.fillMaxSize().background(LocalTheme.current.componentBackground, circle), contentAlignment = Alignment.Center, ) { Icon("profiles", color = LocalTheme.current.textColor, modifier = Modifier.size(8.dp)) @@ -551,52 +446,26 @@ private fun PaletteAccentRow(palette: PaletteInfo) { @Composable private fun AccentSwatch(hint: String, selected: Boolean, onPick: () -> Unit, fill: @Composable () -> Unit) { - ThemeHint(hint) { AccentSwatchBody(selected, onPick, fill) } -} - -@Composable -private fun AccentSwatchBody(selected: Boolean, onPick: () -> Unit, fill: @Composable () -> Unit) { - val theme = LocalTheme.current - val interactionSource = rememberInteractionSource() - Box( - Modifier - .size(AccentSwatchSize) - .border(if (selected) 2.dp else 1.dp, if (selected) theme.textColor else Color.Transparent, CircleShape) - .padding(if (selected) 3.dp else 2.dp) - .then(if (selected) Modifier else Modifier.keepSearchFocus()) - .clickable(interactionSource, indication = null, enabled = !selected, onClick = onPick) - .pointerHoverIcon(PointerIcon.Hand), - ) { - fill() - } -} - -@Composable -internal fun ToggleSegment(icon: String, hint: String, selected: Boolean, onPick: () -> Unit) { - ThemeHint(hint) { ToggleSegmentBody(icon, selected, onPick) } -} - -@Composable -private fun ToggleSegmentBody(icon: String, selected: Boolean, onPick: () -> Unit) { val theme = LocalTheme.current - val interactionSource = rememberInteractionSource() - val shape = theme.buttonShape.concentric(3.dp) - Box( - Modifier - .fillMaxHeight() - .width(ToggleSegmentWidth) - .glassHover(shape, pull = 0.75.dp) - .background(if (selected) theme.textColor.copy(alpha = 0.1f) else Color.Transparent, shape) - .then(if (selected) Modifier else Modifier.keepSearchFocus()) - .clickable(interactionSource, indication = null, enabled = !selected, onClick = onPick) - .pointerHoverIcon(PointerIcon.Hand), - contentAlignment = Alignment.Center, - ) { - Icon( - icon, - color = if (selected) theme.textColor else theme.textColorSecondary, - modifier = Modifier.size(14.dp), - ) + val circle = theme.shapes.circle + TooltipBox(hint, placement = TooltipPlacement.AboveCenter) { + Box( + Modifier + .size(AccentSwatchSize) + .border(if (selected) 2.dp else 1.dp, if (selected) theme.textColor else Color.Transparent, circle) + .padding(if (selected) 3.dp else 2.dp) + .oneConfigClickable( + hint, + onPick, + role = A11yRole.RadioButton, + enabled = !selected, + selected = selected, + shape = circle, + indication = false, + ), + ) { + fill() + } } } @@ -653,7 +522,6 @@ private fun SoundThemePicker() { @Composable private fun SoundThemeCard(option: SoundThemeOption, isSelected: Boolean, modifier: Modifier, onPick: () -> Unit) { - val interactionSource = rememberInteractionSource() val theme = LocalTheme.current val matched = UiSoundTheme.of(theme) val preview = when (option.value) { @@ -669,12 +537,14 @@ private fun SoundThemeCard(option: SoundThemeOption, isSelected: Boolean, modifi } else { translated("oneconfig.themes.sound_theme.${option.key}.caption", option.caption) } + val title = translated("oneconfig.themes.sound_theme.${option.key}", option.title) val borderColor by animateAccentColorAsState( isSelected, - if (isSelected) Accent else theme.borderColor + if (isSelected) Accent else theme.borderColor, + Motion.spec(spring()), ) - val borderWidth by animateDpAsState( + val borderWidth by Motion.animateDpAsState( if (isSelected) 3.dp else 1.dp ) @@ -683,9 +553,15 @@ private fun SoundThemeCard(option: SoundThemeOption, isSelected: Boolean, modifi .hoverGlow(theme.backgroundShape) .background(theme.modCardBackground, theme.backgroundShape) .border(borderWidth, borderColor, theme.backgroundShape) - .keepSearchFocus() - .clickable(interactionSource, indication = null, onClick = onPick) - .pointerHoverIcon(PointerIcon.Hand) + .oneConfigClickable( + title, + onPick, + role = A11yRole.RadioButton, + selected = isSelected, + shape = theme.backgroundShape, + indication = false, + sound = null, + ) .padding(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, @@ -714,7 +590,7 @@ private fun SoundThemeCard(option: SoundThemeOption, isSelected: Boolean, modifi verticalArrangement = Arrangement.spacedBy(4.dp), ) { Text( - translated("oneconfig.themes.sound_theme.${option.key}", option.title), + title, color = theme.textColor, fontSize = 14.sp, fontWeight = FontWeight.Medium, diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/ConfigDocuments.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/ConfigDocuments.kt index 5711614ff..530fc8dbf 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/ConfigDocuments.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/ConfigDocuments.kt @@ -107,7 +107,7 @@ private fun treeDocuments( fun walk(node: Node, path: String, category: String, subcategory: String, section: String?) { if (!include(node)) return - if (node.getMetadata("hidden") != null) return + if (node.getMetadata("hidden").let { it != null && it != false }) return if (node is Property<*> && !isRenderableProperty(node)) return val documentScopes = if (node is Property<*> && node.isKeybindProperty()) scopes + SearchScope.Keybinds diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SettingIndex.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SettingIndex.kt index e65c04c63..726d2b91b 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SettingIndex.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/search/SettingIndex.kt @@ -1,9 +1,12 @@ package org.polyfrost.oneconfig.internal.ui.search import java.util.IdentityHashMap +import org.polyfrost.oneconfig.api.config.v1.MetaKeys import org.polyfrost.oneconfig.api.config.v1.Node import org.polyfrost.oneconfig.api.config.v1.Property import org.polyfrost.oneconfig.api.config.v1.Tree +import org.polyfrost.oneconfig.api.config.v1.enableToggle +import org.polyfrost.oneconfig.api.config.v1.get import org.polyfrost.oneconfig.api.config.v1.internal.ConfigVisualizer import org.polyfrost.oneconfig.internal.ui.api.ConfigData import org.polyfrost.oneconfig.internal.ui.api.ConfigRegistry @@ -54,8 +57,8 @@ internal fun buildCategories(tree: Tree, path: String? = null, include: (Node) - tree.map.values.forEach { node -> if (!include(node)) return@forEach - val category = nodeGroup(node, "category", ConfigVisualizer.DEFAULT_CATEGORY) - val subcategory = nodeGroup(node, "subcategory", ConfigVisualizer.DEFAULT_SUBCATEGORY) + val category = nodeGroup(node, MetaKeys.CATEGORY.name, ConfigVisualizer.DEFAULT_CATEGORY) + val subcategory = nodeGroup(node, MetaKeys.SUBCATEGORY.name, ConfigVisualizer.DEFAULT_SUBCATEGORY) val bucket = grouped.getOrPut(category) { LinkedHashMap() }.getOrPut(subcategory) { ArrayList() } when (node) { @@ -106,6 +109,7 @@ private fun buildSubmenuNode(tree: Tree, parentPath: String?): SettingNode.Subme /** The option shown on the header of [tree] which is the marked primary or else the toggle of an old style accordion */ internal fun groupHead(tree: Tree): Property<*>? { + tree.enableToggle?.let { return it } val properties = tree.map.values.filterIsInstance>() return properties.firstOrNull { it.getMetadata(PRIMARY) == true } ?: properties.firstOrNull(::isAccordionToggle) @@ -117,7 +121,7 @@ private fun isAccordionToggle(prop: Property<*>): Boolean { internal fun Property<*>.isBooleanHead(): Boolean = type == Boolean::class.java || type == Boolean::class.javaPrimitiveType -internal fun Property<*>.hasVisualizer(): Boolean = getMetadata("visualizer") != null +internal fun Property<*>.hasVisualizer(): Boolean = this[MetaKeys.VISUALIZER] != null internal fun Tree.isSubmenu(): Boolean = getMetadata(SUBMENU) == true @@ -139,7 +143,7 @@ internal fun submenuContent(tree: Tree, path: String?): List { } internal fun isRenderableProperty(prop: Property<*>): Boolean { - if (prop.getMetadata("hidden") != null) return false + if (prop.getMetadata(MetaKeys.HIDDEN.name).let { it != null && it != false }) return false return prop.hasVisualizer() } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/shell/ShellController.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/shell/ShellController.kt index 3808a5525..8a56d0ff0 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/shell/ShellController.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/shell/ShellController.kt @@ -459,3 +459,9 @@ fun pageSearchQuery(): String { val entry = LocalLifecycleOwner.current as? NavBackStackEntry return LocalShell.current.search.queryFor(entry?.id) } + +@Composable +fun pageSearchQueryOrNull(): String? { + val entry = LocalLifecycleOwner.current as? NavBackStackEntry ?: return null + return LocalShell.current.search.queryFor(entry.id) +} diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/themes/AccentColorAnimation.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/themes/AccentColorAnimation.kt index 35a0be150..c170891ac 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/themes/AccentColorAnimation.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/themes/AccentColorAnimation.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.lerp +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion private class ColorChange(val from: Color, val progress: Animatable) @@ -29,7 +30,7 @@ fun animateAccentColorAsState(key: Any?, targetValue: Color, animationSpec: Anim if (before == null) ColorChange(targetValue, Animatable(1f)) else ColorChange(before, Animatable(0f)) } target.value = targetValue - LaunchedEffect(change) { change.progress.animateTo(1f, animationSpec) } + LaunchedEffect(change) { change.progress.animateTo(1f, Motion.spec(animationSpec)) } return remember(change) { derivedStateOf { lerp(change.from, target.value, change.progress.value) } }.also { shown[0] = it } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/themes/AnimatedTheme.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/themes/AnimatedTheme.kt index e6ae50d40..a041876ec 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/themes/AnimatedTheme.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/themes/AnimatedTheme.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.lerp import androidx.compose.ui.unit.dp +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion private const val THEME_ANIM_MS = 350 @@ -28,8 +29,8 @@ fun animateTheme(target: UITheme): UITheme { val last = remember { arrayOf(target) } val editing = target.isCustom && last[0].previewImage == target.previewImage && last[0] != target SideEffect { last[0] = target } - val spec: AnimationSpec = if (editing) snap() else tween(THEME_ANIM_MS) - val colorSpec: AnimationSpec = if (editing) snap() else tween(THEME_ANIM_MS) + val spec: AnimationSpec = if (editing) snap() else Motion.spec(tween(THEME_ANIM_MS)) + val colorSpec: AnimationSpec = if (editing) snap() else Motion.spec(tween(THEME_ANIM_MS)) val pageBackground by animateColorAsState(target.pageBackground, colorSpec, label = "pageBackground") val sidebarBackground by animateColorAsState(target.sidebarBackground, colorSpec, label = "sidebarBackground") diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/themes/GlassTintColor.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/themes/GlassTintColor.kt index bb5a83ac9..98ae0c40c 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/themes/GlassTintColor.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/themes/GlassTintColor.kt @@ -22,8 +22,8 @@ import kotlin.math.min import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import org.polyfrost.oneconfig.api.platform.v1.Platform +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion import org.polyfrost.oneconfig.internal.ui.components.AnimationsMotionScale -import org.polyfrost.oneconfig.internal.ui.components.ReducedMotion object GlassTintColor { private const val FILL_DARK = 0.30f @@ -148,7 +148,7 @@ object GlassTintColor { abs(a.red - b.red) < SETTLED && abs(a.green - b.green) < SETTLED && abs(a.blue - b.blue) < SETTLED private fun step(t: Target, dt: Float) { - val snap = ReducedMotion.enabled || !AnimationsMotionScale.animations + val snap = Motion.reduced || !AnimationsMotionScale.animations if (snap) { shownAmount = t.amount if (t.amount > 0f) shownColor = t.color diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/themes/ModTheme.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/themes/ModTheme.kt new file mode 100644 index 000000000..bb0e68a5f --- /dev/null +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/themes/ModTheme.kt @@ -0,0 +1,30 @@ +package org.polyfrost.oneconfig.internal.ui.themes + +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigUITheme +import org.polyfrost.oneconfig.api.ui.v1.theme.ThemeBuilder + +internal fun modTheme(base: OneConfigUITheme, id: String, name: String, builder: ThemeBuilder): UITheme { + require(base is UITheme) { "Custom themes must be derived from a theme OneConfig provides" } + val themeId = "mod-$id" + val theme = base.copy( + previewImage = themeId, + name = name, + pageBackground = builder.pageBackground ?: base.pageBackground, + sidebarBackground = builder.sidebarBackground ?: base.sidebarBackground, + chipBackground = builder.chipBackground ?: base.chipBackground, + modCardBackground = builder.modCardBackground ?: base.modCardBackground, + componentBackground = builder.componentBackground ?: base.componentBackground, + popupBackground = builder.popupBackground ?: base.popupBackground, + borderColor = builder.borderColor ?: base.borderColor, + textColor = builder.textColor ?: base.textColor, + textColorSecondary = builder.textColorSecondary ?: base.textColorSecondary, + accentTextColor = builder.accentTextColor ?: base.accentTextColor, + shadowColor = builder.shadowColor ?: base.shadowColor, + controlThumbColor = builder.controlThumbColor ?: base.controlThumbColor, + favoriteColor = builder.favoriteColor ?: base.favoriteColor, + shadowEnabled = builder.shadowEnabled ?: base.shadowEnabled, + ) + UITheme.copyExtras(base.previewImage, themeId) + base.palette?.let { theme.withPalette(it) } ?: clearPalette(themeId) + return theme.withControlTrackColor(builder.controlTrackColor ?: base.controlTrackColor) +} diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/themes/PrefsSync.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/themes/PrefsSync.kt new file mode 100644 index 000000000..db1e76ca8 --- /dev/null +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/themes/PrefsSync.kt @@ -0,0 +1,34 @@ +package org.polyfrost.oneconfig.internal.ui.themes + +import org.polyfrost.oneconfig.api.ui.v1.OneConfigPrefs +import org.polyfrost.oneconfig.internal.ThemeConfig + +object PrefsSync { + @JvmStatic + fun sync() = push() + + @JvmStatic + fun animations(value: Boolean) = push(animations = value) + + @JvmStatic + fun reducedMotion(value: Boolean) = push(reducedMotion = value) + + @JvmStatic + fun backgroundBlur(value: Boolean) = push(backgroundBlur = value) + + @JvmStatic + fun guiOpenAnimation(value: Boolean) = push(guiOpenAnimation = value) + + @JvmStatic + fun uiScale(percent: Float) = push(uiScalePercent = percent) + + private fun push( + animations: Boolean = ThemeConfig.animations, + reducedMotion: Boolean = ThemeConfig.reducedMotion, + backgroundBlur: Boolean = ThemeConfig.enableBackgroundBlur, + guiOpenAnimation: Boolean = ThemeConfig.guiOpenAnimation, + uiScalePercent: Float = ThemeConfig.uiScale, + ) { + OneConfigPrefs.update(animations, reducedMotion, backgroundBlur, guiOpenAnimation, uiScalePercent / 100f) + } +} diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/themes/Provider.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/themes/Provider.kt index be45d089b..b3334a619 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/themes/Provider.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/themes/Provider.kt @@ -2,6 +2,7 @@ package org.polyfrost.oneconfig.internal.ui.themes import androidx.compose.foundation.LocalScrollbarStyle import androidx.compose.foundation.ScrollbarStyle +import androidx.compose.foundation.shape.CornerBasedShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -13,21 +14,33 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontListFontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import java.io.File +import kotlin.math.abs import kotlin.math.floor import kotlin.math.round import kotlinx.coroutines.delay +import org.polyfrost.compose.backend.PolyFontSource +import org.polyfrost.compose.render.FontManager import org.polyfrost.compose.render.PolyColor import org.polyfrost.oneconfig.api.notifications.v1.NotificationTheme import org.polyfrost.oneconfig.api.platform.v1.Platform +import org.polyfrost.oneconfig.api.ui.v1.theme.ThemeStyle +import org.polyfrost.oneconfig.api.ui.v1.theme.contrastAgainst import org.polyfrost.oneconfig.internal.ThemeConfig import org.polyfrost.oneconfig.internal.ui.DESIGN_HEIGHT_DP import org.polyfrost.oneconfig.internal.ui.DESIGN_WIDTH_DP @@ -65,7 +78,10 @@ fun updateAccent() { _accentEnd = if (accentColor.gradientEnabled && !accentColor.chroma) Color(accentColor.gradientArgb) else null } -val LocalTheme = compositionLocalOf { error("A UI theme is required but was not provided") } +val LocalTheme = compositionLocalOf { + runCatching { ThemeRegistry.activeTheme }.getOrNull() + ?: error("The OneConfig UI has not initialised yet, so there is no theme to use. Wait for OneConfig to finish loading before composing OneConfig UI.") +} private const val TOAST_MIN_ALPHA = 0.92f @@ -163,6 +179,15 @@ fun Theme( designWidth: Dp = DESIGN_WIDTH_DP.dp, designHeight: Dp = DESIGN_HEIGHT_DP.dp, content: @Composable () -> Unit, +) = ThemeWith(null, pixelGrid, designWidth, designHeight, content) + +@Composable +fun ThemeWith( + theme: UITheme?, + pixelGrid: Boolean, + designWidth: Dp = DESIGN_WIDTH_DP.dp, + designHeight: Dp = DESIGN_HEIGHT_DP.dp, + content: @Composable () -> Unit, ) { SideEffect { updateAccent() } @@ -173,13 +198,13 @@ fun Theme( } } - val target = ThemeRegistry.activeTheme ?: error("No active theme provided") + val target = theme ?: ThemeRegistry.activeTheme ?: error("No active theme provided") val accent = Accent val opaque = ReduceTransparency.enabled val shown = target.withAccentText(target.accentTextOn(accent)).let { if (opaque) it.withOpaqueSurfaces() else it } val animated = animateTheme(shown) - LaunchedEffect(target, accent, opaque) { syncNotificationTheme(target) } + LaunchedEffect(target, accent, opaque) { if (theme == null) syncNotificationTheme(target) } if (animated.liquidGlass) GlassTintColor.Drive() CompositionLocalProvider( @@ -193,21 +218,57 @@ fun Theme( internal fun syncNotificationTheme(source: UITheme) { val accent = Accent val theme = if (ReduceTransparency.enabled) source.withOpaqueSurfaces() else source - val minecraft = theme.isMinecraft + val pixel = theme.style == ThemeStyle.Pixel + val unitDensity = Density(1f) + + fun poly(color: Color): PolyColor = PolyColor(color.toArgb()) + + fun onFill(fill: Color): Color = + if (Color.White.contrastAgainst(fill) >= Color.Black.contrastAgainst(fill)) Color.White else Color.Black + + fun radius(shape: Shape, width: Float, height: Float): Float = + (shape as? CornerBasedShape)?.topStart?.toPx(Size(width, height), unitDensity) ?: 0f + + fun fontKey(style: TextStyle): String? { + val wanted = (style.fontWeight ?: FontWeight.Normal).weight + val font = (style.fontFamily as? FontListFontFamily)?.fonts.orEmpty() + .filterIsInstance() + .filter { it.style == FontStyle.Normal } + .minByOrNull { abs(it.weight.weight - wanted) } ?: return null + val key = if (pixel) "${font.key}#pixel" else font.key + if (FontManager.fontData(key) != null) return key + val loaded = FontManager.fontData(font.key)?.let { runCatching { FontManager.register(key, it, pixel) }.isSuccess } + ?: when { + font.key.startsWith("res:") -> FontManager.loadFromResource(font.key.removePrefix("res:"), key, pixelAligned = pixel) + font.key.startsWith("file:") -> runCatching { FontManager.register(key, File(font.key.removePrefix("file:")).readBytes(), pixel) }.isSuccess + else -> false + } + return key.takeIf { loaded } + } + NotificationTheme.set( - background = PolyColor(theme.popupBackground.withMinAlpha(TOAST_MIN_ALPHA).toArgb()), - border = PolyColor(theme.borderColor.toArgb()), - textPrimary = PolyColor(theme.textColor.toArgb()), - textSecondary = PolyColor(theme.textColorSecondary.toArgb()), - accent = PolyColor(accent.toArgb()), - subtleButton = PolyColor(theme.componentBackground.withMinAlpha(TOAST_MIN_ALPHA).toArgb()), - actionPrimaryText = PolyColor(theme.accentTextOn(accent).toArgb()), - actionSubtleText = PolyColor(theme.textColor.toArgb()), - fontTitle = if (minecraft) "minecraft-bold" else "poppins-medium", - fontBody = if (minecraft) "minecraft" else null, - radiusCard = if (minecraft) 0f else 12f, - radiusButton = if (minecraft) 0f else 4f, - radiusProgress = if (minecraft) 0f else 3f, + background = poly(theme.popupBackground.withMinAlpha(TOAST_MIN_ALPHA)), + border = poly(theme.borderColor), + textPrimary = poly(theme.textColor), + textSecondary = poly(theme.textColorSecondary), + accent = poly(accent), + subtleButton = poly(theme.componentBackground.withMinAlpha(TOAST_MIN_ALPHA)), + actionPrimaryText = poly(theme.accentTextOn(accent)), + actionSubtleText = poly(theme.textColor), + fontTitle = fontKey(theme.typography.heading), + fontBody = fontKey(theme.typography.small), + radiusCard = radius(theme.shapes.large, 340f, 72f), + radiusButton = radius(theme.shapes.medium, 80f, 26f), + radiusProgress = radius(theme.shapes.pill, 312f, 6f), + success = poly(theme.success), + error = poly(theme.error), + warning = poly(theme.warning), + info = poly(theme.info), + onSuccess = poly(theme.onSuccess), + onError = poly(theme.onError), + onWarning = poly(onFill(theme.warning)), + onInfo = poly(onFill(theme.info)), ) - NotificationTheme.useIconFolder(if (minecraft) "minecraft" else null) + NotificationTheme.useIconFolder(if (pixel) "minecraft" else null) + NotificationTheme.useDisabledAlpha(theme.disabledAlpha) } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/themes/Radii.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/themes/Radii.kt index 9203803cc..3020285c8 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/themes/Radii.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/themes/Radii.kt @@ -5,10 +5,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.Shape import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import org.polyfrost.oneconfig.api.ui.v1.theme.ThemeStyle /** * The corner radius scale @@ -70,3 +72,13 @@ fun Shape.concentric(inset: Dp, minRadius: Dp = 0.dp): Shape { val inner = (outer - inset).coerceAtLeast(minRadius) return RoundedCornerShape(inner) } + +private val ReferenceButtonRadius = Radii.SM + +@Composable +fun themedShape(radius: Dp): Shape { + val theme = LocalTheme.current + if (theme.style == ThemeStyle.Pixel) return RectangleShape + val scale = theme.buttonShape.cornerRadiusOrNull()?.let { it / ReferenceButtonRadius } ?: 1f + return remember(radius, scale) { if (scale <= 0f) RectangleShape else RoundedCornerShape(radius * scale) } +} diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/themes/Structs.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/themes/Structs.kt index 9c46cc2f1..d70e9dd29 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/themes/Structs.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/themes/Structs.kt @@ -1,15 +1,17 @@ package org.polyfrost.oneconfig.internal.ui.themes import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.compositeOver -import androidx.compose.ui.graphics.luminance import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import java.util.concurrent.ConcurrentHashMap import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTypography import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigUITheme +import org.polyfrost.oneconfig.api.ui.v1.theme.ThemeStyle /** Sets a panel background color's alpha from an opacity percentage between 0 and 100 */ fun Color.withOpacityPercent(percent: Float): Color = @@ -65,16 +67,28 @@ data class UITheme( override val circleShape: Shape, val branding: UIBranding, - val typography: UITypography, + override val typography: UITypography, val iconOverrides: Map = emptyMap(), ) : OneConfigUITheme() { override val fontFamily: FontFamily get() = typography.family override val logoPath: String get() = brandedLogoPath(this) - var controlTrackColor: Color = DefaultControlTrackColor + override var controlTrackColor: Color = DefaultControlTrackColor private set + override val style: ThemeStyle + get() = when { + isMinecraftStyle -> ThemeStyle.Pixel + liquidGlass -> ThemeStyle.Glass + else -> ThemeStyle.Flat + } + + override val focusRingColor: Color + get() = if (isMinecraftStyle) (if (isLight) Color.Black else Color.White) else Accent + + override val accentBrush: Brush get() = AccentBrush + fun withControlTrackColor(color: Color): UITheme = copy().also { it.controlTrackColor = color } @@ -183,8 +197,6 @@ data class UIBranding( data class UITypography( val family: FontFamily, -) - -val UITheme.isLight: Boolean get() = textColor.luminance() < 0.5f +) : OneConfigTypography(family) private fun brandedLogoPath(theme: UITheme): String = OneConfigTheme.logoOverride ?: theme.branding.logoPath diff --git a/modules/mod-toggles/api/mod-toggles.api b/modules/mod-toggles/api/mod-toggles.api index a0544ec84..97da919f9 100644 --- a/modules/mod-toggles/api/mod-toggles.api +++ b/modules/mod-toggles/api/mod-toggles.api @@ -2,6 +2,8 @@ public abstract interface class org/polyfrost/oneconfig/api/toggle/v1/ModToggle public static final field ENTRYPOINT Ljava/lang/String; public abstract fun isEnabled ()Z public fun needsSetup ()Z + public static fun of (Ljava/util/function/BooleanSupplier;)Lorg/polyfrost/oneconfig/api/toggle/v1/ModToggle; + public static fun of (Ljava/util/function/BooleanSupplier;Ljava/util/function/Consumer;)Lorg/polyfrost/oneconfig/api/toggle/v1/ModToggle; public abstract fun setEnabled (Z)V } diff --git a/modules/mod-toggles/src/main/java/org/polyfrost/oneconfig/api/toggle/v1/ModToggle.java b/modules/mod-toggles/src/main/java/org/polyfrost/oneconfig/api/toggle/v1/ModToggle.java index 8187cbb81..e6756af53 100644 --- a/modules/mod-toggles/src/main/java/org/polyfrost/oneconfig/api/toggle/v1/ModToggle.java +++ b/modules/mod-toggles/src/main/java/org/polyfrost/oneconfig/api/toggle/v1/ModToggle.java @@ -1,5 +1,9 @@ package org.polyfrost.oneconfig.api.toggle.v1; +import java.util.Objects; +import java.util.function.BooleanSupplier; +import java.util.function.Consumer; + /** * The master switch shown on a mod's card in OneConfig *
@@ -22,4 +26,25 @@ public interface ModToggle { default boolean needsSetup() { return false; } + + static ModToggle of(BooleanSupplier get, Consumer set) { + Objects.requireNonNull(get, "get"); + Objects.requireNonNull(set, "set"); + return new ModToggle() { + @Override + public boolean isEnabled() { + return get.getAsBoolean(); + } + + @Override + public void setEnabled(boolean enabled) { + set.accept(enabled); + } + }; + } + + static ModToggle of(BooleanSupplier get) { + return of(get, enabled -> { + }); + } } diff --git a/modules/notifications/api/notifications.api b/modules/notifications/api/notifications.api index bfcf5994e..f81e19ab9 100644 --- a/modules/notifications/api/notifications.api +++ b/modules/notifications/api/notifications.api @@ -11,10 +11,11 @@ public final class org/polyfrost/oneconfig/api/notifications/v1/ComposableSingle public final fun getLambda$-1339950985$notifications ()Lkotlin/jvm/functions/Function2; } -public final class org/polyfrost/oneconfig/api/notifications/v1/Notification { +public final class org/polyfrost/oneconfig/api/notifications/v1/Notification : org/polyfrost/oneconfig/api/notifications/v1/NotificationHandle { public static final field $stable I public static final field Companion Lorg/polyfrost/oneconfig/api/notifications/v1/Notification$Companion; public static final field DEFAULT_DURATION F + public static final field DEFAULT_DURATION_MILLIS J public fun (Ljava/lang/String;Ljava/lang/String;)V public fun (Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;)V public synthetic fun (Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Lorg/jetbrains/skia/Image;)V @@ -27,22 +28,35 @@ public final class org/polyfrost/oneconfig/api/notifications/v1/Notification { public fun (Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Lorg/polyfrost/compose/render/PolyBitmap;F)V public fun (Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Lorg/polyfrost/compose/render/PolyBitmap;FLjava/util/concurrent/Callable;)V public fun (Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Lorg/polyfrost/compose/render/PolyBitmap;FLjava/util/concurrent/Callable;Ljava/util/List;)V - public fun (Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Lorg/polyfrost/compose/render/PolyBitmap;FLjava/util/concurrent/Callable;Ljava/util/List;Ljava/lang/Runnable;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Lorg/polyfrost/compose/render/PolyBitmap;FLjava/util/concurrent/Callable;Ljava/util/List;Ljava/lang/Runnable;)V public synthetic fun (Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Lorg/polyfrost/compose/render/PolyBitmap;FLjava/util/concurrent/Callable;Ljava/util/List;Ljava/lang/Runnable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Lorg/polyfrost/compose/render/PolyBitmap;Ljava/lang/String;)V + public fun (Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Lorg/polyfrost/compose/render/PolyBitmap;Ljava/lang/String;J)V + public fun (Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Lorg/polyfrost/compose/render/PolyBitmap;Ljava/lang/String;JZ)V + public fun (Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Lorg/polyfrost/compose/render/PolyBitmap;Ljava/lang/String;JZLjava/util/concurrent/Callable;)V + public fun (Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Lorg/polyfrost/compose/render/PolyBitmap;Ljava/lang/String;JZLjava/util/concurrent/Callable;Ljava/util/List;)V + public fun (Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Lorg/polyfrost/compose/render/PolyBitmap;Ljava/lang/String;JZLjava/util/concurrent/Callable;Ljava/util/List;Ljava/lang/Runnable;)V + public fun (Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Lorg/polyfrost/compose/render/PolyBitmap;Ljava/lang/String;JZLjava/util/concurrent/Callable;Ljava/util/List;Ljava/lang/Runnable;Ljava/lang/Runnable;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Lorg/polyfrost/compose/render/PolyBitmap;Ljava/lang/String;JZLjava/util/concurrent/Callable;Ljava/util/List;Ljava/lang/Runnable;Ljava/lang/Runnable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun dismiss ()V public final fun getActions ()Ljava/util/List; public final fun getCreatedAt ()J public final fun getDuration ()F + public final fun getDurationMillis ()J public final synthetic fun getIcon ()Lorg/jetbrains/skia/Image; public final fun getIcon ()Lorg/polyfrost/compose/render/PolyBitmap; + public final fun getIconName ()Ljava/lang/String; public final fun getId ()J public final fun getMessage ()Ljava/lang/String; public final fun getOnClick ()Ljava/lang/Runnable; + public final fun getOnDismiss ()Ljava/lang/Runnable; public final fun getPersistent ()Z public final fun getProgress ()Ljava/util/concurrent/Callable; public final fun getRead ()Z public final fun getTitle ()Ljava/lang/String; public final fun getType ()Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType; public final fun progressOrNull ()Ljava/lang/Float; + public fun update (Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Ljava/lang/Float;)V } public final class org/polyfrost/oneconfig/api/notifications/v1/Notification$Companion { @@ -54,24 +68,47 @@ public final class org/polyfrost/oneconfig/api/notifications/v1/NotificationActi public fun (Ljava/lang/String;Z)V public fun (Ljava/lang/String;ZLjava/lang/Runnable;)V public synthetic fun (Ljava/lang/String;ZLjava/lang/Runnable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;ZZ)V + public fun (Ljava/lang/String;ZZZ)V + public fun (Ljava/lang/String;ZZZLjava/lang/Runnable;)V + public synthetic fun (Ljava/lang/String;ZZZLjava/lang/Runnable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getDismissOnClick ()Z + public final fun getEnabled ()Z public final fun getLabel ()Ljava/lang/String; public final fun getOnClick ()Ljava/lang/Runnable; public final fun getPrimary ()Z } +public abstract interface class org/polyfrost/oneconfig/api/notifications/v1/NotificationHandle { + public abstract fun dismiss ()V + public abstract fun update (Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Ljava/lang/Float;)V + public static synthetic fun update$default (Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationHandle;Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Ljava/lang/Float;ILjava/lang/Object;)V +} + +public final class org/polyfrost/oneconfig/api/notifications/v1/NotificationHandle$DefaultImpls { + public static synthetic fun update$default (Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationHandle;Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Ljava/lang/Float;ILjava/lang/Object;)V +} + public final class org/polyfrost/oneconfig/api/notifications/v1/NotificationTheme { public static final field $stable I public static final field INSTANCE Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationTheme; public final fun accentFor (Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;)Lorg/polyfrost/compose/render/PolyColor; + public final fun actionFillFor (Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;)Lorg/polyfrost/compose/render/PolyColor; public final fun getAccent ()Lorg/polyfrost/compose/render/PolyColor; public final fun getActionPrimaryText ()Lorg/polyfrost/compose/render/PolyColor; public final fun getActionSubtleText ()Lorg/polyfrost/compose/render/PolyColor; public final fun getBackground ()Lorg/polyfrost/compose/render/PolyColor; public final fun getBorder ()Lorg/polyfrost/compose/render/PolyColor; + public final fun getDisabledAlpha ()F public final fun getError ()Lorg/polyfrost/compose/render/PolyColor; public final fun getFontBody ()Ljava/lang/String; public final fun getFontTitle ()Ljava/lang/String; public final fun getIconFolder ()Ljava/lang/String; + public final fun getInfo ()Lorg/polyfrost/compose/render/PolyColor; + public final fun getOnError ()Lorg/polyfrost/compose/render/PolyColor; + public final fun getOnInfo ()Lorg/polyfrost/compose/render/PolyColor; + public final fun getOnSuccess ()Lorg/polyfrost/compose/render/PolyColor; + public final fun getOnWarning ()Lorg/polyfrost/compose/render/PolyColor; public final fun getRadiusButton ()F public final fun getRadiusCard ()F public final fun getRadiusProgress ()F @@ -79,6 +116,8 @@ public final class org/polyfrost/oneconfig/api/notifications/v1/NotificationThem public final fun getSuccess ()Lorg/polyfrost/compose/render/PolyColor; public final fun getTextPrimary ()Lorg/polyfrost/compose/render/PolyColor; public final fun getTextSecondary ()Lorg/polyfrost/compose/render/PolyColor; + public final fun getWarning ()Lorg/polyfrost/compose/render/PolyColor; + public final fun onActionFillFor (Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;)Lorg/polyfrost/compose/render/PolyColor; } public final class org/polyfrost/oneconfig/api/notifications/v1/NotificationToastsKt { @@ -90,6 +129,7 @@ public final class org/polyfrost/oneconfig/api/notifications/v1/NotificationType public static final field INFO Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType; public static final field PROGRESS Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType; public static final field SUCCESS Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType; + public static final field WARNING Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType; public final fun getAccent ()Lorg/polyfrost/compose/render/PolyColor; public static fun getEntries ()Lkotlin/enums/EnumEntries; public final fun getIconName ()Ljava/lang/String; @@ -105,7 +145,9 @@ public final class org/polyfrost/oneconfig/api/notifications/v1/Notifications { public static final fun dismiss (Lorg/polyfrost/oneconfig/api/notifications/v1/Notification;)V public static final fun error (Ljava/lang/String;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; public static final fun error (Ljava/lang/String;Ljava/lang/String;F)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; - public static synthetic fun error$default (Ljava/lang/String;Ljava/lang/String;FILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; + public static final fun error (Ljava/lang/String;Ljava/lang/String;J)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; + public static final synthetic fun error$default (Ljava/lang/String;Ljava/lang/String;FILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; + public static synthetic fun error$default (Ljava/lang/String;Ljava/lang/String;JILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; public static final synthetic fun icon (Ljava/lang/String;)Lorg/jetbrains/skia/Image; public static final fun icon (Ljava/lang/String;)Lorg/polyfrost/compose/render/PolyBitmap; public static final synthetic fun icon (Ljava/lang/String;I)Lorg/jetbrains/skia/Image; @@ -114,7 +156,10 @@ public final class org/polyfrost/oneconfig/api/notifications/v1/Notifications { public static synthetic fun icon$default (Ljava/lang/String;IILjava/lang/Object;)Lorg/polyfrost/compose/render/PolyBitmap; public static final fun info (Ljava/lang/String;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; public static final fun info (Ljava/lang/String;Ljava/lang/String;F)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; - public static synthetic fun info$default (Ljava/lang/String;Ljava/lang/String;FILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; + public static final fun info (Ljava/lang/String;Ljava/lang/String;J)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; + public static final synthetic fun info$default (Ljava/lang/String;Ljava/lang/String;FILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; + public static synthetic fun info$default (Ljava/lang/String;Ljava/lang/String;JILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; + public static final fun progress (Ljava/lang/String;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; public static final fun progress (Ljava/lang/String;Ljava/lang/String;Ljava/util/concurrent/Callable;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; public static final fun send (Ljava/lang/String;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; public static final fun send (Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; @@ -127,12 +172,38 @@ public final class org/polyfrost/oneconfig/api/notifications/v1/Notifications { public static final fun send (Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Lorg/polyfrost/compose/render/PolyBitmap;F)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; public static final fun send (Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Lorg/polyfrost/compose/render/PolyBitmap;FLjava/util/concurrent/Callable;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; public static final fun send (Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Lorg/polyfrost/compose/render/PolyBitmap;FLjava/util/concurrent/Callable;Ljava/lang/Runnable;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; - public static final fun send (Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Lorg/polyfrost/compose/render/PolyBitmap;FLjava/util/concurrent/Callable;Ljava/lang/Runnable;Ljava/util/List;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; + public static final synthetic fun send (Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Lorg/polyfrost/compose/render/PolyBitmap;FLjava/util/concurrent/Callable;Ljava/lang/Runnable;Ljava/util/List;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; + public static final fun send (Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Lorg/polyfrost/compose/render/PolyBitmap;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; + public static final fun send (Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Lorg/polyfrost/compose/render/PolyBitmap;Ljava/lang/String;J)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; + public static final fun send (Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Lorg/polyfrost/compose/render/PolyBitmap;Ljava/lang/String;JZ)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; + public static final fun send (Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Lorg/polyfrost/compose/render/PolyBitmap;Ljava/lang/String;JZLjava/util/concurrent/Callable;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; + public static final fun send (Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Lorg/polyfrost/compose/render/PolyBitmap;Ljava/lang/String;JZLjava/util/concurrent/Callable;Ljava/util/List;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; + public static final fun send (Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Lorg/polyfrost/compose/render/PolyBitmap;Ljava/lang/String;JZLjava/util/concurrent/Callable;Ljava/util/List;Ljava/lang/Runnable;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; + public static final fun send (Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Lorg/polyfrost/compose/render/PolyBitmap;Ljava/lang/String;JZLjava/util/concurrent/Callable;Ljava/util/List;Ljava/lang/Runnable;Ljava/lang/Runnable;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; public static synthetic fun send$default (Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Lorg/jetbrains/skia/Image;FLjava/util/concurrent/Callable;Ljava/lang/Runnable;Ljava/util/List;ILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; public static synthetic fun send$default (Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Lorg/polyfrost/compose/render/PolyBitmap;FLjava/util/concurrent/Callable;Ljava/lang/Runnable;Ljava/util/List;ILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; + public static synthetic fun send$default (Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Lorg/polyfrost/compose/render/PolyBitmap;Ljava/lang/String;JZLjava/util/concurrent/Callable;Ljava/util/List;Ljava/lang/Runnable;Ljava/lang/Runnable;ILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; + public static final fun sendOnce (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; + public static final fun sendOnce (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; + public static final fun sendOnce (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Lorg/polyfrost/compose/render/PolyBitmap;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; + public static final fun sendOnce (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Lorg/polyfrost/compose/render/PolyBitmap;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; + public static final fun sendOnce (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Lorg/polyfrost/compose/render/PolyBitmap;Ljava/lang/String;J)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; + public static final fun sendOnce (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Lorg/polyfrost/compose/render/PolyBitmap;Ljava/lang/String;JZ)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; + public static final fun sendOnce (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Lorg/polyfrost/compose/render/PolyBitmap;Ljava/lang/String;JZLjava/util/concurrent/Callable;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; + public static final fun sendOnce (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Lorg/polyfrost/compose/render/PolyBitmap;Ljava/lang/String;JZLjava/util/concurrent/Callable;Ljava/util/List;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; + public static final fun sendOnce (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Lorg/polyfrost/compose/render/PolyBitmap;Ljava/lang/String;JZLjava/util/concurrent/Callable;Ljava/util/List;Ljava/lang/Runnable;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; + public static final fun sendOnce (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Lorg/polyfrost/compose/render/PolyBitmap;Ljava/lang/String;JZLjava/util/concurrent/Callable;Ljava/util/List;Ljava/lang/Runnable;Ljava/lang/Runnable;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; + public static synthetic fun sendOnce$default (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Lorg/polyfrost/compose/render/PolyBitmap;Ljava/lang/String;JZLjava/util/concurrent/Callable;Ljava/util/List;Ljava/lang/Runnable;Ljava/lang/Runnable;ILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; + public static final fun sendWithFloatDuration (Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Lorg/polyfrost/compose/render/PolyBitmap;FLjava/util/concurrent/Callable;Ljava/lang/Runnable;Ljava/util/List;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; + public static synthetic fun sendWithFloatDuration$default (Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;Lorg/polyfrost/compose/render/PolyBitmap;FLjava/util/concurrent/Callable;Ljava/lang/Runnable;Ljava/util/List;ILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; public static final fun success (Ljava/lang/String;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; public static final fun success (Ljava/lang/String;Ljava/lang/String;F)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; - public static synthetic fun success$default (Ljava/lang/String;Ljava/lang/String;FILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; + public static final fun success (Ljava/lang/String;Ljava/lang/String;J)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; + public static final synthetic fun success$default (Ljava/lang/String;Ljava/lang/String;FILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; + public static synthetic fun success$default (Ljava/lang/String;Ljava/lang/String;JILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; + public static final fun warning (Ljava/lang/String;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; + public static final fun warning (Ljava/lang/String;Ljava/lang/String;J)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; + public static synthetic fun warning$default (Ljava/lang/String;Ljava/lang/String;JILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; } public final class org/polyfrost/oneconfig/api/notifications/v1/Notifications$Builder { @@ -140,10 +211,14 @@ public final class org/polyfrost/oneconfig/api/notifications/v1/Notifications$Bu public final fun action (Ljava/lang/String;Ljava/lang/Runnable;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notifications$Builder; public final fun action (Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationAction;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notifications$Builder; public final fun duration (F)Lorg/polyfrost/oneconfig/api/notifications/v1/Notifications$Builder; + public final fun duration (J)Lorg/polyfrost/oneconfig/api/notifications/v1/Notifications$Builder; + public final fun icon (Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notifications$Builder; public final synthetic fun icon (Lorg/jetbrains/skia/Image;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notifications$Builder; public final fun icon (Lorg/polyfrost/compose/render/PolyBitmap;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notifications$Builder; public final fun onClick (Ljava/lang/Runnable;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notifications$Builder; + public final fun onDismiss (Ljava/lang/Runnable;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notifications$Builder; public final fun persistent ()Lorg/polyfrost/oneconfig/api/notifications/v1/Notifications$Builder; + public final fun persistent (Z)Lorg/polyfrost/oneconfig/api/notifications/v1/Notifications$Builder; public final fun progress (Ljava/util/concurrent/Callable;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notifications$Builder; public final fun send ()Lorg/polyfrost/oneconfig/api/notifications/v1/Notification; public final fun type (Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;)Lorg/polyfrost/oneconfig/api/notifications/v1/Notifications$Builder; diff --git a/modules/notifications/src/main/kotlin/org/polyfrost/oneconfig/api/notifications/v1/Notification.kt b/modules/notifications/src/main/kotlin/org/polyfrost/oneconfig/api/notifications/v1/Notification.kt index 93783038e..65100d613 100644 --- a/modules/notifications/src/main/kotlin/org/polyfrost/oneconfig/api/notifications/v1/Notification.kt +++ b/modules/notifications/src/main/kotlin/org/polyfrost/oneconfig/api/notifications/v1/Notification.kt @@ -27,10 +27,13 @@ package org.polyfrost.oneconfig.api.notifications.v1 import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import java.util.concurrent.Callable +import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong +import org.jetbrains.annotations.ApiStatus import org.polyfrost.compose.render.PolyColor import org.polyfrost.compose.render.PolyBitmap import org.jetbrains.skia.Image @@ -38,11 +41,16 @@ import org.jetbrains.skia.Image /** * The visual category of a [Notification] */ -public enum class NotificationType(public val accent: PolyColor, public val iconName: String) { - INFO(PolyColor.hex("#DFEAFF"), "info"), - SUCCESS(PolyColor.hex("#3CAF77"), "check-circle"), - ERROR(PolyColor.hex("#F2545A"), "alert-circle"), - PROGRESS(PolyColor.hex("#2B4BFF"), "refresh"), +public enum class NotificationType(public val iconName: String) { + INFO("info"), + SUCCESS("check-circle"), + ERROR("alert-circle"), + PROGRESS("refresh"), + + @ApiStatus.Experimental + WARNING("alert-circle"); + + public val accent: PolyColor get() = NotificationTheme.accentFor(this) } /** @@ -57,8 +65,14 @@ public enum class NotificationType(public val accent: PolyColor, public val icon public class NotificationAction @JvmOverloads constructor( public val label: String, public val primary: Boolean = false, + @get:ApiStatus.Experimental + public val dismissOnClick: Boolean = true, + @get:ApiStatus.Experimental + public val enabled: Boolean = true, public val onClick: Runnable = Runnable {}, -) +) { + public constructor(label: String, primary: Boolean = false, onClick: Runnable = Runnable {}) : this(label, primary, true, true, onClick) +} /** * A single notification @@ -79,26 +93,66 @@ public class NotificationAction @JvmOverloads constructor( * @param onClick optional callback run when the body of the notification is clicked */ public class Notification @JvmOverloads constructor( - public val title: String, - public val message: String, - public val type: NotificationType = NotificationType.INFO, + title: String, + message: String, + type: NotificationType = NotificationType.INFO, public val icon: PolyBitmap? = null, - public val duration: Float = DEFAULT_DURATION, + @get:ApiStatus.Experimental + public val iconName: String? = null, + @get:ApiStatus.Experimental + public val durationMillis: Long = DEFAULT_DURATION_MILLIS, + public val persistent: Boolean = false, public val progress: Callable? = null, public val actions: List = emptyList(), public val onClick: Runnable? = null, -) { + @get:ApiStatus.Experimental + public val onDismiss: Runnable? = null, +) : NotificationHandle { + @Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) + public constructor( + title: String, + message: String, + type: NotificationType = NotificationType.INFO, + icon: PolyBitmap? = null, + duration: Float = LEGACY_DURATION, + progress: Callable? = null, + actions: List = emptyList(), + onClick: Runnable? = null, + ) : this(title, message, type, icon, null, legacyMillis(duration), legacyPersistent(duration, progress), progress, actions, onClick, null) { + legacyDuration = duration + } + + @Deprecated("Use the constructor taking durationMillis: Long and persistent") + public constructor(title: String, message: String, type: NotificationType, icon: PolyBitmap?, duration: Float) : + this(title, message, type, icon, null, legacyMillis(duration), legacyPersistent(duration, null), null, emptyList(), null, null) { + legacyDuration = duration + } + + @Deprecated("Use the constructor taking durationMillis: Long and persistent") + public constructor(title: String, message: String, type: NotificationType, icon: PolyBitmap?, duration: Float, progress: Callable?) : + this(title, message, type, icon, null, legacyMillis(duration), legacyPersistent(duration, progress), progress, emptyList(), null, null) { + legacyDuration = duration + } + + @Deprecated("Use the constructor taking durationMillis: Long and persistent") + public constructor(title: String, message: String, type: NotificationType, icon: PolyBitmap?, duration: Float, progress: Callable?, actions: List) : + this(title, message, type, icon, null, legacyMillis(duration), legacyPersistent(duration, progress), progress, actions, null, null) { + legacyDuration = duration + } + @Deprecated("Skia images are no longer supported", level = DeprecationLevel.HIDDEN) public constructor( title: String, message: String, type: NotificationType = NotificationType.INFO, icon: Image? = null, - duration: Float = DEFAULT_DURATION, + duration: Float = LEGACY_DURATION, progress: Callable? = null, actions: List = emptyList(), onClick: Runnable? = null, - ) : this(title, message, type, null as PolyBitmap?, duration, progress, actions, onClick) + ) : this(title, message, type, null, null, legacyMillis(duration), legacyPersistent(duration, progress), progress, actions, onClick, null) { + legacyDuration = duration + } @Deprecated("Skia images are no longer supported", level = DeprecationLevel.HIDDEN) public constructor(title: String, message: String, type: NotificationType, icon: Image?) : @@ -106,20 +160,40 @@ public class Notification @JvmOverloads constructor( @Deprecated("Skia images are no longer supported", level = DeprecationLevel.HIDDEN) public constructor(title: String, message: String, type: NotificationType, icon: Image?, duration: Float) : - this(title, message, type, null as PolyBitmap?, duration) + this(title, message, type, null, null, legacyMillis(duration), legacyPersistent(duration, null), null, emptyList(), null, null) { + legacyDuration = duration + } @Deprecated("Skia images are no longer supported", level = DeprecationLevel.HIDDEN) public constructor(title: String, message: String, type: NotificationType, icon: Image?, duration: Float, progress: Callable?) : - this(title, message, type, null as PolyBitmap?, duration, progress) + this(title, message, type, null, null, legacyMillis(duration), legacyPersistent(duration, progress), progress, emptyList(), null, null) { + legacyDuration = duration + } @Deprecated("Skia images are no longer supported", level = DeprecationLevel.HIDDEN) public constructor(title: String, message: String, type: NotificationType, icon: Image?, duration: Float, progress: Callable?, actions: List) : - this(title, message, type, null as PolyBitmap?, duration, progress, actions) + this(title, message, type, null, null, legacyMillis(duration), legacyPersistent(duration, progress), progress, actions, null, null) { + legacyDuration = duration + } @Deprecated("Skia images are no longer supported", level = DeprecationLevel.HIDDEN) @get:JvmName("getIcon") public val skiaIcon: Image? get() = null + public var title: String by mutableStateOf(title) + private set + + public var message: String by mutableStateOf(message) + private set + + public var type: NotificationType by mutableStateOf(type) + private set + + @Deprecated("Use durationMillis and persistent") + public val duration: Float get() = legacyDuration ?: if (persistent) -1f else durationMillis.toFloat() + + internal var legacyDuration: Float? = null + public val id: Long = COUNTER.incrementAndGet() public val createdAt: Long = System.currentTimeMillis() @@ -128,22 +202,87 @@ public class Notification @JvmOverloads constructor( internal var dismissRequested by mutableStateOf(false) + internal var revision by mutableIntStateOf(0) + + private var progressValue by mutableStateOf(null) + + private var progressEnded by mutableStateOf(false) + + private val dismissFired = AtomicBoolean(false) + /** * Whether this notification has been seen in the notifications center */ public var read: Boolean by mutableStateOf(false) internal set - public val persistent: Boolean get() = duration <= 0f && progress == null + internal val tracksProgress: Boolean get() = !progressEnded && (progressValue != null || progress != null) public fun progressOrNull(): Float? { + if (progressEnded) return null + progressValue?.let { return it.coerceIn(0f, 1f) } val p = progress ?: return null return runCatching { p.call() }.getOrNull()?.coerceIn(0f, 1f) } + @ApiStatus.Experimental + override fun update(title: String?, message: String?, type: NotificationType?, progress: Float?) { + NotificationsManager.mutate { + if (title != null) this.title = title + if (message != null) this.message = message + if (type != null) { + if (progress == null && type != NotificationType.PROGRESS && type != this.type) progressEnded = true + this.type = type + } + if (progress != null) { + progressValue = progress.coerceIn(0f, 1f) + progressEnded = false + } + if (title != null || message != null || type != null) read = false + revision++ + } + } + + @ApiStatus.Experimental + override fun dismiss() { + NotificationsManager.mutate { dismissRequested = true } + } + + internal fun startProgress() { + progressValue = 0f + } + + internal fun fireDismissed(): Runnable? = if (dismissFired.compareAndSet(false, true)) onDismiss else null + public companion object { + @Deprecated("Use DEFAULT_DURATION_MILLIS", ReplaceWith("Notification.DEFAULT_DURATION_MILLIS")) public const val DEFAULT_DURATION: Float = 4000f + @ApiStatus.Experimental + public const val DEFAULT_DURATION_MILLIS: Long = 4000L + + private const val LEGACY_DURATION = 4000f + private val COUNTER = AtomicLong() + + private fun legacyMillis(duration: Float): Long = if (duration > 0f) duration.toLong() else DEFAULT_DURATION_MILLIS + + private fun legacyPersistent(duration: Float, progress: Callable?): Boolean = duration <= 0f && progress == null } -} \ No newline at end of file +} + +internal fun legacyNotification( + title: String, + message: String, + type: NotificationType, + icon: PolyBitmap?, + duration: Float, + progress: Callable?, + actions: List, + onClick: Runnable?, +): Notification = Notification( + title, message, type, icon, null, + if (duration > 0f) duration.toLong() else Notification.DEFAULT_DURATION_MILLIS, + duration <= 0f && progress == null, + progress, actions, onClick, null, +).also { it.legacyDuration = duration } diff --git a/modules/notifications/src/main/kotlin/org/polyfrost/oneconfig/api/notifications/v1/NotificationHandle.kt b/modules/notifications/src/main/kotlin/org/polyfrost/oneconfig/api/notifications/v1/NotificationHandle.kt new file mode 100644 index 000000000..46ada42db --- /dev/null +++ b/modules/notifications/src/main/kotlin/org/polyfrost/oneconfig/api/notifications/v1/NotificationHandle.kt @@ -0,0 +1,10 @@ +package org.polyfrost.oneconfig.api.notifications.v1 + +import org.jetbrains.annotations.ApiStatus + +@ApiStatus.Experimental +public interface NotificationHandle { + public fun update(title: String? = null, message: String? = null, type: NotificationType? = null, progress: Float? = null) + + public fun dismiss() +} diff --git a/modules/notifications/src/main/kotlin/org/polyfrost/oneconfig/api/notifications/v1/NotificationTheme.kt b/modules/notifications/src/main/kotlin/org/polyfrost/oneconfig/api/notifications/v1/NotificationTheme.kt index 14f4fd49f..fa3a5cd7f 100644 --- a/modules/notifications/src/main/kotlin/org/polyfrost/oneconfig/api/notifications/v1/NotificationTheme.kt +++ b/modules/notifications/src/main/kotlin/org/polyfrost/oneconfig/api/notifications/v1/NotificationTheme.kt @@ -45,7 +45,31 @@ public object NotificationTheme { private set public var success: PolyColor by mutableStateOf(PolyColor.hex("#3CAF77")) private set - public var error: PolyColor by mutableStateOf(PolyColor.hex("#F2545A")) + public var error: PolyColor by mutableStateOf(PolyColor.hex("#E35B5B")) + private set + + @get:ApiStatus.Experimental + public var warning: PolyColor by mutableStateOf(PolyColor.hex("#FFAB1D")) + private set + + @get:ApiStatus.Experimental + public var info: PolyColor by mutableStateOf(PolyColor.hex("#5B9BE3")) + private set + + @get:ApiStatus.Experimental + public var onSuccess: PolyColor by mutableStateOf(PolyColor.hex("#FFFFFF")) + private set + + @get:ApiStatus.Experimental + public var onError: PolyColor by mutableStateOf(PolyColor.hex("#FFFFFF")) + private set + + @get:ApiStatus.Experimental + public var onWarning: PolyColor by mutableStateOf(PolyColor.hex("#000000")) + private set + + @get:ApiStatus.Experimental + public var onInfo: PolyColor by mutableStateOf(PolyColor.hex("#000000")) private set public var subtleButton: PolyColor by mutableStateOf(PolyColor.hex("#FFFFFF").withAlpha(0.08f)) @@ -80,6 +104,23 @@ public object NotificationTheme { NotificationType.SUCCESS -> success NotificationType.ERROR -> error NotificationType.PROGRESS -> accent + NotificationType.WARNING -> warning + } + + @ApiStatus.Experimental + public fun actionFillFor(type: NotificationType): PolyColor = when (type) { + NotificationType.SUCCESS -> success + NotificationType.ERROR -> error + NotificationType.WARNING -> warning + else -> accent + } + + @ApiStatus.Experimental + public fun onActionFillFor(type: NotificationType): PolyColor = when (type) { + NotificationType.SUCCESS -> onSuccess + NotificationType.ERROR -> onError + NotificationType.WARNING -> onWarning + else -> actionPrimaryText } @ApiStatus.Internal @@ -97,6 +138,14 @@ public object NotificationTheme { radiusCard: Float = this.radiusCard, radiusButton: Float = this.radiusButton, radiusProgress: Float = this.radiusProgress, + success: PolyColor = this.success, + error: PolyColor = this.error, + warning: PolyColor = this.warning, + info: PolyColor = this.info, + onSuccess: PolyColor = this.onSuccess, + onError: PolyColor = this.onError, + onWarning: PolyColor = this.onWarning, + onInfo: PolyColor = this.onInfo, ) { this.background = background this.border = border @@ -111,10 +160,27 @@ public object NotificationTheme { this.radiusCard = radiusCard this.radiusButton = radiusButton this.radiusProgress = radiusProgress + this.success = success + this.error = error + this.warning = warning + this.info = info + this.onSuccess = onSuccess + this.onError = onError + this.onWarning = onWarning + this.onInfo = onInfo } @ApiStatus.Internal public fun useIconFolder(iconFolder: String?) { this.iconFolder = iconFolder } + + @get:ApiStatus.Experimental + public var disabledAlpha: Float by mutableStateOf(0.5f) + private set + + @ApiStatus.Internal + public fun useDisabledAlpha(alpha: Float) { + disabledAlpha = alpha + } } diff --git a/modules/notifications/src/main/kotlin/org/polyfrost/oneconfig/api/notifications/v1/NotificationToasts.kt b/modules/notifications/src/main/kotlin/org/polyfrost/oneconfig/api/notifications/v1/NotificationToasts.kt index 66eb705a9..f59bfea25 100644 --- a/modules/notifications/src/main/kotlin/org/polyfrost/oneconfig/api/notifications/v1/NotificationToasts.kt +++ b/modules/notifications/src/main/kotlin/org/polyfrost/oneconfig/api/notifications/v1/NotificationToasts.kt @@ -110,50 +110,47 @@ private fun Toast(notification: Notification) { val progressValue = remember { mutableStateOf(quantize(notification.progressOrNull() ?: 0f)) } LaunchedEffect(notification.id) { - val tracksProgress = notification.progress != null - - fun sampleProgress(): Float { - val p = notification.progressOrNull() ?: 0f - progressValue.value = quantize(p) - return p - } - val enterStart = withFrameNanos { it } while (true) { val now = withFrameNanos { it } val t = ((now - enterStart).coerceAtLeast(0L).toFloat() / ENTER_NANOS).coerceIn(0f, 1f) appear = easeOutCubic(t) - if (tracksProgress) sampleProgress() + notification.progressOrNull()?.let { progressValue.value = quantize(it) } if (t >= 1f) break } appear = 1f - when { - notification.progress != null -> { - while (!notification.dismissRequested) { - if (sampleProgress() >= 1f) break - withFrameNanos { } - } - val lingerStart = withFrameNanos { it } - while (withFrameNanos { it } - lingerStart < PROGRESS_LINGER_NANOS && !notification.dismissRequested) { } - } - notification.persistent -> { - while (!notification.dismissRequested) withFrameNanos { } + var revision = notification.revision + var remaining = durationNanos(notification) + var completedAt = -1L + var last = withFrameNanos { it } + while (!notification.dismissRequested) { + val now = withFrameNanos { it } + val dt = now - last + last = now + if (notification.revision != revision) { + revision = notification.revision + remaining = durationNanos(notification) + completedAt = -1L } - else -> { - val durationNanos = (notification.duration * 1_000_000L).toLong().coerceAtLeast(1L) - var remaining = durationNanos - var last = withFrameNanos { it } - while (remaining > 0L && !notification.dismissRequested) { - val now = withFrameNanos { it } - val dt = now - last - last = now - if (!notification.hovered) remaining -= dt - timeFraction.value = quantize((remaining.toFloat() / durationNanos).coerceIn(0f, 1f)) + if (notification.tracksProgress) { + val progress = notification.progressOrNull() + if (progress != null) progressValue.value = quantize(progress) + if (progress == null || progress < 1f) { + completedAt = -1L + } else { + if (completedAt < 0L) completedAt = now + if (now - completedAt >= PROGRESS_LINGER_NANOS) break } - timeFraction.value = 0f + continue } + completedAt = -1L + if (notification.persistent) continue + if (!notification.hovered) remaining -= dt + timeFraction.value = quantize((remaining.toFloat() / durationNanos(notification)).coerceIn(0f, 1f)) + if (remaining <= 0L) break } + timeFraction.value = 0f val exitStart = withFrameNanos { it } while (true) { @@ -165,7 +162,7 @@ private fun Toast(notification: Notification) { NotificationsManager.dismiss(notification) } - val hasProgress = notification.progress != null + val hasProgress = notification.tracksProgress val bgBase = if (notification.hovered) NotificationTheme.background.lighten(0.06f) else NotificationTheme.background PolyColumn( @@ -183,8 +180,9 @@ private fun Toast(notification: Notification) { PolyColumn(gap = 4f) { PolyText(notification.title, color = NotificationTheme.accentFor(notification.type), fontSize = TITLE_SIZE, font = NotificationTheme.fontTitle) val bodyFont = messageFont() - val lines = remember(notification.message, bodyFont) { - wrapText(notification.message, TEXT_WIDTH, bodyFont) + val message = notification.message + val lines = remember(message, bodyFont) { + wrapText(message, TEXT_WIDTH, bodyFont) } for (line in lines) { PolyText(line, color = NotificationTheme.textSecondary, fontSize = MESSAGE_SIZE, font = NotificationTheme.fontBody) @@ -209,6 +207,9 @@ private fun Toast(notification: Notification) { } } +private fun durationNanos(notification: Notification): Long = + notification.durationMillis.coerceIn(1L, Long.MAX_VALUE / 1_000_000L) * 1_000_000L + private fun quantize(value: Float): Float { val barPixels = (CONTENT_WIDTH * ToastViewport.scale).coerceAtLeast(1f) return Math.round(value * barPixels) / barPixels @@ -232,17 +233,18 @@ private fun ProgressBar(progress: () -> Float) { @Composable private fun ActionButton(notification: Notification, action: NotificationAction) { - val hovered = ToastInput.hoveredAction === action - val base = if (action.primary) NotificationTheme.accent else NotificationTheme.subtleButton - val bg = if (hovered) base.lighten(0.12f) else base + val hovered = action.enabled && ToastInput.hoveredAction === action + val type = notification.type + val base = if (action.primary) NotificationTheme.actionFillFor(type) else NotificationTheme.subtleButton + val fill = if (hovered) base.lighten(0.12f) else base + val textColor = if (action.primary) NotificationTheme.onActionFillFor(type) else NotificationTheme.actionSubtleText PolyBox( modifier = PolyModifier - .background(bg, NotificationTheme.radiusButton) + .background(if (action.enabled) fill else fill.multiplyAlpha(NotificationTheme.disabledAlpha), NotificationTheme.radiusButton) .padding(horizontal = 12f, vertical = 6f) .tag(ToastHit.Action(notification, action)), ) { - val textColor = if (action.primary) NotificationTheme.actionPrimaryText else NotificationTheme.actionSubtleText - PolyText(action.label, color = textColor, fontSize = MESSAGE_SIZE, font = NotificationTheme.fontTitle) + PolyText(action.label, color = if (action.enabled) textColor else textColor.multiplyAlpha(NotificationTheme.disabledAlpha), fontSize = MESSAGE_SIZE, font = NotificationTheme.fontTitle) } } @@ -257,12 +259,16 @@ private fun ToastIcon(notification: Notification) { } else { val accent = NotificationTheme.accentFor(notification.type) val iconFolder = NotificationTheme.iconFolder + val name = notification.iconName ?: notification.type.iconName PolyCanvas(modifier = PolyModifier.size(ICON_SIZE, ICON_SIZE).align(PolyAlign.Top)) { x, y, w, h -> val pixelSize = Math.ceil((w * ToastViewport.scale * ICON_RASTER_SCALE).toDouble()).toInt() - val name = notification.type.iconName // white masks tinted when drawn, so animating theme colours don't rasterize and cache a mask per colour - val img = iconFolder?.let { SvgMask.get("/assets/oneconfig/ico/$it/$name.svg", pixelSize, WHITE) } - ?: SvgMask.get("/assets/oneconfig/ico/$name.svg", pixelSize, WHITE) + val img = if (name.endsWith(".svg")) { + SvgMask.get(if (name.startsWith("/")) name else "/$name", pixelSize, WHITE) + } else { + iconFolder?.let { SvgMask.get("/assets/oneconfig/ico/$it/$name.svg", pixelSize, WHITE) } + ?: SvgMask.get("/assets/oneconfig/ico/$name.svg", pixelSize, WHITE) + } if (img != null) image(img, x, y, w, h, 0f, 0f, 1f, 1f, accent) } } diff --git a/modules/notifications/src/main/kotlin/org/polyfrost/oneconfig/api/notifications/v1/Notifications.kt b/modules/notifications/src/main/kotlin/org/polyfrost/oneconfig/api/notifications/v1/Notifications.kt index fdb30fc0d..f8f684a6d 100644 --- a/modules/notifications/src/main/kotlin/org/polyfrost/oneconfig/api/notifications/v1/Notifications.kt +++ b/modules/notifications/src/main/kotlin/org/polyfrost/oneconfig/api/notifications/v1/Notifications.kt @@ -27,7 +27,9 @@ package org.polyfrost.oneconfig.api.notifications.v1 import java.util.concurrent.Callable +import java.util.concurrent.ConcurrentHashMap import org.apache.logging.log4j.LogManager +import org.jetbrains.annotations.ApiStatus import org.polyfrost.compose.render.PolyBitmap import org.jetbrains.skia.Image @@ -54,6 +56,10 @@ import org.jetbrains.skia.Image public object Notifications { private val LOGGER = LogManager.getLogger("OneConfig/Notifications") + private const val LEGACY_DURATION = 4000f + + private val sentOnce: MutableSet = ConcurrentHashMap.newKeySet() + /** * Sends a fully specified notification * @@ -68,14 +74,80 @@ public object Notifications { message: String, type: NotificationType = NotificationType.INFO, icon: PolyBitmap? = null, - duration: Float = Notification.DEFAULT_DURATION, + iconName: String? = null, + durationMillis: Long = Notification.DEFAULT_DURATION_MILLIS, + persistent: Boolean = false, progress: Callable? = null, + actions: List = emptyList(), onClick: Runnable? = null, + onDismiss: Runnable? = null, + ): Notification = + push(Notification(title, message, type, icon, iconName, durationMillis, persistent, progress, actions, onClick, onDismiss)) + + @ApiStatus.Experimental + @JvmStatic + @JvmOverloads + public fun sendOnce( + key: String, + title: String, + message: String, + type: NotificationType = NotificationType.INFO, + icon: PolyBitmap? = null, + iconName: String? = null, + durationMillis: Long = Notification.DEFAULT_DURATION_MILLIS, + persistent: Boolean = false, + progress: Callable? = null, actions: List = emptyList(), - ): Notification { - return push(Notification(title, message, type, icon, duration, progress, actions, onClick)) + onClick: Runnable? = null, + onDismiss: Runnable? = null, + ): Notification? { + if (!sentOnce.add(key)) return null + return send(title, message, type, icon, iconName, durationMillis, persistent, progress, actions, onClick, onDismiss) } + @Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) + @JvmStatic + @JvmName("send") + public fun sendLegacy( + title: String, + message: String, + type: NotificationType = NotificationType.INFO, + icon: PolyBitmap? = null, + duration: Float = LEGACY_DURATION, + progress: Callable? = null, + onClick: Runnable? = null, + actions: List = emptyList(), + ): Notification = push(legacyNotification(title, message, type, icon, duration, progress, actions, onClick)) + + @Deprecated("Use the overload taking durationMillis: Long and persistent") + @JvmStatic + @JvmName("sendWithFloatDuration") + public fun send( + title: String, + message: String, + type: NotificationType = NotificationType.INFO, + icon: PolyBitmap? = null, + duration: Float, + progress: Callable? = null, + onClick: Runnable? = null, + actions: List = emptyList(), + ): Notification = push(legacyNotification(title, message, type, icon, duration, progress, actions, onClick)) + + @Deprecated("Use the overload taking durationMillis: Long and persistent") + @JvmStatic + public fun send(title: String, message: String, type: NotificationType, icon: PolyBitmap?, duration: Float): Notification = + push(legacyNotification(title, message, type, icon, duration, null, emptyList(), null)) + + @Deprecated("Use the overload taking durationMillis: Long and persistent") + @JvmStatic + public fun send(title: String, message: String, type: NotificationType, icon: PolyBitmap?, duration: Float, progress: Callable?): Notification = + push(legacyNotification(title, message, type, icon, duration, progress, emptyList(), null)) + + @Deprecated("Use the overload taking durationMillis: Long and persistent") + @JvmStatic + public fun send(title: String, message: String, type: NotificationType, icon: PolyBitmap?, duration: Float, progress: Callable?, onClick: Runnable?): Notification = + push(legacyNotification(title, message, type, icon, duration, progress, emptyList(), onClick)) + @Deprecated("Skia images are no longer supported", level = DeprecationLevel.HIDDEN) @JvmStatic @JvmName("send") @@ -84,35 +156,35 @@ public object Notifications { message: String, type: NotificationType = NotificationType.INFO, icon: Image? = null, - duration: Float = Notification.DEFAULT_DURATION, + duration: Float = LEGACY_DURATION, progress: Callable? = null, onClick: Runnable? = null, actions: List = emptyList(), - ): Notification = send(title, message, type, null, duration, progress, onClick, actions) + ): Notification = push(legacyNotification(title, message, type, null, duration, progress, actions, onClick)) @Deprecated("Skia images are no longer supported", level = DeprecationLevel.HIDDEN) @JvmStatic @JvmName("send") public fun sendSkia(title: String, message: String, type: NotificationType, icon: Image?): Notification = - send(title, message, type, null) + push(legacyNotification(title, message, type, null, LEGACY_DURATION, null, emptyList(), null)) @Deprecated("Skia images are no longer supported", level = DeprecationLevel.HIDDEN) @JvmStatic @JvmName("send") public fun sendSkia(title: String, message: String, type: NotificationType, icon: Image?, duration: Float): Notification = - send(title, message, type, null, duration) + push(legacyNotification(title, message, type, null, duration, null, emptyList(), null)) @Deprecated("Skia images are no longer supported", level = DeprecationLevel.HIDDEN) @JvmStatic @JvmName("send") public fun sendSkia(title: String, message: String, type: NotificationType, icon: Image?, duration: Float, progress: Callable?): Notification = - send(title, message, type, null, duration, progress) + push(legacyNotification(title, message, type, null, duration, progress, emptyList(), null)) @Deprecated("Skia images are no longer supported", level = DeprecationLevel.HIDDEN) @JvmStatic @JvmName("send") public fun sendSkia(title: String, message: String, type: NotificationType, icon: Image?, duration: Float, progress: Callable?, onClick: Runnable?): Notification = - send(title, message, type, null, duration, progress, onClick) + push(legacyNotification(title, message, type, null, duration, progress, emptyList(), onClick)) private fun push(notification: Notification): Notification { NotificationsManager.push(notification) @@ -122,18 +194,57 @@ public object Notifications { @JvmStatic @JvmOverloads - public fun info(title: String, message: String, duration: Float = Notification.DEFAULT_DURATION): Notification = - send(title, message, NotificationType.INFO, duration = duration) + public fun info(title: String, message: String, durationMillis: Long = Notification.DEFAULT_DURATION_MILLIS): Notification = + push(Notification(title, message, NotificationType.INFO, durationMillis = durationMillis)) + + @JvmStatic + @JvmOverloads + public fun success(title: String, message: String, durationMillis: Long = Notification.DEFAULT_DURATION_MILLIS): Notification = + push(Notification(title, message, NotificationType.SUCCESS, durationMillis = durationMillis)) @JvmStatic @JvmOverloads - public fun success(title: String, message: String, duration: Float = Notification.DEFAULT_DURATION): Notification = - send(title, message, NotificationType.SUCCESS, duration = duration) + public fun error(title: String, message: String, durationMillis: Long = Notification.DEFAULT_DURATION_MILLIS): Notification = + push(Notification(title, message, NotificationType.ERROR, durationMillis = durationMillis)) + @ApiStatus.Experimental @JvmStatic @JvmOverloads - public fun error(title: String, message: String, duration: Float = Notification.DEFAULT_DURATION): Notification = - send(title, message, NotificationType.ERROR, duration = duration) + public fun warning(title: String, message: String, durationMillis: Long = Notification.DEFAULT_DURATION_MILLIS): Notification = + push(Notification(title, message, NotificationType.WARNING, durationMillis = durationMillis)) + + @Deprecated("Use the overload taking durationMillis: Long") + @JvmStatic + public fun info(title: String, message: String, duration: Float): Notification = + push(legacyNotification(title, message, NotificationType.INFO, null, duration, null, emptyList(), null)) + + @Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) + @JvmStatic + @JvmName("info\$default") + public fun infoLegacyDefault(title: String, message: String, duration: Float, mask: Int, marker: Any?): Notification = + push(legacyNotification(title, message, NotificationType.INFO, null, if (mask and 4 != 0) LEGACY_DURATION else duration, null, emptyList(), null)) + + @Deprecated("Use the overload taking durationMillis: Long") + @JvmStatic + public fun success(title: String, message: String, duration: Float): Notification = + push(legacyNotification(title, message, NotificationType.SUCCESS, null, duration, null, emptyList(), null)) + + @Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) + @JvmStatic + @JvmName("success\$default") + public fun successLegacyDefault(title: String, message: String, duration: Float, mask: Int, marker: Any?): Notification = + push(legacyNotification(title, message, NotificationType.SUCCESS, null, if (mask and 4 != 0) LEGACY_DURATION else duration, null, emptyList(), null)) + + @Deprecated("Use the overload taking durationMillis: Long") + @JvmStatic + public fun error(title: String, message: String, duration: Float): Notification = + push(legacyNotification(title, message, NotificationType.ERROR, null, duration, null, emptyList(), null)) + + @Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) + @JvmStatic + @JvmName("error\$default") + public fun errorLegacyDefault(title: String, message: String, duration: Float, mask: Int, marker: Any?): Notification = + push(legacyNotification(title, message, NotificationType.ERROR, null, if (mask and 4 != 0) LEGACY_DURATION else duration, null, emptyList(), null)) /** * Sends a [NotificationType.PROGRESS] notification driven by [progress] @@ -143,7 +254,12 @@ public object Notifications { */ @JvmStatic public fun progress(title: String, message: String, progress: Callable): Notification = - send(title, message, NotificationType.PROGRESS, duration = -1f, progress = progress) + push(Notification(title, message, NotificationType.PROGRESS, progress = progress)) + + @ApiStatus.Experimental + @JvmStatic + public fun progress(title: String, message: String): Notification = + push(Notification(title, message, NotificationType.PROGRESS).also { it.startProgress() }) @JvmStatic public fun dismiss(notification: Notification): Unit = NotificationsManager.dismiss(notification) @@ -167,23 +283,40 @@ public object Notifications { public class Builder internal constructor(private val title: String, private val message: String) { private var type: NotificationType = NotificationType.INFO private var icon: PolyBitmap? = null - private var duration: Float = Notification.DEFAULT_DURATION + private var iconName: String? = null + private var durationMillis: Long = Notification.DEFAULT_DURATION_MILLIS + private var persistent: Boolean = false private var progress: Callable? = null private var onClick: Runnable? = null + private var onDismiss: Runnable? = null private val actions = ArrayList() public fun type(type: NotificationType): Builder = apply { this.type = type } public fun icon(icon: PolyBitmap?): Builder = apply { this.icon = icon } + @ApiStatus.Experimental + public fun icon(name: String): Builder = apply { this.iconName = name } + @Deprecated("Skia images are no longer supported", level = DeprecationLevel.HIDDEN) @JvmName("icon") public fun skiaIcon(icon: Image?): Builder = apply { this.icon = null } - public fun duration(duration: Float): Builder = apply { this.duration = duration } + + @ApiStatus.Experimental + public fun duration(durationMillis: Long): Builder = apply { this.durationMillis = durationMillis } + + @Deprecated("Use duration(durationMillis: Long) and persistent()") + public fun duration(duration: Float): Builder = apply { + this.durationMillis = if (duration > 0f) duration.toLong() else Notification.DEFAULT_DURATION_MILLIS + this.persistent = duration <= 0f + } /** * Makes the notification persistent so it stays until dismissed or progress completes */ - public fun persistent(): Builder = apply { this.duration = -1f } + public fun persistent(): Builder = apply { this.persistent = true } + + @ApiStatus.Experimental + public fun persistent(persistent: Boolean): Builder = apply { this.persistent = persistent } public fun progress(progress: Callable?): Builder = apply { this.progress = progress @@ -191,6 +324,10 @@ public object Notifications { } public fun onClick(onClick: Runnable?): Builder = apply { this.onClick = onClick } + + @ApiStatus.Experimental + public fun onDismiss(onDismiss: Runnable?): Builder = apply { this.onDismiss = onDismiss } + public fun action(action: NotificationAction): Builder = apply { this.actions.add(action) } public fun action(label: String, onClick: Runnable): Builder = apply { this.actions.add(NotificationAction(label, onClick = onClick)) } @@ -198,6 +335,6 @@ public object Notifications { * Builds and pushes the notification */ public fun send(): Notification = - push(Notification(title, message, type, icon, duration, progress, actions.toList(), onClick)) + push(Notification(title, message, type, icon, iconName, durationMillis, persistent, progress, actions.toList(), onClick, onDismiss)) } } diff --git a/modules/notifications/src/main/kotlin/org/polyfrost/oneconfig/api/notifications/v1/NotificationsManager.kt b/modules/notifications/src/main/kotlin/org/polyfrost/oneconfig/api/notifications/v1/NotificationsManager.kt index 48c73127d..08e7cea3a 100644 --- a/modules/notifications/src/main/kotlin/org/polyfrost/oneconfig/api/notifications/v1/NotificationsManager.kt +++ b/modules/notifications/src/main/kotlin/org/polyfrost/oneconfig/api/notifications/v1/NotificationsManager.kt @@ -28,6 +28,7 @@ package org.polyfrost.oneconfig.api.notifications.v1 import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.snapshots.Snapshot +import org.apache.logging.log4j.LogManager import org.jetbrains.annotations.ApiStatus /** @@ -36,6 +37,8 @@ import org.jetbrains.annotations.ApiStatus public object NotificationsManager { private const val MAX_HISTORY = 50 + private val LOGGER = LogManager.getLogger("OneConfig/Notifications") + private val _active = mutableStateListOf() private val _history = mutableStateListOf() @@ -44,11 +47,19 @@ public object NotificationsManager { if (_active.size < 0 || _history.size < 0) error("unreachable") } - private fun mutate(block: () -> Unit) { + internal fun mutate(block: () -> Unit) { if (Snapshot.current.readOnly) Snapshot.global { Snapshot.withMutableSnapshot(block) } else Snapshot.withMutableSnapshot(block) } + private fun fireDismissed(removed: List) { + for (notification in removed) { + val callback = notification.fireDismissed() ?: continue + runCatching { callback.run() } + .onFailure { LOGGER.error("Notification #${notification.id} onDismiss threw", it) } + } + } + /** * The notifications currently on screen with the oldest first */ @@ -107,21 +118,29 @@ public object NotificationsManager { * Removes [notification] immediately and skips any exit animation */ public fun dismiss(notification: Notification) { + var removed = false mutate { - _active.remove(notification) + removed = _active.remove(notification) } + if (removed) fireDismissed(listOf(notification)) } public fun dismiss(id: Long) { + val removed = ArrayList() mutate { - _active.removeAll { it.id == id } + removed += _active.filter { it.id == id } + _active.removeAll(removed) } + fireDismissed(removed) } public fun clearAll() { + val removed = ArrayList() mutate { + removed += _active _active.clear() } + fireDismissed(removed) } public val count: Int get() = _active.size diff --git a/modules/notifications/src/main/kotlin/org/polyfrost/oneconfig/api/notifications/v1/NotificationsRenderer.kt b/modules/notifications/src/main/kotlin/org/polyfrost/oneconfig/api/notifications/v1/NotificationsRenderer.kt index b287e4f72..b293ba90b 100644 --- a/modules/notifications/src/main/kotlin/org/polyfrost/oneconfig/api/notifications/v1/NotificationsRenderer.kt +++ b/modules/notifications/src/main/kotlin/org/polyfrost/oneconfig/api/notifications/v1/NotificationsRenderer.kt @@ -140,10 +140,11 @@ public object NotificationsRenderer { } when (target) { is ToastHit.Action -> { + if (!target.action.enabled) return LOGGER.info("Notification #{} action '{}' clicked", target.notification.id, target.action.label) runCatching { target.action.onClick.run() } .onFailure { LOGGER.error("Notification action '${target.action.label}' threw", it) } - NotificationsManager.remove(target.notification) + if (target.action.dismissOnClick) NotificationsManager.remove(target.notification) } is ToastHit.Body -> { LOGGER.info("Notification #{} body clicked", target.notification.id) diff --git a/modules/poly-compose/api/poly-compose.api b/modules/poly-compose/api/poly-compose.api index e500a4b10..90182084b 100644 --- a/modules/poly-compose/api/poly-compose.api +++ b/modules/poly-compose/api/poly-compose.api @@ -1453,6 +1453,16 @@ public final class org/polyfrost/compose/render/RenderContext { public final fun translate (FF)V } +public final class org/polyfrost/compose/render/ShapeDrawingKt { + public static final fun addRoundedRect (Lorg/polyfrost/compose/render/PolyPath;FFFFFFFF)V + public static final fun addShape (Lorg/polyfrost/compose/render/PolyPath;Ljava/awt/Shape;)V + public static final fun fillShape (Lorg/polyfrost/compose/render/RenderContext;Ljava/awt/Shape;Lorg/polyfrost/compose/render/PolyColor;)V + public static final fun roundedRect (Lorg/polyfrost/compose/render/RenderContext;FFFFLorg/polyfrost/compose/render/PolyColor;FFFF)V + public static final fun snappedRect (Lorg/polyfrost/compose/render/RenderContext;FFFF)[F + public static final fun strokeShape (Lorg/polyfrost/compose/render/RenderContext;Ljava/awt/Shape;Lorg/polyfrost/compose/render/PolyColor;FII)V + public static synthetic fun strokeShape$default (Lorg/polyfrost/compose/render/RenderContext;Ljava/awt/Shape;Lorg/polyfrost/compose/render/PolyColor;FIIILjava/lang/Object;)V +} + public final class org/polyfrost/compose/runtime/PolyApplier : androidx/compose/runtime/AbstractApplier { public static final field $stable I public fun (Lorg/polyfrost/compose/node/PolyNode;)V diff --git a/modules/poly-compose/src/main/kotlin/org/polyfrost/compose/render/ShapeDrawing.kt b/modules/poly-compose/src/main/kotlin/org/polyfrost/compose/render/ShapeDrawing.kt new file mode 100644 index 000000000..b56250c96 --- /dev/null +++ b/modules/poly-compose/src/main/kotlin/org/polyfrost/compose/render/ShapeDrawing.kt @@ -0,0 +1,119 @@ +package org.polyfrost.compose.render + +import java.awt.Shape +import java.awt.geom.PathIterator +import kotlin.math.min +import kotlin.math.roundToInt + +private const val SHAPE_FLATNESS = 0.02 + +public fun RenderContext.snappedRect(x: Float, y: Float, w: Float, h: Float): FloatArray { + val m = deviceTransform() + if (m == null || m[0] == 0f || m[1] == 0f) return floatArrayOf(x, y, w, h) + val left = snapEdge(x, m[0], m[2]) + val top = snapEdge(y, m[1], m[3]) + val right = snapFarEdge(x, w, left, m[0], m[2]) + val bottom = snapFarEdge(y, h, top, m[1], m[3]) + return floatArrayOf(left, top, right - left, bottom - top) +} + +private fun snapEdge(start: Float, scale: Float, translate: Float): Float = + ((start * scale + translate).roundToInt() - translate) / scale + +private fun snapFarEdge(start: Float, size: Float, snappedStart: Float, scale: Float, translate: Float): Float { + val from = (snappedStart * scale + translate).roundToInt() + val to = ((start + size) * scale + translate).roundToInt() + val kept = if (to != from || size == 0f) to else if (size > 0f) from + 1 else from - 1 + return (kept - translate) / scale +} + +public fun PolyPath.addShape(shape: Shape) { + val iterator = shape.getPathIterator(null, SHAPE_FLATNESS) + if (iterator.windingRule == PathIterator.WIND_EVEN_ODD) evenOdd = true + val coords = FloatArray(6) + while (!iterator.isDone) { + when (iterator.currentSegment(coords)) { + PathIterator.SEG_MOVETO -> moveTo(coords[0], coords[1]) + PathIterator.SEG_LINETO -> lineTo(coords[0], coords[1]) + PathIterator.SEG_CLOSE -> close() + } + iterator.next() + } +} + +public fun PolyPath.addRoundedRect( + x: Float, y: Float, w: Float, h: Float, + topLeft: Float, topRight: Float, bottomRight: Float, bottomLeft: Float, +) { + val limit = min(w, h) / 2f + val tl = topLeft.coerceIn(0f, limit) + val tr = topRight.coerceIn(0f, limit) + val br = bottomRight.coerceIn(0f, limit) + val bl = bottomLeft.coerceIn(0f, limit) + val r = x + w + val b = y + h + moveTo(x + tl, y) + tangentArcTo(r, y, r, b, tr) + tangentArcTo(r, b, x, b, br) + tangentArcTo(x, b, x, y, bl) + tangentArcTo(x, y, r, y, tl) + close() +} + +public fun RenderContext.fillShape(shape: Shape, color: PolyColor) { + if (color.alpha == 0) return + fillPath(PolyPath().also { it.addShape(shape) }, color) +} + +public fun RenderContext.strokeShape( + shape: Shape, + color: PolyColor, + strokeWidth: Float = 1f, + cap: Int = PolyRenderer.CAP_BUTT, + join: Int = PolyRenderer.JOIN_MITER, +) { + if (color.alpha == 0 || strokeWidth <= 0f) return + val argb = color.argb + val iterator = shape.getPathIterator(null, SHAPE_FLATNESS) + val coords = FloatArray(6) + var points = FloatArray(32) + var count = 0 + + fun flush(closed: Boolean) { + var n = count + if (closed && n >= 4 && points[n - 2] == points[0] && points[n - 1] == points[1]) n -= 2 + if (n >= 4) renderer.strokePolyline(points.copyOf(n), closed, argb, strokeWidth, cap, join) + count = 0 + } + + fun add(px: Float, py: Float) { + if (count + 2 > points.size) points = points.copyOf(points.size * 2) + points[count++] = px + points[count++] = py + } + + while (!iterator.isDone) { + when (iterator.currentSegment(coords)) { + PathIterator.SEG_MOVETO -> { + flush(false) + add(coords[0], coords[1]) + } + PathIterator.SEG_LINETO -> add(coords[0], coords[1]) + PathIterator.SEG_CLOSE -> flush(true) + } + iterator.next() + } + flush(false) +} + +public fun RenderContext.roundedRect( + x: Float, y: Float, w: Float, h: Float, color: PolyColor, + topLeft: Float, topRight: Float, bottomRight: Float, bottomLeft: Float, +) { + if (color.alpha == 0 || w <= 0f || h <= 0f) return + if (topLeft == topRight && topRight == bottomRight && bottomRight == bottomLeft) { + rect(x, y, w, h, color, topLeft.coerceIn(0f, min(w, h) / 2f)) + return + } + fillPath(PolyPath().also { it.addRoundedRect(x, y, w, h, topLeft, topRight, bottomRight, bottomLeft) }, color) +} diff --git a/modules/root.gradle.kts b/modules/root.gradle.kts index b00d6e82d..1d659ab22 100644 --- a/modules/root.gradle.kts +++ b/modules/root.gradle.kts @@ -119,6 +119,7 @@ subprojects { filters.exclude { byNames.add("org.polyfrost.oneconfig.api.**.internal.**") annotatedWith.add("org.jetbrains.annotations.ApiStatus.Internal") + annotatedWith.add("org.polyfrost.oneconfig.api.ui.v1.InternalOneConfigUiApi") } } } @@ -129,8 +130,10 @@ subprojects { } val kotlinModuleName = project.name + val seesUiApi = name in listOf("ui", "hud", "internal") tasks.withType().configureEach { compilerOptions.moduleName = kotlinModuleName + if (seesUiApi) compilerOptions.optIn.add("org.polyfrost.oneconfig.api.ui.v1.InternalOneConfigUiApi") if (name.contains("Test")) { // JUnit 6's nullable assertion parameters get misread as non-null compilerOptions.freeCompilerArgs.add("-Xjspecify-annotations=ignore") diff --git a/modules/ui/README.md b/modules/ui/README.md index 9a07d26c3..217ac5246 100644 --- a/modules/ui/README.md +++ b/modules/ui/README.md @@ -1,15 +1,348 @@ # OneConfig UI -`dependsOn("events", "utils", "polyui", "lwjgl")` +The UI module is OneConfig's Compose UI kit. It gives your mod the same theme, controls, menus, dialogs and toasts +that OneConfig's own screens use, so your screens look and behave like the rest of OneConfig. The components live in +`org.polyfrost.oneconfig.api.ui.v1` and its subpackages. Screens, HUD layers and other code that needs Minecraft +types live in `org.polyfrost.oneconfig.api.ui.v1.platform`, which ships in the per-version OneConfig jar. -This module contains the UI system used in OneConfig. It provides the methods which -bridge the gap between PolyUI and Minecraft, as well as a simple Notifications system. +## Setup -By default, it uses a nanovg-based rendering implementation, which is loaded by `UIManager`. -The implementation details are version specific and so are in `versions/src`. +Add the Polyfrost repository and depend on the module. The per-version jar is named after the Minecraft version and +loader, such as `26.3-fabric` or `1.8.9-ornithe`. You need it to host a screen or HUD layer. -For more information on how the UI works and how to use it, see the `PolyUI` project - our UI framework -utilized for OneConfig's frontend. +```kotlin +plugins { + id("org.jetbrains.kotlin.plugin.compose") +} -Outside of this, it also provides the ability -to blur backgrounds in menus, and exposes native file dialogs through `TinyFdApi`. \ No newline at end of file +repositories { + maven("https://repo.polyfrost.org/releases") +} + +dependencies { + modImplementation("org.polyfrost.oneconfig:26.3-fabric:1.8.0") + implementation("org.polyfrost.oneconfig:ui:1.8.0") + implementation("org.polyfrost.oneconfig:notifications:1.8.0") +} +``` + +The `notifications` artifact is only needed at compile time when you pass a `NotificationType`, for example to +`Toasts.show`. + +The kit's API is marked `@ExperimentalOneConfigUiApi`, and using it without opting in is a compile error. Opt in per +file with `@file:OptIn(ExperimentalOneConfigUiApi::class)`, or for the whole module: + +```kotlin +kotlin { + compilerOptions.optIn.add("org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi") +} +``` + +The same annotation covers the HUD API. Anything under an `internal` package or marked `@InternalOneConfigUiApi` is +not for mods. + +## Theme and tokens + +Kit components need a theme and a few hosts (context menus, confirm dialogs, toasts) above them. Inside a +`OneConfigComposeScreen`, a `ComposeHudLayer` or OneConfig's own pages they are already there. Anywhere else, wrap +your content in `ProvideOneConfigTheme`. Nesting it is harmless. + +```kotlin +ProvideOneConfigTheme(theme = null, scaleToUi = true) { + MyPanel() +} +``` + +`theme = null` uses the player's active theme. `scaleToUi = true` applies OneConfig's UI scale and pixel snapping. + +Read the current theme with `OneConfigTheme.current`. It returns a `OneConfigUITheme`, and its tokens follow the +player's theme: + +```kotlin +val theme = OneConfigTheme.current +Box(Modifier.background(theme.componentBackground, OneConfigTheme.shapes.medium)) +Text("Saved", color = theme.success, style = OneConfigTheme.typography.small) +``` + +Surfaces and text: `pageBackground`, `sidebarBackground`, `componentBackground`, `popupBackground`, +`modCardBackground`, `chipBackground`, `borderColor`, `textColor`, `textColorSecondary`, `mutedText`, +`accentTextColor`, `accentBrush`. `OneConfigTheme.accent` is the accent colour on its own. + +State colours: `error`, `warning`, `success`, `info`, with `onError` and `onSuccess` for text drawn on top of them. +They are adjusted to stay readable on the page. Interaction values: `focusRingColor`, `scrimColor`, `disabledAlpha`, +`hoverOverlayAlpha`, `pressedOverlayAlpha`, `controlTrackColor`, `controlThumbColor`. For text over images use +`onMedia` and `onMediaShadow`. `checkerLight` and `checkerDark` back `Modifier.transparencyCheckerboard()`. + +`theme.style` is a `ThemeStyle` (`Flat`, `Glass` or `Pixel`) and `theme.isLight` tells light from dark. Check these +when your drawing depends on the theme, since players can pick custom themes you have never seen. + +Shapes, typography and spacing come with the theme: + +- `OneConfigTheme.shapes`: `small`, `medium`, `large`, `field`, `pill`, `circle`. +- `OneConfigTheme.typography`: `title`, `heading`, `body`, `label`, `small`, `caption`, `eyebrow`, `mono`. +- `OneConfigTheme.spacing`: `xxs` to `xxl` (2dp to 24dp) and `pageGutter`. +- `OneConfigTheme.shape(radius)` gives a shape with that corner radius that turns square under the pixel theme. + `Shape.concentric(inset)` gives the inner shape for content inset inside another. +- `OneConfigTheme.icon(name)` returns the pixel variant of a built-in icon under the pixel theme. The kit's `Icon` + already does this. + +`OneConfigPrefs` exposes the player's appearance settings as read-only snapshot state: `animationsEnabled`, +`reducedMotion`, `backgroundBlur`, `guiOpenAnimation` and `uiScale`. For your own animations, wrap specs in +`Motion.spec(...)` or `Motion.finiteSpec(...)` and they snap when the player wants less motion. + +To ship your own theme, start from a built-in one and override what you need: + +```kotlin +val midnight = OneConfigTheme.custom(OneConfigTheme.POLYFROSTED_DARK, "mymod:midnight", "Midnight") { + pageBackground = Color(0xFF0B1020) + accentTextColor = Color(0xFF7AA2FF) +} +OneConfigTheme.activate(midnight) +``` + +Custom themes are not listed on the Themes page and are not remembered across restarts. For a light or dark area +inside a screen, use `ProvideThemeVariant(dark = true) { ... }`. + +## Components + +Every control follows the same shape: required values, then `modifier`, then options with defaults, then the trailing +lambda. Interactive controls take `enabled` and `interactionSource`, and announce themselves to the narrator. Heights +come from `ControlSize` (`Compact` 28dp, `Regular` 32dp, `Large` 38dp). Buttons and menu items share one style enum, +`ControlStyle`: `Primary`, `Secondary`, `Ghost`, `Danger`, `DangerFilled`. Defaults sit in `XxxDefaults` objects such +as `ButtonDefaults` and `TextFieldDefaults`. + +| Group | Components | +|---|---| +| Buttons | `TextButton`, `IconButton`, `SplitButton`, `MoreButton`, `AnchoredPopupButton`, `ArmedConfirmButton`, `LinkText`, `Chip` | +| Fields | `TextField`, `SearchField`, `NumberField`, `KeybindField` | +| Selection | `Switch`, `SwitchRow`, `Checkbox`, `ToggleRow`, `Slider`, `Select`, `MultiSelect`, `RadioGroup`, `TabRow`, `SegmentedChips`, `ColorSwatchButton`, `ColorPickerPopup` | +| Text and icons | `Text`, `Icon`, `TooltipBox`, `Badge` | +| Layout | `PageScaffold`, `CenteredPanel`, `SectionHeader`, `Divider`, `KeyValueRow`, `CodeBlock`, `CardGrid`, `SelectionBar`, `ScrollContainer`, `Scrollbar` | +| Feedback | `ProgressBar`, `StatusText`, `EmptyState`, `NotificationsCenterPanel` | +| Images | `rememberImageBitmap`, `PolyBitmap.fromFile`, `ImageViewer` | +| Player heads | `PlayerHead`, `PlayerHeadStack`, `PlayerHeads` | + +Buttons: + +```kotlin +TextButton("Delete", icon = "trash", onClick = ::delete, style = ControlStyle.Danger) +IconButton("settings", contentDescription = "Settings", onClick = ::openSettings) +``` + +`IconButton` shows its `contentDescription` as a tooltip unless you pass `tooltip`. Its look is an +`IconButtonStyle`: `Ghost`, `Framed`, `Filled` or `Overlay`. Icons take a built-in name, a classpath path or a file +path, and every icon parameter has a `Painter` overload. + +Fields and selection: + +```kotlin +TextField(name, onValueChange = { name = it }, placeholder = "Profile name") +Slider(scale, onValueChange = { scale = it }, range = 0.5f..2f, step = 0.1f, label = "Scale") +Select(mode, options = Mode.entries, onSelect = { mode = it }, label = { it.title }) +``` + +`TabRow` takes a list of `TabItem(value, label)` and a `TabStyle` (`Pill`, `Underline`, `Segmented`). `NumberField` +takes `range`, `step`, `decimals` and `unit`. For colours, `ColorSwatchButton` opens the picker itself; `ColorPickerPopup` with `rememberColorPickerModel` +gives you the picker alone. + +Text and icons: + +```kotlin +TooltipBox("Copies the code") { Icon("copy", contentDescription = "Copy") } +``` + +`Text` also accepts an `AnnotatedString`, an Adventure `Component` and a list of lines. Text you write for players +can be a `PageText` (`PageText.translatable(key, fallback)`), turned into a string with `PageText.resolve()`. + +Layout and scrolling: `ScrollContainer` scrolls its content with OneConfig's wheel feel and scrollbar. +`Modifier.smoothWheelScroll(state)` adds that wheel feel to your own lazy lists, `Scrollbar(state)` draws the bar, +and `Modifier.fadingEdgesMask(state)` fades the ends. `CardGrid(items, key = { it.id }) { card -> ... }` lays out +cards in responsive columns with optional group headers. For your own surfaces, `Modifier.themedSurface(kind)` draws +a `SurfaceKind` (`Panel`, `Popup`, `Card`, `Field`) in the theme's flat, glass or pixel look. `Modifier.selectableRow` +and `Modifier.selectableCard` make rows and cards pickable. `rememberReorderState(onMove)` with +`Modifier.reorderItem(state, key, index)` lets players drag items into a new order. + +Images and heads: + +```kotlin +val shot = rememberImageBitmap(path, maxWidth = 512) +PlayerHead(playerId, size = 24.dp, name = playerName) +``` + +`rememberImageBitmap` loads off the render thread and reloads when the file changes. `PlayerHead` falls back to a +coloured initial from `name` while the skin is missing, `PlayerHeadStack` overlaps several heads, and +`PlayerHeads.face(id)` and `PlayerHeads.bitmap(id)` give you the image for your own drawing. + +Interaction helpers for custom controls: + +- `Modifier.oneConfigClickable(label, onClick)` adds hover and press feedback, the click sound, the focus ring, the + hand cursor and the narrator role in one call. +- `rememberHoverState()`, `Modifier.hiddenUntilHover(visible)` and `playUiSound(UiSound.Click)`. +- `Modifier.focusRegion(order)` and `Modifier.focusRoot()` for Tab and arrow-key focus, and + `Modifier.captureKeybinds()` to stop mod keybinds firing while an element is focused or hovered. +- `EscapeCloses(onClose)` closes your own popup on Escape before the screen behind it. +- `rememberAnchoredPosition(placement)` positions a popup next to its anchor, flipping and clamping to the window. + `popupMaxHeight()` gives the tallest a popup may be. + +## Menus + +A menu is a list of `MenuEntry` values. `MenuAction` is a clickable item; `MenuSeparator`, `MenuHeading`, +`MenuRadioGroup`, `MenuIconRow` and `MenuCustomItem` cover the rest. A `MenuAction` with `children` opens a submenu. + +```kotlin +val entries = listOf( + MenuAction("Rename", onClick = ::rename, icon = "text-input", shortcutHint = "F2"), + MenuAction("Duplicate", onClick = ::duplicate), + MenuSeparator, + MenuAction("Delete", onClick = ::delete, icon = "trash", danger = true), +) +``` + +Show it from a trigger with `DropdownMenu`, placed in the same `Box` as the trigger: + +```kotlin +var open by remember { mutableStateOf(false) } +Box { + TextButton("Options", onClick = { open = true }) + DropdownMenu(expanded = open, onDismiss = { open = false }, entries = entries) +} +``` + +Or attach it to any element as a right-click menu. The entries lambda runs each time the menu opens, so it can read +current state. The Menu key and Shift+F10 open it too. + +```kotlin +Row(Modifier.contextMenu { entries }) { ... } +``` + +`SplitButton` and `MoreButton` take entries directly. `MenuItem`, `MenuSurface` and `MenuList` let you build a menu +by hand. Arrow keys, type-ahead and Escape work in all of them. + +To add items to OneConfig's own menus, register a contributor. It runs each time the menu opens and returns the +entries to add, or an empty list. + +```kotlin +ModCardMenuContributors.register { modId -> + if (modId == "mymod") listOf(MenuAction("Open wiki", onClick = ::openWiki)) else emptyList() +} +``` + +`OptionMenuContributors` does the same for the menu on each config option, and gets the option's `Property`. + +## Dialogs and feedback + +`ModalDialog` is the kit's modal, and the other dialogs build on it. It draws over a scrim, closes on Escape and on a scrim click, and works inside and +outside OneConfig's screens. + +```kotlin +if (showDialog) { + ModalDialog( + onDismiss = { showDialog = false }, + title = { DialogTitle("Reset layout") }, + buttons = { + TextButton(DialogDefaults.cancelLabel, onClick = { showDialog = false }, style = ControlStyle.Ghost) + TextButton("Reset", onClick = { reset(); showDialog = false }, style = ControlStyle.Danger) + }, + ) { + Text("Every HUD goes back to its default position.") + } +} +``` + +`ConfirmDialog(title, text, confirmLabel, onConfirm, onDismiss)` is the common yes or no case. When you do not want +to keep dialog state, `Confirm.ask` queues one from any thread, including non-composable code: + +```kotlin +Confirm.ask("Delete profile?", "This cannot be undone.", confirmLabel = "Delete profile", onConfirm = ::deleteProfile) +``` + +`TextPromptDialog(title, initial, onConfirm, onDismiss, validate = { ... })` asks for one line of text. `validate` +returns a message when the text is wrong, or null when it is fine. For an inline confirm, `ArmedConfirmButton` needs +a second click within a few seconds. + +Toasts are short messages over the current screen. They queue, pause on hover and follow reduced motion. + +```kotlin +Toasts.show("Copied to clipboard") +Toasts.show("Upload failed", NotificationType.ERROR, durationMillis = 6000) +``` + +For a message inside your layout, `rememberStatusMessage()` with `StatusText` shows a line that clears itself. + +`EmptyState` fills a page or list that has nothing to show, and also covers loading and errors through `LoadState`: + +```kotlin +EmptyState("No screenshots yet", icon = "folder", state = if (loading) LoadState.Loading else LoadState.Idle) +``` + +## Hosting + +These classes are in `org.polyfrost.oneconfig.api.ui.v1.platform`, in the per-version jar. Use them from the client +thread. + +`OneConfigComposeScreen` is a full-window Compose screen with the theme, Escape handling, confirm and toast hosts, +and background blur already set up. Closing it shows the screen that was open before. + +```kotlin +class MyScreen : OneConfigComposeScreen(Component.literal("My Mod")) { + @Composable + override fun Content() { + CenteredPanel { + SectionHeader("General") + TextButton("Done", onClick = ::dismiss, style = ControlStyle.Primary) + } + } +} + +OneConfigScreens.display(MyScreen()) +``` + +Override `blurBackground`, `closeOnEscape` or `onClosed()` to change the defaults. Pass +`renderMode = OneConfigComposeScreen.RenderMode.Continuous` when the content changes every frame in ways Compose does +not see. `OneConfigScreens.display`, `current` and `preventReplacement` work the same on every Minecraft version, and +`ScreenBlur.draw(alpha)` blurs the world behind a screen of your own. + +`ComposeHudLayer` draws a Compose scene into the HUD, outside any screen. Create it once and call `draw` from your +HUD render callback every frame it should show. The sample uses the 26.1 name `GuiGraphicsExtractor`; older versions +call it `GuiGraphics`, and on 1.8.9 the call is `draw()` with no argument. + +```kotlin +val watermark = ComposeHudLayer("mymod:watermark") { + Text("My Mod", color = OneConfigTheme.current.onMedia) +} + +fun renderHud(graphics: GuiGraphicsExtractor) { + watermark.draw(graphics) +} +``` + +The layer takes pointer input while chat or a screen implementing `ComposeHudLayer.InputScreen` is open. Set +`acceptsInput = false` for display-only layers, toggle `visible` to hide it, and `close()` it when you are done. A +layer that throws is logged once and retried after a growing delay. + +`InlinePolyCanvas` draws with OneConfig's renderer straight into the frame, without a Compose scene. Coordinates are +framebuffer pixels from the top left of the window. + +```kotlin +InlinePolyCanvas.draw(graphics) { canvas -> + canvas.context.rect(10f, 10f, 120f, 24f, PolyColor.WHITE, radius = 4f) +} +``` + +`PolyOffscreen(width, height)` is an offscreen target for Minecraft rendering. Render into it with +`renderBeforePresent { ... }` (or `render` at a safe point in the frame) and show the result with +`RenderContext.blit(x, y, w, h)`, for example inside `drawPoly` in a Compose draw. `EntityPreview(playerId, yaw = 30f)` +does this for you and shows a live player model, with a head as the fallback when the player is not loaded. + +Mods that show a screen before OneConfig has finished starting, such as a crash screen, call +`OneConfigUI.ensureInitialized()` first. + +## Config helpers + +These belong to the config modules; see [config](../config/README.md) and [config-impl](../config-impl/README.md). + +- `Property.collectAsState()` and `Property.asFlow()` (in `org.polyfrost.oneconfig.api.ui.v1.config`) follow an + option's value from Compose or a coroutine. +- `Tree.switch`, `Tree.slider`, `Tree.dropdown`, `Tree.color`, `Tree.button`, `Tree.text`, `Tree.keybind`, + `Tree.hidden` and `Tree.group` build a config tree without annotations. +- `MetaKeys` lists the typed metadata keys, read and written with `node[MetaKeys.TITLE]`. diff --git a/modules/ui/api/ui.api b/modules/ui/api/ui.api index a8d247356..3e0571541 100644 --- a/modules/ui/api/ui.api +++ b/modules/ui/api/ui.api @@ -16,6 +16,39 @@ public final class org/polyfrost/oneconfig/api/ui/v1/ChangelogSources { public abstract interface annotation class org/polyfrost/oneconfig/api/ui/v1/ExperimentalOneConfigUiApi : java/lang/annotation/Annotation { } +public final class org/polyfrost/oneconfig/api/ui/v1/IconSpec { + public static final field $stable I + public static final field Companion Lorg/polyfrost/oneconfig/api/ui/v1/IconSpec$Companion; + public fun (Ljava/lang/String;III)V + public fun (Ljava/lang/String;IIII)V + public fun (Ljava/lang/String;IIIII)V + public fun (Ljava/lang/String;IIIIII)V + public fun (Ljava/lang/String;IIIIIIZ)V + public synthetic fun (Ljava/lang/String;IIIIIIZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun equals (Ljava/lang/Object;)Z + public final fun getHeight ()I + public final fun getPath ()Ljava/lang/String; + public final fun getPixelated ()Z + public final fun getSheetHeight ()I + public final fun getSheetWidth ()I + public final fun getWidth ()I + public final fun getX ()I + public final fun getY ()I + public static final fun grid (Ljava/lang/String;III)Lorg/polyfrost/oneconfig/api/ui/v1/IconSpec; + public static final fun grid (Ljava/lang/String;IIII)Lorg/polyfrost/oneconfig/api/ui/v1/IconSpec; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/polyfrost/oneconfig/api/ui/v1/IconSpec$Companion { + public final fun grid (Ljava/lang/String;III)Lorg/polyfrost/oneconfig/api/ui/v1/IconSpec; + public final fun grid (Ljava/lang/String;IIII)Lorg/polyfrost/oneconfig/api/ui/v1/IconSpec; + public static synthetic fun grid$default (Lorg/polyfrost/oneconfig/api/ui/v1/IconSpec$Companion;Ljava/lang/String;IIIIILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/ui/v1/IconSpec; +} + +public abstract interface annotation class org/polyfrost/oneconfig/api/ui/v1/InternalOneConfigUiApi : java/lang/annotation/Annotation { +} + public abstract interface class org/polyfrost/oneconfig/api/ui/v1/ModCardIconProvider { public abstract fun iconFor (Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String; } @@ -42,12 +75,18 @@ public final class org/polyfrost/oneconfig/api/ui/v1/ModCardType { public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;I)V public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IZ)V public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Lorg/polyfrost/oneconfig/api/ui/v1/page/PageText;)V + public fun (Ljava/lang/String;Lorg/polyfrost/oneconfig/api/ui/v1/page/PageText;Ljava/lang/String;)V + public fun (Ljava/lang/String;Lorg/polyfrost/oneconfig/api/ui/v1/page/PageText;Ljava/lang/String;I)V + public fun (Ljava/lang/String;Lorg/polyfrost/oneconfig/api/ui/v1/page/PageText;Ljava/lang/String;IZ)V + public synthetic fun (Ljava/lang/String;Lorg/polyfrost/oneconfig/api/ui/v1/page/PageText;Ljava/lang/String;IZILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun equals (Ljava/lang/Object;)Z public final fun getCollapsedByDefault ()Z public final fun getIcon ()Ljava/lang/String; public final fun getId ()Ljava/lang/String; public final fun getPriority ()I public final fun getTitle ()Ljava/lang/String; + public final fun getTitleText ()Lorg/polyfrost/oneconfig/api/ui/v1/page/PageText; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -67,6 +106,9 @@ public final class org/polyfrost/oneconfig/api/ui/v1/ModCardTypes { public static final fun registerResolver (Lorg/polyfrost/oneconfig/api/ui/v1/ModCardTypeResolver;)V public static final fun typeFor (Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/ui/v1/ModCardType; public static final fun types ()Ljava/util/List; + public static final fun unassign (Ljava/lang/String;)Z + public static final fun unregister (Ljava/lang/String;)Z + public static final fun unregisterResolver (Lorg/polyfrost/oneconfig/api/ui/v1/ModCardTypeResolver;)Z } public final class org/polyfrost/oneconfig/api/ui/v1/ModSortMode { @@ -74,11 +116,16 @@ public final class org/polyfrost/oneconfig/api/ui/v1/ModSortMode { public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function0;)V public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function0;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function0;)V + public fun (Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/ui/v1/page/PageText;Lkotlin/jvm/functions/Function0;)V + public fun (Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/ui/v1/page/PageText;Lorg/polyfrost/oneconfig/api/ui/v1/page/PageText;Lkotlin/jvm/functions/Function0;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/ui/v1/page/PageText;Lorg/polyfrost/oneconfig/api/ui/v1/page/PageText;Lkotlin/jvm/functions/Function0;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getConfirmation ()Ljava/lang/String; + public final fun getConfirmationText ()Lorg/polyfrost/oneconfig/api/ui/v1/page/PageText; public final fun getIcon ()Ljava/lang/String; public final fun getId ()Ljava/lang/String; public final fun getOnSelect ()Lkotlin/jvm/functions/Function0; public final fun getTitle ()Ljava/lang/String; + public final fun getTitleText ()Lorg/polyfrost/oneconfig/api/ui/v1/page/PageText; } public final class org/polyfrost/oneconfig/api/ui/v1/ModSortModes { @@ -107,6 +154,9 @@ public final class org/polyfrost/oneconfig/api/ui/v1/ModToggles { public static final fun manage ([Ljava/lang/String;)V public static final fun register (Ljava/lang/String;Lorg/polyfrost/oneconfig/api/toggle/v1/ModToggle;)V public static final synthetic fun register (Ljava/lang/String;Lorg/polyfrost/oneconfig/api/ui/v1/ModToggle;)V + public static final fun registerReadOnly (Ljava/lang/String;Lkotlin/jvm/functions/Function0;)V + public static final fun registerReadOnly (Ljava/lang/String;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;)V + public static synthetic fun registerReadOnly$default (Ljava/lang/String;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V public static final fun removeListener (Lorg/polyfrost/oneconfig/api/ui/v1/ModToggles$Listener;)V public static final fun setEnabled (Ljava/lang/String;Z)V public static final fun setStore (Lorg/polyfrost/oneconfig/api/ui/v1/ModToggles$Store;)V @@ -136,26 +186,50 @@ public final class org/polyfrost/oneconfig/api/ui/v1/ModsPageDecorators { public static final fun unregister (Lorg/polyfrost/oneconfig/api/ui/v1/ModsPageDecorator;)Z } +public final class org/polyfrost/oneconfig/api/ui/v1/OneConfigPrefs { + public static final field $stable I + public static final field INSTANCE Lorg/polyfrost/oneconfig/api/ui/v1/OneConfigPrefs; + public static final fun getAnimationsEnabled ()Z + public static final fun getBackgroundBlur ()Z + public static final fun getGuiOpenAnimation ()Z + public static final fun getReducedMotion ()Z + public static final fun getUiScale ()F +} + public final class org/polyfrost/oneconfig/api/ui/v1/OneConfigUI { public static final field $stable I public static final field INSTANCE Lorg/polyfrost/oneconfig/api/ui/v1/OneConfigUI; public static final fun createScreen ()Ljava/lang/Object; public static final fun createScreen (Ljava/lang/Object;)Ljava/lang/Object; public static synthetic fun createScreen$default (Ljava/lang/Object;ILjava/lang/Object;)Ljava/lang/Object; + public static final fun ensureInitialized ()V + public static final fun getCurrentPageArgument ()Ljava/lang/Object; public static final fun isOneConfigScreen (Ljava/lang/Object;)Z public static final fun open ()V public static final fun open (Ljava/lang/Object;)V + public static final fun open (Ljava/lang/String;)V + public static final fun open (Lorg/polyfrost/oneconfig/api/ui/v1/page/OneConfigDestination;)V + public static final fun open (Lorg/polyfrost/oneconfig/api/ui/v1/page/OneConfigPage;)V + public static final fun open (Lorg/polyfrost/oneconfig/api/ui/v1/page/OneConfigPage;Ljava/lang/Object;)V public static synthetic fun open$default (Ljava/lang/Object;ILjava/lang/Object;)V + public static synthetic fun open$default (Lorg/polyfrost/oneconfig/api/ui/v1/page/OneConfigDestination;ILjava/lang/Object;)V + public static synthetic fun open$default (Lorg/polyfrost/oneconfig/api/ui/v1/page/OneConfigPage;Ljava/lang/Object;ILjava/lang/Object;)V } public abstract interface class org/polyfrost/oneconfig/api/ui/v1/OneConfigUIBridge { public abstract fun createScreen (Ljava/lang/Object;)Ljava/lang/Object; + public fun ensureInitialized ()V public fun isOneConfigScreen (Ljava/lang/Object;)Z public abstract fun open (Ljava/lang/Object;)V + public fun openPage (Lorg/polyfrost/oneconfig/api/ui/v1/page/OneConfigPage;Ljava/lang/Object;)V + public fun takePageArgument ()Ljava/lang/Object; } public final class org/polyfrost/oneconfig/api/ui/v1/OneConfigUIBridge$DefaultImpls { + public static fun ensureInitialized (Lorg/polyfrost/oneconfig/api/ui/v1/OneConfigUIBridge;)V public static fun isOneConfigScreen (Lorg/polyfrost/oneconfig/api/ui/v1/OneConfigUIBridge;Ljava/lang/Object;)Z + public static fun openPage (Lorg/polyfrost/oneconfig/api/ui/v1/OneConfigUIBridge;Lorg/polyfrost/oneconfig/api/ui/v1/page/OneConfigPage;Ljava/lang/Object;)V + public static fun takePageArgument (Lorg/polyfrost/oneconfig/api/ui/v1/OneConfigUIBridge;)Ljava/lang/Object; } public final class org/polyfrost/oneconfig/api/ui/v1/OptionWidgets { @@ -164,6 +238,15 @@ public final class org/polyfrost/oneconfig/api/ui/v1/OptionWidgets { public static final fun refresh (Lorg/polyfrost/oneconfig/api/config/v1/Property;)V } +public abstract interface class org/polyfrost/oneconfig/api/ui/v1/ReadOnlyModToggle : org/polyfrost/oneconfig/api/toggle/v1/ModToggle { + public abstract fun getLockedReason ()Ljava/lang/String; + public fun setEnabled (Z)V +} + +public final class org/polyfrost/oneconfig/api/ui/v1/ReadOnlyModToggle$DefaultImpls { + public static fun setEnabled (Lorg/polyfrost/oneconfig/api/ui/v1/ReadOnlyModToggle;Z)V +} + public abstract class org/polyfrost/oneconfig/api/ui/v1/SettingRowDecorator { public static final field $stable I public fun ()V @@ -178,6 +261,20 @@ public final class org/polyfrost/oneconfig/api/ui/v1/SettingRowDecorators { public static final fun unregister (Lorg/polyfrost/oneconfig/api/ui/v1/SettingRowDecorator;)Z } +public abstract class org/polyfrost/oneconfig/api/ui/v1/TreeHeaderDecorator { + public static final field $stable I + public fun ()V + public abstract fun Decorate (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)V +} + +public final class org/polyfrost/oneconfig/api/ui/v1/TreeHeaderDecorators { + public static final field $stable I + public static final field INSTANCE Lorg/polyfrost/oneconfig/api/ui/v1/TreeHeaderDecorators; + public static final fun all ()Ljava/util/List; + public static final fun register (Lorg/polyfrost/oneconfig/api/ui/v1/TreeHeaderDecorator;)V + public static final fun unregister (Lorg/polyfrost/oneconfig/api/ui/v1/TreeHeaderDecorator;)Z +} + public final class org/polyfrost/oneconfig/api/ui/v1/a11y/A11y { public static final field $stable I public static final field INSTANCE Lorg/polyfrost/oneconfig/api/ui/v1/a11y/A11y; @@ -205,8 +302,15 @@ public final class org/polyfrost/oneconfig/api/ui/v1/a11y/A11yKt { public final class org/polyfrost/oneconfig/api/ui/v1/a11y/A11yRole : java/lang/Enum { public static final field Button Lorg/polyfrost/oneconfig/api/ui/v1/a11y/A11yRole; public static final field Checkbox Lorg/polyfrost/oneconfig/api/ui/v1/a11y/A11yRole; + public static final field Dialog Lorg/polyfrost/oneconfig/api/ui/v1/a11y/A11yRole; + public static final field Heading Lorg/polyfrost/oneconfig/api/ui/v1/a11y/A11yRole; + public static final field Image Lorg/polyfrost/oneconfig/api/ui/v1/a11y/A11yRole; public static final field Link Lorg/polyfrost/oneconfig/api/ui/v1/a11y/A11yRole; + public static final field MenuItem Lorg/polyfrost/oneconfig/api/ui/v1/a11y/A11yRole; public static final field Option Lorg/polyfrost/oneconfig/api/ui/v1/a11y/A11yRole; + public static final field ProgressBar Lorg/polyfrost/oneconfig/api/ui/v1/a11y/A11yRole; + public static final field RadioButton Lorg/polyfrost/oneconfig/api/ui/v1/a11y/A11yRole; + public static final field Slider Lorg/polyfrost/oneconfig/api/ui/v1/a11y/A11yRole; public static final field Switch Lorg/polyfrost/oneconfig/api/ui/v1/a11y/A11yRole; public static final field Tab Lorg/polyfrost/oneconfig/api/ui/v1/a11y/A11yRole; public static final field TextField Lorg/polyfrost/oneconfig/api/ui/v1/a11y/A11yRole; @@ -219,6 +323,10 @@ public final class org/polyfrost/oneconfig/api/ui/v1/a11y/A11yRole : java/lang/E public final class org/polyfrost/oneconfig/api/ui/v1/a11y/Motion { public static final field $stable I public static final field INSTANCE Lorg/polyfrost/oneconfig/api/ui/v1/a11y/Motion; + public final fun animateColorAsState-KTwxG1Y (JLandroidx/compose/animation/core/AnimationSpec;Ljava/lang/String;Landroidx/compose/runtime/Composer;II)Landroidx/compose/runtime/State; + public final fun animateDpAsState-Kz89ssw (FLandroidx/compose/animation/core/AnimationSpec;Ljava/lang/String;Landroidx/compose/runtime/Composer;II)Landroidx/compose/runtime/State; + public final fun animateFloatAsState (FLandroidx/compose/animation/core/AnimationSpec;Ljava/lang/String;Landroidx/compose/runtime/Composer;II)Landroidx/compose/runtime/State; + public final fun animateInfiniteFloat (FFLandroidx/compose/animation/core/InfiniteRepeatableSpec;Ljava/lang/String;Landroidx/compose/runtime/Composer;II)Landroidx/compose/runtime/State; public static final fun enter (Landroidx/compose/animation/EnterTransition;)Landroidx/compose/animation/EnterTransition; public static final fun exit (Landroidx/compose/animation/ExitTransition;)Landroidx/compose/animation/ExitTransition; public static final fun finiteSpec (Landroidx/compose/animation/core/FiniteAnimationSpec;)Landroidx/compose/animation/core/FiniteAnimationSpec; @@ -300,15 +408,47 @@ public abstract interface class org/polyfrost/oneconfig/api/ui/v1/api/TinyFdApi public abstract fun showNotification (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)I } +public final class org/polyfrost/oneconfig/api/ui/v1/components/AnchoringKt { + public static final fun popupMaxHeight-kHDZbjc (FLandroidx/compose/runtime/Composer;II)F + public static final fun rememberAnchoredPosition-uFdPcIQ (Lorg/polyfrost/oneconfig/api/ui/v1/components/Placement;FZLandroidx/compose/runtime/Composer;II)Landroidx/compose/ui/window/PopupPositionProvider; +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/BadgeTone : java/lang/Enum { + public static final field Accent Lorg/polyfrost/oneconfig/api/ui/v1/components/BadgeTone; + public static final field Danger Lorg/polyfrost/oneconfig/api/ui/v1/components/BadgeTone; + public static final field Neutral Lorg/polyfrost/oneconfig/api/ui/v1/components/BadgeTone; + public static final field Success Lorg/polyfrost/oneconfig/api/ui/v1/components/BadgeTone; + public static final field Warning Lorg/polyfrost/oneconfig/api/ui/v1/components/BadgeTone; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/ui/v1/components/BadgeTone; + public static fun values ()[Lorg/polyfrost/oneconfig/api/ui/v1/components/BadgeTone; +} + public final class org/polyfrost/oneconfig/api/ui/v1/components/BordersKt { public static final fun fadingEdges-K6bRomo (Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/ScrollState;JFLandroidx/compose/runtime/Composer;I)Landroidx/compose/ui/Modifier; public static final fun fadingEdges-XO-JAsU (Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/ScrollState;JLandroidx/compose/runtime/Composer;I)Landroidx/compose/ui/Modifier; } +public final class org/polyfrost/oneconfig/api/ui/v1/components/ButtonDefaults { + public static final field $stable I + public static final field DANGER_HOVER_ALPHA F + public static final field DANGER_REST_ALPHA F + public static final field FILLED_HOVER_ALPHA F + public static final field INSTANCE Lorg/polyfrost/oneconfig/api/ui/v1/components/ButtonDefaults; + public final fun contentColor-XeAY9LY (Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlStyle;Landroidx/compose/runtime/Composer;I)J + public final fun fontSize-kPz2Gy4 (Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize;)J + public final fun getContentSpacing-D9Ej5fM ()F + public final fun getShape (Landroidx/compose/runtime/Composer;I)Landroidx/compose/ui/graphics/Shape; + public final fun getSize ()Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize; + public final fun getStyle ()Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlStyle; + public final fun horizontalPadding-u2uoSUM (Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize;)F + public final fun iconSize-u2uoSUM (Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize;)F +} + public final class org/polyfrost/oneconfig/api/ui/v1/components/ButtonKt { - public static final fun IconButton (Ljava/lang/String;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V - public static final fun IconButton (Ljava/lang/String;Landroidx/compose/ui/Modifier;ZLkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V - public static final fun IconButton-Y0xEhic (Ljava/lang/String;Landroidx/compose/ui/Modifier;JJZZLkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V + public static final synthetic fun IconButton (Ljava/lang/String;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V + public static final synthetic fun IconButton (Ljava/lang/String;Landroidx/compose/ui/Modifier;ZLkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V + public static final synthetic fun IconButton-Y0xEhic (Ljava/lang/String;Landroidx/compose/ui/Modifier;JJZZLkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V } public final class org/polyfrost/oneconfig/api/ui/v1/components/ButtonStyle : java/lang/Enum { @@ -321,21 +461,64 @@ public final class org/polyfrost/oneconfig/api/ui/v1/components/ButtonStyle : ja public static fun values ()[Lorg/polyfrost/oneconfig/api/ui/v1/components/ButtonStyle; } +public final class org/polyfrost/oneconfig/api/ui/v1/components/CardGridKt { + public static final fun CardGrid--b7W0Lw (Ljava/util/List;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;FLandroidx/compose/foundation/lazy/grid/LazyGridState;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/CheckboxKt { + public static final fun Checkbox (ZLkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;ZLandroidx/compose/foundation/interaction/MutableInteractionSource;Landroidx/compose/runtime/Composer;II)V +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/CheckerboardKt { + public static final fun transparencyCheckerboard-rAjV9yQ (Landroidx/compose/ui/Modifier;FLandroidx/compose/runtime/Composer;II)Landroidx/compose/ui/Modifier; +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/ChipDefaults { + public static final field $stable I + public static final field HOVER_ACCENT_ALPHA F + public static final field INSTANCE Lorg/polyfrost/oneconfig/api/ui/v1/components/ChipDefaults; + public final fun contentColor-XeAY9LY (ZLandroidx/compose/runtime/Composer;I)J + public final fun contentSpacing-u2uoSUM (Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize;)F + public final fun fontSize-kPz2Gy4 (Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize;)J + public final fun getFill (Landroidx/compose/runtime/Composer;I)J + public final fun getShape (Landroidx/compose/runtime/Composer;I)Landroidx/compose/ui/graphics/Shape; + public final fun getSize ()Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize; + public final fun horizontalPadding-u2uoSUM (Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize;)F + public final fun iconSize-u2uoSUM (Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize;)F +} + public final class org/polyfrost/oneconfig/api/ui/v1/components/ChipsKt { - public static final fun Chip (Ljava/lang/String;ZLjava/lang/String;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V - public static final fun Chip (Ljava/lang/String;ZLkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V + public static final synthetic fun Chip (Ljava/lang/String;ZLjava/lang/String;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V + public static final synthetic fun Chip (Ljava/lang/String;ZLkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V + public static final fun Chip (Ljava/lang/String;ZLkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Ljava/lang/String;ZLorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize;Lorg/polyfrost/oneconfig/api/ui/v1/a11y/A11yRole;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Landroidx/compose/foundation/interaction/MutableInteractionSource;Landroidx/compose/runtime/Composer;III)V +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/CodeBlockKt { + public static final fun CodeBlock-942rkJo (Ljava/lang/String;Landroidx/compose/ui/Modifier;FLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V } public final class org/polyfrost/oneconfig/api/ui/v1/components/ColorPickerKt { - public static final fun ColorPickerPopup (Lorg/polyfrost/oneconfig/api/ui/v1/components/ColorPickerModel;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V - public static final fun ColorPickerPopup (Lorg/polyfrost/oneconfig/api/ui/v1/components/ColorPickerModel;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;ZLandroidx/compose/runtime/Composer;I)V + public static final synthetic fun ColorPickerPopup (Lorg/polyfrost/oneconfig/api/ui/v1/components/ColorPickerModel;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V + public static final fun ColorPickerPopup (Lorg/polyfrost/oneconfig/api/ui/v1/components/ColorPickerModel;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Lorg/polyfrost/oneconfig/api/ui/v1/components/Placement;ZZLandroidx/compose/runtime/Composer;II)V + public static final synthetic fun ColorPickerPopup (Lorg/polyfrost/oneconfig/api/ui/v1/components/ColorPickerModel;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;ZLandroidx/compose/runtime/Composer;I)V + public static final fun ColorSwatchButton (Lorg/polyfrost/compose/render/PolyColor;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Ljava/lang/String;ZZZZLandroidx/compose/foundation/interaction/MutableInteractionSource;Landroidx/compose/runtime/Composer;II)V + public static final fun ColorSwatchButton-pzZJ40c (JLkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Ljava/lang/String;ZZZLandroidx/compose/foundation/interaction/MutableInteractionSource;Landroidx/compose/runtime/Composer;II)V public static final fun colorToHex-8_81llA (J)Ljava/lang/String; + public static final fun rememberColorPickerModel (Lorg/polyfrost/compose/render/PolyColor;ZLandroidx/compose/runtime/Composer;II)Lorg/polyfrost/oneconfig/api/ui/v1/components/ColorPickerModel; + public static final fun rememberColorPickerModel-3J-VO9M (JZLandroidx/compose/runtime/Composer;II)Lorg/polyfrost/oneconfig/api/ui/v1/components/ColorPickerModel; + public static final fun toPolyColor (Lorg/polyfrost/oneconfig/api/ui/v1/components/ColorPickerModel;)Lorg/polyfrost/compose/render/PolyColor; } -public final class org/polyfrost/oneconfig/api/ui/v1/components/ColorPickerModel { +public class org/polyfrost/oneconfig/api/ui/v1/components/ColorPickerModel { public static final field $stable I + protected fun ()V public synthetic fun (JLkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun (JZLkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun getChroma ()Z + public fun getChromaSpeed ()F + public fun getColor-0d7_KjU ()J + public fun getGradient-QN2ZGVo ()Landroidx/compose/ui/graphics/Color; + public fun set-8_81llA (J)V } public final class org/polyfrost/oneconfig/api/ui/v1/components/CommonKt { @@ -344,62 +527,336 @@ public final class org/polyfrost/oneconfig/api/ui/v1/components/CommonKt { public static final fun rememberInteractionSource (Landroidx/compose/runtime/Composer;I)Landroidx/compose/foundation/interaction/MutableInteractionSource; } +public final class org/polyfrost/oneconfig/api/ui/v1/components/Confirm { + public static final field $stable I + public static final field INSTANCE Lorg/polyfrost/oneconfig/api/ui/v1/components/Confirm; + public static final fun ask (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlStyle;Ljava/lang/String;)V + public static synthetic fun ask$default (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlStyle;Ljava/lang/String;ILjava/lang/Object;)V +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/ConfirmDefaults { + public static final field $stable I + public static final field INSTANCE Lorg/polyfrost/oneconfig/api/ui/v1/components/ConfirmDefaults; + public static final field armDelayMillis J + public static final field timeoutMillis J + public static final fun armedText (Ljava/lang/String;)Ljava/lang/String; +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/ConfirmKt { + public static final fun ArmedConfirmButton (Landroidx/compose/ui/text/AnnotatedString;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/text/AnnotatedString;JLorg/polyfrost/oneconfig/api/ui/v1/components/ControlStyle;Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize;ZLandroidx/compose/foundation/interaction/MutableInteractionSource;Landroidx/compose/runtime/Composer;II)V + public static final fun ArmedConfirmButton (Ljava/lang/String;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Ljava/lang/String;JLorg/polyfrost/oneconfig/api/ui/v1/components/ControlStyle;Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize;ZLandroidx/compose/foundation/interaction/MutableInteractionSource;Landroidx/compose/runtime/Composer;II)V +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/ContextMenuState { + public static final field $stable I + public final fun close ()V + public final fun isOpen ()Z + public final fun open-3MmeM6k (JLjava/util/List;)V +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/ControlDefaults { + public static final field $stable I + public static final field INSTANCE Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlDefaults; + public final fun getDisabledAlpha (Landroidx/compose/runtime/Composer;I)F + public final fun getSize ()Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize; +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/ControlSize : java/lang/Enum { + public static final field Compact Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize; + public static final field Large Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize; + public static final field Regular Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public final fun getHeight-D9Ej5fM ()F + public static fun valueOf (Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize; + public static fun values ()[Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize; +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/ControlStyle : java/lang/Enum { + public static final field Danger Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlStyle; + public static final field DangerFilled Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlStyle; + public static final field Ghost Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlStyle; + public static final field Primary Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlStyle; + public static final field Secondary Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlStyle; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlStyle; + public static fun values ()[Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlStyle; +} + public final class org/polyfrost/oneconfig/api/ui/v1/components/ControlsKt { - public static final fun IconButton (Ljava/lang/String;Ljava/lang/String;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V - public static final fun IconButton-_trzp-w (Ljava/lang/String;Ljava/lang/String;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/graphics/Color;Lorg/polyfrost/oneconfig/api/ui/v1/components/TooltipPlacement;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)V - public static final fun IconButton-mP4MEow (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/graphics/Color;JLorg/polyfrost/oneconfig/api/ui/v1/components/TooltipPlacement;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V - public static final fun TabRow (Ljava/util/List;Ljava/lang/Object;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;I)V - public static final fun ToggleRow (Ljava/lang/String;ZLandroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V + public static final synthetic fun IconButton (Ljava/lang/String;Ljava/lang/String;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V + public static final synthetic fun IconButton-_trzp-w (Ljava/lang/String;Ljava/lang/String;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/graphics/Color;Lorg/polyfrost/oneconfig/api/ui/v1/components/TooltipPlacement;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)V + public static final synthetic fun IconButton-mP4MEow (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/graphics/Color;JLorg/polyfrost/oneconfig/api/ui/v1/components/TooltipPlacement;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V + public static final synthetic fun TabRow (Ljava/util/List;Ljava/lang/Object;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;I)V + public static final synthetic fun ToggleRow (Ljava/lang/String;ZLandroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V + public static final fun ToggleRow (Ljava/lang/String;ZLkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Ljava/lang/String;ZLorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize;Landroidx/compose/foundation/interaction/MutableInteractionSource;Landroidx/compose/runtime/Composer;II)V +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/DialogDefaults { + public static final field $stable I + public static final field INSTANCE Lorg/polyfrost/oneconfig/api/ui/v1/components/DialogDefaults; + public final fun getButtonSpacing-D9Ej5fM ()F + public final fun getCancelLabel ()Ljava/lang/String; + public final fun getPadding ()Landroidx/compose/foundation/layout/PaddingValues; + public final fun getSaveLabel ()Ljava/lang/String; + public final fun getSpacing-D9Ej5fM ()F + public final fun getWidth-D9Ej5fM ()F } public final class org/polyfrost/oneconfig/api/ui/v1/components/DialogKt { - public static final fun ConfirmDialog (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V - public static final fun ConfirmDialog (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V - public static final fun DialogButtons (Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;I)V - public static final fun DialogTitle (Ljava/lang/String;Landroidx/compose/runtime/Composer;I)V - public static final fun ModalDialog (Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;I)V - public static final fun ModalDialog-VxB0X1c (Lkotlin/jvm/functions/Function0;FFLandroidx/compose/foundation/layout/PaddingValues;FZLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;I)V - public static final fun ModalDialog-uFdPcIQ (Lkotlin/jvm/functions/Function0;FZLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;I)V - public static final fun ModalPanel--orJrPs (FLandroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;I)V - public static final fun ModalPanel-l2d-XRM (FFLandroidx/compose/foundation/layout/PaddingValues;FLandroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;I)V + public static final synthetic fun ConfirmDialog (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V + public static final synthetic fun ConfirmDialog (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V + public static final fun ConfirmDialog (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlStyle;ZLjava/lang/String;ZLandroidx/compose/runtime/Composer;III)V + public static final fun DialogButtons (Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V + public static final synthetic fun DialogButtons (Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;I)V + public static final synthetic fun DialogTitle (Ljava/lang/String;Landroidx/compose/runtime/Composer;I)V + public static final fun DialogTitle (Ljava/lang/String;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V + public static final synthetic fun ModalDialog (Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;I)V + public static final fun ModalDialog-UR9CgXA (Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;FLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;ZZLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V + public static final synthetic fun ModalDialog-VxB0X1c (Lkotlin/jvm/functions/Function0;FFLandroidx/compose/foundation/layout/PaddingValues;FZLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;I)V + public static final synthetic fun ModalDialog-uFdPcIQ (Lkotlin/jvm/functions/Function0;FZLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;I)V + public static final synthetic fun ModalPanel--orJrPs (FLandroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;I)V + public static final fun ModalPanel-VxB0X1c (Landroidx/compose/ui/Modifier;FFLandroidx/compose/foundation/layout/PaddingValues;FLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V + public static final synthetic fun ModalPanel-l2d-XRM (FFLandroidx/compose/foundation/layout/PaddingValues;FLandroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;I)V +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/DropdownKt { + public static final fun DropdownMenu (ZLkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Lorg/polyfrost/oneconfig/api/ui/v1/components/Placement;ZLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V + public static final fun DropdownMenu-au3_HiA (ZLkotlin/jvm/functions/Function0;Ljava/util/List;Landroidx/compose/ui/Modifier;Lorg/polyfrost/oneconfig/api/ui/v1/components/Placement;ZFLandroidx/compose/runtime/Composer;II)V +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/EmptyStateKt { + public static final fun EmptyState (Ljava/lang/String;Landroidx/compose/ui/Modifier;Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/ui/v1/components/LoadState;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V + public static final fun EmptyState (Ljava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;Landroidx/compose/ui/Modifier;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/ui/v1/components/LoadState;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V + public static final fun EmptyState (Lorg/polyfrost/oneconfig/api/ui/v1/page/PageText;Landroidx/compose/ui/Modifier;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/ui/v1/page/PageText;Lorg/polyfrost/oneconfig/api/ui/v1/components/LoadState;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V + public static final fun EmptyState (Lorg/polyfrost/oneconfig/api/ui/v1/page/PageText;Landroidx/compose/ui/graphics/painter/Painter;Landroidx/compose/ui/Modifier;Lorg/polyfrost/oneconfig/api/ui/v1/page/PageText;Lorg/polyfrost/oneconfig/api/ui/v1/components/LoadState;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V } public final class org/polyfrost/oneconfig/api/ui/v1/components/EscapeClosesKt { public static final fun EscapeAwareTextMenus (Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)V public static final fun EscapeCloses (Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V + public static final fun handleEscapeStack (Landroidx/compose/ui/Modifier;)Landroidx/compose/ui/Modifier; } public final class org/polyfrost/oneconfig/api/ui/v1/components/EscapeStack { public static final field $stable I public static final field INSTANCE Lorg/polyfrost/oneconfig/api/ui/v1/components/EscapeStack; public static final fun closeTop ()Z + public static final fun dispatch ()Z public static final fun getHasOpen ()Z public static final fun push (Lkotlin/jvm/functions/Function0;)V public static final fun remove (Lkotlin/jvm/functions/Function0;)V } +public final class org/polyfrost/oneconfig/api/ui/v1/components/FocusRegionsKt { + public static final fun captureKeybinds (Landroidx/compose/ui/Modifier;Z)Landroidx/compose/ui/Modifier; + public static synthetic fun captureKeybinds$default (Landroidx/compose/ui/Modifier;ZILjava/lang/Object;)Landroidx/compose/ui/Modifier; + public static final fun focusRegion (Landroidx/compose/ui/Modifier;I)Landroidx/compose/ui/Modifier; + public static final fun focusRoot (Landroidx/compose/ui/Modifier;)Landroidx/compose/ui/Modifier; +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/GridReorderKt { + public static final fun rememberGridReorderState-HYR8e34 (Landroidx/compose/foundation/lazy/LazyListState;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;FLandroidx/compose/runtime/Composer;II)Lorg/polyfrost/oneconfig/api/ui/v1/components/GridReorderState; + public static final fun rememberGridReorderState-HYR8e34 (Landroidx/compose/foundation/lazy/grid/LazyGridState;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;FLandroidx/compose/runtime/Composer;II)Lorg/polyfrost/oneconfig/api/ui/v1/components/GridReorderState; + public static final fun reorderAutoScroll (Landroidx/compose/ui/Modifier;Lorg/polyfrost/oneconfig/api/ui/v1/components/GridReorderState;)Landroidx/compose/ui/Modifier; + public static final fun reorderContainer (Landroidx/compose/ui/Modifier;Lorg/polyfrost/oneconfig/api/ui/v1/components/GridReorderState;)Landroidx/compose/ui/Modifier; + public static final fun reorderOverlay (Landroidx/compose/ui/Modifier;Lorg/polyfrost/oneconfig/api/ui/v1/components/GridReorderState;)Landroidx/compose/ui/Modifier; + public static final fun reorderableItem (Landroidx/compose/ui/Modifier;Lorg/polyfrost/oneconfig/api/ui/v1/components/GridReorderState;Ljava/lang/Object;IZ)Landroidx/compose/ui/Modifier; + public static synthetic fun reorderableItem$default (Landroidx/compose/ui/Modifier;Lorg/polyfrost/oneconfig/api/ui/v1/components/GridReorderState;Ljava/lang/Object;IZILjava/lang/Object;)Landroidx/compose/ui/Modifier; +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/GridReorderState { + public static final field $stable I + public final fun cancel ()V + public final fun getDraggingKey ()Ljava/lang/Object; + public final fun getOverlayKey ()Ljava/lang/Object; + public final fun isDragging ()Z + public final fun moveBy (Ljava/lang/Object;I)Z +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/HoverState { + public static final field $stable I + public fun (Landroidx/compose/foundation/interaction/MutableInteractionSource;Z)V + public final fun getHovered ()Z + public final fun getInteractionSource ()Landroidx/compose/foundation/interaction/MutableInteractionSource; +} + public final class org/polyfrost/oneconfig/api/ui/v1/components/HoverTooltipKt { - public static final fun TooltipBox (Ljava/lang/String;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)V - public static final fun TooltipBox--jt2gSs (Ljava/lang/String;Landroidx/compose/ui/Modifier;Lorg/polyfrost/oneconfig/api/ui/v1/components/TooltipPlacement;FLkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)V - public static final fun TooltipPopup (Ljava/lang/String;Lorg/polyfrost/oneconfig/api/ui/v1/components/TooltipPlacement;Landroidx/compose/runtime/Composer;I)V - public static final fun TooltipPopup-TDGSqEk (Ljava/lang/String;Lorg/polyfrost/oneconfig/api/ui/v1/components/TooltipPlacement;FLandroidx/compose/runtime/Composer;I)V + public static final synthetic fun TooltipBox (Ljava/lang/String;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)V + public static final synthetic fun TooltipBox--jt2gSs (Ljava/lang/String;Landroidx/compose/ui/Modifier;Lorg/polyfrost/oneconfig/api/ui/v1/components/TooltipPlacement;FLkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)V + public static final fun TooltipBox-jIwJxvA (Ljava/lang/String;Landroidx/compose/ui/Modifier;Lorg/polyfrost/oneconfig/api/ui/v1/components/TooltipPlacement;FZLkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V + public static final fun TooltipBox-jIwJxvA (Lkotlin/jvm/functions/Function2;Landroidx/compose/ui/Modifier;Lorg/polyfrost/oneconfig/api/ui/v1/components/TooltipPlacement;FZLkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V + public static final synthetic fun TooltipPopup (Ljava/lang/String;Lorg/polyfrost/oneconfig/api/ui/v1/components/TooltipPlacement;Landroidx/compose/runtime/Composer;I)V + public static final fun TooltipPopup--jt2gSs (Ljava/lang/String;Landroidx/compose/ui/Modifier;Lorg/polyfrost/oneconfig/api/ui/v1/components/TooltipPlacement;FLandroidx/compose/runtime/Composer;II)V + public static final synthetic fun TooltipPopup-TDGSqEk (Ljava/lang/String;Lorg/polyfrost/oneconfig/api/ui/v1/components/TooltipPlacement;FLandroidx/compose/runtime/Composer;I)V +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/IconButtonDefaults { + public static final field $stable I + public static final field DANGER_HOVER_ALPHA F + public static final field INSTANCE Lorg/polyfrost/oneconfig/api/ui/v1/components/IconButtonDefaults; + public final fun contentColor-OmjzabM (Lorg/polyfrost/oneconfig/api/ui/v1/components/IconButtonStyle;ZZZLandroidx/compose/runtime/Composer;II)J + public final fun getSize ()Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize; + public final fun getStyle ()Lorg/polyfrost/oneconfig/api/ui/v1/components/IconButtonStyle; + public final fun getTooltipPlacement ()Lorg/polyfrost/oneconfig/api/ui/v1/components/TooltipPlacement; + public final fun iconSize-u2uoSUM (Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize;)F + public final fun shape (Lorg/polyfrost/oneconfig/api/ui/v1/components/IconButtonStyle;Landroidx/compose/runtime/Composer;I)Landroidx/compose/ui/graphics/Shape; +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/IconButtonKt { + public static final fun IconButton--9UV06g (Landroidx/compose/ui/graphics/painter/Painter;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;ZLorg/polyfrost/oneconfig/api/ui/v1/components/IconButtonStyle;Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize;ZZLjava/lang/String;Lorg/polyfrost/oneconfig/api/ui/v1/components/TooltipPlacement;JLandroidx/compose/foundation/interaction/MutableInteractionSource;Landroidx/compose/ui/graphics/Shape;Landroidx/compose/runtime/Composer;III)V + public static final fun IconButton--9UV06g (Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;ZLorg/polyfrost/oneconfig/api/ui/v1/components/IconButtonStyle;Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize;ZZLjava/lang/String;Lorg/polyfrost/oneconfig/api/ui/v1/components/TooltipPlacement;JLandroidx/compose/foundation/interaction/MutableInteractionSource;Landroidx/compose/ui/graphics/Shape;Landroidx/compose/runtime/Composer;III)V +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/IconButtonStyle : java/lang/Enum { + public static final field Filled Lorg/polyfrost/oneconfig/api/ui/v1/components/IconButtonStyle; + public static final field Framed Lorg/polyfrost/oneconfig/api/ui/v1/components/IconButtonStyle; + public static final field Ghost Lorg/polyfrost/oneconfig/api/ui/v1/components/IconButtonStyle; + public static final field Overlay Lorg/polyfrost/oneconfig/api/ui/v1/components/IconButtonStyle; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/ui/v1/components/IconButtonStyle; + public static fun values ()[Lorg/polyfrost/oneconfig/api/ui/v1/components/IconButtonStyle; } public final class org/polyfrost/oneconfig/api/ui/v1/components/IconKt { - public static final fun Icon-3IgeMak (Ljava/lang/String;JLandroidx/compose/ui/Modifier;ZLandroidx/compose/runtime/Composer;I)V - public static final fun Icon-iJQMabo (Ljava/lang/String;JLandroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;I)V + public static final synthetic fun Icon-3IgeMak (Ljava/lang/String;JLandroidx/compose/ui/Modifier;ZLandroidx/compose/runtime/Composer;I)V + public static final fun Icon-fWhpE4E (Ljava/lang/String;Ljava/lang/String;Landroidx/compose/ui/Modifier;JLkotlin/jvm/functions/Function0;ZLandroidx/compose/runtime/Composer;II)V + public static final synthetic fun Icon-iJQMabo (Ljava/lang/String;JLandroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;I)V + public static final fun Icon-ww6aTOc (Landroidx/compose/ui/graphics/painter/Painter;Ljava/lang/String;Landroidx/compose/ui/Modifier;JLandroidx/compose/runtime/Composer;II)V +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/ImageViewerKt { + public static final fun ImageViewer (IILkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/ImagesKt { + public static final fun fromFile (Lorg/polyfrost/compose/render/PolyBitmap$Companion;Ljava/nio/file/Path;J)Lorg/polyfrost/compose/render/PolyBitmap; + public static synthetic fun fromFile$default (Lorg/polyfrost/compose/render/PolyBitmap$Companion;Ljava/nio/file/Path;JILjava/lang/Object;)Lorg/polyfrost/compose/render/PolyBitmap; + public static final fun rememberImageBitmap (Ljava/nio/file/Path;ILandroidx/compose/runtime/Composer;I)Landroidx/compose/ui/graphics/ImageBitmap; +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/InteractionKt { + public static final fun hiddenUntilHover (Landroidx/compose/ui/Modifier;Z)Landroidx/compose/ui/Modifier; + public static final fun oneConfigClickable (Landroidx/compose/ui/Modifier;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Lorg/polyfrost/oneconfig/api/ui/v1/a11y/A11yRole;ZLjava/lang/Boolean;Ljava/lang/Boolean;Landroidx/compose/ui/graphics/Shape;ZLorg/polyfrost/oneconfig/api/ui/v1/components/UiSound;Landroidx/compose/foundation/interaction/MutableInteractionSource;)Landroidx/compose/ui/Modifier; + public static synthetic fun oneConfigClickable$default (Landroidx/compose/ui/Modifier;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Lorg/polyfrost/oneconfig/api/ui/v1/a11y/A11yRole;ZLjava/lang/Boolean;Ljava/lang/Boolean;Landroidx/compose/ui/graphics/Shape;ZLorg/polyfrost/oneconfig/api/ui/v1/components/UiSound;Landroidx/compose/foundation/interaction/MutableInteractionSource;ILjava/lang/Object;)Landroidx/compose/ui/Modifier; + public static final fun playUiSound (Lorg/polyfrost/oneconfig/api/ui/v1/components/UiSound;)V + public static final fun rememberHoverState (Landroidx/compose/runtime/Composer;I)Lorg/polyfrost/oneconfig/api/ui/v1/components/HoverState; +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/KeybindFieldKt { + public static final fun KeybindField (Lorg/polyfrost/oneconfig/api/ui/v1/keybind/OneConfigKeybind;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;ZLjava/lang/Boolean;ZLandroidx/compose/foundation/interaction/MutableInteractionSource;Landroidx/compose/runtime/Composer;II)V +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/LayoutPiecesKt { + public static final fun Badge (Ljava/lang/String;Landroidx/compose/ui/Modifier;Lorg/polyfrost/oneconfig/api/ui/v1/components/BadgeTone;ZLandroidx/compose/runtime/Composer;II)V + public static final fun CenteredPanel-DzVHIIc (Landroidx/compose/ui/Modifier;FZLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V + public static final fun Divider (Landroidx/compose/ui/Modifier;ZLandroidx/compose/runtime/Composer;II)V + public static final fun KeyValueRow (Ljava/lang/String;Ljava/lang/String;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V + public static final fun PageScaffold-hGBTI10 (Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;FLkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V + public static final fun SectionHeader (Ljava/lang/String;Landroidx/compose/ui/Modifier;ZLkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/LinkTextKt { + public static final fun LinkText (Ljava/lang/String;Ljava/lang/String;Landroidx/compose/ui/Modifier;Ljava/lang/String;ZLandroidx/compose/foundation/interaction/MutableInteractionSource;Landroidx/compose/runtime/Composer;II)V + public static final fun LinkText (Ljava/lang/String;Ljava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;Landroidx/compose/ui/Modifier;ZLandroidx/compose/foundation/interaction/MutableInteractionSource;Landroidx/compose/runtime/Composer;II)V + public static final fun LinkText (Ljava/lang/String;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Ljava/lang/String;ZLandroidx/compose/foundation/interaction/MutableInteractionSource;Landroidx/compose/runtime/Composer;II)V + public static final fun LinkText (Ljava/lang/String;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/graphics/painter/Painter;Landroidx/compose/ui/Modifier;ZLandroidx/compose/foundation/interaction/MutableInteractionSource;Landroidx/compose/runtime/Composer;II)V +} + +public abstract class org/polyfrost/oneconfig/api/ui/v1/components/LoadState { + public static final field $stable I } -public final class org/polyfrost/oneconfig/api/ui/v1/components/MenuAction { +public final class org/polyfrost/oneconfig/api/ui/v1/components/LoadState$Error : org/polyfrost/oneconfig/api/ui/v1/components/LoadState { + public static final field $stable I + public fun (Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/ui/v1/components/LoadState$Error; + public static synthetic fun copy$default (Lorg/polyfrost/oneconfig/api/ui/v1/components/LoadState$Error;Ljava/lang/String;ILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/ui/v1/components/LoadState$Error; + public fun equals (Ljava/lang/Object;)Z + public final fun getMessage ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/LoadState$Idle : org/polyfrost/oneconfig/api/ui/v1/components/LoadState { + public static final field $stable I + public static final field INSTANCE Lorg/polyfrost/oneconfig/api/ui/v1/components/LoadState$Idle; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/LoadState$Loading : org/polyfrost/oneconfig/api/ui/v1/components/LoadState { + public static final field $stable I + public static final field INSTANCE Lorg/polyfrost/oneconfig/api/ui/v1/components/LoadState$Loading; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/MenuAction : org/polyfrost/oneconfig/api/ui/v1/components/MenuEntry { public static final field $stable I public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZZLkotlin/jvm/functions/Function0;)V public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZZLkotlin/jvm/functions/Function0;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Lkotlin/jvm/functions/Function0;Ljava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlStyle;ZLjava/lang/String;Ljava/lang/Boolean;Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/ui/v1/components/MenuItemSize;Lkotlin/jvm/functions/Function2;Ljava/util/List;Z)V + public synthetic fun (Ljava/lang/String;Lkotlin/jvm/functions/Function0;Ljava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlStyle;ZLjava/lang/String;Ljava/lang/Boolean;Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/ui/v1/components/MenuItemSize;Lkotlin/jvm/functions/Function2;Ljava/util/List;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lorg/polyfrost/oneconfig/api/ui/v1/page/PageText;Lkotlin/jvm/functions/Function0;Ljava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlStyle;ZLjava/lang/String;Ljava/lang/Boolean;Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/ui/v1/components/MenuItemSize;Lkotlin/jvm/functions/Function2;Ljava/util/List;Z)V + public synthetic fun (Lorg/polyfrost/oneconfig/api/ui/v1/page/PageText;Lkotlin/jvm/functions/Function0;Ljava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlStyle;ZLjava/lang/String;Ljava/lang/Boolean;Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/ui/v1/components/MenuItemSize;Lkotlin/jvm/functions/Function2;Ljava/util/List;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getBadge ()Ljava/lang/String; + public final fun getChecked ()Ljava/lang/Boolean; + public final fun getChildren ()Ljava/util/List; public final fun getDanger ()Z public final fun getDetail ()Ljava/lang/String; + public final fun getDisabledReason ()Ljava/lang/String; public final fun getEnabled ()Z public final fun getIcon ()Ljava/lang/String; public final fun getLabel ()Ljava/lang/String; + public final fun getLabelText ()Lorg/polyfrost/oneconfig/api/ui/v1/page/PageText; + public final fun getLeading ()Lkotlin/jvm/functions/Function2; public final fun getOnClick ()Lkotlin/jvm/functions/Function0; + public final fun getPainter ()Landroidx/compose/ui/graphics/painter/Painter; + public final fun getShortcutHint ()Ljava/lang/String; + public final fun getSize ()Lorg/polyfrost/oneconfig/api/ui/v1/components/MenuItemSize; + public final fun getStyle ()Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlStyle; +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/MenuCustomItem : org/polyfrost/oneconfig/api/ui/v1/components/MenuEntry { + public static final field $stable I + public fun (Lkotlin/jvm/functions/Function2;)V + public final fun getContent ()Lkotlin/jvm/functions/Function2; +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/MenuDefaults { + public static final field $stable I + public static final field HOVER_ALPHA F + public static final field INSTANCE Lorg/polyfrost/oneconfig/api/ui/v1/components/MenuDefaults; + public static final field SUBMENU_DELAY_MILLIS J + public static final field TYPE_AHEAD_RESET_MILLIS J + public final fun contentColor-XeAY9LY (Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlStyle;Landroidx/compose/runtime/Composer;I)J + public final fun getContentPadding-D9Ej5fM ()F + public final fun getItemShape (Landroidx/compose/runtime/Composer;I)Landroidx/compose/ui/graphics/Shape; + public final fun getItemSpacing-D9Ej5fM ()F + public final fun getMaxWidth-D9Ej5fM ()F + public final fun getMinWidth-D9Ej5fM ()F + public final fun getShape (Landroidx/compose/runtime/Composer;I)Landroidx/compose/ui/graphics/Shape; + public final fun getSize ()Lorg/polyfrost/oneconfig/api/ui/v1/components/MenuItemSize; + public final fun getSubmenuGap-D9Ej5fM ()F + public final fun getTooltipMaxWidth-D9Ej5fM ()F +} + +public abstract interface class org/polyfrost/oneconfig/api/ui/v1/components/MenuEntry { +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/MenuHeading : org/polyfrost/oneconfig/api/ui/v1/components/MenuEntry { + public static final field $stable I + public fun (Ljava/lang/String;)V + public final fun getText ()Ljava/lang/String; +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/MenuIconRow : org/polyfrost/oneconfig/api/ui/v1/components/MenuEntry { + public static final field $stable I + public fun (Ljava/util/List;)V + public final fun getItems ()Ljava/util/List; } public final class org/polyfrost/oneconfig/api/ui/v1/components/MenuItemSize : java/lang/Enum { @@ -411,42 +868,141 @@ public final class org/polyfrost/oneconfig/api/ui/v1/components/MenuItemSize : j } public final class org/polyfrost/oneconfig/api/ui/v1/components/MenuKt { - public static final fun ContextMenu-vxkq2O8 (ZJLkotlin/jvm/functions/Function0;Ljava/util/List;Landroidx/compose/runtime/Composer;I)V - public static final fun ContextMenu-vxkq2O8 (ZJLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;I)V - public static final fun ContextMenuHost (Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)V - public static final fun MenuItem (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/ui/v1/components/MenuItemSize;ZZZLjava/lang/String;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)V - public static final fun MenuItem (Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V - public static final fun MenuItem (Ljava/lang/String;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V - public static final fun MenuList (Ljava/util/List;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V - public static final fun MenuList (Ljava/util/List;Lorg/polyfrost/oneconfig/api/ui/v1/components/MenuItemSize;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V - public static final fun MenuSurface (Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;I)V - public static final fun contextMenu (Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)Landroidx/compose/ui/Modifier; + public static final fun ContextMenu-K5zGePQ (ZJLkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V + public static final fun ContextMenu-K5zGePQ (ZJLkotlin/jvm/functions/Function0;Ljava/util/List;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V + public static final synthetic fun ContextMenu-vxkq2O8 (ZJLkotlin/jvm/functions/Function0;Ljava/util/List;Landroidx/compose/runtime/Composer;I)V + public static final synthetic fun ContextMenu-vxkq2O8 (ZJLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;I)V + public static final synthetic fun ContextMenuHost (Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)V + public static final synthetic fun MenuItem (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/ui/v1/components/MenuItemSize;ZZZLjava/lang/String;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)V + public static final synthetic fun MenuItem (Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V + public static final synthetic fun MenuItem (Ljava/lang/String;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V + public static final fun MenuItem (Ljava/lang/String;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Ljava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlStyle;Lorg/polyfrost/oneconfig/api/ui/v1/components/MenuItemSize;ZZLjava/lang/Boolean;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Landroidx/compose/foundation/interaction/MutableInteractionSource;Landroidx/compose/runtime/Composer;III)V + public static final synthetic fun MenuList (Ljava/util/List;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V + public static final fun MenuList (Ljava/util/List;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Lorg/polyfrost/oneconfig/api/ui/v1/components/MenuItemSize;Landroidx/compose/runtime/Composer;II)V + public static final synthetic fun MenuList (Ljava/util/List;Lorg/polyfrost/oneconfig/api/ui/v1/components/MenuItemSize;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V + public static final synthetic fun MenuSurface (Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;I)V + public static final fun MenuSurface-uFdPcIQ (Landroidx/compose/ui/Modifier;FLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V + public static final synthetic fun contextMenu (Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)Landroidx/compose/ui/Modifier; + public static final fun contextMenu (Landroidx/compose/ui/Modifier;ZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;)Landroidx/compose/ui/Modifier; + public static synthetic fun contextMenu$default (Landroidx/compose/ui/Modifier;ZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Landroidx/compose/ui/Modifier; + public static final fun rememberContextMenuState (Landroidx/compose/runtime/Composer;I)Lorg/polyfrost/oneconfig/api/ui/v1/components/ContextMenuState; +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/MenuRadioGroup : org/polyfrost/oneconfig/api/ui/v1/components/MenuEntry { + public static final field $stable I + public fun (Ljava/util/List;Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V + public final fun getLabel ()Lkotlin/jvm/functions/Function1; + public final fun getOnSelect ()Lkotlin/jvm/functions/Function1; + public final fun getOptions ()Ljava/util/List; + public final fun getSelected ()Ljava/lang/Object; +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/MenuSeparator : org/polyfrost/oneconfig/api/ui/v1/components/MenuEntry { + public static final field $stable I + public static final field INSTANCE Lorg/polyfrost/oneconfig/api/ui/v1/components/MenuSeparator; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/ModCardMenuContributors { + public static final field $stable I + public static final field INSTANCE Lorg/polyfrost/oneconfig/api/ui/v1/components/ModCardMenuContributors; + public static final fun entriesFor (Ljava/lang/String;)Ljava/util/List; + public static final fun register (Lkotlin/jvm/functions/Function1;)V + public static final fun unregister (Lkotlin/jvm/functions/Function1;)Z +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/MoreButtonDefaults { + public static final field $stable I + public static final field INSTANCE Lorg/polyfrost/oneconfig/api/ui/v1/components/MoreButtonDefaults; + public final fun getContentDescription (Landroidx/compose/runtime/Composer;I)Ljava/lang/String; +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/MoreButtonKt { + public static final fun MoreButton (Ljava/util/List;Landroidx/compose/ui/Modifier;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/ui/v1/components/IconButtonStyle;Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize;Landroidx/compose/runtime/Composer;II)V +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/NotificationsCenterPanelKt { + public static final fun NotificationsCenterPanel (Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/NumberFieldKt { + public static final fun NumberField (FLkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Lkotlin/ranges/ClosedFloatingPointRange;FIZLjava/lang/String;Lkotlin/jvm/functions/Function1;ZLorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize;Ljava/lang/String;Landroidx/compose/foundation/interaction/MutableInteractionSource;Landroidx/compose/runtime/Composer;III)V +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/OptionMenuContributors { + public static final field $stable I + public static final field INSTANCE Lorg/polyfrost/oneconfig/api/ui/v1/components/OptionMenuContributors; + public static final fun builtInEntries (Lorg/polyfrost/oneconfig/api/config/v1/Property;)Ljava/util/List; + public static final fun builtInEntries (Lorg/polyfrost/oneconfig/api/config/v1/Property;Lkotlin/jvm/functions/Function0;)Ljava/util/List; + public static synthetic fun builtInEntries$default (Lorg/polyfrost/oneconfig/api/config/v1/Property;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Ljava/util/List; + public static final fun entriesFor (Lorg/polyfrost/oneconfig/api/config/v1/Property;)Ljava/util/List; + public static final fun register (Lkotlin/jvm/functions/Function1;)V + public static final fun unregister (Lkotlin/jvm/functions/Function1;)Z } public final class org/polyfrost/oneconfig/api/ui/v1/components/OptionWidthKt { public static final fun getCurrentOptionWidth (Landroidx/compose/runtime/Composer;I)F + public static final fun getCurrentOptionWidthOrNull (Landroidx/compose/runtime/Composer;I)Landroidx/compose/ui/unit/Dp; +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/Placement : java/lang/Enum { + public static final field AboveCenter Lorg/polyfrost/oneconfig/api/ui/v1/components/Placement; + public static final field AboveEnd Lorg/polyfrost/oneconfig/api/ui/v1/components/Placement; + public static final field AboveStart Lorg/polyfrost/oneconfig/api/ui/v1/components/Placement; + public static final field BelowCenter Lorg/polyfrost/oneconfig/api/ui/v1/components/Placement; + public static final field BelowEnd Lorg/polyfrost/oneconfig/api/ui/v1/components/Placement; + public static final field BelowStart Lorg/polyfrost/oneconfig/api/ui/v1/components/Placement; + public static final field End Lorg/polyfrost/oneconfig/api/ui/v1/components/Placement; + public static final field Start Lorg/polyfrost/oneconfig/api/ui/v1/components/Placement; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/ui/v1/components/Placement; + public static fun values ()[Lorg/polyfrost/oneconfig/api/ui/v1/components/Placement; } public final class org/polyfrost/oneconfig/api/ui/v1/components/PlayerHeadKt { - public static final fun PlayerHead-rAjV9yQ (Ljava/util/UUID;FLandroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;I)V - public static final fun PlayerHead-uFdPcIQ (Ljava/util/UUID;FLandroidx/compose/ui/Modifier;Landroidx/compose/ui/graphics/Shape;Landroidx/compose/runtime/Composer;I)V + public static final fun PlayerHead-RfXq3Jk (Ljava/util/UUID;FLandroidx/compose/ui/Modifier;Ljava/lang/String;Landroidx/compose/ui/graphics/Shape;Ljava/lang/String;Landroidx/compose/runtime/Composer;II)V + public static final synthetic fun PlayerHead-rAjV9yQ (Ljava/util/UUID;FLandroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;I)V + public static final synthetic fun PlayerHead-uFdPcIQ (Ljava/util/UUID;FLandroidx/compose/ui/Modifier;Landroidx/compose/ui/graphics/Shape;Landroidx/compose/runtime/Composer;I)V + public static final fun PlayerHeadStack-DzVHIIc (Ljava/util/List;FLandroidx/compose/ui/Modifier;ILandroidx/compose/runtime/Composer;II)V } public final class org/polyfrost/oneconfig/api/ui/v1/components/PlayerHeads { public static final field $stable I public static final field INSTANCE Lorg/polyfrost/oneconfig/api/ui/v1/components/PlayerHeads; + public static final fun bitmap (Ljava/util/UUID;)Lorg/polyfrost/compose/render/PolyBitmap; public static final fun defaultFace (Ljava/util/UUID;)Landroidx/compose/ui/graphics/ImageBitmap; public static final fun face (Ljava/util/UUID;)Landroidx/compose/ui/graphics/ImageBitmap; } public final class org/polyfrost/oneconfig/api/ui/v1/components/PopupButtonKt { - public static final fun AnchoredPopupButton (Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;I)V - public static final fun AnchoredPopupButton-FJfuzF0 (Ljava/lang/String;Ljava/lang/String;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/Alignment$Horizontal;FLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;I)V + public static final synthetic fun AnchoredPopupButton (Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;I)V + public static final synthetic fun AnchoredPopupButton-FJfuzF0 (Ljava/lang/String;Ljava/lang/String;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/Alignment$Horizontal;FLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;I)V + public static final fun AnchoredPopupButton-Fsagccs (Landroidx/compose/ui/graphics/painter/Painter;Ljava/lang/String;Landroidx/compose/ui/Modifier;Lorg/polyfrost/oneconfig/api/ui/v1/components/Placement;FZLorg/polyfrost/oneconfig/api/ui/v1/components/IconButtonStyle;Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize;Landroidx/compose/foundation/interaction/MutableInteractionSource;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V + public static final fun AnchoredPopupButton-Fsagccs (Ljava/lang/String;Ljava/lang/String;Landroidx/compose/ui/Modifier;Lorg/polyfrost/oneconfig/api/ui/v1/components/Placement;FZLorg/polyfrost/oneconfig/api/ui/v1/components/IconButtonStyle;Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize;Landroidx/compose/foundation/interaction/MutableInteractionSource;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/ProgressBarDefaults { + public static final field $stable I + public static final field INDETERMINATE_MILLIS I + public static final field INSTANCE Lorg/polyfrost/oneconfig/api/ui/v1/components/ProgressBarDefaults; + public final fun thickness-u2uoSUM (Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize;)F +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/ProgressBarKt { + public static final fun ProgressBar (Ljava/lang/Float;Landroidx/compose/ui/Modifier;Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize;Landroidx/compose/runtime/Composer;II)V +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/RadioGroupKt { + public static final fun RadioGroup (Ljava/lang/Object;Ljava/util/List;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Landroidx/compose/foundation/gestures/Orientation;ZLandroidx/compose/foundation/interaction/MutableInteractionSource;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V } public final class org/polyfrost/oneconfig/api/ui/v1/components/ReorderKt { public static final fun rememberReorderState (Landroidx/compose/foundation/gestures/Orientation;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)Lorg/polyfrost/oneconfig/api/ui/v1/components/ReorderState; public static final fun rememberReorderState (Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)Lorg/polyfrost/oneconfig/api/ui/v1/components/ReorderState; + public static final fun reorderHandle (Landroidx/compose/ui/Modifier;Lorg/polyfrost/oneconfig/api/ui/v1/components/ReorderState;Ljava/lang/Object;Z)Landroidx/compose/ui/Modifier; + public static synthetic fun reorderHandle$default (Landroidx/compose/ui/Modifier;Lorg/polyfrost/oneconfig/api/ui/v1/components/ReorderState;Ljava/lang/Object;ZILjava/lang/Object;)Landroidx/compose/ui/Modifier; public static final fun reorderItem (Landroidx/compose/ui/Modifier;Lorg/polyfrost/oneconfig/api/ui/v1/components/ReorderState;Ljava/lang/Object;I)Landroidx/compose/ui/Modifier; public static final fun reorderItem (Landroidx/compose/ui/Modifier;Lorg/polyfrost/oneconfig/api/ui/v1/components/ReorderState;Ljava/lang/Object;IZ)Landroidx/compose/ui/Modifier; public static final fun withItemMoved (Ljava/util/List;II)Ljava/util/List; @@ -458,33 +1014,206 @@ public final class org/polyfrost/oneconfig/api/ui/v1/components/ReorderState { public final fun isDragging ()Z } +public final class org/polyfrost/oneconfig/api/ui/v1/components/ScrollingKt { + public static final fun ScrollContainer (Landroidx/compose/foundation/ScrollState;Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/gestures/Orientation;ZZLkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V + public static final fun Scrollbar (Landroidx/compose/foundation/lazy/LazyListState;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V + public static final fun Scrollbar (Landroidx/compose/foundation/lazy/grid/LazyGridState;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V + public static final fun fadingEdgesMask-wH6b6FI (Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/gestures/ScrollableState;F)Landroidx/compose/ui/Modifier; + public static synthetic fun fadingEdgesMask-wH6b6FI$default (Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/gestures/ScrollableState;FILjava/lang/Object;)Landroidx/compose/ui/Modifier; + public static final fun smoothWheelScroll (Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/gestures/ScrollableState;Landroidx/compose/foundation/gestures/Orientation;)Landroidx/compose/ui/Modifier; + public static synthetic fun smoothWheelScroll$default (Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/gestures/ScrollableState;Landroidx/compose/foundation/gestures/Orientation;ILjava/lang/Object;)Landroidx/compose/ui/Modifier; +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/SearchFieldKt { + public static final fun SearchField (Landroidx/compose/ui/text/input/TextFieldValue;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Ljava/lang/String;Lkotlin/jvm/functions/Function0;ZLorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize;ZLandroidx/compose/ui/focus/FocusRequester;Landroidx/compose/foundation/interaction/MutableInteractionSource;Lkotlin/jvm/functions/Function0;ZLandroidx/compose/runtime/Composer;III)V + public static final fun SearchField (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Ljava/lang/String;Lkotlin/jvm/functions/Function0;ZLorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize;ZLandroidx/compose/ui/focus/FocusRequester;Landroidx/compose/foundation/interaction/MutableInteractionSource;Lkotlin/jvm/functions/Function0;ZLandroidx/compose/runtime/Composer;III)V +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/SelectDefaults { + public static final field $stable I + public static final field INSTANCE Lorg/polyfrost/oneconfig/api/ui/v1/components/SelectDefaults; + public static final field MAX_NAMED I + public final fun getHorizontalPadding-D9Ej5fM ()F + public final fun getMinWidth-D9Ej5fM ()F +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/SelectKt { + public static final fun MultiSelect (Ljava/util/Set;Ljava/util/List;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;ZLandroidx/compose/foundation/interaction/MutableInteractionSource;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V + public static final fun Select (Ljava/lang/Object;Ljava/util/List;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;ZLjava/lang/String;Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize;Landroidx/compose/foundation/interaction/MutableInteractionSource;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/SelectionKt { + public static final fun SelectionBar (ILkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V + public static final fun selectableCard (Landroidx/compose/ui/Modifier;ZLkotlin/jvm/functions/Function0;Ljava/lang/String;Landroidx/compose/ui/graphics/Shape;Z)Landroidx/compose/ui/Modifier; + public static synthetic fun selectableCard$default (Landroidx/compose/ui/Modifier;ZLkotlin/jvm/functions/Function0;Ljava/lang/String;Landroidx/compose/ui/graphics/Shape;ZILjava/lang/Object;)Landroidx/compose/ui/Modifier; + public static final fun selectableRow (Landroidx/compose/ui/Modifier;ZLkotlin/jvm/functions/Function0;Ljava/lang/String;Landroidx/compose/ui/graphics/Shape;Z)Landroidx/compose/ui/Modifier; + public static synthetic fun selectableRow$default (Landroidx/compose/ui/Modifier;ZLkotlin/jvm/functions/Function0;Ljava/lang/String;Landroidx/compose/ui/graphics/Shape;ZILjava/lang/Object;)Landroidx/compose/ui/Modifier; +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/SliderKt { + public static final fun Slider (FLkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Lkotlin/ranges/ClosedFloatingPointRange;FZLjava/lang/String;Lkotlin/jvm/functions/Function1;Landroidx/compose/foundation/interaction/MutableInteractionSource;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)V +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/SplitButtonDefaults { + public static final field $stable I + public static final field INSTANCE Lorg/polyfrost/oneconfig/api/ui/v1/components/SplitButtonDefaults; + public final fun getChevronPadding-D9Ej5fM ()F + public final fun getDivider-D9Ej5fM ()F +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/SplitButtonKt { + public static final fun SplitButton (Ljava/lang/String;Lkotlin/jvm/functions/Function0;Ljava/util/List;Landroidx/compose/ui/Modifier;Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlStyle;Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize;ZLandroidx/compose/runtime/Composer;II)V +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/StatusMessage { + public static final field $stable I + public final fun clear ()V + public final fun getText ()Ljava/lang/String; + public final fun getType ()Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType; + public final fun show (Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;)V + public static synthetic fun show$default (Lorg/polyfrost/oneconfig/api/ui/v1/components/StatusMessage;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;ILjava/lang/Object;)V +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/StatusTextKt { + public static final fun StatusText (Lorg/polyfrost/oneconfig/api/ui/v1/components/StatusMessage;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V + public static final fun rememberStatusMessage (JLandroidx/compose/runtime/Composer;II)Lorg/polyfrost/oneconfig/api/ui/v1/components/StatusMessage; +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/SurfaceKind : java/lang/Enum { + public static final field Card Lorg/polyfrost/oneconfig/api/ui/v1/components/SurfaceKind; + public static final field Field Lorg/polyfrost/oneconfig/api/ui/v1/components/SurfaceKind; + public static final field Panel Lorg/polyfrost/oneconfig/api/ui/v1/components/SurfaceKind; + public static final field Popup Lorg/polyfrost/oneconfig/api/ui/v1/components/SurfaceKind; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/ui/v1/components/SurfaceKind; + public static fun values ()[Lorg/polyfrost/oneconfig/api/ui/v1/components/SurfaceKind; +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/SurfacesKt { + public static final fun ProvideThemeVariant (ZZLkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V + public static final fun liquidGlass (Landroidx/compose/ui/Modifier;Landroidx/compose/ui/graphics/Shape;ZLandroidx/compose/runtime/Composer;II)Landroidx/compose/ui/Modifier; + public static final fun themedSurface (Landroidx/compose/ui/Modifier;Lorg/polyfrost/oneconfig/api/ui/v1/components/SurfaceKind;Landroidx/compose/ui/graphics/Shape;ZLandroidx/compose/runtime/Composer;II)Landroidx/compose/ui/Modifier; +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/SwitchKt { + public static final fun Switch (ZLkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;ZLandroidx/compose/foundation/interaction/MutableInteractionSource;Landroidx/compose/runtime/Composer;II)V + public static final fun SwitchRow (Ljava/lang/String;ZLkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Ljava/lang/String;ZLandroidx/compose/foundation/interaction/MutableInteractionSource;Landroidx/compose/runtime/Composer;II)V +} + public final class org/polyfrost/oneconfig/api/ui/v1/components/TabItem { public static final field $stable I - public fun (Ljava/lang/Object;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/Object;Ljava/lang/String;)V + public fun (Ljava/lang/Object;Ljava/lang/String;Ljava/lang/String;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize;Landroidx/compose/foundation/interaction/MutableInteractionSource;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/Object;Ljava/lang/String;Ljava/lang/String;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize;Landroidx/compose/foundation/interaction/MutableInteractionSource;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/Object;Lorg/polyfrost/oneconfig/api/ui/v1/page/PageText;Ljava/lang/String;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize;Landroidx/compose/foundation/interaction/MutableInteractionSource;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/Object;Lorg/polyfrost/oneconfig/api/ui/v1/page/PageText;Ljava/lang/String;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize;Landroidx/compose/foundation/interaction/MutableInteractionSource;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getBadge ()Ljava/lang/String; + public final fun getEnabled ()Z + public final fun getIcon ()Ljava/lang/String; + public final fun getIconPainter ()Landroidx/compose/ui/graphics/painter/Painter; + public final fun getInteractionSource ()Landroidx/compose/foundation/interaction/MutableInteractionSource; public final fun getLabel ()Ljava/lang/String; + public final fun getLabelText ()Lorg/polyfrost/oneconfig/api/ui/v1/page/PageText; + public final fun getSize ()Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize; + public final fun getTooltip ()Ljava/lang/String; public final fun getValue ()Ljava/lang/Object; } +public final class org/polyfrost/oneconfig/api/ui/v1/components/TabStyle : java/lang/Enum { + public static final field Pill Lorg/polyfrost/oneconfig/api/ui/v1/components/TabStyle; + public static final field Segmented Lorg/polyfrost/oneconfig/api/ui/v1/components/TabStyle; + public static final field Underline Lorg/polyfrost/oneconfig/api/ui/v1/components/TabStyle; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/ui/v1/components/TabStyle; + public static fun values ()[Lorg/polyfrost/oneconfig/api/ui/v1/components/TabStyle; +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/TabsKt { + public static final fun SegmentedChips (Ljava/util/List;Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;ZLorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize;Landroidx/compose/runtime/Composer;II)V + public static final fun TabRow (Ljava/util/List;Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Lorg/polyfrost/oneconfig/api/ui/v1/components/TabStyle;Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize;ZZLandroidx/compose/foundation/interaction/MutableInteractionSource;Lorg/polyfrost/oneconfig/api/ui/v1/components/TooltipPlacement;Landroidx/compose/runtime/Composer;II)V +} + public final class org/polyfrost/oneconfig/api/ui/v1/components/TextButtonKt { - public static final fun TextButton (Ljava/lang/String;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V - public static final fun TextButton (Ljava/lang/String;Landroidx/compose/ui/Modifier;Lorg/polyfrost/oneconfig/api/ui/v1/components/ButtonStyle;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V - public static final fun TextButton (Ljava/lang/String;Ljava/lang/String;Landroidx/compose/ui/Modifier;Lorg/polyfrost/oneconfig/api/ui/v1/components/ButtonStyle;ZLkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V + public static final fun TextButton (Landroidx/compose/ui/text/AnnotatedString;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlStyle;Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize;ZZLkotlin/jvm/functions/Function2;Landroidx/compose/foundation/interaction/MutableInteractionSource;Landroidx/compose/runtime/Composer;II)V + public static final fun TextButton (Landroidx/compose/ui/text/AnnotatedString;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlStyle;Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize;ZZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Landroidx/compose/foundation/interaction/MutableInteractionSource;Landroidx/compose/runtime/Composer;II)V + public static final synthetic fun TextButton (Ljava/lang/String;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V + public static final synthetic fun TextButton (Ljava/lang/String;Landroidx/compose/ui/Modifier;Lorg/polyfrost/oneconfig/api/ui/v1/components/ButtonStyle;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V + public static final synthetic fun TextButton (Ljava/lang/String;Ljava/lang/String;Landroidx/compose/ui/Modifier;Lorg/polyfrost/oneconfig/api/ui/v1/components/ButtonStyle;ZLkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V + public static final fun TextButton (Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlStyle;Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize;ZZLkotlin/jvm/functions/Function2;Landroidx/compose/foundation/interaction/MutableInteractionSource;Landroidx/compose/runtime/Composer;II)V + public static final fun TextButton (Ljava/lang/String;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlStyle;Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize;ZZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Landroidx/compose/foundation/interaction/MutableInteractionSource;Landroidx/compose/runtime/Composer;II)V +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/TextContextMenuKt { + public static final fun ProvideOneConfigTextContextMenu (Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)V +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/TextFieldDefaults { + public static final field $stable I + public static final field INSTANCE Lorg/polyfrost/oneconfig/api/ui/v1/components/TextFieldDefaults; + public final fun fontSize-kPz2Gy4 (Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize;)J + public final fun getBorder (Landroidx/compose/runtime/Composer;I)J + public final fun getContentSpacing-D9Ej5fM ()F + public final fun getErrorBorder (Landroidx/compose/runtime/Composer;I)J + public final fun getFill (Landroidx/compose/runtime/Composer;I)J + public final fun getFocusBorder (Landroidx/compose/runtime/Composer;I)J + public final fun getHorizontalPadding-D9Ej5fM ()F + public final fun getShape (Landroidx/compose/runtime/Composer;I)Landroidx/compose/ui/graphics/Shape; + public final fun getSize ()Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize; + public final fun iconSize-u2uoSUM (Lorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize;)F } public final class org/polyfrost/oneconfig/api/ui/v1/components/TextFieldKt { - public static final fun TextField (Landroidx/compose/ui/text/input/TextFieldValue;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Ljava/lang/String;Landroidx/compose/runtime/Composer;I)V - public static final fun TextField (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;I)V - public static final fun TextField (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Ljava/lang/String;Landroidx/compose/runtime/Composer;I)V - public static final fun TextField (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Ljava/lang/String;Ljava/lang/String;IZLandroidx/compose/runtime/Composer;I)V - public static final fun TextField-OrAvtKI (Landroidx/compose/ui/text/input/TextFieldValue;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Ljava/lang/String;Ljava/lang/String;IZZZZJLjava/lang/String;ZLandroidx/compose/ui/focus/FocusRequester;Landroidx/compose/foundation/interaction/MutableInteractionSource;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)V - public static final fun TextField-OrAvtKI (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Ljava/lang/String;Ljava/lang/String;IZZZZJLjava/lang/String;ZLandroidx/compose/ui/focus/FocusRequester;Landroidx/compose/foundation/interaction/MutableInteractionSource;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)V + public static final synthetic fun TextField (Landroidx/compose/ui/text/input/TextFieldValue;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Ljava/lang/String;Landroidx/compose/runtime/Composer;I)V + public static final synthetic fun TextField (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;I)V + public static final synthetic fun TextField (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Ljava/lang/String;Landroidx/compose/runtime/Composer;I)V + public static final synthetic fun TextField (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Ljava/lang/String;Ljava/lang/String;IZLandroidx/compose/runtime/Composer;I)V + public static final synthetic fun TextField-OrAvtKI (Landroidx/compose/ui/text/input/TextFieldValue;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Ljava/lang/String;Ljava/lang/String;IZZZZJLjava/lang/String;ZLandroidx/compose/ui/focus/FocusRequester;Landroidx/compose/foundation/interaction/MutableInteractionSource;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)V + public static final synthetic fun TextField-OrAvtKI (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Ljava/lang/String;Ljava/lang/String;IZZZZJLjava/lang/String;ZLandroidx/compose/ui/focus/FocusRequester;Landroidx/compose/foundation/interaction/MutableInteractionSource;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)V + public static final fun TextField-Tu70ims (Landroidx/compose/ui/text/input/TextFieldValue;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Ljava/lang/String;ZZZLorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;ZIZJLandroidx/compose/ui/focus/FocusRequester;Lkotlin/jvm/functions/Function0;Landroidx/compose/foundation/interaction/MutableInteractionSource;Landroidx/compose/ui/text/input/VisualTransformation;ZLandroidx/compose/runtime/Composer;IIII)V + public static final fun TextField-Tu70ims (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Ljava/lang/String;ZZZLorg/polyfrost/oneconfig/api/ui/v1/components/ControlSize;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;ZIZJLandroidx/compose/ui/focus/FocusRequester;Lkotlin/jvm/functions/Function0;Landroidx/compose/foundation/interaction/MutableInteractionSource;Landroidx/compose/ui/text/input/VisualTransformation;ZLandroidx/compose/runtime/Composer;IIII)V } public final class org/polyfrost/oneconfig/api/ui/v1/components/TextKt { - public static final fun Text-5ts07b0 (Ljava/lang/Object;Landroidx/compose/ui/Modifier;JJLandroidx/compose/runtime/Composer;I)V - public static final fun Text-Vukvz_4 (Ljava/lang/Object;Landroidx/compose/ui/Modifier;JJLandroidx/compose/ui/unit/TextUnit;FLandroidx/compose/ui/text/font/FontWeight;IIILandroidx/compose/runtime/Composer;I)V - public static final fun Text-XO-JAsU (Ljava/lang/Object;Landroidx/compose/ui/Modifier;JLandroidx/compose/runtime/Composer;I)V - public static final fun Text-mhOCef0 (Ljava/lang/Object;Landroidx/compose/ui/Modifier;JJLandroidx/compose/ui/text/font/FontWeight;Landroidx/compose/runtime/Composer;I)V + public static final synthetic fun Text-5ts07b0 (Ljava/lang/Object;Landroidx/compose/ui/Modifier;JJLandroidx/compose/runtime/Composer;I)V + public static final synthetic fun Text-Vukvz_4 (Ljava/lang/Object;Landroidx/compose/ui/Modifier;JJLandroidx/compose/ui/unit/TextUnit;FLandroidx/compose/ui/text/font/FontWeight;IIILandroidx/compose/runtime/Composer;I)V + public static final synthetic fun Text-XO-JAsU (Ljava/lang/Object;Landroidx/compose/ui/Modifier;JLandroidx/compose/runtime/Composer;I)V + public static final fun Text-hwE3cfs (Landroidx/compose/ui/text/AnnotatedString;Landroidx/compose/ui/Modifier;JLandroidx/compose/ui/text/TextStyle;IIZLandroidx/compose/ui/text/style/TextAlign;Landroidx/compose/ui/graphics/ColorProducer;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V + public static final fun Text-hwE3cfs (Ljava/lang/String;Landroidx/compose/ui/Modifier;JLandroidx/compose/ui/text/TextStyle;IIZLandroidx/compose/ui/text/style/TextAlign;Landroidx/compose/ui/graphics/ColorProducer;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V + public static final fun Text-hwE3cfs (Lnet/kyori/adventure/text/Component;Landroidx/compose/ui/Modifier;JLandroidx/compose/ui/text/TextStyle;IIZLandroidx/compose/ui/text/style/TextAlign;Landroidx/compose/ui/graphics/ColorProducer;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V + public static final synthetic fun Text-mhOCef0 (Ljava/lang/Object;Landroidx/compose/ui/Modifier;JJLandroidx/compose/ui/text/font/FontWeight;Landroidx/compose/runtime/Composer;I)V + public static final fun Text-qS5SVU0 (Ljava/util/List;Landroidx/compose/ui/Modifier;JLandroidx/compose/ui/text/TextStyle;IIZLandroidx/compose/ui/text/style/TextAlign;Landroidx/compose/ui/graphics/ColorProducer;Landroidx/compose/runtime/Composer;II)V +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/TextPromptDialogKt { + public static final fun TextPromptDialog (Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/ToastDefaults { + public static final field $stable I + public static final field INSTANCE Lorg/polyfrost/oneconfig/api/ui/v1/components/ToastDefaults; + public static final field maxVisible I + public final fun getAlignment ()Landroidx/compose/ui/Alignment; + public final fun getMargin-D9Ej5fM ()F + public final fun getMaxWidth-D9Ej5fM ()F +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/Toasts { + public static final field $stable I + public static final field INSTANCE Lorg/polyfrost/oneconfig/api/ui/v1/components/Toasts; + public static final fun show (Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;J)V + public static synthetic fun show$default (Ljava/lang/String;Lorg/polyfrost/oneconfig/api/notifications/v1/NotificationType;JILjava/lang/Object;)V +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/ToastsKt { + public static final fun ToastHost (Landroidx/compose/ui/Modifier;Landroidx/compose/ui/Alignment;Landroidx/compose/runtime/Composer;II)V +} + +public final class org/polyfrost/oneconfig/api/ui/v1/components/TooltipDefaults { + public static final field $stable I + public static final field INSTANCE Lorg/polyfrost/oneconfig/api/ui/v1/components/TooltipDefaults; + public static final field delayMillis J + public final fun getMaxWidth-D9Ej5fM ()F + public final fun getPlacement ()Lorg/polyfrost/oneconfig/api/ui/v1/components/TooltipPlacement; } public final class org/polyfrost/oneconfig/api/ui/v1/components/TooltipKt { @@ -504,6 +1233,21 @@ public final class org/polyfrost/oneconfig/api/ui/v1/components/TooltipPlacement public static fun values ()[Lorg/polyfrost/oneconfig/api/ui/v1/components/TooltipPlacement; } +public final class org/polyfrost/oneconfig/api/ui/v1/components/UiSound : java/lang/Enum { + public static final field Click Lorg/polyfrost/oneconfig/api/ui/v1/components/UiSound; + public static final field Close Lorg/polyfrost/oneconfig/api/ui/v1/components/UiSound; + public static final field Open Lorg/polyfrost/oneconfig/api/ui/v1/components/UiSound; + public static final field SliderTick Lorg/polyfrost/oneconfig/api/ui/v1/components/UiSound; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/ui/v1/components/UiSound; + public static fun values ()[Lorg/polyfrost/oneconfig/api/ui/v1/components/UiSound; +} + +public final class org/polyfrost/oneconfig/api/ui/v1/config/PropertyStates { + public static final fun asFlow (Lorg/polyfrost/oneconfig/api/config/v1/Property;)Lkotlinx/coroutines/flow/Flow; + public static final fun collectAsState (Lorg/polyfrost/oneconfig/api/config/v1/Property;Landroidx/compose/runtime/Composer;I)Landroidx/compose/runtime/State; +} + public final class org/polyfrost/oneconfig/api/ui/v1/keybind/BindNotInScreen : org/polyfrost/oneconfig/api/ui/v1/keybind/OneConfigKeybind { public static final field $stable I public fun ([I[IBJLkotlin/jvm/functions/Function1;)V @@ -551,6 +1295,7 @@ public final class org/polyfrost/oneconfig/api/ui/v1/keybind/KeybindHelper { public final fun inScreens ()Lorg/polyfrost/oneconfig/api/ui/v1/keybind/KeybindHelper; public final fun key ([I)Lorg/polyfrost/oneconfig/api/ui/v1/keybind/KeybindHelper; public final fun meta ()Lorg/polyfrost/oneconfig/api/ui/v1/keybind/KeybindHelper; + public final fun mirrorToVanilla (Z)Lorg/polyfrost/oneconfig/api/ui/v1/keybind/KeybindHelper; public final fun mouse ([I)Lorg/polyfrost/oneconfig/api/ui/v1/keybind/KeybindHelper; public final fun name (Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/ui/v1/keybind/KeybindHelper; public final fun register ()Lorg/polyfrost/oneconfig/api/ui/v1/keybind/OneConfigKeybind; @@ -587,6 +1332,12 @@ public final class org/polyfrost/oneconfig/api/ui/v1/keybind/KeybindManager { public static final fun unregister (Lorg/polyfrost/oneconfig/api/ui/v1/keybind/OneConfigKeybind;)V } +public final class org/polyfrost/oneconfig/api/ui/v1/keybind/KeybindMetadata { + public static final field $stable I + public static final field INSTANCE Lorg/polyfrost/oneconfig/api/ui/v1/keybind/KeybindMetadata; + public static final field NO_MIRROR Ljava/lang/String; +} + public final class org/polyfrost/oneconfig/api/ui/v1/keybind/KeybindUtils { public static final field $stable I public static final field INSTANCE Lorg/polyfrost/oneconfig/api/ui/v1/keybind/KeybindUtils; @@ -615,6 +1366,7 @@ public class org/polyfrost/oneconfig/api/ui/v1/keybind/OneConfigKeybind : org/po public final fun getFiresWhileTyping ()Z public final fun getHasUnresolvedInputs ()Z public final fun getKeyCodes ()[I + public final fun getMirrorToVanilla ()Z public final fun getMods ()B public final fun getMouseBtns ()[I public final fun getName ()Ljava/lang/String; @@ -629,6 +1381,7 @@ public class org/polyfrost/oneconfig/api/ui/v1/keybind/OneConfigKeybind : org/po public final fun setDurationNanos (J)V public final fun setFiresWhileTyping (Z)V public final fun setKeyCodes ([I)V + public final fun setMirrorToVanilla (Z)V public final fun setMods (B)V public final fun setMouseBtns ([I)V public final fun setName (Ljava/lang/String;)V @@ -662,6 +1415,45 @@ public final class org/polyfrost/oneconfig/api/ui/v1/page/ModConfigTarget { public fun toString ()Ljava/lang/String; } +public abstract interface class org/polyfrost/oneconfig/api/ui/v1/page/OneConfigDestination { +} + +public final class org/polyfrost/oneconfig/api/ui/v1/page/OneConfigDestination$Custom : org/polyfrost/oneconfig/api/ui/v1/page/OneConfigDestination { + public static final field $stable I + public fun (Lorg/polyfrost/oneconfig/api/ui/v1/page/OneConfigPage;)V + public final fun component1 ()Lorg/polyfrost/oneconfig/api/ui/v1/page/OneConfigPage; + public final fun copy (Lorg/polyfrost/oneconfig/api/ui/v1/page/OneConfigPage;)Lorg/polyfrost/oneconfig/api/ui/v1/page/OneConfigDestination$Custom; + public static synthetic fun copy$default (Lorg/polyfrost/oneconfig/api/ui/v1/page/OneConfigDestination$Custom;Lorg/polyfrost/oneconfig/api/ui/v1/page/OneConfigPage;ILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/ui/v1/page/OneConfigDestination$Custom; + public fun equals (Ljava/lang/Object;)Z + public final fun getPage ()Lorg/polyfrost/oneconfig/api/ui/v1/page/OneConfigPage; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/polyfrost/oneconfig/api/ui/v1/page/OneConfigDestination$ModConfig : org/polyfrost/oneconfig/api/ui/v1/page/OneConfigDestination { + public static final field $stable I + public fun (Lorg/polyfrost/oneconfig/api/ui/v1/page/ModConfigTarget;)V + public final fun component1 ()Lorg/polyfrost/oneconfig/api/ui/v1/page/ModConfigTarget; + public final fun copy (Lorg/polyfrost/oneconfig/api/ui/v1/page/ModConfigTarget;)Lorg/polyfrost/oneconfig/api/ui/v1/page/OneConfigDestination$ModConfig; + public static synthetic fun copy$default (Lorg/polyfrost/oneconfig/api/ui/v1/page/OneConfigDestination$ModConfig;Lorg/polyfrost/oneconfig/api/ui/v1/page/ModConfigTarget;ILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/ui/v1/page/OneConfigDestination$ModConfig; + public fun equals (Ljava/lang/Object;)Z + public final fun getTarget ()Lorg/polyfrost/oneconfig/api/ui/v1/page/ModConfigTarget; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/polyfrost/oneconfig/api/ui/v1/page/OneConfigDestination$Page : org/polyfrost/oneconfig/api/ui/v1/page/OneConfigDestination { + public static final field $stable I + public fun (Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/ui/v1/page/OneConfigDestination$Page; + public static synthetic fun copy$default (Lorg/polyfrost/oneconfig/api/ui/v1/page/OneConfigDestination$Page;Ljava/lang/String;ILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/ui/v1/page/OneConfigDestination$Page; + public fun equals (Ljava/lang/Object;)Z + public final fun getId ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public abstract class org/polyfrost/oneconfig/api/ui/v1/page/OneConfigPage { public static final field $stable I public fun (Ljava/lang/String;Lorg/polyfrost/oneconfig/api/ui/v1/page/PageText;Ljava/lang/String;Lorg/polyfrost/oneconfig/api/ui/v1/page/PageGroup;)V @@ -684,6 +1476,7 @@ public final class org/polyfrost/oneconfig/api/ui/v1/page/OneConfigPages { public static final fun modConfig (Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/ui/v1/page/ModConfigTarget; public static final fun modConfig (Ljava/lang/String;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/ui/v1/page/ModConfigTarget; public static final fun register (Lorg/polyfrost/oneconfig/api/ui/v1/page/OneConfigPage;)V + public static final fun registerIfAbsent (Lorg/polyfrost/oneconfig/api/ui/v1/page/OneConfigPage;)Z public static final fun unregister (Ljava/lang/String;)Z } @@ -708,6 +1501,7 @@ public final class org/polyfrost/oneconfig/api/ui/v1/page/PageGroup$Companion { public final class org/polyfrost/oneconfig/api/ui/v1/page/PageSearchQueryKt { public static final fun getCurrentPageSearchQuery (Landroidx/compose/runtime/Composer;I)Ljava/lang/String; + public static final fun getCurrentPageSearchQueryOrNull (Landroidx/compose/runtime/Composer;I)Ljava/lang/String; } public final class org/polyfrost/oneconfig/api/ui/v1/page/PageText { @@ -727,6 +1521,10 @@ public final class org/polyfrost/oneconfig/api/ui/v1/page/PageText$Companion { public final fun translatable (Ljava/lang/String;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/ui/v1/page/PageText; } +public final class org/polyfrost/oneconfig/api/ui/v1/page/PageTextKt { + public static final fun resolve (Lorg/polyfrost/oneconfig/api/ui/v1/page/PageText;Landroidx/compose/runtime/Composer;I)Ljava/lang/String; +} + public abstract interface class org/polyfrost/oneconfig/api/ui/v1/screen/BlurScreen { public abstract fun hasBackgroundBlur ()Z } @@ -791,11 +1589,43 @@ public final class org/polyfrost/oneconfig/api/ui/v1/search/SearchScope$Companio public final fun config (Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/ui/v1/search/SearchScope; } +public final class org/polyfrost/oneconfig/api/ui/v1/theme/OneConfigShapes { + public static final field $stable I + public fun (Landroidx/compose/ui/graphics/Shape;Landroidx/compose/ui/graphics/Shape;Landroidx/compose/ui/graphics/Shape;Landroidx/compose/ui/graphics/Shape;Landroidx/compose/ui/graphics/Shape;Landroidx/compose/ui/graphics/Shape;)V + public final fun getCircle ()Landroidx/compose/ui/graphics/Shape; + public final fun getField ()Landroidx/compose/ui/graphics/Shape; + public final fun getLarge ()Landroidx/compose/ui/graphics/Shape; + public final fun getMedium ()Landroidx/compose/ui/graphics/Shape; + public final fun getPill ()Landroidx/compose/ui/graphics/Shape; + public final fun getSmall ()Landroidx/compose/ui/graphics/Shape; +} + +public final class org/polyfrost/oneconfig/api/ui/v1/theme/OneConfigSpacing { + public static final field $stable I + public static final field Companion Lorg/polyfrost/oneconfig/api/ui/v1/theme/OneConfigSpacing$Companion; + public static final field Default Lorg/polyfrost/oneconfig/api/ui/v1/theme/OneConfigSpacing; + public fun ()V + public synthetic fun (FFFFFFFFILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (FFFFFFFFLkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getL-D9Ej5fM ()F + public final fun getM-D9Ej5fM ()F + public final fun getPageGutter-D9Ej5fM ()F + public final fun getS-D9Ej5fM ()F + public final fun getXl-D9Ej5fM ()F + public final fun getXs-D9Ej5fM ()F + public final fun getXxl-D9Ej5fM ()F + public final fun getXxs-D9Ej5fM ()F +} + +public final class org/polyfrost/oneconfig/api/ui/v1/theme/OneConfigSpacing$Companion { +} + public final class org/polyfrost/oneconfig/api/ui/v1/theme/OneConfigTheme { public static final field $stable I public static final field INSTANCE Lorg/polyfrost/oneconfig/api/ui/v1/theme/OneConfigTheme; public static final fun activate (Lorg/polyfrost/oneconfig/api/ui/v1/theme/OneConfigUITheme;)V public static final fun builtIn ()Ljava/util/List; + public static final fun custom (Lorg/polyfrost/oneconfig/api/ui/v1/theme/OneConfigUITheme;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Lorg/polyfrost/oneconfig/api/ui/v1/theme/OneConfigUITheme; public static final fun ensureLoaded ()V public final fun getAccent (Landroidx/compose/runtime/Composer;I)J public static final fun getActive ()Lorg/polyfrost/oneconfig/api/ui/v1/theme/OneConfigUITheme; @@ -809,40 +1639,83 @@ public final class org/polyfrost/oneconfig/api/ui/v1/theme/OneConfigTheme { public static final fun getPOLYFROSTED_LIGHT ()Lorg/polyfrost/oneconfig/api/ui/v1/theme/OneConfigUITheme; public static final fun getPOLY_GLASS_DARK ()Lorg/polyfrost/oneconfig/api/ui/v1/theme/OneConfigUITheme; public static final fun getPOLY_GLASS_LIGHT ()Lorg/polyfrost/oneconfig/api/ui/v1/theme/OneConfigUITheme; + public final fun getShapes (Landroidx/compose/runtime/Composer;I)Lorg/polyfrost/oneconfig/api/ui/v1/theme/OneConfigShapes; + public final fun getSpacing (Landroidx/compose/runtime/Composer;I)Lorg/polyfrost/oneconfig/api/ui/v1/theme/OneConfigSpacing; + public final fun getTypography (Landroidx/compose/runtime/Composer;I)Lorg/polyfrost/oneconfig/api/ui/v1/theme/OneConfigTypography; + public final fun icon (Ljava/lang/String;Landroidx/compose/runtime/Composer;I)Ljava/lang/String; public static final fun overrideLogo (Ljava/lang/String;)V + public final fun shape-8Feqmps (FLandroidx/compose/runtime/Composer;I)Landroidx/compose/ui/graphics/Shape; } public final class org/polyfrost/oneconfig/api/ui/v1/theme/OneConfigThemeKt { - public static final fun ProvideOneConfigTheme (Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)V + public static final synthetic fun ProvideOneConfigTheme (Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)V + public static final fun ProvideOneConfigTheme (Lorg/polyfrost/oneconfig/api/ui/v1/theme/OneConfigUITheme;ZLkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V +} + +public class org/polyfrost/oneconfig/api/ui/v1/theme/OneConfigTypography { + public static final field $stable I + public fun (Landroidx/compose/ui/text/TextStyle;Landroidx/compose/ui/text/TextStyle;Landroidx/compose/ui/text/TextStyle;Landroidx/compose/ui/text/TextStyle;Landroidx/compose/ui/text/TextStyle;Landroidx/compose/ui/text/TextStyle;Landroidx/compose/ui/text/TextStyle;Landroidx/compose/ui/text/TextStyle;)V + public fun (Landroidx/compose/ui/text/font/FontFamily;)V + public final fun getBody ()Landroidx/compose/ui/text/TextStyle; + public final fun getCaption ()Landroidx/compose/ui/text/TextStyle; + public final fun getEyebrow ()Landroidx/compose/ui/text/TextStyle; + public final fun getHeading ()Landroidx/compose/ui/text/TextStyle; + public final fun getLabel ()Landroidx/compose/ui/text/TextStyle; + public final fun getMono ()Landroidx/compose/ui/text/TextStyle; + public final fun getSmall ()Landroidx/compose/ui/text/TextStyle; + public final fun getTitle ()Landroidx/compose/ui/text/TextStyle; } public abstract class org/polyfrost/oneconfig/api/ui/v1/theme/OneConfigUITheme { public static final field $stable I - public fun ()V + public fun getAccentBrush ()Landroidx/compose/ui/graphics/Brush; public abstract fun getAccentTextColor-0d7_KjU ()J public abstract fun getBackgroundShape ()Landroidx/compose/ui/graphics/Shape; public abstract fun getBorderColor-0d7_KjU ()J public abstract fun getButtonShape ()Landroidx/compose/ui/graphics/Shape; public abstract fun getCheckBoxShape ()Landroidx/compose/ui/graphics/Shape; + public fun getCheckerDark-0d7_KjU ()J + public fun getCheckerLight-0d7_KjU ()J public abstract fun getChipBackground-0d7_KjU ()J public abstract fun getCircleShape ()Landroidx/compose/ui/graphics/Shape; public abstract fun getComponentBackground-0d7_KjU ()J public abstract fun getControlThumbColor-0d7_KjU ()J + public fun getControlTrackColor-0d7_KjU ()J + public fun getDisabledAlpha ()F + public fun getError-0d7_KjU ()J public abstract fun getFavoriteColor-0d7_KjU ()J + public fun getFocusRingColor-0d7_KjU ()J public abstract fun getFontFamily ()Landroidx/compose/ui/text/font/FontFamily; + public fun getHoverOverlayAlpha ()F + public fun getInfo-0d7_KjU ()J public abstract fun getLogoPath ()Ljava/lang/String; public abstract fun getModCardBackground-0d7_KjU ()J public abstract fun getModCardShape ()Landroidx/compose/ui/graphics/Shape; + public fun getMutedText-0d7_KjU ()J public abstract fun getName ()Ljava/lang/String; + public fun getOnError-0d7_KjU ()J + public fun getOnMedia-0d7_KjU ()J + public fun getOnMediaShadow-0d7_KjU ()J + public fun getOnSuccess-0d7_KjU ()J public abstract fun getPageBackground-0d7_KjU ()J + public final fun getPageSurface-0d7_KjU ()J public abstract fun getPopupBackground-0d7_KjU ()J public abstract fun getPopupShape ()Landroidx/compose/ui/graphics/Shape; + public fun getPressedOverlayAlpha ()F + public fun getScrimColor-0d7_KjU ()J public abstract fun getShadowColor-0d7_KjU ()J public abstract fun getShadowEnabled ()Z + public fun getShapes ()Lorg/polyfrost/oneconfig/api/ui/v1/theme/OneConfigShapes; public abstract fun getSideBarNavigationEntryShape ()Landroidx/compose/ui/graphics/Shape; public abstract fun getSidebarBackground-0d7_KjU ()J + public fun getSpacing ()Lorg/polyfrost/oneconfig/api/ui/v1/theme/OneConfigSpacing; + public fun getStyle ()Lorg/polyfrost/oneconfig/api/ui/v1/theme/ThemeStyle; + public fun getSuccess-0d7_KjU ()J public abstract fun getTextColor-0d7_KjU ()J public abstract fun getTextColorSecondary-0d7_KjU ()J + public fun getTypography ()Lorg/polyfrost/oneconfig/api/ui/v1/theme/OneConfigTypography; + public fun getWarning-0d7_KjU ()J + public fun isLight ()Z } public final class org/polyfrost/oneconfig/api/ui/v1/theme/RadiiKt { @@ -850,3 +1723,52 @@ public final class org/polyfrost/oneconfig/api/ui/v1/theme/RadiiKt { public static final fun concentric-ziNgDLE (Landroidx/compose/ui/graphics/Shape;FLandroidx/compose/runtime/Composer;I)Landroidx/compose/ui/graphics/Shape; } +public final class org/polyfrost/oneconfig/api/ui/v1/theme/ThemeBuilder { + public static final field $stable I + public final fun getAccentTextColor-QN2ZGVo ()Landroidx/compose/ui/graphics/Color; + public final fun getBorderColor-QN2ZGVo ()Landroidx/compose/ui/graphics/Color; + public final fun getChipBackground-QN2ZGVo ()Landroidx/compose/ui/graphics/Color; + public final fun getComponentBackground-QN2ZGVo ()Landroidx/compose/ui/graphics/Color; + public final fun getControlThumbColor-QN2ZGVo ()Landroidx/compose/ui/graphics/Color; + public final fun getControlTrackColor-QN2ZGVo ()Landroidx/compose/ui/graphics/Color; + public final fun getFavoriteColor-QN2ZGVo ()Landroidx/compose/ui/graphics/Color; + public final fun getModCardBackground-QN2ZGVo ()Landroidx/compose/ui/graphics/Color; + public final fun getPageBackground-QN2ZGVo ()Landroidx/compose/ui/graphics/Color; + public final fun getPopupBackground-QN2ZGVo ()Landroidx/compose/ui/graphics/Color; + public final fun getShadowColor-QN2ZGVo ()Landroidx/compose/ui/graphics/Color; + public final fun getShadowEnabled ()Ljava/lang/Boolean; + public final fun getSidebarBackground-QN2ZGVo ()Landroidx/compose/ui/graphics/Color; + public final fun getTextColor-QN2ZGVo ()Landroidx/compose/ui/graphics/Color; + public final fun getTextColorSecondary-QN2ZGVo ()Landroidx/compose/ui/graphics/Color; + public final fun setAccentTextColor-Y2TPw74 (Landroidx/compose/ui/graphics/Color;)V + public final fun setBorderColor-Y2TPw74 (Landroidx/compose/ui/graphics/Color;)V + public final fun setChipBackground-Y2TPw74 (Landroidx/compose/ui/graphics/Color;)V + public final fun setComponentBackground-Y2TPw74 (Landroidx/compose/ui/graphics/Color;)V + public final fun setControlThumbColor-Y2TPw74 (Landroidx/compose/ui/graphics/Color;)V + public final fun setControlTrackColor-Y2TPw74 (Landroidx/compose/ui/graphics/Color;)V + public final fun setFavoriteColor-Y2TPw74 (Landroidx/compose/ui/graphics/Color;)V + public final fun setModCardBackground-Y2TPw74 (Landroidx/compose/ui/graphics/Color;)V + public final fun setPageBackground-Y2TPw74 (Landroidx/compose/ui/graphics/Color;)V + public final fun setPopupBackground-Y2TPw74 (Landroidx/compose/ui/graphics/Color;)V + public final fun setShadowColor-Y2TPw74 (Landroidx/compose/ui/graphics/Color;)V + public final fun setShadowEnabled (Ljava/lang/Boolean;)V + public final fun setSidebarBackground-Y2TPw74 (Landroidx/compose/ui/graphics/Color;)V + public final fun setTextColor-Y2TPw74 (Landroidx/compose/ui/graphics/Color;)V + public final fun setTextColorSecondary-Y2TPw74 (Landroidx/compose/ui/graphics/Color;)V +} + +public final class org/polyfrost/oneconfig/api/ui/v1/theme/ThemeStyle : java/lang/Enum { + public static final field Flat Lorg/polyfrost/oneconfig/api/ui/v1/theme/ThemeStyle; + public static final field Glass Lorg/polyfrost/oneconfig/api/ui/v1/theme/ThemeStyle; + public static final field Pixel Lorg/polyfrost/oneconfig/api/ui/v1/theme/ThemeStyle; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/ui/v1/theme/ThemeStyle; + public static fun values ()[Lorg/polyfrost/oneconfig/api/ui/v1/theme/ThemeStyle; +} + +public final class org/polyfrost/oneconfig/api/ui/v1/theme/ThemeTokensKt { + public static final fun contrastAgainst--OWjLjI (JJ)F + public static final fun legibleOn-jxsXWHM (JJF)J + public static synthetic fun legibleOn-jxsXWHM$default (JJFILjava/lang/Object;)J +} + diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/IconSpec.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/IconSpec.kt new file mode 100644 index 000000000..795d973fa --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/IconSpec.kt @@ -0,0 +1,46 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1 + +@ExperimentalOneConfigUiApi +public class IconSpec @JvmOverloads public constructor( + public val path: String, + public val x: Int, + public val y: Int, + public val width: Int, + public val height: Int = width, + public val sheetWidth: Int = 0, + public val sheetHeight: Int = sheetWidth, + public val pixelated: Boolean = true, +) { + init { + require(width > 0 && height > 0) { "IconSpec cell size must be positive, got ${width}x$height" } + require(x >= 0 && y >= 0) { "IconSpec cell position must not be negative, got $x,$y" } + require(sheetWidth >= 0 && sheetHeight >= 0) { "IconSpec sheet size must not be negative" } + } + + override fun equals(other: Any?): Boolean = other is IconSpec && other.path == path && other.x == x && + other.y == y && other.width == width && other.height == height && other.sheetWidth == sheetWidth && + other.sheetHeight == sheetHeight && other.pixelated == pixelated + + override fun hashCode(): Int { + var result = path.hashCode() + result = 31 * result + x + result = 31 * result + y + result = 31 * result + width + result = 31 * result + height + result = 31 * result + sheetWidth + result = 31 * result + sheetHeight + result = 31 * result + pixelated.hashCode() + return result + } + + override fun toString(): String = "IconSpec($path, $x, $y, ${width}x$height)" + + public companion object { + @JvmStatic + @JvmOverloads + public fun grid(path: String, column: Int, row: Int, cellSize: Int, sheetSize: Int = 0): IconSpec = + IconSpec(path, column * cellSize, row * cellSize, cellSize, cellSize, sheetSize, sheetSize) + } +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/InternalOneConfigUiApi.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/InternalOneConfigUiApi.kt new file mode 100644 index 000000000..0a576820e --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/InternalOneConfigUiApi.kt @@ -0,0 +1,16 @@ +package org.polyfrost.oneconfig.api.ui.v1 + +@RequiresOptIn( + message = "This is OneConfig's internal UI plumbing and can change in any release. Use the public kit instead.", + level = RequiresOptIn.Level.ERROR, +) +@Retention(AnnotationRetention.BINARY) +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY, + AnnotationTarget.CONSTRUCTOR, + AnnotationTarget.TYPEALIAS, + AnnotationTarget.PROPERTY_SETTER, +) +public annotation class InternalOneConfigUiApi diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/ModCardType.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/ModCardType.kt index 1e29d1f17..02473c4b0 100644 --- a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/ModCardType.kt +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/ModCardType.kt @@ -1,12 +1,31 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + package org.polyfrost.oneconfig.api.ui.v1 -public class ModCardType @JvmOverloads constructor( +import org.polyfrost.oneconfig.api.ui.v1.page.PageText +import org.polyfrost.oneconfig.api.ui.v1.page.translate + +public class ModCardType @ExperimentalOneConfigUiApi @JvmOverloads constructor( public val id: String, - public val title: String, + title: PageText, public val icon: String? = null, public val priority: Int = 0, public val collapsedByDefault: Boolean = false, ) { + @JvmOverloads + public constructor( + id: String, + title: String, + icon: String? = null, + priority: Int = 0, + collapsedByDefault: Boolean = false, + ) : this(id, PageText.literal(title), icon, priority, collapsedByDefault) + + @ExperimentalOneConfigUiApi + public val titleText: PageText = title + + public val title: String get() = titleText.translate() + override fun equals(other: Any?): Boolean = this === other || (other is ModCardType && other.id == id) override fun hashCode(): Int = id.hashCode() diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/ModCardTypes.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/ModCardTypes.kt index fb8ca1f90..c05fd9966 100644 --- a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/ModCardTypes.kt +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/ModCardTypes.kt @@ -72,6 +72,44 @@ public object ModCardTypes { bump() } + @ExperimentalOneConfigUiApi + @JvmStatic + public fun unregister(typeId: String): Boolean { + val removed = synchronized(lock) { + if (typesById[typeId] == null) return@synchronized false + typesById = typesById - typeId + sortedTypes = typesById.values.sortedWith(TypeOrder) + true + } + if (removed) bump() + return removed + } + + @ExperimentalOneConfigUiApi + @JvmStatic + public fun unassign(modId: String): Boolean { + val key = normalise(modId) + val removed = synchronized(lock) { + if (key !in assignments) return@synchronized false + assignments = assignments - key + true + } + if (removed) bump() + return removed + } + + @ExperimentalOneConfigUiApi + @JvmStatic + public fun unregisterResolver(resolver: ModCardTypeResolver): Boolean { + val removed = synchronized(lock) { + if (resolver !in resolvers) return@synchronized false + resolvers = resolvers - resolver + true + } + if (removed) bump() + return removed + } + @JvmStatic public fun types(): List = sortedTypes diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/ModSortModes.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/ModSortModes.kt index 04f593d87..acbac8f9f 100644 --- a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/ModSortModes.kt +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/ModSortModes.kt @@ -6,15 +6,34 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.Snapshot import org.apache.logging.log4j.LogManager import org.polyfrost.oneconfig.api.ui.v1.internal.extensionsBridge +import org.polyfrost.oneconfig.api.ui.v1.page.PageText +import org.polyfrost.oneconfig.api.ui.v1.page.translate @ExperimentalOneConfigUiApi public class ModSortMode @JvmOverloads constructor( public val id: String, public val icon: String, - public val title: String, - public val confirmation: String? = null, + title: PageText, + confirmation: PageText? = null, public val onSelect: () -> Unit, -) +) { + @JvmOverloads + public constructor( + id: String, + icon: String, + title: String, + confirmation: String? = null, + onSelect: () -> Unit, + ) : this(id, icon, PageText.literal(title), confirmation?.let(PageText::literal), onSelect) + + public val titleText: PageText = title + + public val confirmationText: PageText? = confirmation + + public val title: String get() = titleText.translate() + + public val confirmation: String? get() = confirmationText?.translate() +} @ExperimentalOneConfigUiApi public object ModSortModes { diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/ModToggles.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/ModToggles.kt index 0e5b52e7f..cfe34d534 100644 --- a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/ModToggles.kt +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/ModToggles.kt @@ -70,6 +70,13 @@ public object ModToggles { public fun register(modId: String, toggle: org.polyfrost.oneconfig.api.ui.v1.ModToggle): Unit = register(modId, toggle as ModToggle) + @ExperimentalOneConfigUiApi + @JvmStatic + @JvmOverloads + public fun registerReadOnly(modId: String, isEnabled: () -> Boolean, lockedReason: () -> String? = { null }) { + register(modId, LambdaReadOnlyModToggle(isEnabled, lockedReason)) + } + /** * Shows a switch on the cards of [modIds] whose state is stored by OneConfig * diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/OneConfigPrefs.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/OneConfigPrefs.kt new file mode 100644 index 000000000..e4a8885ca --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/OneConfigPrefs.kt @@ -0,0 +1,39 @@ +package org.polyfrost.oneconfig.api.ui.v1 + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +@ExperimentalOneConfigUiApi +public object OneConfigPrefs { + @JvmStatic + public var animationsEnabled: Boolean by mutableStateOf(true) + private set + + @JvmStatic + public var reducedMotion: Boolean by mutableStateOf(false) + private set + + @JvmStatic + public var backgroundBlur: Boolean by mutableStateOf(true) + private set + + @JvmStatic + public var guiOpenAnimation: Boolean by mutableStateOf(true) + private set + + @JvmStatic + public var uiScale: Float by mutableFloatStateOf(1f) + private set + + @InternalOneConfigUiApi + @JvmStatic + public fun update(animationsEnabled: Boolean, reducedMotion: Boolean, backgroundBlur: Boolean, guiOpenAnimation: Boolean, uiScale: Float) { + this.animationsEnabled = animationsEnabled + this.reducedMotion = reducedMotion || !animationsEnabled + this.backgroundBlur = backgroundBlur + this.guiOpenAnimation = guiOpenAnimation + this.uiScale = uiScale + } +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/OneConfigUI.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/OneConfigUI.kt index 047cffa7b..d3026f0f8 100644 --- a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/OneConfigUI.kt +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/OneConfigUI.kt @@ -1,7 +1,12 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + package org.polyfrost.oneconfig.api.ui.v1 import java.util.ServiceLoader import org.apache.logging.log4j.LogManager +import org.polyfrost.oneconfig.api.ui.v1.keybind.KeybindManager +import org.polyfrost.oneconfig.api.ui.v1.page.OneConfigDestination +import org.polyfrost.oneconfig.api.ui.v1.page.OneConfigPage /** * Utility for opening the OneConfig UI @@ -38,6 +43,44 @@ public object OneConfigUI { } } + @JvmStatic + public fun open() { + show(null) + } + + @ExperimentalOneConfigUiApi + @JvmStatic + public fun open(destination: OneConfigDestination? = null) { + show(routeOf(destination)) + } + + @ExperimentalOneConfigUiApi + @JvmStatic + @JvmOverloads + public fun open(page: OneConfigPage, argument: Any? = null) { + if (argument == null) { + open(OneConfigDestination.Custom(page)) + return + } + val bridge = bridge + if (bridge == null) { + LOGGER.warn("Cannot open the OneConfig UI: no OneConfigUIBridge is available") + return + } + bridge.openPage(page, argument) + } + + @ExperimentalOneConfigUiApi + @JvmStatic + public val currentPageArgument: Any? + get() = bridge?.takePageArgument() + + @ExperimentalOneConfigUiApi + @JvmStatic + public fun open(pageId: String) { + open(OneConfigDestination.Page(pageId)) + } + /** * Opens the OneConfig UI on [route] or on the page set by the user's opening behavior when null * @@ -47,9 +90,13 @@ public object OneConfigUI { * * If the UI is already open it navigates to [route] in place */ + @Deprecated("Open a OneConfigDestination, a OneConfigPage or a page id instead") @JvmStatic - @JvmOverloads public fun open(route: Any? = null) { + show(normalizeRoute(route)) + } + + private fun show(route: Any?) { val bridge = bridge if (bridge == null) { LOGGER.warn("Cannot open the OneConfig UI: no OneConfigUIBridge is available") @@ -58,6 +105,18 @@ public object OneConfigUI { bridge.open(route) } + private fun routeOf(destination: OneConfigDestination?): Any? = when (destination) { + null -> null + is OneConfigDestination.Page -> destination.id + is OneConfigDestination.ModConfig -> destination.target + is OneConfigDestination.Custom -> destination.page + } + + private fun normalizeRoute(route: Any?): Any? = when (route) { + is OneConfigDestination -> routeOf(route) + else -> route + } + /** * Builds the OneConfig UI screen opened on [route] without showing it for callers that set the screen * themselves @@ -72,9 +131,20 @@ public object OneConfigUI { */ @JvmStatic @JvmOverloads - public fun createScreen(route: Any? = null): Any? = bridge?.createScreen(route) + public fun createScreen(route: Any? = null): Any? = bridge?.createScreen(normalizeRoute(route)) @ExperimentalOneConfigUiApi @JvmStatic public fun isOneConfigScreen(screen: Any?): Boolean = bridge?.isOneConfigScreen(screen) ?: false + + @ExperimentalOneConfigUiApi + @JvmStatic + public fun ensureInitialized() { + try { + Class.forName(KeybindManager::class.java.name, true, KeybindManager::class.java.classLoader) + } catch (t: Throwable) { + LOGGER.warn("Failed to arm the OneConfig keybind handlers", t) + } + bridge?.ensureInitialized() + } } diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/OneConfigUIBridge.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/OneConfigUIBridge.kt index f514ff780..6271c8e20 100644 --- a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/OneConfigUIBridge.kt +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/OneConfigUIBridge.kt @@ -1,5 +1,7 @@ package org.polyfrost.oneconfig.api.ui.v1 +import org.polyfrost.oneconfig.api.ui.v1.page.OneConfigPage + /** * Platform bridge that builds and shows the OneConfig UI screen */ @@ -11,4 +13,15 @@ public interface OneConfigUIBridge { public fun open(route: Any?) public fun isOneConfigScreen(screen: Any?): Boolean = false + + @ExperimentalOneConfigUiApi + public fun openPage(page: OneConfigPage, argument: Any?) { + open(page) + } + + @ExperimentalOneConfigUiApi + public fun takePageArgument(): Any? = null + + @ExperimentalOneConfigUiApi + public fun ensureInitialized() {} } diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/ReadOnlyModToggle.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/ReadOnlyModToggle.kt new file mode 100644 index 000000000..73fde4ecb --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/ReadOnlyModToggle.kt @@ -0,0 +1,21 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1 + +import org.polyfrost.oneconfig.api.toggle.v1.ModToggle + +@ExperimentalOneConfigUiApi +public interface ReadOnlyModToggle : ModToggle { + public val lockedReason: String? + + override fun setEnabled(enabled: Boolean) {} +} + +internal class LambdaReadOnlyModToggle( + private val enabled: () -> Boolean, + private val reason: () -> String?, +) : ReadOnlyModToggle { + override val lockedReason: String? get() = reason() + + override fun isEnabled(): Boolean = enabled() +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/TreeHeaderDecorators.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/TreeHeaderDecorators.kt new file mode 100644 index 000000000..9680d86a5 --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/TreeHeaderDecorators.kt @@ -0,0 +1,46 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1 + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot +import org.apache.logging.log4j.LogManager +import org.polyfrost.oneconfig.api.config.v1.Tree + +@ExperimentalOneConfigUiApi +public abstract class TreeHeaderDecorator { + @Composable + public abstract fun Decorate(tree: Tree, content: @Composable () -> Unit) +} + +@ExperimentalOneConfigUiApi +public object TreeHeaderDecorators { + private val LOGGER = LogManager.getLogger("OneConfig/TreeHeaderDecorators") + private val lock = Any() + + private var entries by mutableStateOf(emptyList()) + + @JvmStatic + public fun register(entry: TreeHeaderDecorator) { + synchronized(lock) { + if (entry in entries) { + LOGGER.debug("Ignoring TreeHeaderDecorator {} that is already registered", entry.javaClass.name) + return + } + Snapshot.withMutableSnapshot { entries = entries + entry } + } + } + + @JvmStatic + public fun unregister(entry: TreeHeaderDecorator): Boolean = synchronized(lock) { + if (entry !in entries) return false + Snapshot.withMutableSnapshot { entries = entries - entry } + true + } + + @JvmStatic + public fun all(): List = entries +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/a11y/A11y.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/a11y/A11y.kt index 7999e356c..31c846fd5 100644 --- a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/a11y/A11y.kt +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/a11y/A11y.kt @@ -32,6 +32,7 @@ import org.jetbrains.annotations.ApiStatus import org.polyfrost.oneconfig.api.platform.v1.Platform import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme +import org.polyfrost.oneconfig.api.ui.v1.theme.ThemeStyle @ExperimentalOneConfigUiApi public enum class A11yRole(public val spoken: String) { @@ -42,16 +43,25 @@ public enum class A11yRole(public val spoken: String) { Option("option"), Link("link"), TextField("edit box"), + Slider("slider"), + RadioButton("radio button"), + MenuItem("menu item"), + Dialog("dialog"), + Heading("heading"), + Image("image"), + ProgressBar("progress bar"), ; internal val semanticsRole: Role? get() = when (this) { - Button -> Role.Button + Button, MenuItem -> Role.Button Tab -> Role.Tab Checkbox -> Role.Checkbox Switch -> Role.Switch Option -> Role.DropdownList - Link, TextField -> null + RadioButton -> Role.RadioButton + Image -> Role.Image + Link, TextField, Slider, Dialog, Heading, ProgressBar -> null } } @@ -128,22 +138,20 @@ private val RING_WIDTH = 2.dp private val HALO_WIDTH = 1.dp private const val HALO_ALPHA = 0.7f -@Composable -private fun isMinecraftTheme(): Boolean { - val theme = OneConfigTheme.current - return theme === OneConfigTheme.MINECRAFT_DARK || theme === OneConfigTheme.MINECRAFT_LIGHT -} - @ExperimentalOneConfigUiApi public fun Modifier.focusRing(): Modifier = focusRing(null) @ExperimentalOneConfigUiApi public fun Modifier.focusRing(shape: Shape?): Modifier = composed { var focused by remember { mutableStateOf(false) } - val minecraft = isMinecraftTheme() - val ring = if (minecraft) Color.White else OneConfigTheme.accent - val halo = (if (minecraft) Color.Black else OneConfigTheme.current.pageBackground).copy(alpha = HALO_ALPHA) - val outline = shape ?: OneConfigTheme.current.buttonShape + val theme = OneConfigTheme.current + val ring = theme.focusRingColor + val halo = when { + theme.style != ThemeStyle.Pixel -> theme.pageBackground + theme.isLight -> Color.White + else -> Color.Black + }.copy(alpha = HALO_ALPHA) + val outline = shape ?: theme.buttonShape onFocusChanged { focused = it.isFocused }.drawWithContent { drawContent() if (focused && A11y.keyboardMode) drawFocusRing(outline, ring, halo) diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/a11y/Motion.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/a11y/Motion.kt index f58753330..96c316d81 100644 --- a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/a11y/Motion.kt +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/a11y/Motion.kt @@ -6,6 +6,9 @@ import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.InfiniteRepeatableSpec +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.Spring import androidx.compose.animation.core.VisibilityThreshold import androidx.compose.animation.core.snap @@ -13,9 +16,14 @@ import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi -import org.polyfrost.oneconfig.api.ui.v1.internal.componentsBridge +import org.polyfrost.oneconfig.api.ui.v1.OneConfigPrefs @ExperimentalOneConfigUiApi public object Motion { @@ -23,7 +31,7 @@ public object Motion { @JvmStatic public val reduced: Boolean - get() = runCatching { componentsBridge().reducedMotion() }.getOrDefault(false) + get() = OneConfigPrefs.reducedMotion @JvmStatic public fun spec(spec: AnimationSpec): AnimationSpec = if (reduced) snap() else spec @@ -40,4 +48,27 @@ public object Motion { @JvmStatic public fun itemPlacementSpec(): FiniteAnimationSpec? = if (reduced) null else spring(stiffness = Spring.StiffnessMediumLow, visibilityThreshold = IntOffset.VisibilityThreshold) + + @Composable + public fun animateColorAsState(target: Color, spec: AnimationSpec = spring(), label: String = "ColorAnimation"): State = + androidx.compose.animation.animateColorAsState(target, spec(spec), label) + + @Composable + public fun animateFloatAsState(target: Float, spec: AnimationSpec = spring(), label: String = "FloatAnimation"): State = + androidx.compose.animation.core.animateFloatAsState(target, spec(spec), label = label) + + @Composable + public fun animateDpAsState(target: Dp, spec: AnimationSpec = spring(), label: String = "DpAnimation"): State = + androidx.compose.animation.core.animateDpAsState(target, spec(spec), label = label) + + @Composable + public fun animateInfiniteFloat( + initial: Float, + target: Float, + spec: InfiniteRepeatableSpec, + label: String = "InfiniteAnimation", + ): State { + if (reduced) return rememberUpdatedState(initial) + return rememberInfiniteTransition(label).animateFloat(initial, target, spec, label) + } } diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Anchoring.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Anchoring.kt new file mode 100644 index 000000000..00b08da36 --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Anchoring.kt @@ -0,0 +1,98 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.PopupPositionProvider +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi + +@ExperimentalOneConfigUiApi +public enum class Placement { + BelowStart, + BelowCenter, + BelowEnd, + AboveStart, + AboveCenter, + AboveEnd, + + Start, + + End, +} + +@ExperimentalOneConfigUiApi +@Composable +public fun rememberAnchoredPosition(placement: Placement = Placement.BelowStart, gap: Dp = 6.dp, flip: Boolean = true): PopupPositionProvider { + val gapPx = with(LocalDensity.current) { gap.roundToPx() } + return remember(placement, gapPx, flip) { AnchoredPositionProvider(placement, gapPx, flip) } +} + +@ExperimentalOneConfigUiApi +@Composable +public fun popupMaxHeight(margin: Dp = 16.dp): Dp { + val density = LocalDensity.current + val window = LocalWindowInfo.current.containerSize + return with(density) { (window.height.toDp() - margin * 2).coerceAtLeast(48.dp) } +} + +internal class AnchoredPositionProvider( + private val placement: Placement, + private val gap: Int, + private val flip: Boolean, +) : PopupPositionProvider { + override fun calculatePosition(anchorBounds: IntRect, windowSize: IntSize, layoutDirection: LayoutDirection, popupContentSize: IntSize): IntOffset { + val w = popupContentSize.width + val h = popupContentSize.height + val rtl = layoutDirection == LayoutDirection.Rtl + val (x, y) = when (placement) { + Placement.Start, Placement.End -> { + val endSide = (placement == Placement.End) != rtl + val after = anchorBounds.right + gap + val before = anchorBounds.left - gap - w + val fitsAfter = after + w <= windowSize.width + val fitsBefore = before >= 0 + val x = when { + endSide && (fitsAfter || !flip || !fitsBefore) -> after + endSide -> before + fitsBefore || !flip || !fitsAfter -> before + else -> after + } + x to anchorBounds.top + } + else -> { + val above = placement == Placement.AboveStart || placement == Placement.AboveCenter || placement == Placement.AboveEnd + val startX = if (rtl) anchorBounds.right - w else anchorBounds.left + val endX = if (rtl) anchorBounds.left else anchorBounds.right - w + val x = when (placement) { + Placement.BelowStart, Placement.AboveStart -> startX + Placement.BelowEnd, Placement.AboveEnd -> endX + else -> anchorBounds.left + (anchorBounds.width - w) / 2 + } + val belowY = anchorBounds.bottom + gap + val aboveY = anchorBounds.top - gap - h + val fitsBelow = belowY + h <= windowSize.height + val fitsAbove = aboveY >= 0 + val y = when { + !above && (fitsBelow || !flip || !fitsAbove) -> belowY + !above -> aboveY + fitsAbove || !flip || !fitsBelow -> aboveY + else -> belowY + } + x to y + } + } + return IntOffset( + x.coerceIn(0, (windowSize.width - w).coerceAtLeast(0)), + y.coerceIn(0, (windowSize.height - h).coerceAtLeast(0)), + ) + } +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Button.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Button.kt index bfe9c8189..68e974f49 100644 --- a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Button.kt +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Button.kt @@ -1,25 +1,44 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + package org.polyfrost.oneconfig.api.ui.v1.components +import androidx.compose.animation.core.tween +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.unit.dp import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi -import org.polyfrost.oneconfig.api.ui.v1.internal.componentsBridge +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme +private const val BARE_FADE_MILLIS = 150 + +private const val BARE_DISABLED_ALPHA = 0.35f + +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) @ExperimentalOneConfigUiApi @Composable public fun IconButton(iconName: String, modifier: Modifier, onClick: () -> Unit) { - IconButton(iconName, modifier, true, onClick) + BareIconButton(iconName, modifier, OneConfigTheme.current.textColor.copy(alpha = 0.7f), OneConfigTheme.current.textColor, true, false, onClick) } +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) @ExperimentalOneConfigUiApi @Composable public fun IconButton(iconName: String, modifier: Modifier, enabled: Boolean, onClick: () -> Unit) { val textColor = OneConfigTheme.current.textColor - componentsBridge().IconButton(iconName, modifier, textColor.copy(alpha = 0.7f), textColor, enabled, false, onClick) + BareIconButton(iconName, modifier, textColor.copy(alpha = 0.7f), textColor, enabled, false, onClick) } +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) @ExperimentalOneConfigUiApi @Composable public fun IconButton( @@ -31,5 +50,42 @@ public fun IconButton( hoverBorder: Boolean, onClick: () -> Unit, ) { - componentsBridge().IconButton(iconName, modifier, foreground, hoveredForeground, enabled, hoverBorder, onClick) + BareIconButton(iconName, modifier, foreground, hoveredForeground, enabled, hoverBorder, onClick) +} + +@Composable +private fun BareIconButton( + iconName: String, + modifier: Modifier, + foreground: Color, + hoveredForeground: Color, + enabled: Boolean, + hoverBorder: Boolean, + onClick: () -> Unit, +) { + val theme = OneConfigTheme.current + val source = remember { MutableInteractionSource() } + val hovered by source.collectIsHoveredAsState() + val tint by Motion.animateColorAsState( + when { + !enabled -> theme.textColorSecondary.copy(alpha = BARE_DISABLED_ALPHA) + hovered -> hoveredForeground + else -> foreground + }, + tween(BARE_FADE_MILLIS), + ) + val outline by Motion.animateColorAsState( + if (hoverBorder && enabled && hovered) theme.borderColor else theme.borderColor.copy(alpha = 0f), + tween(BARE_FADE_MILLIS), + ) + Icon( + iconName, + null, + Modifier + .onClick(source, enabled, onClick) + .then(if (enabled) Modifier.pointerHoverIcon(PointerIcon.Hand) else Modifier) + .then(modifier) + .then(if (hoverBorder) Modifier.border(1.dp, outline, theme.buttonShape).padding(4.dp) else Modifier), + tint, + ) } diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/ButtonDefaults.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/ButtonDefaults.kt new file mode 100644 index 000000000..0cf535ea7 --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/ButtonDefaults.kt @@ -0,0 +1,51 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme + +private const val WHITE_MIN_CONTRAST = 3f + +@ExperimentalOneConfigUiApi +public object ButtonDefaults { + public val style: ControlStyle = ControlStyle.Secondary + + public val size: ControlSize = ControlSize.Regular + + public val contentSpacing: Dp = 8.dp + + public const val FILLED_HOVER_ALPHA: Float = 0.75f + + public const val DANGER_REST_ALPHA: Float = 0.12f + + public const val DANGER_HOVER_ALPHA: Float = 0.2f + + public val shape: Shape + @Composable get() = OneConfigTheme.current.buttonShape + + public fun horizontalPadding(size: ControlSize): Dp = if (size == ControlSize.Compact) 10.dp else 16.dp + + public fun fontSize(size: ControlSize): TextUnit = if (size == ControlSize.Large) 14.sp else 13.sp + + public fun iconSize(size: ControlSize): Dp = if (size == ControlSize.Compact) 14.dp else 16.dp + + @Composable + public fun contentColor(style: ControlStyle): Color { + val theme = OneConfigTheme.current + return when (style) { + ControlStyle.Primary -> theme.accentTextColor + ControlStyle.DangerFilled -> if (1.05f / (theme.error.luminance() + 0.05f) >= WHITE_MIN_CONTRAST) Color.White else theme.onError + ControlStyle.Danger -> theme.error + ControlStyle.Secondary, ControlStyle.Ghost -> theme.textColor + } + } +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/CardGrid.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/CardGrid.kt new file mode 100644 index 000000000..964d466a2 --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/CardGrid.kt @@ -0,0 +1,95 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme + +private data class CardGridHeaderKey(val name: String, val run: Int) + +private sealed class CardGridEntry { + class Header(val name: String, val key: CardGridHeaderKey) : CardGridEntry() + + class Item(val value: T, val key: Any) : CardGridEntry() +} + +@ExperimentalOneConfigUiApi +@Composable +public fun CardGrid( + items: List, + key: (T) -> Any, + modifier: Modifier = Modifier, + minCellWidth: Dp = 180.dp, + state: LazyGridState = rememberLazyGridState(), + group: ((T) -> String)? = null, + content: @Composable (T) -> Unit, +) { + val spacing = OneConfigTheme.current.spacing + val entries = remember(items, key, group) { cardGridEntries(items, key, group) } + val gutter = themedScrollbarStyle().thickness + spacing.xs * 2 + Box(modifier.smoothWheelScroll(state)) { + LazyVerticalGrid( + columns = GridCells.Adaptive(minCellWidth), + modifier = Modifier.fillMaxSize(), + state = state, + contentPadding = PaddingValues(end = gutter, bottom = spacing.l), + horizontalArrangement = Arrangement.spacedBy(spacing.l), + verticalArrangement = Arrangement.spacedBy(spacing.l), + ) { + for (entry in entries) { + when (entry) { + is CardGridEntry.Header -> item(key = entry.key, span = { GridItemSpan(maxLineSpan) }, contentType = CardGridHeaderKey::class) { + SectionHeader( + entry.name, + Modifier + .animateItem(fadeInSpec = null, placementSpec = Motion.itemPlacementSpec(), fadeOutSpec = null) + .padding(top = if (entry.key.run == 0) 0.dp else spacing.m), + ) + } + is CardGridEntry.Item -> item(key = entry.key) { + Box(Modifier.animateItem(fadeInSpec = null, placementSpec = Motion.itemPlacementSpec(), fadeOutSpec = null)) { + content(entry.value) + } + } + } + } + } + Box(Modifier.matchParentSize()) { + Scrollbar(state, Modifier.align(Alignment.CenterEnd).fillMaxHeight().padding(horizontal = spacing.xs)) + } + } +} + +private fun cardGridEntries(items: List, key: (T) -> Any, group: ((T) -> String)?): List> { + if (group == null) return items.map { CardGridEntry.Item(it, key(it)) } + val out = ArrayList>(items.size + 4) + var current: String? = null + var run = 0 + for (item in items) { + val name = group(item) + if (name != current) { + out += CardGridEntry.Header(name, CardGridHeaderKey(name, run++)) + current = name + } + out += CardGridEntry.Item(item, key(item)) + } + return out +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Checkbox.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Checkbox.kt new file mode 100644 index 000000000..d3e6300ef --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Checkbox.kt @@ -0,0 +1,96 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.dp +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.a11y.A11yRole +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme + +private val BOX_SIZE = 24.dp +private val TICK_SIZE = 26.dp +private val BOX_BORDER = 1.5.dp +private const val FADE_MILLIS = 150 + +@ExperimentalOneConfigUiApi +@Composable +public fun Checkbox( + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + interactionSource: MutableInteractionSource? = null, +) { + val theme = OneConfigTheme.current + val source = interactionSource ?: remember { MutableInteractionSource() } + val hovered by source.collectIsHoveredAsState() + CheckboxBox( + checked, + hovered && enabled, + modifier + .graphicsLayer { alpha = if (enabled) 1f else theme.disabledAlpha } + .oneConfigClickable( + if (checked) formOnLabel() else formOffLabel(), + { onCheckedChange(!checked) }, + role = A11yRole.Checkbox, + enabled = enabled, + checked = checked, + shape = theme.checkBoxShape, + indication = false, + interactionSource = source, + ), + ) +} + +@Composable +internal fun CheckboxBox(checked: Boolean, hovered: Boolean, modifier: Modifier) { + val theme = OneConfigTheme.current + val accent = OneConfigTheme.accent + val shape = theme.checkBoxShape + val accentAlpha = animateFloatAsState(if (checked) 1f else 0f, Motion.spec(tween(FADE_MILLIS)), label = "checkboxAccent") + val border = animateColorAsState( + when { + checked -> accent + hovered -> theme.textColorSecondary + else -> theme.borderColor + }, + Motion.spec(tween(FADE_MILLIS)), + label = "checkboxBorder", + ) + Box( + modifier = modifier + .size(BOX_SIZE) + .clip(shape) + .formFrame( + shape, + theme.accentBrush, + fill = { theme.componentBackground }, + accentAlpha = { accentAlpha.value }, + borderWidth = BOX_BORDER, + border = { border.value }, + ), + contentAlignment = Alignment.Center, + ) { + AnimatedVisibility(checked, enter = Motion.enter(fadeIn(tween(FADE_MILLIS))), exit = Motion.exit(fadeOut(tween(FADE_MILLIS)))) { + Icon("tick", null, Modifier.size(TICK_SIZE), tint = theme.textColor) + } + } +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Checkerboard.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Checkerboard.kt new file mode 100644 index 000000000..09a42d69c --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Checkerboard.kt @@ -0,0 +1,38 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlin.math.ceil +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme + +@ExperimentalOneConfigUiApi +@Composable +public fun Modifier.transparencyCheckerboard(cell: Dp = 6.dp): Modifier { + val theme = OneConfigTheme.current + val light = theme.checkerLight + val dark = theme.checkerDark + return drawBehind { + val side = cell.toPx() + if (side <= 0f) return@drawBehind + clipRect { + drawRect(dark) + val columns = ceil(size.width / side).toInt() + val rows = ceil(size.height / side).toInt() + val square = Size(side, side) + for (row in 0 until rows) { + for (column in row % 2 until columns step 2) { + drawRect(light, Offset(column * side, row * side), square) + } + } + } + } +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Chips.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Chips.kt index 8d0e75e39..68390f188 100644 --- a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Chips.kt +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Chips.kt @@ -1,17 +1,130 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + package org.polyfrost.oneconfig.api.ui.v1.components +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi -import org.polyfrost.oneconfig.api.ui.v1.internal.componentsBridge +import org.polyfrost.oneconfig.api.ui.v1.a11y.A11yRole +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme + +private const val CHIP_FADE_MILLIS = 150 + +@ExperimentalOneConfigUiApi +public object ChipDefaults { + public val size: ControlSize = ControlSize.Regular + + public const val HOVER_ACCENT_ALPHA: Float = 0.5f + + public val shape: Shape + @Composable get() = OneConfigTheme.current.sideBarNavigationEntryShape + + public fun contentSpacing(size: ControlSize): Dp = if (size == ControlSize.Compact) 6.dp else 10.dp + + public fun horizontalPadding(size: ControlSize): Dp = if (size == ControlSize.Compact) 10.dp else 12.dp + + public fun fontSize(size: ControlSize): TextUnit = if (size == ControlSize.Compact) 13.sp else 14.sp + + public fun iconSize(size: ControlSize): Dp = if (size == ControlSize.Compact) 14.dp else 16.dp + + public val fill: Color + @Composable get() = OneConfigTheme.current.chipBackground + + @Composable + public fun contentColor(selected: Boolean): Color = + if (selected) OneConfigTheme.current.accentTextColor else OneConfigTheme.current.textColor +} + +@ExperimentalOneConfigUiApi +@Composable +public fun Chip( + label: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + icon: String? = null, + enabled: Boolean = true, + size: ControlSize = ChipDefaults.size, + role: A11yRole = A11yRole.Button, + contentDescription: String? = null, + leading: (@Composable () -> Unit)? = null, + trailing: (@Composable () -> Unit)? = null, + interactionSource: MutableInteractionSource? = null, +) { + val theme = OneConfigTheme.current + val source = interactionSource ?: remember { MutableInteractionSource() } + val hovered by source.collectIsHoveredAsState() + val active = hovered && enabled + val rest = ChipDefaults.fill + val hoverFill = OneConfigTheme.accent.copy(alpha = ChipDefaults.HOVER_ACCENT_ALPHA).compositeOver(rest) + val fill by animateColorAsState(if (active) hoverFill else rest, Motion.spec(tween(CHIP_FADE_MILLIS))) + val accentAlpha by animateFloatAsState(if (selected) 1f else 0f, Motion.spec(tween(CHIP_FADE_MILLIS))) + val content by animateColorAsState(ChipDefaults.contentColor(selected), Motion.spec(tween(CHIP_FADE_MILLIS))) + val border = theme.borderColor + val shape = ChipDefaults.shape + Row( + modifier = modifier + .graphicsLayer { alpha = if (enabled) 1f else theme.disabledAlpha } + .heightIn(min = size.height) + .glassFrame(shape, { fill }, { border }, { accentAlpha }, hover = enabled) + .oneConfigClickable( + contentDescription ?: label, + onClick, + role = role, + enabled = enabled, + selected = selected, + shape = shape, + indication = !glassAvailable, + interactionSource = source, + ) + .padding(horizontal = ChipDefaults.horizontalPadding(size)), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(ChipDefaults.contentSpacing(size), Alignment.CenterHorizontally), + ) { + when { + leading != null -> leading() + icon != null -> Icon(icon, null, Modifier.size(ChipDefaults.iconSize(size)), tint = content) + } + Text(label, Modifier, content, OneConfigTheme.typography.body.copy(fontSize = ChipDefaults.fontSize(size), fontWeight = FontWeight.Normal), 1, TextOverflow.Ellipsis, textAlign = TextAlign.Center) + trailing?.invoke() + } +} +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) @ExperimentalOneConfigUiApi @Composable public fun Chip(label: String, selected: Boolean, onClick: () -> Unit) { - componentsBridge().Chip(label, selected, null, onClick) + Chip(label, selected, onClick, Modifier) } +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) @ExperimentalOneConfigUiApi @Composable public fun Chip(label: String, selected: Boolean, icon: String?, onClick: () -> Unit) { - componentsBridge().Chip(label, selected, icon, onClick) + Chip(label, selected, onClick, Modifier, icon = icon) } diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/CodeBlock.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/CodeBlock.kt new file mode 100644 index 000000000..52006fb02 --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/CodeBlock.kt @@ -0,0 +1,87 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.foundation.HorizontalScrollbar +import androidx.compose.foundation.VerticalScrollbar +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.rememberScrollbarAdapter +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme + +@ExperimentalOneConfigUiApi +@Composable +public fun CodeBlock( + text: String, + modifier: Modifier = Modifier, + height: Dp = 240.dp, + actions: (@Composable RowScope.() -> Unit)? = null, +) { + val theme = OneConfigTheme.current + val spacing = theme.spacing + val shape = theme.shapes.field + val vertical = rememberScrollState() + val horizontal = rememberScrollState() + val shown = remember(text) { text.replace("\t", " ") } + Box( + modifier + .fillMaxWidth() + .height(height) + .clip(shape) + .background(theme.componentBackground, shape) + .border(1.dp, theme.borderColor, shape), + ) { + SelectionContainer(Modifier.fillMaxSize()) { + LayoutText( + shown, + theme.typography.mono, + theme.textColor, + Modifier + .fillMaxSize() + .verticalScroll(vertical) + .horizontalScroll(horizontal) + .padding(spacing.l), + softWrap = false, + ) + } + Box(Modifier.matchParentSize()) { + VerticalScrollbar( + rememberScrollbarAdapter(vertical), + Modifier.align(Alignment.CenterEnd).fillMaxHeight().padding(vertical = spacing.xs, horizontal = spacing.xxs), + ) + HorizontalScrollbar( + rememberScrollbarAdapter(horizontal), + Modifier.align(Alignment.BottomStart).fillMaxWidth().padding(vertical = spacing.xxs, horizontal = spacing.xs), + ) + } + if (actions != null) { + Row( + Modifier.align(Alignment.TopEnd).padding(spacing.s), + horizontalArrangement = Arrangement.spacedBy(spacing.xs), + verticalAlignment = Alignment.CenterVertically, + content = actions, + ) + } + } +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/ColorPicker.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/ColorPicker.kt index 7b964dd2f..58e8ffc04 100644 --- a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/ColorPicker.kt +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/ColorPicker.kt @@ -1,27 +1,283 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class, InternalOneConfigUiApi::class) + package org.polyfrost.oneconfig.api.ui.v1.components +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.withInfiniteAnimationFrameNanos +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.polyfrost.compose.render.PolyColor import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.InternalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion import org.polyfrost.oneconfig.api.ui.v1.internal.componentsBridge +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme + +private val SWATCH_SIZE = 24.dp + +private const val SWATCH_BORDER_MILLIS = 120 @ExperimentalOneConfigUiApi -public class ColorPickerModel(initialColor: Color, hasAlpha: Boolean) { +public open class ColorPickerModel private constructor(private val delegate: ColorPickerModel?, @Suppress("UNUSED_PARAMETER") marker: Unit) { + protected constructor() : this(null, Unit) + + public constructor(initialColor: Color, hasAlpha: Boolean) : + this(componentsBridge().createColorPicker(initialColor.opaqueUnless(hasAlpha), hasAlpha, false, 1f, null), Unit) + public constructor(initialColor: Color) : this(initialColor, true) - internal val handle: Any = componentsBridge().createColorPicker(initialColor, hasAlpha) + public open val color: Color get() = requireNotNull(delegate).color + + public open val chroma: Boolean get() = requireNotNull(delegate).chroma + + public open val chromaSpeed: Float get() = requireNotNull(delegate).chromaSpeed + + public open val gradient: Color? get() = requireNotNull(delegate).gradient + + public open fun set(color: Color) { + requireNotNull(delegate).set(color) + } + + @InternalOneConfigUiApi + public val implementation: ColorPickerModel get() = delegate ?: this } +@ExperimentalOneConfigUiApi +@Composable +public fun rememberColorPickerModel(initial: Color, hasAlpha: Boolean = true): ColorPickerModel = + remember(hasAlpha) { componentsBridge().createColorPicker(initial.opaqueUnless(hasAlpha), hasAlpha, false, 1f, null) } + +@ExperimentalOneConfigUiApi +@Composable +public fun rememberColorPickerModel(initial: PolyColor, hasAlpha: Boolean = true): ColorPickerModel = + remember(hasAlpha) { + componentsBridge().createColorPicker( + Color(initial.rawArgb).opaqueUnless(hasAlpha), + hasAlpha, + initial.chroma, + initial.chromaSpeed, + if (initial.gradientEnabled) Color(initial.gradientArgb) else null, + ) + } + +@ExperimentalOneConfigUiApi +public fun ColorPickerModel.toPolyColor(): PolyColor { + val argb = color.toArgb() + return PolyColor(argb, chroma, chromaSpeed).withGradient(gradient != null, gradient?.toArgb() ?: argb) +} + +@ExperimentalOneConfigUiApi +@Composable +public fun ColorPickerPopup( + model: ColorPickerModel, + onColorChanged: (Color) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + placement: Placement = Placement.BelowStart, + chromaCapable: Boolean = false, + gradientCapable: Boolean = false, +) { + TriggeredColorPickerPopup(model, onColorChanged, onDismiss, null, modifier, placement, chromaCapable, gradientCapable) +} + +@Composable +private fun TriggeredColorPickerPopup( + model: ColorPickerModel, + onColorChanged: (Color) -> Unit, + onDismiss: () -> Unit, + trigger: PopupTrigger?, + modifier: Modifier = Modifier, + placement: Placement = Placement.BelowStart, + chromaCapable: Boolean = false, + gradientCapable: Boolean = false, +) { + AnchoredPopup(true, onDismiss, placement, trigger = trigger) { + componentsBridge().ColorPickerPanel(model, onColorChanged, onDismiss, modifier, chromaCapable, gradientCapable) + } +} + +@ExperimentalOneConfigUiApi +@Composable +public fun ColorSwatchButton( + color: Color, + onColorChanged: (Color) -> Unit, + modifier: Modifier = Modifier, + label: String? = null, + hasAlpha: Boolean = true, + chromaCapable: Boolean = false, + enabled: Boolean = true, + interactionSource: MutableInteractionSource? = null, +) { + val model = rememberColorPickerModel(color, hasAlpha) + LaunchedEffect(model, color) { + if (model.color.toArgb() != color.toArgb()) model.set(color.opaqueUnless(hasAlpha)) + } + SwatchButton(model, onColorChanged, modifier, label, false, false, enabled, interactionSource) +} + +@ExperimentalOneConfigUiApi +@Composable +public fun ColorSwatchButton( + color: PolyColor, + onColorChanged: (PolyColor) -> Unit, + modifier: Modifier = Modifier, + label: String? = null, + hasAlpha: Boolean = true, + chromaCapable: Boolean = true, + gradientCapable: Boolean = false, + enabled: Boolean = true, + interactionSource: MutableInteractionSource? = null, +) { + val model = rememberColorPickerModel(color, hasAlpha) + val gradient = if (color.gradientEnabled) color.gradientArgb else null + LaunchedEffect(model, color.rawArgb, color.chroma, color.chromaSpeed, gradient) { + val current = model.toPolyColor() + val gradientChanged = (if (current.gradientEnabled) current.gradientArgb else null) != gradient + if (current.rawArgb != color.rawArgb || current.chroma != color.chroma || current.chromaSpeed != color.chromaSpeed || gradientChanged) { + componentsBridge().updateColorPicker( + model, + Color(color.rawArgb).opaqueUnless(hasAlpha), + color.chroma, + color.chromaSpeed, + gradient?.let(::Color), + ) + } + } + SwatchButton(model, { onColorChanged(model.toPolyColor()) }, modifier, label, chromaCapable, gradientCapable, enabled, interactionSource) +} + +@Composable +private fun SwatchButton( + model: ColorPickerModel, + onColorChanged: (Color) -> Unit, + modifier: Modifier, + label: String?, + chromaCapable: Boolean, + gradientCapable: Boolean, + enabled: Boolean, + interactionSource: MutableInteractionSource?, +) { + val theme = OneConfigTheme.current + val source = interactionSource ?: remember { MutableInteractionSource() } + val hovered by source.collectIsHoveredAsState() + var open by remember { mutableStateOf(false) } + LaunchedEffect(enabled) { if (!enabled) open = false } + val trigger = rememberPopupTrigger() + val shape = theme.shapes.small + val shown = rememberShownColor(model) + val gradient = model.gradient + val fill = if (gradient != null && !model.chroma && gradientCapable) Brush.horizontalGradient(listOf(shown, gradient)) else SolidColor(shown) + val border by animateColorAsState( + if (enabled && (hovered || open)) theme.textColor.copy(alpha = 0.3f) else theme.borderColor, + Motion.spec(tween(SWATCH_BORDER_MILLIS)), + label = "swatchBorder", + ) + val spoken = label ?: "${kitString("oneconfig.colour", "Colour")} #${colorToHex(model.color)}" + Box(modifier) { + Row( + Modifier + .popupTrigger(trigger) + .graphicsLayer { alpha = if (enabled) 1f else theme.disabledAlpha } + .oneConfigClickable( + label = spoken, + onClick = { open = !open }, + enabled = enabled, + shape = if (label == null) shape else theme.shapes.medium, + indication = label != null, + interactionSource = source, + ) + .then(if (label != null) Modifier.heightIn(min = ControlSize.Compact.height).padding(horizontal = 6.dp) else Modifier), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + Modifier + .size(SWATCH_SIZE) + .clip(shape) + .drawBehind { + val cell = 6.dp.toPx() + val columns = (size.width / cell).toInt() + val rows = (size.height / cell).toInt() + for (row in 0..rows) { + for (column in 0..columns) { + drawRect( + if ((row + column) % 2 == 0) theme.checkerLight else theme.checkerDark, + Offset(column * cell, row * cell), + Size(cell, cell), + ) + } + } + } + .background(fill, shape) + .border(1.dp, border, shape), + ) + if (label != null) Text(label, color = theme.textColor, style = theme.typography.label, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + if (open) { + TriggeredColorPickerPopup(model, onColorChanged, { open = false }, trigger, chromaCapable = chromaCapable, gradientCapable = gradientCapable) + } + } +} + +@Composable +private fun rememberShownColor(model: ColorPickerModel): Color { + var shown by remember(model) { mutableStateOf(model.color) } + val chroma = model.chroma + val base = model.color + val argb = base.toArgb() + val speed = model.chromaSpeed + LaunchedEffect(model, chroma, argb, speed) { + if (!chroma) return@LaunchedEffect + val turning = PolyColor(argb, true, speed) + while (true) { + withInfiniteAnimationFrameNanos { shown = Color(turning.argb) } + } + } + return if (chroma) shown else base +} + +private fun Color.opaqueUnless(hasAlpha: Boolean): Color = if (hasAlpha) this else copy(alpha = 1f) + +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) @ExperimentalOneConfigUiApi @Composable public fun ColorPickerPopup(model: ColorPickerModel, onColorChanged: (Color) -> Unit, onClose: () -> Unit) { - componentsBridge().ColorPickerPopup(model.handle, onColorChanged, onClose, true) + componentsBridge().ColorPickerPanel(model, onColorChanged, onClose, Modifier, true, false) } +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) @ExperimentalOneConfigUiApi @Composable public fun ColorPickerPopup(model: ColorPickerModel, onColorChanged: (Color) -> Unit, onClose: () -> Unit, chromaCapable: Boolean) { - componentsBridge().ColorPickerPopup(model.handle, onColorChanged, onClose, chromaCapable) + componentsBridge().ColorPickerPanel(model, onColorChanged, onClose, Modifier, chromaCapable, false) } @ExperimentalOneConfigUiApi diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Confirm.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Confirm.kt new file mode 100644 index 000000000..dc315a16a --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Confirm.kt @@ -0,0 +1,161 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.widthIn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.AnnotatedString +import kotlinx.coroutines.delay +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.a11y.Announce + +@ExperimentalOneConfigUiApi +public object Confirm { + internal class Request( + val title: String, + val text: String, + val confirmLabel: String, + val onConfirm: () -> Unit, + val style: ControlStyle, + val icon: String?, + ) + + internal val queue = mutableStateListOf() + + internal val hosts = HostStack() + + @JvmStatic + public fun ask( + title: String, + text: String, + confirmLabel: String, + onConfirm: () -> Unit, + style: ControlStyle = ControlStyle.Danger, + icon: String? = null, + ) { + val request = Request(title, text, confirmLabel, onConfirm, style, icon) + mutateFeedbackState { queue += request } + } + + internal fun finish(request: Request): Boolean { + var removed = false + mutateFeedbackState { removed = queue.remove(request) } + return removed + } +} + +@ExperimentalOneConfigUiApi +public object ConfirmDefaults { + public const val timeoutMillis: Long = 3000L + + public const val armDelayMillis: Long = 400L + + @JvmStatic + public fun armedText(text: String): String { + val action = text.lowercase() + return kitString("oneconfig.ui.confirm.armed", "Click again to $action", action) + } +} + +@Composable +internal fun ConfirmHost(slot: Any) { + val request = Confirm.queue.firstOrNull() ?: return + if (!Confirm.hosts.isActive(slot)) return + if (currentScreen() == null) return + key(request) { + ConfirmDialog( + request.title, + request.text, + request.confirmLabel, + onConfirm = { if (Confirm.finish(request)) request.onConfirm() }, + onDismiss = { Confirm.finish(request) }, + style = request.style, + icon = request.icon, + ) + } +} + +@ExperimentalOneConfigUiApi +@Composable +public fun ArmedConfirmButton( + text: String, + onConfirm: () -> Unit, + modifier: Modifier = Modifier, + armedText: String = ConfirmDefaults.armedText(text), + timeoutMillis: Long = ConfirmDefaults.timeoutMillis, + style: ControlStyle = ControlStyle.Danger, + size: ControlSize = ControlSize.Regular, + enabled: Boolean = true, + interactionSource: MutableInteractionSource? = null, +) { + ArmedConfirmButton( + AnnotatedString(text), onConfirm, modifier, AnnotatedString(armedText), + timeoutMillis, style, size, enabled, interactionSource, + ) +} + +@ExperimentalOneConfigUiApi +@Composable +public fun ArmedConfirmButton( + text: AnnotatedString, + onConfirm: () -> Unit, + modifier: Modifier = Modifier, + armedText: AnnotatedString = AnnotatedString(ConfirmDefaults.armedText(text.text)), + timeoutMillis: Long = ConfirmDefaults.timeoutMillis, + style: ControlStyle = ControlStyle.Danger, + size: ControlSize = ControlSize.Regular, + enabled: Boolean = true, + interactionSource: MutableInteractionSource? = null, +) { + var armedAt by remember { mutableStateOf(null) } + val armed = armedAt != null + var restWidth by remember { mutableIntStateOf(0) } + val density = LocalDensity.current + LaunchedEffect(armedAt) { + if (armedAt != null) { + delay(timeoutMillis) + armedAt = null + } + } + LaunchedEffect(enabled) { if (!enabled) armedAt = null } + if (armed) Announce(armedText.text) + TextButton( + if (armed) armedText else text, + onClick = { + val at = armedAt + when { + at == null -> armedAt = System.currentTimeMillis() + System.currentTimeMillis() - at >= ConfirmDefaults.armDelayMillis -> { + armedAt = null + onConfirm() + } + } + }, + modifier = modifier + .onFocusChanged { if (!it.hasFocus) armedAt = null } + .onSizeChanged { if (!armed) restWidth = it.width } + .then(if (armed && restWidth > 0) Modifier.widthIn(min = with(density) { restWidth.toDp() }) else Modifier), + style = if (armed) armedStyle(style) else style, + size = size, + enabled = enabled, + interactionSource = interactionSource, + ) +} + +private fun armedStyle(style: ControlStyle): ControlStyle = when (style) { + ControlStyle.Danger, ControlStyle.DangerFilled -> ControlStyle.DangerFilled + ControlStyle.Primary, ControlStyle.Secondary, ControlStyle.Ghost -> ControlStyle.Primary +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/ContextMenu.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/ContextMenu.kt new file mode 100644 index 000000000..a3a83fc2c --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/ContextMenu.kt @@ -0,0 +1,330 @@ +@file:JvmMultifileClass +@file:JvmName("MenuKt") +@file:OptIn(ExperimentalOneConfigUiApi::class, InternalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalContext +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.currentCompositionLocalContext +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isShiftPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.nativeKeyCode +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.pointer.PointerEvent +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.PointerId +import androidx.compose.ui.input.pointer.isPrimaryPressed +import androidx.compose.ui.input.pointer.isSecondaryPressed +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.unit.IntOffset +import java.awt.event.KeyEvent as AwtKeyEvent +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.math.roundToInt +import org.apache.logging.log4j.LogManager +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.InternalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.internal.componentsBridge +import org.polyfrost.oneconfig.api.ui.v1.internal.keybindRecordingBridge + +@ExperimentalOneConfigUiApi +@Composable +public fun ContextMenu( + expanded: Boolean, + offset: IntOffset, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + if (!expanded) return + EscapeCloses(onDismiss) + val provider = remember(offset) { PointPosition(offset, true) } + componentsBridge().Popup(provider, onDismiss, true) { + MenuSurface(modifier, content = content) + } +} + +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) +@ExperimentalOneConfigUiApi +@Composable +public fun ContextMenu(expanded: Boolean, offset: IntOffset, onDismiss: () -> Unit, content: @Composable ColumnScope.() -> Unit) { + ContextMenu(expanded, offset, onDismiss, Modifier, content) +} + +@ExperimentalOneConfigUiApi +@Composable +public fun ContextMenu( + expanded: Boolean, + offset: IntOffset, + onDismiss: () -> Unit, + entries: List, + modifier: Modifier = Modifier, +) { + if (!expanded) return + ContextMenuPopup(offset, true, entries, modifier, onDismiss) +} + +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) +@ExperimentalOneConfigUiApi +@Composable +public fun ContextMenu(expanded: Boolean, offset: IntOffset, onDismiss: () -> Unit, items: List) { + ContextMenu(expanded, offset, onDismiss, items, Modifier) +} + +@ExperimentalOneConfigUiApi +public class ContextMenuState internal constructor(private val host: ContextMenuHostState?) { + internal var locals: CompositionLocalContext? = null + + public val isOpen: Boolean + get() = host?.open?.owner === this + + public fun open(at: Offset, entries: List) { + if (host == null) { + logMissingHost() + return + } + if (entries.isEmpty()) return + host.show(OpenContextMenu(at.round(), entries, this, null, locals)) + } + + public fun close() { + val menu = host?.open ?: return + if (menu.owner === this) host.close(menu) + } +} + +@ExperimentalOneConfigUiApi +@Composable +public fun rememberContextMenuState(): ContextMenuState { + val host = LocalContextMenuHost.current + val state = remember(host) { ContextMenuState(host) } + state.locals = currentCompositionLocalContext + DisposableEffect(state) { + onDispose { state.close() } + } + return state +} + +@InternalOneConfigUiApi +@ExperimentalOneConfigUiApi +@Composable +public fun ContextMenuRoot(content: @Composable () -> Unit) { + if (LocalContextMenuHost.current != null) { + content() + return + } + val host = remember { ContextMenuHostState() } + CompositionLocalProvider(LocalContextMenuHost provides host) { + content() + ContextMenuLayer(host) + } +} + +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) +@ExperimentalOneConfigUiApi +@Composable +public fun ContextMenuHost(content: @Composable () -> Unit) { + ContextMenuRoot(content) +} + +@ExperimentalOneConfigUiApi +public fun Modifier.contextMenu( + enabled: Boolean = true, + onOpened: (() -> Unit)? = null, + entries: () -> List, +): Modifier { + if (!enabled) return this + return composed { + val host = LocalContextMenuHost.current + if (host == null) { + logMissingHost() + Modifier + } else { + Modifier.contextMenuTrigger(host, onOpened, entries) + } + } +} + +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) +@ExperimentalOneConfigUiApi +@Composable +public fun Modifier.contextMenu(items: () -> List): Modifier = contextMenu(true, null, items) + +internal class OpenContextMenu( + val at: IntOffset, + val entries: List, + val owner: Any?, + val onClosed: (() -> Unit)?, + val locals: CompositionLocalContext? = null, +) + +internal data class PressKey(val id: PointerId, val uptime: Long) + +internal class ContextMenuHostState { + var open by mutableStateOf(null) + private set + + private var pendingKey: PressKey? = null + private var pendingMenu: OpenContextMenu? = null + private var pendingOpened: (() -> Unit)? = null + + fun show(menu: OpenContextMenu) { + open = menu + } + + fun close(menu: OpenContextMenu) { + if (open !== menu) return + open = null + menu.onClosed?.invoke() + } + + fun isPending(key: PressKey): Boolean = pendingKey == key + + fun claim(key: PressKey, menu: OpenContextMenu, onOpened: (() -> Unit)?) { + pendingKey = key + pendingMenu = menu + pendingOpened = onOpened + } + + fun commit(key: PressKey) { + if (pendingKey != key) return + val menu = pendingMenu + val opened = pendingOpened + pendingKey = null + pendingMenu = null + pendingOpened = null + if (menu != null) { + show(menu) + opened?.invoke() + } + } +} + +internal val LocalContextMenuHost = staticCompositionLocalOf { null } + +private class AnchorHolder { + var coordinates: LayoutCoordinates? = null + var focused = false +} + +@Composable +internal fun Modifier.contextMenuTrigger( + host: ContextMenuHostState, + onOpened: (() -> Unit)?, + entries: () -> List, +): Modifier { + val latestEntries by rememberUpdatedState(entries) + val latestOpened by rememberUpdatedState(onOpened) + val latestLocals by rememberUpdatedState(currentCompositionLocalContext) + val anchor = remember { AnchorHolder() } + val focus = remember { FocusRequester() } + return this + .onGloballyPositioned { anchor.coordinates = it } + .focusRequester(focus) + .onFocusChanged { anchor.focused = it.hasFocus } + .onKeyEvent { event -> + if (event.type != KeyEventType.KeyDown || !event.opensContextMenu() || isRecordingKeybind()) return@onKeyEvent false + val coordinates = anchor.coordinates?.takeIf { it.isAttached } ?: return@onKeyEvent false + val list = latestEntries() + if (list.isEmpty()) return@onKeyEvent false + val center = Offset(coordinates.size.width / 2f, coordinates.size.height / 2f) + val restoreFocus = { runCatching { focus.requestFocus() }; Unit } + host.show(OpenContextMenu(coordinates.localToWindow(center).round(), list, null, restoreFocus, latestLocals)) + latestOpened?.invoke() + true + } + .pointerInput(host) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + if (event.type != PointerEventType.Press || !event.secondaryPress()) continue + val change = event.changes.firstOrNull() ?: continue + val key = PressKey(change.id, change.uptimeMillis) + if (event.changes.any { it.isConsumed } && !host.isPending(key)) continue + if (isRecordingKeybind()) continue + val coordinates = anchor.coordinates?.takeIf { it.isAttached } ?: continue + val list = latestEntries() + if (list.isEmpty()) continue + event.changes.forEach { it.consume() } + val restoreFocus = if (anchor.focused) ({ runCatching { focus.requestFocus() }; Unit }) else null + host.claim(key, OpenContextMenu(coordinates.localToWindow(change.position).round(), list, null, restoreFocus, latestLocals), latestOpened) + awaitPointerEvent(PointerEventPass.Final) + host.commit(key) + } + } + } +} + +@Composable +internal fun ContextMenuLayer(host: ContextMenuHostState) { + val menu = host.open ?: return + key(menu) { + val locals = menu.locals + if (locals == null) { + ContextMenuPopup(menu.at, false, menu.entries, Modifier) { host.close(menu) } + } else { + CompositionLocalProvider(locals) { + ContextMenuPopup(menu.at, false, menu.entries, Modifier) { host.close(menu) } + } + } + } +} + +@Composable +private fun ContextMenuPopup( + at: IntOffset, + relative: Boolean, + entries: List, + modifier: Modifier, + onDismiss: () -> Unit, +) { + EscapeCloses(onDismiss) + val provider = remember(at, relative) { PointPosition(at, relative) } + componentsBridge().Popup(provider, onDismiss, true) { + MenuList(entries, onDismiss, modifier) + } +} + +private val CONTEXT_MENU_LOGGER = LogManager.getLogger("OneConfig/UI") +private val missingHostLogged = AtomicBoolean(false) + +private fun logMissingHost() { + if (missingHostLogged.compareAndSet(false, true)) { + CONTEXT_MENU_LOGGER.warn( + "A context menu was used outside any context menu host, so it cannot open. Wrap the Compose content in ProvideOneConfigTheme.", + Throwable("Context menu used here"), + ) + } +} + +private fun isRecordingKeybind(): Boolean = runCatching { keybindRecordingBridge()?.isRecording }.getOrNull() == true + +private fun KeyEvent.opensContextMenu(): Boolean = + key == Key.Menu || key.nativeKeyCode == AwtKeyEvent.VK_CONTEXT_MENU || (key == Key.F10 && isShiftPressed) + +private fun PointerEvent.secondaryPress(): Boolean = buttons.isSecondaryPressed && !buttons.isPrimaryPressed + +private fun Offset.round(): IntOffset = IntOffset(x.roundToInt(), y.roundToInt()) diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/ControlTypes.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/ControlTypes.kt new file mode 100644 index 000000000..6b2a97a55 --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/ControlTypes.kt @@ -0,0 +1,39 @@ +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme + +@ExperimentalOneConfigUiApi +public enum class ControlSize( + public val height: Dp, +) { + Compact(28.dp), + + Regular(32.dp), + + Large(38.dp), +} + +@ExperimentalOneConfigUiApi +public enum class ControlStyle { + Primary, + + Secondary, + + Ghost, + + Danger, + + DangerFilled, +} + +@ExperimentalOneConfigUiApi +public object ControlDefaults { + public val size: ControlSize = ControlSize.Regular + + public val disabledAlpha: Float + @Composable get() = OneConfigTheme.current.disabledAlpha +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Controls.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Controls.kt index e65b292a1..2523f631e 100644 --- a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Controls.kt +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Controls.kt @@ -4,36 +4,41 @@ package org.polyfrost.oneconfig.api.ui.v1.components import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue +import org.polyfrost.oneconfig.api.ui.v1.a11y.isKeyboardMode +import org.polyfrost.oneconfig.api.ui.v1.a11y.a11y +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.runtime.setValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.foundation.hoverable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.input.pointer.PointerIcon -import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.BaselineShift import androidx.compose.ui.text.style.TextAlign @@ -42,20 +47,21 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi import org.polyfrost.oneconfig.api.ui.v1.a11y.A11yRole -import org.polyfrost.oneconfig.api.ui.v1.a11y.a11y -import org.polyfrost.oneconfig.api.ui.v1.a11y.isKeyboardMode +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme -private val ICON_BUTTON_SIZE = 34.dp -private val TOGGLE_HEIGHT = 38.dp private val CHECK_SIZE = 20.dp +private val LEGACY_ICON_BUTTON_SIZE = 34.dp +private const val FADE_MILLIS = 150 +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) @ExperimentalOneConfigUiApi @Composable public fun IconButton(iconName: String, tooltip: String?, modifier: Modifier, onClick: () -> Unit) { - IconButton(iconName, tooltip, null, modifier, null, OneConfigTheme.current.textColor, TooltipPlacement.BelowStart, onClick) + LegacyIconButton(iconName, tooltip, modifier, TooltipPlacement.BelowStart, onClick, null, OneConfigTheme.current.textColor, null) } +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) @ExperimentalOneConfigUiApi @Composable public fun IconButton( @@ -68,11 +74,10 @@ public fun IconButton( placement: TooltipPlacement, onClick: () -> Unit, ) { - IconButton(tooltip, label, modifier, background, placement, onClick) { - Icon(iconName, tint, Modifier.size(16.dp)) - } + LegacyIconButton(iconName, tooltip, modifier, placement, onClick, label, tint, background) } +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) @ExperimentalOneConfigUiApi @Composable public fun IconButton( @@ -83,6 +88,35 @@ public fun IconButton( placement: TooltipPlacement, onClick: () -> Unit, glyph: @Composable () -> Unit, +) { + LegacyIconButtonFrame(tooltip, label, modifier, background, placement, onClick, glyph) +} + +@Composable +private fun LegacyIconButton( + iconName: String, + tooltip: String?, + modifier: Modifier, + placement: TooltipPlacement, + onClick: () -> Unit, + label: String?, + tint: Color, + background: Color?, +) { + LegacyIconButtonFrame(tooltip, label, modifier, background, placement, onClick) { + Icon(iconName, null, Modifier.size(16.dp), tint) + } +} + +@Composable +private fun LegacyIconButtonFrame( + tooltip: String?, + label: String?, + modifier: Modifier, + background: Color?, + placement: TooltipPlacement, + onClick: () -> Unit, + glyph: @Composable () -> Unit, ) { val theme = OneConfigTheme.current val source = remember { MutableInteractionSource() } @@ -94,7 +128,7 @@ public fun IconButton( val shape = theme.buttonShape Box( modifier = modifier - .size(ICON_BUTTON_SIZE) + .size(LEGACY_ICON_BUTTON_SIZE) .clip(shape) .background(fill, shape) .border(1.dp, hairline, shape) @@ -106,110 +140,71 @@ public fun IconButton( contentAlignment = Alignment.Center, ) { glyph() - if (tooltip != null && (hovered || focused && keyboard)) TooltipPopup(tooltip, placement) + if (tooltip != null && (hovered || focused && keyboard)) TooltipPopup(tooltip, Modifier, placement) } } @ExperimentalOneConfigUiApi @Composable -public fun ToggleRow(label: String, checked: Boolean, modifier: Modifier, onToggle: () -> Unit) { +public fun ToggleRow( + label: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + description: String? = null, + enabled: Boolean = true, + size: ControlSize = ControlSize.Large, + interactionSource: MutableInteractionSource? = null, +) { val theme = OneConfigTheme.current - val accent = OneConfigTheme.accent - val source = remember { MutableInteractionSource() } + val source = interactionSource ?: remember { MutableInteractionSource() } val hovered by source.collectIsHoveredAsState() - val rowBorder by animateColorAsState(if (hovered) theme.textColorSecondary else theme.borderColor) + val active = hovered && enabled + val spec = Motion.spec(tween(FADE_MILLIS)) + val rowBorder by animateColorAsState(if (active) theme.textColorSecondary else theme.borderColor, spec) val shape = theme.buttonShape Row( modifier = modifier .fillMaxWidth() - .heightIn(min = TOGGLE_HEIGHT) + .heightIn(min = size.height) + .graphicsLayer { alpha = if (enabled) 1f else theme.disabledAlpha } .clip(shape) .background(theme.componentBackground, shape) .border(1.dp, rowBorder, shape) - .hoverable(source) - .a11y(label, A11yRole.Checkbox, null, checked, shape, true) - .onClick(source, onToggle) - .pointerHoverIcon(PointerIcon.Hand) - .padding(horizontal = 12.dp), + .oneConfigClickable( + label, + { onCheckedChange(!checked) }, + role = A11yRole.Checkbox, + enabled = enabled, + checked = checked, + shape = shape, + indication = false, + interactionSource = source, + ) + .padding(horizontal = 12.dp, vertical = if (description != null) 6.dp else 0.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, ) { - Text(label, Modifier.weight(1f), theme.textColor, 14.sp, null, BaselineShift.None, FontWeight.Normal, TextAlign.Start, Int.MAX_VALUE, TextOverflow.Clip) - val boxColor by animateColorAsState(if (checked) accent else theme.componentBackground) - val boxBorder by animateColorAsState( - when { - checked -> accent - hovered -> theme.textColorSecondary - else -> theme.borderColor - }, - ) - Box( - modifier = Modifier - .size(CHECK_SIZE) - .clip(theme.checkBoxShape) - .background(boxColor, theme.checkBoxShape) - .border(1.5.dp, boxBorder, theme.checkBoxShape), - contentAlignment = Alignment.Center, - ) { - this@Row.AnimatedVisibility(visible = checked, enter = fadeIn(), exit = fadeOut()) { - val tick = theme.accentTextColor - Canvas(Modifier.size(12.dp)) { - val w = size.width - val h = size.height - val path = Path().apply { - moveTo(w * 0.2f, h * 0.52f) - lineTo(w * 0.42f, h * 0.72f) - lineTo(w * 0.8f, h * 0.3f) - } - drawPath(path, color = tick, style = Stroke(width = w * 0.16f, cap = StrokeCap.Round)) - } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(label, Modifier, theme.textColor, OneConfigTheme.typography.body.copy(fontSize = if (size == ControlSize.Compact) 13.sp else 14.sp, fontWeight = FontWeight.Normal)) + if (description != null) { + Text(description, Modifier, theme.textColorSecondary, OneConfigTheme.typography.body.copy(fontSize = 12.sp, fontWeight = FontWeight.Normal)) } } + CheckboxBox(checked, active, Modifier) } } -@ExperimentalOneConfigUiApi -public class TabItem(public val value: T, public val label: String) - +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) @ExperimentalOneConfigUiApi @Composable -public fun TabRow(tabs: List>, selected: T, modifier: Modifier, onSelect: (T) -> Unit) { - Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - tabs.forEach { tab -> - val isSelected = tab.value == selected - TabButton(tab.label, isSelected, Modifier.weight(1f)) { onSelect(tab.value) } - } - } +public fun ToggleRow(label: String, checked: Boolean, modifier: Modifier, onToggle: () -> Unit) { + ToggleRow(label, checked, { onToggle() }, modifier) } +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) +@ExperimentalOneConfigUiApi @Composable -private fun TabButton(label: String, selected: Boolean, modifier: Modifier, onClick: () -> Unit) { - val theme = OneConfigTheme.current - val accent = OneConfigTheme.accent - val source = remember { MutableInteractionSource() } - val hovered by source.collectIsHoveredAsState() - val fill by animateColorAsState(if (selected) accent else theme.componentBackground) - val border by animateColorAsState( - when { - selected -> accent - hovered -> theme.textColorSecondary - else -> theme.borderColor - }, - ) - val shape = theme.buttonShape - Box( - modifier = modifier - .heightIn(min = TOGGLE_HEIGHT) - .clip(shape) - .background(fill, shape) - .border(1.dp, border, shape) - .hoverable(source) - .a11y(label, A11yRole.Tab, selected, null, shape, true) - .onClick(source, onClick) - .pointerHoverIcon(PointerIcon.Hand) - .padding(horizontal = 14.dp), - contentAlignment = Alignment.Center, - ) { - val content = if (selected) theme.accentTextColor else theme.textColor - Text(label, Modifier, content, 14.sp, null, BaselineShift.None, FontWeight.Normal, TextAlign.Center, 1, TextOverflow.Ellipsis) - } +public fun TabRow(tabs: List>, selected: T, modifier: Modifier, onSelect: (T) -> Unit) { + TabRow(tabs, selected, onSelect, modifier, size = ControlSize.Large) } diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Dialog.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Dialog.kt index 012d051ae..609c944dc 100644 --- a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Dialog.kt +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Dialog.kt @@ -2,7 +2,8 @@ package org.polyfrost.oneconfig.api.ui.v1.components -import androidx.compose.foundation.background +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement @@ -15,95 +16,109 @@ import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.BaselineShift -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupProperties import org.polyfrost.oneconfig.api.platform.v1.Platform import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi import org.polyfrost.oneconfig.api.ui.v1.a11y.Announce +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion import org.polyfrost.oneconfig.api.ui.v1.a11y.notFocusable import org.polyfrost.oneconfig.api.ui.v1.internal.componentsBridge import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme -private val DIALOG_WIDTH = 420.dp -private val DIALOG_PADDING = PaddingValues(20.dp) -private val DIALOG_SPACING = 12.dp -private val SCRIM = Color.Black.copy(alpha = 0.45f) +private const val SCRIM_FADE_MILLIS = 150 -@ExperimentalOneConfigUiApi -@Composable -public fun ModalDialog(onDismiss: () -> Unit, content: @Composable ColumnScope.() -> Unit) { - ModalDialog(onDismiss, DIALOG_WIDTH, Dp.Unspecified, DIALOG_PADDING, DIALOG_SPACING, true, content) -} +private val WindowOrigin = PointPosition(IntOffset.Zero, false) @ExperimentalOneConfigUiApi -@Composable -public fun ModalDialog(onDismiss: () -> Unit, width: Dp, dismissOnScrim: Boolean, content: @Composable ColumnScope.() -> Unit) { - ModalDialog(onDismiss, width, Dp.Unspecified, DIALOG_PADDING, DIALOG_SPACING, dismissOnScrim, content) +public object DialogDefaults { + public val width: Dp = 420.dp + + public val padding: PaddingValues = PaddingValues(20.dp) + + public val spacing: Dp = 12.dp + + public val buttonSpacing: Dp = 8.dp + + public val cancelLabel: String + get() = kitString("gui.cancel", "Cancel") + + public val saveLabel: String + get() = kitString("oneconfig.ui.dialog.save", "Save") } @ExperimentalOneConfigUiApi @Composable public fun ModalDialog( onDismiss: () -> Unit, - width: Dp, - height: Dp, - padding: PaddingValues, - spacing: Dp, - dismissOnScrim: Boolean, + modifier: Modifier = Modifier, + width: Dp = DialogDefaults.width, + title: (@Composable () -> Unit)? = null, + buttons: (@Composable RowScope.() -> Unit)? = null, + dismissOnScrim: Boolean = true, + dismissOnEscape: Boolean = true, content: @Composable ColumnScope.() -> Unit, ) { - EscapeCloses(onDismiss) - Popup( - alignment = Alignment.Center, - onDismissRequest = onDismiss, - properties = PopupProperties(focusable = true, dismissOnClickOutside = dismissOnScrim), - ) { + DialogLayer(onDismiss, dismissOnScrim, dismissOnEscape) { + ModalPanel(modifier.heightIn(max = popupMaxHeight()), width) { + title?.invoke() + content() + if (buttons != null) DialogButtons(content = buttons) + } + } +} + +@Composable +private fun DialogLayer(onDismiss: () -> Unit, dismissOnScrim: Boolean, dismissOnEscape: Boolean, panel: @Composable () -> Unit) { + if (dismissOnEscape) EscapeCloses(onDismiss) + val latest by rememberUpdatedState(onDismiss) + val scrimColor = OneConfigTheme.current.scrimColor + val fade = remember { Animatable(0f) } + LaunchedEffect(Unit) { fade.animateTo(1f, Motion.finiteSpec(tween(SCRIM_FADE_MILLIS))) } + componentsBridge().Popup(WindowOrigin, null, true) { Box( modifier = Modifier .fillMaxSize() - .background(SCRIM) + .drawBehind { drawRect(scrimColor, alpha = fade.value) } .then( if (dismissOnScrim) { - Modifier.notFocusable().clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onDismiss) + Modifier.notFocusable().clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { latest() } } else { Modifier.swallowClicks() }, ), contentAlignment = Alignment.Center, ) { - ModalPanel(width, height, padding, spacing, Modifier, content) + Box(Modifier.graphicsLayer { alpha = fade.value }) { + panel() + } } } } -@ExperimentalOneConfigUiApi -@Composable -public fun ModalPanel(width: Dp, modifier: Modifier, content: @Composable ColumnScope.() -> Unit) { - ModalPanel(width, Dp.Unspecified, DIALOG_PADDING, DIALOG_SPACING, modifier, content) -} - @ExperimentalOneConfigUiApi @Composable public fun ModalPanel( - width: Dp, - height: Dp, - padding: PaddingValues, - spacing: Dp, - modifier: Modifier, + modifier: Modifier = Modifier, + width: Dp = DialogDefaults.width, + height: Dp = Dp.Unspecified, + padding: PaddingValues = DialogDefaults.padding, + spacing: Dp = DialogDefaults.spacing, content: @Composable ColumnScope.() -> Unit, ) { val sized = modifier @@ -120,23 +135,141 @@ public fun ModalPanel( @ExperimentalOneConfigUiApi @Composable -public fun DialogTitle(text: String) { +public fun DialogTitle(text: String, modifier: Modifier = Modifier) { Announce(text) - Text(text, Modifier, OneConfigTheme.current.textColor, 18.sp, null, BaselineShift.None, FontWeight.SemiBold, TextAlign.Start, Int.MAX_VALUE, TextOverflow.Clip) + val theme = OneConfigTheme.current + Text(text, modifier.semantics { heading() }, theme.textColor, theme.typography.title) +} + +@ExperimentalOneConfigUiApi +@Composable +public fun DialogButtons(modifier: Modifier = Modifier, content: @Composable RowScope.() -> Unit) { + Row( + modifier.fillMaxWidth().padding(top = 4.dp), + horizontalArrangement = Arrangement.spacedBy(DialogDefaults.buttonSpacing, Alignment.End), + verticalAlignment = Alignment.CenterVertically, + content = content, + ) +} + +@ExperimentalOneConfigUiApi +@Composable +public fun ConfirmDialog( + title: String, + text: String, + confirmLabel: String?, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + dismissLabel: String = DialogDefaults.cancelLabel, + style: ControlStyle = ControlStyle.Primary, + danger: Boolean = false, + icon: String? = null, + confirmEnabled: Boolean = true, +) { + val confirmStyle = if (danger) ControlStyle.Danger else style + ModalDialog( + onDismiss, + modifier, + title = { DialogTitle(title) }, + buttons = { + TextButton(dismissLabel, onDismiss) + if (confirmLabel != null) { + if (icon != null) { + TextButton(confirmLabel, icon, onConfirm, style = confirmStyle, enabled = confirmEnabled) + } else { + TextButton(confirmLabel, onConfirm, style = confirmStyle, enabled = confirmEnabled) + } + } + }, + ) { + DialogBody(text) + } } +@Composable +internal fun DialogBody(text: String) { + val theme = OneConfigTheme.current + Text(text, Modifier, theme.textColorSecondary, theme.typography.body) +} + +internal fun kitString(key: String, fallback: String, vararg args: Any): String = + runCatching { Platform.i18n().translateString(key, *args) }.getOrNull()?.takeIf { it != key } ?: fallback + +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) +@ExperimentalOneConfigUiApi +@Composable +public fun ModalDialog(onDismiss: () -> Unit, content: @Composable ColumnScope.() -> Unit) { + ModalDialog(onDismiss, Modifier, content = content) +} + +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) +@ExperimentalOneConfigUiApi +@Composable +public fun ModalDialog(onDismiss: () -> Unit, width: Dp, dismissOnScrim: Boolean, content: @Composable ColumnScope.() -> Unit) { + ModalDialog(onDismiss, Modifier, width, dismissOnScrim = dismissOnScrim, content = content) +} + +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) +@ExperimentalOneConfigUiApi +@Composable +public fun ModalDialog( + onDismiss: () -> Unit, + width: Dp, + height: Dp, + padding: PaddingValues, + spacing: Dp, + dismissOnScrim: Boolean, + content: @Composable ColumnScope.() -> Unit, +) { + DialogLayer(onDismiss, dismissOnScrim, true) { + ModalPanel(Modifier, width, height, padding, spacing, content) + } +} + +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) +@ExperimentalOneConfigUiApi +@Composable +public fun ModalPanel(width: Dp, modifier: Modifier, content: @Composable ColumnScope.() -> Unit) { + ModalPanel(modifier, width, content = content) +} + +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) +@ExperimentalOneConfigUiApi +@Composable +public fun ModalPanel( + width: Dp, + height: Dp, + padding: PaddingValues, + spacing: Dp, + modifier: Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + ModalPanel(modifier, width, height, padding, spacing, content) +} + +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) +@ExperimentalOneConfigUiApi +@Composable +public fun DialogTitle(text: String) { + DialogTitle(text, Modifier) +} + +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) @ExperimentalOneConfigUiApi @Composable public fun DialogButtons(content: @Composable RowScope.() -> Unit) { - Row(Modifier.fillMaxWidth().padding(top = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), content = content) + DialogButtons(Modifier, content) } +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) @ExperimentalOneConfigUiApi @Composable public fun ConfirmDialog(title: String, text: String, confirmLabel: String, onConfirm: () -> Unit, onDismiss: () -> Unit) { - ConfirmDialog(title, text, confirmLabel, cancelLabel(), null, true, true, onConfirm, onDismiss) + ConfirmDialog(title, text, confirmLabel, onConfirm, onDismiss, Modifier) } +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) @ExperimentalOneConfigUiApi @Composable public fun ConfirmDialog( @@ -150,16 +283,5 @@ public fun ConfirmDialog( onConfirm: () -> Unit, onDismiss: () -> Unit, ) { - ModalDialog(onDismiss) { - DialogTitle(title) - Text(text, Modifier, OneConfigTheme.current.textColorSecondary, 14.sp, null, BaselineShift.None, FontWeight.Normal, TextAlign.Start, Int.MAX_VALUE, TextOverflow.Clip) - DialogButtons { - TextButton(dismissLabel, null, Modifier, ButtonStyle.Secondary, true, onDismiss) - if (confirmLabel != null) { - TextButton(confirmLabel, icon, Modifier, if (danger) ButtonStyle.Danger else ButtonStyle.Primary, confirmEnabled, onConfirm) - } - } - } + ConfirmDialog(title, text, confirmLabel, onConfirm, onDismiss, Modifier, dismissLabel, ControlStyle.Primary, danger, icon, confirmEnabled) } - -private fun cancelLabel(): String = runCatching { Platform.i18n().translateString("gui.cancel") }.getOrNull()?.takeIf { it != "gui.cancel" } ?: "Cancel" diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Dropdown.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Dropdown.kt new file mode 100644 index 000000000..896d00d7f --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Dropdown.kt @@ -0,0 +1,132 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.widthIn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.onPlaced +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.internal.componentsBridge + +@ExperimentalOneConfigUiApi +@Composable +public fun DropdownMenu( + expanded: Boolean, + onDismiss: () -> Unit, + entries: List, + modifier: Modifier = Modifier, + placement: Placement = Placement.BelowStart, + matchAnchorWidth: Boolean = false, + maxHeight: Dp = popupMaxHeight(), +) { + TriggeredDropdownMenu(expanded, onDismiss, entries, null, modifier, placement, matchAnchorWidth, maxHeight) +} + +@ExperimentalOneConfigUiApi +@Composable +public fun DropdownMenu( + expanded: Boolean, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + placement: Placement = Placement.BelowStart, + matchAnchorWidth: Boolean = false, + content: @Composable ColumnScope.() -> Unit, +) { + TriggeredDropdownMenu(expanded, onDismiss, null, modifier, placement, matchAnchorWidth, content) +} + +@Composable +internal fun TriggeredDropdownMenu( + expanded: Boolean, + onDismiss: () -> Unit, + entries: List, + trigger: PopupTrigger?, + modifier: Modifier = Modifier, + placement: Placement = Placement.BelowStart, + matchAnchorWidth: Boolean = false, + maxHeight: Dp = popupMaxHeight(), +) { + AnchoredPopup(expanded, onDismiss, placement, matchAnchorWidth = matchAnchorWidth, trigger = trigger) { anchorWidth -> + MenuList(entries, onDismiss, anchorWidth.then(modifier).heightIn(max = maxHeight)) + } +} + +@Composable +internal fun TriggeredDropdownMenu( + expanded: Boolean, + onDismiss: () -> Unit, + trigger: PopupTrigger?, + modifier: Modifier = Modifier, + placement: Placement = Placement.BelowStart, + matchAnchorWidth: Boolean = false, + content: @Composable ColumnScope.() -> Unit, +) { + AnchoredPopup(expanded, onDismiss, placement, matchAnchorWidth = matchAnchorWidth, trigger = trigger) { anchorWidth -> + MenuSurface(anchorWidth.then(modifier), popupMaxHeight(), content) + } +} + +internal class PopupTrigger { + val focusRequester = FocusRequester() + var focused = false + + fun restoreFocus() { + runCatching { focusRequester.requestFocus() } + } +} + +@Composable +internal fun rememberPopupTrigger(): PopupTrigger = remember { PopupTrigger() } + +internal fun Modifier.popupTrigger(trigger: PopupTrigger): Modifier = + focusRequester(trigger.focusRequester).onFocusChanged { trigger.focused = it.hasFocus } + +@Composable +internal fun AnchoredPopup( + expanded: Boolean, + onDismiss: () -> Unit, + placement: Placement, + gap: Dp = 6.dp, + matchAnchorWidth: Boolean = false, + trigger: PopupTrigger? = null, + content: @Composable (anchorWidth: Modifier) -> Unit, +) { + val scope = rememberCoroutineScope() + if (!expanded) return + var anchorWidth by remember { mutableIntStateOf(0) } + if (matchAnchorWidth) { + Layout( + content = {}, + modifier = Modifier.onPlaced { anchorWidth = it.parentCoordinates?.size?.width ?: 0 }, + ) { _, _ -> layout(0, 0) {} } + } + if (trigger != null) { + val hadFocus = remember(trigger) { trigger.focused } + DisposableEffect(trigger) { + onDispose { if (hadFocus) scope.launch { trigger.restoreFocus() } } + } + } + EscapeCloses(onDismiss) + val density = LocalDensity.current + componentsBridge().Popup(rememberAnchoredPosition(placement, gap), onDismiss, true) { + val width = if (matchAnchorWidth && anchorWidth > 0) Modifier.widthIn(min = with(density) { anchorWidth.toDp() }) else Modifier + content(width) + } +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/EmptyState.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/EmptyState.kt new file mode 100644 index 000000000..caabd1574 --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/EmptyState.kt @@ -0,0 +1,148 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.a11y.Announce +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.api.ui.v1.page.PageText +import org.polyfrost.oneconfig.api.ui.v1.page.resolve +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme + +@ExperimentalOneConfigUiApi +public sealed class LoadState { + public data object Idle : LoadState() + + public data object Loading : LoadState() + + public data class Error(public val message: String) : LoadState() +} + +private val ICON_SIZE = 32.dp +private val PROGRESS_WIDTH = 160.dp +private val TEXT_MAX_WIDTH = 420.dp +private const val RESIZE_MILLIS = 150 + +@ExperimentalOneConfigUiApi +@Composable +public fun EmptyState( + title: String, + modifier: Modifier = Modifier, + icon: String? = null, + text: String? = null, + state: LoadState = LoadState.Idle, + onRetry: (() -> Unit)? = null, + action: (@Composable () -> Unit)? = null, +) { + val glyph: (@Composable (Modifier, Color) -> Unit)? = if (icon == null) null else { { iconModifier, tint -> Icon(icon, null, iconModifier, tint = tint) } } + EmptyStateFrame(title, modifier, glyph, text, state, onRetry, action) +} + +@ExperimentalOneConfigUiApi +@Composable +public fun EmptyState( + title: String, + icon: Painter, + modifier: Modifier = Modifier, + text: String? = null, + state: LoadState = LoadState.Idle, + onRetry: (() -> Unit)? = null, + action: (@Composable () -> Unit)? = null, +) { + EmptyStateFrame(title, modifier, { iconModifier, tint -> Image(icon, null, iconModifier, colorFilter = ColorFilter.tint(tint)) }, text, state, onRetry, action) +} + +@Composable +private fun EmptyStateFrame( + title: String, + modifier: Modifier, + icon: (@Composable (Modifier, Color) -> Unit)?, + text: String?, + state: LoadState, + onRetry: (() -> Unit)?, + action: (@Composable () -> Unit)?, +) { + val theme = OneConfigTheme.current + val spacing = theme.spacing + val typography = theme.typography + Announce((state as? LoadState.Error)?.message) + Column( + modifier + .animateContentSize(Motion.finiteSpec(tween(RESIZE_MILLIS))) + .padding(spacing.xxl), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(spacing.m, Alignment.CenterVertically), + ) { + when (state) { + LoadState.Loading -> ProgressBar(null, Modifier.widthIn(max = PROGRESS_WIDTH).padding(bottom = spacing.xs)) + is LoadState.Error -> Icon("alert-circle", null, Modifier.size(ICON_SIZE), tint = theme.error) + LoadState.Idle -> icon?.invoke(Modifier.size(ICON_SIZE), theme.mutedText) + } + LayoutText( + title, + typography.heading, + theme.textColor, + Modifier.widthIn(max = TEXT_MAX_WIDTH).semantics { heading() }, + align = TextAlign.Center, + ) + val body = (state as? LoadState.Error)?.message?.takeIf { it.isNotBlank() } ?: text + if (body != null) { + LayoutText(body, typography.small, theme.mutedText, Modifier.widthIn(max = TEXT_MAX_WIDTH), align = TextAlign.Center) + } + when { + state is LoadState.Error && onRetry != null -> TextButton( + layoutString("oneconfig.ui.retry", "Try again"), + "refresh", + onRetry, + Modifier.padding(top = spacing.xs), + ) + state != LoadState.Loading && action != null -> Column(Modifier.padding(top = spacing.xs)) { action() } + } + } +} + +@ExperimentalOneConfigUiApi +@Composable +public fun EmptyState( + title: PageText, + modifier: Modifier = Modifier, + icon: String? = null, + text: PageText? = null, + state: LoadState = LoadState.Idle, + onRetry: (() -> Unit)? = null, + action: (@Composable () -> Unit)? = null, +) { + EmptyState(title.resolve(), modifier, icon, text?.resolve(), state, onRetry, action) +} + +@ExperimentalOneConfigUiApi +@Composable +public fun EmptyState( + title: PageText, + icon: Painter, + modifier: Modifier = Modifier, + text: PageText? = null, + state: LoadState = LoadState.Idle, + onRetry: (() -> Unit)? = null, + action: (@Composable () -> Unit)? = null, +) { + EmptyState(title.resolve(), icon, modifier, text?.resolve(), state, onRetry, action) +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/EscapeCloses.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/EscapeCloses.kt index 053e935eb..00c6c4cc1 100644 --- a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/EscapeCloses.kt +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/EscapeCloses.kt @@ -10,8 +10,17 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type import org.polyfrost.oneconfig.api.platform.v1.Platform import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi @@ -25,6 +34,9 @@ public object EscapeStack { public val hasOpen: Boolean get() = top() != null + @JvmStatic + public fun dispatch(): Boolean = closeTop() + @JvmStatic public fun closeTop(): Boolean { val close = top() ?: return false @@ -51,6 +63,19 @@ public object EscapeStack { private fun currentScreen(): Any? = runCatching { Platform.screen().current() }.getOrNull() } +@ExperimentalOneConfigUiApi +public fun Modifier.handleEscapeStack(): Modifier = composed { + var swallowRelease by remember { mutableStateOf(false) } + onPreviewKeyEvent { event -> + if (event.key != Key.Escape) return@onPreviewKeyEvent false + when (event.type) { + KeyEventType.KeyDown -> EscapeStack.dispatch().also { swallowRelease = it } + KeyEventType.KeyUp -> swallowRelease.also { swallowRelease = false } + else -> false + } + } +} + @ExperimentalOneConfigUiApi @Composable public fun EscapeCloses(onClose: () -> Unit) { diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/FeedbackHosts.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/FeedbackHosts.kt new file mode 100644 index 000000000..08148e1e7 --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/FeedbackHosts.kt @@ -0,0 +1,68 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import org.polyfrost.oneconfig.api.platform.v1.Platform +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.InternalOneConfigUiApi + +private val LocalFeedbackDepth = staticCompositionLocalOf { 0 } + +@InternalOneConfigUiApi +@ExperimentalOneConfigUiApi +@Composable +public fun FeedbackHosts(content: @Composable () -> Unit) { + val depth = LocalFeedbackDepth.current + 1 + val confirmSlot = Confirm.hosts.rememberSlot(fallback = false, depth = depth) + val toastSlot = Toasts.hosts.rememberSlot(fallback = true, depth = depth) + CompositionLocalProvider(LocalFeedbackDepth provides depth) { + content() + ConfirmHost(confirmSlot) + ToastHostLayer(toastSlot, Modifier, ToastDefaults.alignment) + } +} + +@Composable +internal fun feedbackDepth(): Int = LocalFeedbackDepth.current + +internal class HostStack { + private class Entry(val token: Any, val fallback: Boolean, val depth: Int, val owner: Any?) + + private val entries = mutableStateListOf() + + @Composable + fun rememberSlot(fallback: Boolean, depth: Int): Any { + val token = remember { Any() } + val owner = remember { currentScreen() } + DisposableEffect(token, fallback, depth) { + val entry = Entry(token, fallback, depth, owner) + entries += entry + onDispose { entries -= entry } + } + return token + } + + fun isActive(token: Any): Boolean { + val screen = currentScreen() + val onScreen = if (screen == null) emptyList() else entries.filter { it.owner === screen } + val candidates = onScreen.ifEmpty { entries.toList() } + val preferred = candidates.filter { !it.fallback }.ifEmpty { candidates } + val deepest = preferred.maxOfOrNull { it.depth } ?: return false + return preferred.lastOrNull { it.depth == deepest }?.token === token + } +} + +internal fun currentScreen(): Any? = runCatching { Platform.screen().current() }.getOrNull() + +internal fun mutateFeedbackState(block: () -> Unit) { + if (Snapshot.current.readOnly) Snapshot.global { Snapshot.withMutableSnapshot(block) } + else Snapshot.withMutableSnapshot(block) +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/FocusRegions.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/FocusRegions.kt new file mode 100644 index 000000000..a2dc4ef63 --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/FocusRegions.kt @@ -0,0 +1,207 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.foundation.focusGroup +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusEnterExitScope +import androidx.compose.ui.focus.FocusManager +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isAltPressed +import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.isShiftPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.TraversableNode +import androidx.compose.ui.node.TraversableNode.Companion.TraverseDescendantsAction +import androidx.compose.ui.node.traverseAncestors +import androidx.compose.ui.node.traverseDescendants +import androidx.compose.ui.platform.LocalFocusManager +import java.util.function.BooleanSupplier +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.keybind.KeybindManager + +@ExperimentalOneConfigUiApi +public fun Modifier.focusRegion(order: Int): Modifier = composed { + val scope = remember { FocusScopeHandle(isRoot = false) } + scope.order = order + Modifier + .then(FocusScopeElement(scope)) + .focusRequester(scope.requester) + .focusProperties { onExit = { scope.exitRegion(this) } } + .focusGroup() +} + +@ExperimentalOneConfigUiApi +public fun Modifier.focusRoot(): Modifier = composed { + val scope = remember { FocusScopeHandle(isRoot = true) } + val focus = LocalFocusManager.current + Modifier + .then(FocusScopeElement(scope)) + .focusProperties { onEnter = { scope.enterRoot(requestedFocusDirection) } } + .onKeyEvent { event -> focusRootKey(event, focus) } + .focusGroup() +} + +@ExperimentalOneConfigUiApi +public fun Modifier.captureKeybinds(enabled: Boolean = true): Modifier = composed { + val capture = remember { CaptureKeybindsState() } + capture.enabled = enabled + DisposableEffect(capture) { + KeybindManager.addInputCapture(capture) + onDispose { + KeybindManager.removeInputCapture(capture) + capture.focused = false + capture.hovered = false + } + } + Modifier + .onFocusChanged { capture.focused = it.hasFocus } + .pointerInput(capture) { + awaitPointerEventScope { + while (true) { + when (awaitPointerEvent().type) { + PointerEventType.Enter -> capture.hovered = true + PointerEventType.Exit -> capture.hovered = false + } + } + } + } +} + +private class CaptureKeybindsState : BooleanSupplier { + @Volatile + var enabled = true + + @Volatile + var focused = false + + @Volatile + var hovered = false + + override fun getAsBoolean(): Boolean = enabled && (focused || hovered) +} + +private fun focusRootKey(event: KeyEvent, focus: FocusManager): Boolean { + if (event.type != KeyEventType.KeyDown || event.isCtrlPressed || event.isAltPressed) return false + val direction = when (event.key) { + Key.Tab -> if (event.isShiftPressed) FocusDirection.Previous else FocusDirection.Next + Key.DirectionLeft -> FocusDirection.Left + Key.DirectionRight -> FocusDirection.Right + Key.DirectionUp -> FocusDirection.Up + Key.DirectionDown -> FocusDirection.Down + else -> return false + } + if (focus.moveFocus(direction)) return true + if (event.key != Key.Tab) return false + focus.clearFocus() + return focus.moveFocus(direction) +} + +private object FocusScopeTraverseKey + +private class FocusScopeHandle(val isRoot: Boolean) { + val requester = FocusRequester() + var order = 0 + var node: FocusScopeNode? = null + + fun exitRegion(scope: FocusEnterExitScope) { + when (val direction = scope.requestedFocusDirection) { + FocusDirection.Next, FocusDirection.Previous -> { + val regions = rootRegions() + val at = regions.indexOf(this) + if (at < 0 || regions.size < 2) return + val forward = direction == FocusDirection.Next + for (step in 1 until regions.size) { + val target = regions[(if (forward) at + step else at - step).mod(regions.size)] + if (target.requester.requestFocus(direction)) return + } + } + FocusDirection.Left, FocusDirection.Right, FocusDirection.Up, FocusDirection.Down -> scope.cancelFocusChange() + } + } + + fun enterRoot(direction: FocusDirection) { + if (direction != FocusDirection.Next && direction != FocusDirection.Previous) return + val regions = node?.let(::regionsUnder) ?: return + val ordered = if (direction == FocusDirection.Next) regions else regions.asReversed() + for (region in ordered) { + if (region.requester.requestFocus(direction)) return + } + } + + private fun rootRegions(): List { + val self = node ?: return emptyList() + if (!self.isAttached) return emptyList() + var root: FocusScopeNode? = null + self.traverseAncestors(FocusScopeTraverseKey) { ancestor -> + val scope = (ancestor as FocusScopeNode).handle + if (scope.isRoot) root = ancestor + !scope.isRoot + } + return root?.let(::regionsUnder) ?: emptyList() + } +} + +private fun regionsUnder(root: FocusScopeNode): List { + if (!root.isAttached) return emptyList() + val found = ArrayList() + root.traverseDescendants(FocusScopeTraverseKey) { descendant -> + val scope = (descendant as FocusScopeNode).handle + if (scope.isRoot) { + TraverseDescendantsAction.SkipSubtreeAndContinueTraversal + } else { + found += scope + TraverseDescendantsAction.ContinueTraversal + } + } + return found.sortedBy { it.order } +} + +private class FocusScopeElement(private val handle: FocusScopeHandle) : ModifierNodeElement() { + override fun create(): FocusScopeNode = FocusScopeNode(handle) + + override fun update(node: FocusScopeNode) { + node.rebind(handle) + } + + override fun equals(other: Any?): Boolean = other is FocusScopeElement && other.handle === handle + + override fun hashCode(): Int = System.identityHashCode(handle) +} + +private class FocusScopeNode(handle: FocusScopeHandle) : Modifier.Node(), TraversableNode { + var handle: FocusScopeHandle = handle + private set + + override val traverseKey: Any get() = FocusScopeTraverseKey + + fun rebind(next: FocusScopeHandle) { + if (next === handle) return + if (handle.node === this) handle.node = null + handle = next + if (isAttached) next.node = this + } + + override fun onAttach() { + handle.node = this + } + + override fun onDetach() { + if (handle.node === this) handle.node = null + } +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/FormControlSupport.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/FormControlSupport.kt new file mode 100644 index 000000000..0be7f64be --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/FormControlSupport.kt @@ -0,0 +1,90 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.FocusInteraction +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.isSimple +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlin.math.ceil +import kotlin.math.min +import org.polyfrost.oneconfig.api.platform.v1.Platform +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi + +internal fun formControlString(key: String, fallback: String): String = + runCatching { Platform.i18n().translateString(key) }.getOrNull()?.takeIf { it.isNotBlank() && it != key } ?: fallback + +internal fun formOnLabel(): String = formControlString("oneconfig.control.on", "On") + +internal fun formOffLabel(): String = formControlString("oneconfig.control.off", "Off") + +internal fun Modifier.formFrame( + shape: Shape, + accent: Brush, + fill: () -> Color = { Color.Transparent }, + accentAlpha: () -> Float = { 0f }, + borderWidth: Dp = 1.dp, + border: () -> Color, +): Modifier = drawWithCache { + val outline = shape.createOutline(size, layoutDirection, this) + val strokeWidth = min(ceil(borderWidth.toPx()), ceil(size.minDimension / 2f)) + val half = strokeWidth / 2f + val stroke = Stroke(strokeWidth) + val inset = Size(size.width - strokeWidth, size.height - strokeWidth) + onDrawWithContent { + val f = fill() + if (f.alpha > 0f) drawOutline(outline, f) + val a = accentAlpha() + if (a > 0f) drawOutline(outline, accent, a) + drawContent() + val b = border() + if (b.alpha <= 0f) return@onDrawWithContent + val rr = (outline as? Outline.Rounded)?.roundRect + when { + outline is Outline.Rectangle -> drawRect(b, Offset(half, half), inset, style = stroke) + rr != null && rr.isSimple && rr.topLeftCornerRadius.x >= half -> + drawRoundRect(b, Offset(half, half), inset, CornerRadius(rr.topLeftCornerRadius.x - half, rr.topLeftCornerRadius.y - half), stroke) + else -> drawOutline(outline, b, style = stroke) + } + } +} + +@Composable +internal fun Modifier.groupInteractions(source: MutableInteractionSource?, enabled: Boolean): Modifier { + if (source == null) return this + val focus = remember(source) { arrayOfNulls(1) } + DisposableEffect(source) { + onDispose { + focus[0]?.let { source.tryEmit(FocusInteraction.Unfocus(it)) } + focus[0] = null + } + } + return hoverable(source, enabled).onFocusChanged { state -> + val current = focus[0] + if (state.hasFocus && current == null) { + val next = FocusInteraction.Focus() + focus[0] = next + source.tryEmit(next) + } else if (!state.hasFocus && current != null) { + source.tryEmit(FocusInteraction.Unfocus(current)) + focus[0] = null + } + } +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/GlassFrame.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/GlassFrame.kt new file mode 100644 index 000000000..d2f10afba --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/GlassFrame.kt @@ -0,0 +1,52 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.internal.glassBridge +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme + +internal val glassAvailable: Boolean get() = glassBridge() != null + +@Composable +internal fun Modifier.glassFrame( + shape: Shape, + fill: () -> Color, + border: () -> Color, + accentAlpha: () -> Float = { 0f }, + hover: Boolean = true, + glass: Boolean = true, + pull: Dp = 1.dp, +): Modifier { + val bridge = glassBridge() + if (bridge == null) { + val accent = OneConfigTheme.current.accentBrush + return drawWithCache { + val outline = shape.createOutline(size, layoutDirection, this) + val stroke = Stroke(1.dp.toPx()) + onDrawWithContent { + val f = fill() + if (f.alpha > 0f) drawOutline(outline, f) + val a = accentAlpha() + if (a > 0f) drawOutline(outline, accent, a) + drawContent() + val b = border() + if (b.alpha > 0f) drawOutline(outline, b, style = stroke) + } + } + } + val pulled = if (hover) bridge.glassHover(this, shape, pull) else this + val hovered = if (pulled === this) this else pulled.graphicsLayer {} + val glassed = if (glass) bridge.liquidGlass(hovered, shape) else hovered + return bridge.animatedFrame(glassed, shape, fill, accentAlpha, border) +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/GridReorder.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/GridReorder.kt new file mode 100644 index 000000000..feceec4c5 --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/GridReorder.kt @@ -0,0 +1,587 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.core.spring +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.KeyInputModifierNode +import androidx.compose.ui.input.key.isAltPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.pointer.AwaitPointerEventScope +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.PointerId +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.isPrimaryPressed +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.onPlaced +import androidx.compose.ui.node.LayoutAwareModifierNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.round +import androidx.compose.ui.unit.toOffset +import androidx.compose.ui.unit.toSize +import androidx.compose.ui.zIndex +import kotlin.math.abs +import kotlin.math.roundToInt +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion + +private val AutoScrollZone = 40.dp +private val AutoScrollRamp = 240.dp +private val AutoScrollMaxSpeed = 3200.dp +private const val SCROLL_LIFT_THRESHOLD = 1f +private const val LIFT_SCALE = 1.04f + +private val SettleSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + visibilityThreshold = Offset(0.5f, 0.5f), +) + +internal class ReorderSlot(val key: Any, val index: Int, val offset: Offset, val size: IntSize) { + val bounds: Rect get() = Rect(offset, size.toSize()) +} + +internal interface ReorderViewport { + val vertical: Boolean + val viewportStart: Int + val viewportEnd: Int + val totalCount: Int + val firstIndex: Int + val firstOffset: Int + fun slots(crossPadding: Float): List + fun keepScroll(index: Int, offset: Int) + fun scrollBy(delta: Float) +} + +private class GridViewport(private val state: LazyGridState) : ReorderViewport { + override val vertical: Boolean get() = state.layoutInfo.orientation == Orientation.Vertical + override val viewportStart: Int get() = state.layoutInfo.viewportStartOffset + override val viewportEnd: Int get() = state.layoutInfo.viewportEndOffset + override val totalCount: Int get() = state.layoutInfo.totalItemsCount + override val firstIndex: Int get() = state.firstVisibleItemIndex + override val firstOffset: Int get() = state.firstVisibleItemScrollOffset + + override fun slots(crossPadding: Float): List { + val pad = if (vertical) Offset(crossPadding, 0f) else Offset(0f, crossPadding) + return state.layoutInfo.visibleItemsInfo.map { ReorderSlot(it.key, it.index, it.offset.toOffset() + pad, it.size) } + } + + override fun keepScroll(index: Int, offset: Int) { + state.requestScrollToItem(index, offset) + } + + override fun scrollBy(delta: Float) { + state.dispatchRawDelta(delta) + } +} + +private class ListViewport(private val state: LazyListState) : ReorderViewport { + override val vertical: Boolean get() = state.layoutInfo.orientation == Orientation.Vertical + override val viewportStart: Int get() = state.layoutInfo.viewportStartOffset + override val viewportEnd: Int get() = state.layoutInfo.viewportEndOffset + override val totalCount: Int get() = state.layoutInfo.totalItemsCount + override val firstIndex: Int get() = state.firstVisibleItemIndex + override val firstOffset: Int get() = state.firstVisibleItemScrollOffset + + override fun slots(crossPadding: Float): List { + val info = state.layoutInfo + val vertical = info.orientation == Orientation.Vertical + val viewport = if (vertical) info.viewportSize.width else info.viewportSize.height + val cross = (viewport - 2 * crossPadding).roundToInt().coerceAtLeast(0) + return info.visibleItemsInfo.map { + if (vertical) { + ReorderSlot(it.key, it.index, Offset(crossPadding, it.offset.toFloat()), IntSize(cross, it.size)) + } else { + ReorderSlot(it.key, it.index, Offset(it.offset.toFloat(), crossPadding), IntSize(it.size, cross)) + } + } + } + + override fun keepScroll(index: Int, offset: Int) { + state.requestScrollToItem(index, offset) + } + + override fun scrollBy(delta: Float) { + state.dispatchRawDelta(delta) + } +} + +@ExperimentalOneConfigUiApi +public class GridReorderState internal constructor( + private val viewport: ReorderViewport, + private val scope: CoroutineScope, + private val onMove: (from: Int, to: Int) -> Unit, + private val onDragEnded: () -> Unit, + private val dropRange: (index: Int) -> IntRange?, + internal val onClick: (key: Any) -> Unit, + private val crossPadding: () -> Dp, + private val density: () -> Density, +) { + public var draggingKey: Any? by mutableStateOf(null) + private set + + public var overlayKey: Any? by mutableStateOf(null) + private set + + public val isDragging: Boolean get() = draggingKey != null + + internal var overlaySize by mutableStateOf(IntSize.Zero) + private set + + private var draggingIndex by mutableIntStateOf(-1) + private var initialOffset by mutableStateOf(Offset.Zero) + private var dragDelta by mutableStateOf(Offset.Zero) + private val settleOffset = Animatable(Offset.Zero, Offset.VectorConverter) + private var settling by mutableStateOf(false) + private val lift = Animatable(1f) + + private var slot = Offset.Zero + private var referenceKey: Any? = null + private var referenceOffset = Offset.Zero + private var settle: Job? = null + private var dragLoop: Job? = null + + internal var autoScrollers = 0 + internal var containerCoordinates: LayoutCoordinates? = null + internal val items = HashMap() + + internal val liftScale: Float get() = lift.value + + internal val overlayOffset: Offset + get() = if (settling) trackSlot() + settleOffset.value else initialOffset + dragDelta + + public fun cancel() { + if (draggingKey == null) return + stop() + overlayKey = null + } + + public fun moveBy(key: Any, delta: Int): Boolean { + if (draggingKey != null || delta == 0) return false + val item = items[key]?.takeIf { it.enabled } ?: return false + val from = slots().firstOrNull { it.key == key }?.index ?: item.index + val to = from + delta + val range = dropRange(from) + if (to !in 0 until viewport.totalCount || (range != null && to !in range)) return false + keepScroll(from, to) + onMove(from, to) + onDragEnded() + playUiSound(UiSound.SliderTick) + return true + } + + private fun slots(): List = viewport.slots(with(density()) { crossPadding().toPx() }) + + private fun slotAt(index: Int): ReorderSlot? = slots().firstOrNull { it.index == index } + + private fun trackSlot(): Offset { + val slots = slots() + val own = slots.firstOrNull { it.key == overlayKey } + if (own != null) { + slot = own.offset + } else { + slots.firstOrNull { it.key == referenceKey }?.let { slot += it.offset - referenceOffset } + } + val reference = slots.getOrNull(slots.size / 2) + referenceKey = reference?.key + referenceOffset = reference?.offset ?: Offset.Zero + return slot + } + + private fun drawnBounds(info: ReorderSlot): Rect { + val container = containerCoordinates ?: return info.bounds + val item = items[info.key]?.coordinates ?: return info.bounds + if (!item.isAttached || !container.isAttached) return info.bounds + return container.localBoundingBoxOf(item, clipBounds = false) + } + + internal fun isOverSettlingItem(position: Offset): Boolean = + settling && Rect(overlayOffset, overlaySize.toSize()).contains(position) + + internal fun draggableKeyAt(position: Offset): Any? { + if (isOverSettlingItem(position)) return overlayKey + val info = slots().lastOrNull { drawnBounds(it).contains(position) } ?: return null + if (items[info.key]?.enabled != true) return null + val range = dropRange(info.index) + return info.key.takeIf { range == null || info.index in range } + } + + internal fun startDrag(key: Any): Boolean { + val info = slots().firstOrNull { it.key == key } ?: return false + val from = if (settling && overlayKey == key) overlayOffset else drawnBounds(info).topLeft + settle?.cancel() + settling = false + draggingKey = key + overlayKey = key + overlaySize = info.size + draggingIndex = info.index + initialOffset = from + dragDelta = Offset.Zero + trackSlot() + animateLift(if (Motion.reduced) 1f else LIFT_SCALE) + dragLoop = scope.launch { dragLoop() } + return true + } + + internal fun dragBy(delta: Offset) { + if (draggingKey != null) dragDelta += delta + } + + internal fun endDrag() { + val key = draggingKey ?: return + settleOnHoveredItem() + val fromSlot = overlayOffset - (slotAt(draggingIndex)?.offset ?: trackSlot()) + stop() + onDragEnded() + settling = true + settle = scope.launch(start = CoroutineStart.UNDISPATCHED) { + settleOffset.snapTo(fromSlot) + settleOffset.animateTo(Offset.Zero, Motion.spec(SettleSpec)) + settling = false + if (overlayKey == key) overlayKey = null + } + } + + private fun stop() { + dragLoop?.cancel() + dragLoop = null + draggingKey = null + draggingIndex = -1 + dragDelta = Offset.Zero + animateLift(1f) + } + + private fun animateLift(target: Float) { + scope.launch { lift.animateTo(target, Motion.spec(spring())) } + } + + private fun keepScroll(from: Int, to: Int) { + val first = viewport.firstIndex + if (first in minOf(from, to)..maxOf(from, to)) viewport.keepScroll(first, viewport.firstOffset) + } + + private fun settleOnHoveredItem() { + if (draggingKey == null) return + val slots = slots() + val own = slots.firstOrNull { it.index == draggingIndex } + val range = dropRange(draggingIndex) + val dragged = Rect(overlayOffset, overlaySize.toSize()) + val allowed = slots.filter { range == null || it.index in range } + var best: ReorderSlot? = null + var bestOverlap = own?.let { dragged.overlap(it.bounds) } ?: 0f + allowed.forEach { other -> + if (other.index == draggingIndex) return@forEach + val overlap = dragged.overlap(other.bounds) + if (overlap > bestOverlap) { + best = other + bestOverlap = overlap + } + } + if (best == null && range != null && bestOverlap == 0f) { + fun distance(item: ReorderSlot) = (item.bounds.center - dragged.center).getDistance() + val ownDistance = own?.let(::distance) ?: Float.POSITIVE_INFINITY + best = allowed.minByOrNull(::distance)?.takeIf { distance(it) < ownDistance } + } + val target = best ?: return + keepScroll(draggingIndex, target.index) + onMove(draggingIndex, target.index) + draggingIndex = target.index + playUiSound(UiSound.SliderTick) + } + + private suspend fun dragLoop() { + var lastFrame = withFrameNanos { it } + while (scope.isActive && draggingKey != null) { + val frame = withFrameNanos { it } + val seconds = ((frame - lastFrame) / 1e9f).coerceAtMost(1 / 30f) + lastFrame = frame + trackSlot() + settleOnHoveredItem() + if (autoScrollers > 0) autoScroll(seconds) + } + } + + private fun autoScroll(seconds: Float) { + val vertical = viewport.vertical + fun Offset.main() = if (vertical) y else x + fun IntSize.main() = if (vertical) height else width + val start = overlayOffset.main() + val end = start + overlaySize.main() + val viewStart = viewport.viewportStart + val viewEnd = viewport.viewportEnd + val range = dropRange(draggingIndex) + val inView = slots().filter { it.offset.main() >= viewStart && it.offset.main() + it.size.main() <= viewEnd } + val canScrollBack = range == null || inView.isEmpty() || range.first < inView.first().index + val canScrollForward = range == null || inView.isEmpty() || range.last > inView.last().index + val (zone, rampDistance, maxSpeed) = with(density()) { + Triple(AutoScrollZone.toPx(), AutoScrollRamp.toPx(), AutoScrollMaxSpeed.toPx()) + } + val intoStart = minOf(viewStart + zone, initialOffset.main()) - start + val intoEnd = end - maxOf(viewEnd - zone, initialOffset.main() + overlaySize.main()) + val depth = when { + intoStart > 0f && canScrollBack -> -intoStart + intoEnd > 0f && canScrollForward -> intoEnd + else -> 0f + } + val ramp = (depth / rampDistance).coerceIn(-1f, 1f) + if (depth != 0f) viewport.scrollBy(ramp * abs(ramp) * maxSpeed * seconds) + } +} + +@ExperimentalOneConfigUiApi +@Composable +public fun rememberGridReorderState( + lazyState: LazyGridState, + onMove: (from: Int, to: Int) -> Unit, + onDragEnd: () -> Unit = {}, + dropRange: (index: Int) -> IntRange? = { null }, + onClick: (key: Any) -> Unit = {}, + crossAxisPadding: Dp = 0.dp, +): GridReorderState = rememberLazyReorderState(lazyState, onMove, onDragEnd, dropRange, onClick, crossAxisPadding) { GridViewport(lazyState) } + +@ExperimentalOneConfigUiApi +@Composable +public fun rememberGridReorderState( + lazyState: LazyListState, + onMove: (from: Int, to: Int) -> Unit, + onDragEnd: () -> Unit = {}, + dropRange: (index: Int) -> IntRange? = { null }, + onClick: (key: Any) -> Unit = {}, + crossAxisPadding: Dp = 0.dp, +): GridReorderState = rememberLazyReorderState(lazyState, onMove, onDragEnd, dropRange, onClick, crossAxisPadding) { ListViewport(lazyState) } + +@Composable +private fun rememberLazyReorderState( + lazyState: Any, + onMove: (from: Int, to: Int) -> Unit, + onDragEnd: () -> Unit, + dropRange: (index: Int) -> IntRange?, + onClick: (key: Any) -> Unit, + crossAxisPadding: Dp, + viewport: () -> ReorderViewport, +): GridReorderState { + val scope = rememberCoroutineScope() + val currentOnMove by rememberUpdatedState(onMove) + val currentOnDragEnd by rememberUpdatedState(onDragEnd) + val currentDropRange by rememberUpdatedState(dropRange) + val currentOnClick by rememberUpdatedState(onClick) + val currentPadding by rememberUpdatedState(crossAxisPadding) + val currentDensity by rememberUpdatedState(LocalDensity.current) + return remember(lazyState, scope) { + GridReorderState( + viewport = viewport(), + scope = scope, + onMove = { from, to -> currentOnMove(from, to) }, + onDragEnded = { currentOnDragEnd() }, + dropRange = { index -> currentDropRange(index) }, + onClick = { key -> currentOnClick(key) }, + crossPadding = { currentPadding }, + density = { currentDensity }, + ) + } +} + +@ExperimentalOneConfigUiApi +public fun Modifier.reorderContainer(state: GridReorderState): Modifier = pointerInput(state) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + if (!currentEvent.buttons.isPrimaryPressed) return@awaitEachGesture + val key = state.draggableKeyAt(down.position) ?: return@awaitEachGesture + val caught = state.isOverSettlingItem(down.position) + var last = down.position + if (caught) { + down.consume() + } else { + var scrolled = 0f + do { + val change = awaitPressedChange(down.id, consume = false) ?: return@awaitEachGesture + val scroll = currentEvent.type == PointerEventType.Scroll + if (scroll) scrolled += abs(change.scrollDelta.y) + val passedSlop = scrolled >= SCROLL_LIFT_THRESHOLD || + (change.position - down.position).getDistance() > viewConfiguration.touchSlop + if (passedSlop) { + if (!scroll) change.consume() + last = change.position + } + } while (!passedSlop) + } + if (!state.startDrag(key)) return@awaitEachGesture + state.dragBy(last - down.position) + var click = caught + try { + while (true) { + val change = awaitPressedChange(down.id, consume = true) ?: break + if (currentEvent.type == PointerEventType.Scroll || + (change.position - down.position).getDistance() > viewConfiguration.touchSlop + ) { + click = false + } + state.dragBy(change.position - last) + last = change.position + } + } finally { + state.endDrag() + } + if (click) state.onClick(key) + } +}.onPlaced { state.containerCoordinates = it } + +@ExperimentalOneConfigUiApi +public fun Modifier.reorderableItem(state: GridReorderState, key: Any, index: Int, enabled: Boolean = true): Modifier = this + .then(ReorderItemElement(state, key, index, enabled)) + .graphicsLayer { alpha = if (state.overlayKey == key) 0f else 1f } + +@ExperimentalOneConfigUiApi +public fun Modifier.reorderOverlay(state: GridReorderState): Modifier = this + .zIndex(1f) + .offset { state.overlayOffset.round() } + .layout { measurable, _ -> + val size = state.overlaySize + val placeable = measurable.measure(Constraints.fixed(size.width, size.height)) + layout(size.width, size.height) { placeable.place(0, 0) } + } + .graphicsLayer { + scaleX = state.liftScale + scaleY = state.liftScale + } + +@ExperimentalOneConfigUiApi +public fun Modifier.reorderAutoScroll(state: GridReorderState): Modifier = this.then(ReorderAutoScrollElement(state)) + +private suspend fun AwaitPointerEventScope.awaitPressedChange(id: PointerId, consume: Boolean): PointerInputChange? { + val event = awaitPointerEvent(PointerEventPass.Initial) + val change = event.changes.firstOrNull { it.id == id } ?: return null + if (consume && event.type != PointerEventType.Scroll) change.consume() + return change.takeIf { it.pressed } +} + +private data class ReorderItemElement( + val state: GridReorderState, + val key: Any, + val index: Int, + val enabled: Boolean, +) : ModifierNodeElement() { + override fun create(): ReorderItemNode = ReorderItemNode(state, key, index, enabled) + + override fun update(node: ReorderItemNode) { + node.update(state, key, index, enabled) + } +} + +internal class ReorderItemNode( + private var state: GridReorderState, + private var key: Any, + var index: Int, + var enabled: Boolean, +) : Modifier.Node(), LayoutAwareModifierNode, KeyInputModifierNode { + var coordinates: LayoutCoordinates? = null + private set + + override fun onAttach() { + state.items[key] = this + } + + override fun onDetach() { + if (state.items[key] === this) state.items.remove(key) + coordinates = null + } + + fun update(state: GridReorderState, key: Any, index: Int, enabled: Boolean) { + if (state !== this.state || key != this.key) { + if (this.state.items[this.key] === this) this.state.items.remove(this.key) + this.state = state + this.key = key + if (isAttached) state.items[key] = this + } + this.index = index + this.enabled = enabled + } + + override fun onPlaced(coordinates: LayoutCoordinates) { + this.coordinates = coordinates + } + + override fun onKeyEvent(event: KeyEvent): Boolean = false + + override fun onPreKeyEvent(event: KeyEvent): Boolean { + if (!enabled || event.type != KeyEventType.KeyDown || !event.isAltPressed) return false + val delta = when (event.key) { + Key.DirectionUp -> -1 + Key.DirectionDown -> 1 + else -> return false + } + return state.moveBy(key, delta) + } +} + +private data class ReorderAutoScrollElement(val state: GridReorderState) : ModifierNodeElement() { + override fun create(): ReorderAutoScrollNode = ReorderAutoScrollNode(state) + + override fun update(node: ReorderAutoScrollNode) { + node.rebind(state) + } +} + +private class ReorderAutoScrollNode(private var state: GridReorderState) : Modifier.Node() { + override fun onAttach() { + state.autoScrollers++ + } + + override fun onDetach() { + state.autoScrollers-- + } + + fun rebind(next: GridReorderState) { + if (next === state) return + if (isAttached) { + state.autoScrollers-- + next.autoScrollers++ + } + state = next + } +} + +private fun Rect.overlap(other: Rect): Float { + val width = minOf(right, other.right) - maxOf(left, other.left) + val height = minOf(bottom, other.bottom) - maxOf(top, other.top) + return if (width > 0f && height > 0f) width * height else 0f +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/HoverTooltip.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/HoverTooltip.kt index 5f9acd449..ea066555e 100644 --- a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/HoverTooltip.kt +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/HoverTooltip.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -18,17 +19,15 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.BaselineShift import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.compose.ui.window.PopupPositionProvider +import kotlinx.coroutines.delay import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi import org.polyfrost.oneconfig.api.ui.v1.a11y.isKeyboardMode import org.polyfrost.oneconfig.api.ui.v1.internal.componentsBridge @@ -46,7 +45,6 @@ public enum class TooltipPlacement { private val TOOLTIP_GAP = 8.dp private val TOOLTIP_EDGE = 8.dp -private val TOOLTIP_WRAP = 240.dp private class TooltipPosition(private val placement: TooltipPlacement, private val gap: Int, private val edge: Int) : PopupPositionProvider { override fun calculatePosition(anchorBounds: IntRect, windowSize: IntSize, layoutDirection: LayoutDirection, popupContentSize: IntSize): IntOffset { @@ -79,56 +77,134 @@ private class TooltipPosition(private val placement: TooltipPlacement, private v } } +@ExperimentalOneConfigUiApi +public object TooltipDefaults { + public val maxWidth: Dp = 240.dp + + public const val delayMillis: Long = 300L + + public val placement: TooltipPlacement = TooltipPlacement.AboveCenter +} + +@ExperimentalOneConfigUiApi +@Composable +public fun TooltipPopup( + text: String, + modifier: Modifier = Modifier, + placement: TooltipPlacement = TooltipDefaults.placement, + maxWidth: Dp = TooltipDefaults.maxWidth, +) { + TooltipSurface(placement, maxWidth, modifier) { TooltipText(text) } +} + +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) @ExperimentalOneConfigUiApi @Composable public fun TooltipPopup(text: String, placement: TooltipPlacement) { - TooltipPopup(text, placement, TOOLTIP_WRAP) + TooltipPopup(text, Modifier, placement, TooltipDefaults.maxWidth) } +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) @ExperimentalOneConfigUiApi @Composable public fun TooltipPopup(text: String, placement: TooltipPlacement, maxWidth: Dp) { + TooltipPopup(text, Modifier, placement, maxWidth) +} + +@Composable +internal fun rememberTooltipShown(wanted: Boolean): Boolean { + var shown by remember { mutableStateOf(false) } + LaunchedEffect(wanted) { + if (wanted) { + delay(TooltipDefaults.delayMillis) + shown = true + } else { + shown = false + } + } + return shown && wanted +} + +@Composable +private fun TooltipText(text: String) { + val theme = OneConfigTheme.current + Text(text, Modifier, theme.textColor, theme.typography.label.copy(fontWeight = FontWeight.Normal), textAlign = TextAlign.Center) +} + +@Composable +private fun TooltipSurface(placement: TooltipPlacement, maxWidth: Dp, modifier: Modifier = Modifier, content: @Composable () -> Unit) { val density = LocalDensity.current val provider = remember(density, placement) { TooltipPosition(placement, with(density) { TOOLTIP_GAP.roundToPx() }, with(density) { TOOLTIP_EDGE.roundToPx() }) } componentsBridge().Popup(provider, null, false) { Box( - modifier = componentsBridge().popupSurface(Modifier.widthIn(max = maxWidth), OneConfigTheme.current.popupShape) + modifier = componentsBridge().popupSurface(modifier.widthIn(max = maxWidth), OneConfigTheme.current.popupShape) .padding(horizontal = 12.dp, vertical = 8.dp), contentAlignment = Alignment.Center, ) { - Text( - text, - Modifier, - OneConfigTheme.current.textColor, - 13.sp, - null, - BaselineShift.None, - FontWeight.Normal, - TextAlign.Center, - Int.MAX_VALUE, - TextOverflow.Clip, - ) + content() } } } @ExperimentalOneConfigUiApi @Composable -public fun TooltipBox(text: String?, modifier: Modifier, content: @Composable () -> Unit) { - TooltipBox(text, modifier, TooltipPlacement.AboveCenter, TOOLTIP_WRAP, content) +public fun TooltipBox( + text: String?, + modifier: Modifier = Modifier, + placement: TooltipPlacement = TooltipDefaults.placement, + maxWidth: Dp = TooltipDefaults.maxWidth, + showOnFocus: Boolean = true, + content: @Composable () -> Unit, +) { + val tooltip: (@Composable () -> Unit)? = if (text == null) null else { { TooltipText(text) } } + TooltipHost(tooltip, modifier, placement, maxWidth, showOnFocus, content) } @ExperimentalOneConfigUiApi @Composable -public fun TooltipBox(text: String?, modifier: Modifier, placement: TooltipPlacement, maxWidth: Dp, content: @Composable () -> Unit) { +public fun TooltipBox( + tooltip: @Composable () -> Unit, + modifier: Modifier = Modifier, + placement: TooltipPlacement = TooltipDefaults.placement, + maxWidth: Dp = TooltipDefaults.maxWidth, + showOnFocus: Boolean = true, + content: @Composable () -> Unit, +) { + TooltipHost(tooltip, modifier, placement, maxWidth, showOnFocus, content) +} + +@Composable +private fun TooltipHost( + tooltip: (@Composable () -> Unit)?, + modifier: Modifier, + placement: TooltipPlacement, + maxWidth: Dp, + showOnFocus: Boolean, + content: @Composable () -> Unit, +) { val source = remember { MutableInteractionSource() } val hovered by source.collectIsHoveredAsState() var focused by remember { mutableStateOf(false) } val keyboard = isKeyboardMode() + val shown = rememberTooltipShown(tooltip != null && (hovered || showOnFocus && focused && keyboard)) Box(modifier.hoverable(source).onFocusChanged { focused = it.hasFocus }) { content() - if (text != null && (hovered || focused && keyboard)) TooltipPopup(text, placement, maxWidth) + if (shown && tooltip != null) TooltipSurface(placement, maxWidth, content = tooltip) } } + +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) +@ExperimentalOneConfigUiApi +@Composable +public fun TooltipBox(text: String?, modifier: Modifier, content: @Composable () -> Unit) { + TooltipBox(text, modifier, TooltipDefaults.placement, TooltipDefaults.maxWidth, true, content) +} + +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) +@ExperimentalOneConfigUiApi +@Composable +public fun TooltipBox(text: String?, modifier: Modifier, placement: TooltipPlacement, maxWidth: Dp, content: @Composable () -> Unit) { + TooltipBox(text, modifier, placement, maxWidth, true, content) +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Icon.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Icon.kt index 8b604e066..0095c3895 100644 --- a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Icon.kt +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Icon.kt @@ -1,19 +1,111 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + package org.polyfrost.oneconfig.api.ui.v1.components +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.layout +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.constrainHeight +import androidx.compose.ui.unit.constrainWidth +import androidx.compose.ui.unit.dp +import kotlin.math.roundToInt import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi import org.polyfrost.oneconfig.api.ui.v1.internal.componentsBridge +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme +import org.polyfrost.oneconfig.api.ui.v1.theme.ThemeStyle + +private val DEFAULT_ICON_SIZE = 18.dp + +private const val PIXEL_ICON_CELLS = 12 + +private const val PIXEL_MIN_FRACTION_NUM = 9 + +private const val PIXEL_MIN_FRACTION_DEN = 10 + +private const val PIXEL_MAX_FRACTION_NUM = 11 +@ExperimentalOneConfigUiApi +@Composable +public fun Icon( + name: String, + contentDescription: String?, + modifier: Modifier = Modifier, + tint: Color = Color.Unspecified, + tintProducer: (() -> Color)? = null, + fitAspectRatio: Boolean = false, +) { + val pixel = !fitAspectRatio && OneConfigTheme.current.style == ThemeStyle.Pixel && + (OneConfigTheme.icon(name) != name || "/minecraft/" in name) + val sized = modifier.describedAs(contentDescription).then(if (pixel) Modifier.snapToCells(PIXEL_ICON_CELLS) else Modifier) + componentsBridge().Icon(name, tint, sized, fitAspectRatio, tintProducer) +} + +@ExperimentalOneConfigUiApi +@Composable +public fun Icon( + painter: Painter, + contentDescription: String?, + modifier: Modifier = Modifier, + tint: Color = Color.Unspecified, +) { + val theme = OneConfigTheme.current + val cells = if (theme.style != ThemeStyle.Pixel) null + else (painter as? BitmapPainter)?.intrinsicSize?.width?.takeIf { it.isFinite() && it >= 1f }?.roundToInt() + Image( + painter = painter, + contentDescription = contentDescription, + modifier = modifier + .then(if (cells != null) Modifier.snapToCells(cells) else Modifier) + .size(DEFAULT_ICON_SIZE), + colorFilter = ColorFilter.tint(if (tint.isSpecified) tint else theme.textColor), + ) +} + +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) @ExperimentalOneConfigUiApi @Composable public fun Icon(iconName: String, color: Color, modifier: Modifier) { - componentsBridge().Icon(iconName, color, modifier, false) + Icon(iconName, null, modifier, color) } +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) @ExperimentalOneConfigUiApi @Composable public fun Icon(iconName: String, color: Color, modifier: Modifier, fitAspectRatio: Boolean) { - componentsBridge().Icon(iconName, color, modifier, fitAspectRatio) + Icon(iconName, null, modifier, color, fitAspectRatio = fitAspectRatio) +} + +private fun Modifier.describedAs(contentDescription: String?): Modifier = + if (contentDescription == null) this + else semantics { + this.contentDescription = contentDescription + role = Role.Image + } + +private fun Modifier.snapToCells(cells: Int): Modifier = layout { measurable, constraints -> + val limit = minOf(constraints.maxWidth, constraints.maxHeight) + val requested = if (constraints.hasFixedWidth && constraints.hasFixedHeight) limit else DEFAULT_ICON_SIZE.roundToPx().coerceAtMost(limit) + val up = ((requested + cells - 1) / cells) * cells + val down = (requested / cells) * cells + val side = when { + up <= limit && up * PIXEL_MIN_FRACTION_DEN <= requested * PIXEL_MAX_FRACTION_NUM -> up + down * PIXEL_MIN_FRACTION_DEN >= requested * PIXEL_MIN_FRACTION_NUM -> down + else -> requested + } + val placeable = measurable.measure(Constraints.fixed(side, side)) + val width = constraints.constrainWidth(side) + val height = constraints.constrainHeight(side) + layout(width, height) { placeable.place((width - side) / 2, (height - side) / 2) } } diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/IconButton.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/IconButton.kt new file mode 100644 index 000000000..0525f7bcd --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/IconButton.kt @@ -0,0 +1,230 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.api.ui.v1.a11y.isKeyboardMode +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme + +@ExperimentalOneConfigUiApi +public enum class IconButtonStyle { + Ghost, + + Framed, + + Filled, + + Overlay, +} + +@ExperimentalOneConfigUiApi +@Composable +public fun IconButton( + icon: String, + contentDescription: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + style: IconButtonStyle = IconButtonStyle.Ghost, + size: ControlSize = ControlSize.Regular, + selected: Boolean = false, + danger: Boolean = false, + tooltip: String? = contentDescription, + tooltipPlacement: TooltipPlacement = TooltipPlacement.BelowCenter, + tint: Color = Color.Unspecified, + interactionSource: MutableInteractionSource? = null, + shape: Shape = IconButtonDefaults.shape(style), +) { + IconButtonFrame(contentDescription, onClick, modifier, enabled, style, size, selected, danger, tooltip, tooltipPlacement, interactionSource, shape) { color -> + Icon(icon, null, Modifier.size(IconButtonDefaults.iconSize(size)), if (tint == Color.Unspecified) color else tint) + } +} + +@ExperimentalOneConfigUiApi +@Composable +public fun IconButton( + painter: Painter, + contentDescription: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + style: IconButtonStyle = IconButtonStyle.Ghost, + size: ControlSize = ControlSize.Regular, + selected: Boolean = false, + danger: Boolean = false, + tooltip: String? = contentDescription, + tooltipPlacement: TooltipPlacement = TooltipPlacement.BelowCenter, + tint: Color = Color.Unspecified, + interactionSource: MutableInteractionSource? = null, + shape: Shape = IconButtonDefaults.shape(style), +) { + IconButtonFrame(contentDescription, onClick, modifier, enabled, style, size, selected, danger, tooltip, tooltipPlacement, interactionSource, shape) { color -> + Image(painter, null, Modifier.size(IconButtonDefaults.iconSize(size)), colorFilter = ColorFilter.tint(if (tint == Color.Unspecified) color else tint)) + } +} + +@ExperimentalOneConfigUiApi +public object IconButtonDefaults { + public val style: IconButtonStyle = IconButtonStyle.Ghost + + public val size: ControlSize = ControlSize.Regular + + public val tooltipPlacement: TooltipPlacement = TooltipPlacement.BelowCenter + + public const val DANGER_HOVER_ALPHA: Float = 0.15f + + @Composable + public fun shape(style: IconButtonStyle): Shape { + val theme = OneConfigTheme.current + if (style == IconButtonStyle.Overlay) return theme.shapes.circle + val base = theme.buttonShape + return remember(base) { ProportionalCornerShape(base) } + } + + public fun iconSize(size: ControlSize): Dp = when (size) { + ControlSize.Compact -> 14.dp + ControlSize.Regular -> 16.dp + ControlSize.Large -> 18.dp + } + + @Composable + public fun contentColor(style: IconButtonStyle, hovered: Boolean, selected: Boolean = false, danger: Boolean = false): Color { + val theme = OneConfigTheme.current + return when { + style == IconButtonStyle.Filled || selected -> theme.accentTextColor + style == IconButtonStyle.Overlay -> theme.onMedia + hovered && danger -> theme.error + hovered -> theme.textColor + else -> theme.textColorSecondary + } + } +} + +private const val ICON_FADE_MILLIS = 150 + +@Composable +internal fun IconButtonFrame( + label: String, + onClick: () -> Unit, + modifier: Modifier, + enabled: Boolean, + style: IconButtonStyle, + size: ControlSize, + selected: Boolean, + danger: Boolean, + tooltip: String?, + placement: TooltipPlacement, + interactionSource: MutableInteractionSource?, + shape: Shape = IconButtonDefaults.shape(style), + glyph: @Composable (Color) -> Unit, +) { + val theme = OneConfigTheme.current + val source = interactionSource ?: remember { MutableInteractionSource() } + val hovered by source.collectIsHoveredAsState() + val pressed by source.collectIsPressedAsState() + val focused by source.collectIsFocusedAsState() + val keyboard = isKeyboardMode() + val active = hovered && enabled + val accented = style == IconButtonStyle.Filled || selected + val ghost = style == IconButtonStyle.Ghost && !accented + val spec = Motion.spec(tween(ICON_FADE_MILLIS)) + val fill by animateColorAsState( + when { + accented -> Color.Transparent + style == IconButtonStyle.Overlay -> theme.onMediaShadow + active && danger -> theme.error.copy(alpha = IconButtonDefaults.DANGER_HOVER_ALPHA) + style == IconButtonStyle.Framed -> theme.componentBackground + ghost && enabled && pressed -> theme.textColor.copy(alpha = theme.textColor.alpha * theme.pressedOverlayAlpha) + ghost && active -> theme.textColor.copy(alpha = theme.textColor.alpha * theme.hoverOverlayAlpha) + ghost -> theme.textColor.copy(alpha = 0f) + else -> Color.Transparent + }, + spec, + ) + val accentAlpha by animateFloatAsState( + when { + !accented -> 0f + active -> ButtonDefaults.FILLED_HOVER_ALPHA + else -> 1f + }, + Motion.spec(tween(ICON_FADE_MILLIS)), + ) + val frame by animateColorAsState( + when { + accented || style == IconButtonStyle.Overlay -> Color.Transparent + active && danger -> theme.error.copy(alpha = 0f) + style == IconButtonStyle.Framed && active -> theme.textColorSecondary + style == IconButtonStyle.Framed -> theme.borderColor + active -> theme.borderColor + else -> theme.borderColor.copy(alpha = 0f) + }, + spec, + ) + val content by animateColorAsState(IconButtonDefaults.contentColor(style, active, selected, danger), spec) + val glass = style == IconButtonStyle.Framed || accented + Box( + modifier = modifier + .size(size.height) + .graphicsLayer { alpha = if (enabled) 1f else theme.disabledAlpha } + .glassFrame(shape, { fill }, { frame }, { accentAlpha }, hover = enabled && glass, glass = glass, pull = 0.75.dp) + .oneConfigClickable( + label, + onClick, + enabled = enabled, + selected = if (selected) true else null, + shape = shape, + indication = !ghost && (!glass || !glassAvailable), + interactionSource = source, + ), + contentAlignment = Alignment.Center, + ) { + glyph(content) + val tooltipShown = rememberTooltipShown(tooltip != null && (hovered || focused && keyboard)) + if (tooltipShown && tooltip != null) TooltipPopup(tooltip, Modifier, placement) + } +} + +private const val MAX_CORNER_FRACTION = 0.25f + +private class ProportionalCornerShape(private val base: Shape) : Shape { + override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline { + val outline = base.createOutline(size, layoutDirection, density) + if (outline !is Outline.Rounded) return outline + val corner = outline.roundRect.topLeftCornerRadius + val radius = minOf(corner.x, corner.y, size.minDimension * MAX_CORNER_FRACTION) + return Outline.Rounded(RoundRect(0f, 0f, size.width, size.height, CornerRadius(radius))) + } + + override fun equals(other: Any?): Boolean = other is ProportionalCornerShape && other.base == base + + override fun hashCode(): Int = base.hashCode() +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/ImageViewer.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/ImageViewer.kt new file mode 100644 index 000000000..616de7a2f --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/ImageViewer.kt @@ -0,0 +1,102 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.a11y.A11yRole +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.api.ui.v1.a11y.a11y +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme + +private const val VIEWER_FADE_MILLIS = 160 + +@ExperimentalOneConfigUiApi +@Composable +public fun ImageViewer( + count: Int, + index: Int, + onIndexChange: (Int) -> Unit, + modifier: Modifier = Modifier, + onDismiss: (() -> Unit)? = null, + image: @Composable (Int) -> Unit, +) { + val theme = OneConfigTheme.current + val spacing = theme.spacing + val focus = remember { FocusRequester() } + val shown = if (count > 0) index.coerceIn(0, count - 1) else 0 + val latestShown by rememberUpdatedState(shown) + val latestCount by rememberUpdatedState(count) + val latestChange by rememberUpdatedState(onIndexChange) + if (onDismiss != null) EscapeCloses(onDismiss) + LaunchedEffect(Unit) { runCatching { focus.requestFocus() } } + val position = layoutString("oneconfig.ui.image.position", "%s of %s", shown + 1, count) + Box( + modifier + .onKeyEvent { event -> + if (event.type != KeyEventType.KeyDown || latestCount <= 0) return@onKeyEvent false + val target = when (event.key) { + Key.DirectionLeft -> latestShown - 1 + Key.DirectionRight -> latestShown + 1 + Key.MoveHome -> 0 + Key.MoveEnd -> latestCount - 1 + else -> return@onKeyEvent false + } + if (target !in 0 until latestCount || target == latestShown) return@onKeyEvent false + latestChange(target) + true + } + .focusRequester(focus) + .a11y(position, A11yRole.Image, null, null, theme.shapes.large, true) + .focusable(), + contentAlignment = Alignment.Center, + ) { + if (count > 0) { + Crossfade(shown, Modifier.fillMaxSize(), Motion.finiteSpec(tween(VIEWER_FADE_MILLIS)), label = "imageViewer") { at -> + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { image(at) } + } + } + if (shown > 0) { + IconButton( + "left-arrow", + layoutString("oneconfig.ui.image.previous", "Previous image"), + { onIndexChange(shown - 1) }, + Modifier.align(Alignment.CenterStart).padding(spacing.l), + style = IconButtonStyle.Overlay, + size = ControlSize.Large, + ) + } + if (shown < count - 1) { + IconButton( + "right-arrow", + layoutString("oneconfig.ui.image.next", "Next image"), + { onIndexChange(shown + 1) }, + Modifier.align(Alignment.CenterEnd).padding(spacing.l), + style = IconButtonStyle.Overlay, + size = ControlSize.Large, + ) + } + if (count > 1) { + Badge(position, Modifier.align(Alignment.BottomCenter).padding(spacing.l), overImage = true) + } + } +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Images.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Images.kt new file mode 100644 index 000000000..82f250f75 --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Images.kt @@ -0,0 +1,172 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.ImageBitmap +import java.awt.RenderingHints +import java.awt.image.BufferedImage +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.ConcurrentHashMap +import javax.imageio.ImageIO +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext +import org.polyfrost.compose.backend.asComposeImageBitmap +import org.polyfrost.compose.render.PolyBitmap +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi + +@ExperimentalOneConfigUiApi +public fun PolyBitmap.Companion.fromFile(path: Path, maxBytes: Long = 16L shl 20): PolyBitmap? = runCatching { + if (!Files.isRegularFile(path) || Files.size(path) > maxBytes) return null + decode(Files.readAllBytes(path)) +}.getOrNull() + +@ExperimentalOneConfigUiApi +@Composable +public fun rememberImageBitmap(path: Path, maxWidth: Int): ImageBitmap? { + val file = remember(path) { path.toAbsolutePath().normalize() } + val width = maxWidth.coerceAtLeast(1) + return produceState(KitImageCache.peek(file, width), file, width) { + var loadedAt: Long? = null + while (true) { + val modified = withContext(Dispatchers.IO) { modifiedTime(file) } + if (modified == null) { + value = null + loadedAt = null + } else if (modified != loadedAt) { + value = KitImageCache.load(KitImageCacheKey(file, width, modified)) + loadedAt = modified + } + delay(CHANGE_CHECK_MILLIS) + } + }.value +} + +private const val CHANGE_CHECK_MILLIS = 3000L + +private fun modifiedTime(path: Path): Long? = + runCatching { if (Files.isRegularFile(path)) Files.getLastModifiedTime(path).toMillis() else null }.getOrNull() + +private data class KitImageCacheKey(val path: Path, val maxWidth: Int, val modified: Long) + +private class KitImageCacheEntry(val bitmap: ImageBitmap, val bytes: Long) + +private object KitImageCache { + private const val MAX_BYTES = 128L shl 20 + private const val MAX_FILE_BYTES = 256L shl 20 + + private val decoders = Semaphore(2) + private val entries = LinkedHashMap(64, 0.75f, true) + private var bytes = 0L + private val latest = ConcurrentHashMap, KitImageCacheKey>() + private val failed = ConcurrentHashMap.newKeySet() + private val inFlight = ConcurrentHashMap>() + + fun peek(path: Path, maxWidth: Int): ImageBitmap? { + val key = latest[path to maxWidth] ?: return null + return synchronized(entries) { entries[key]?.bitmap } + } + + suspend fun load(key: KitImageCacheKey): ImageBitmap? { + while (true) { + synchronized(entries) { entries[key] }?.let { return it.bitmap } + if (key in failed) return null + val mine = CompletableDeferred() + val existing = inFlight.putIfAbsent(key, mine) + if (existing != null) { + try { + return existing.await() + } catch (e: CancellationException) { + currentCoroutineContext().ensureActive() + continue + } + } + try { + val bitmap = decoders.withPermit { withContext(Dispatchers.IO) { decodeScaled(key.path, key.maxWidth) } } + if (bitmap == null) { + failed += key + mine.complete(null) + return null + } + val image = bitmap.asComposeImageBitmap() + put(key, KitImageCacheEntry(image, bitmap.width.toLong() * bitmap.height * 4)) + mine.complete(image) + return image + } catch (t: Throwable) { + mine.completeExceptionally(t) + throw t + } finally { + inFlight.remove(key, mine) + } + } + } + + private fun put(key: KitImageCacheKey, entry: KitImageCacheEntry) = synchronized(entries) { + entries.put(key, entry)?.let { bytes -= it.bytes } + bytes += entry.bytes + latest[key.path to key.maxWidth] = key + val iterator = entries.entries.iterator() + while (bytes > MAX_BYTES && entries.size > 1 && iterator.hasNext()) { + val (eldestKey, eldest) = iterator.next() + if (eldest === entry) continue + bytes -= eldest.bytes + iterator.remove() + latest.remove(eldestKey.path to eldestKey.maxWidth, eldestKey) + } + } + + private fun decodeScaled(path: Path, maxWidth: Int): PolyBitmap? = + decodeSubsampled(path, maxWidth) ?: PolyBitmap.fromFile(path, MAX_FILE_BYTES)?.let { scaleDown(it, maxWidth) } + + private fun decodeSubsampled(path: Path, maxWidth: Int): PolyBitmap? = runCatching { + ImageIO.createImageInputStream(path.toFile())?.use { stream -> + val reader = ImageIO.getImageReaders(stream).asSequence().firstOrNull() ?: return@use null + try { + reader.input = stream + val param = reader.defaultReadParam + val step = reader.getWidth(0) / maxWidth + if (step > 1) param.setSourceSubsampling(step, step, 0, 0) + PolyBitmap.fromAwt(scaleDown(reader.read(0, param), maxWidth)) + } finally { + reader.dispose() + } + } + }.getOrNull() + + private fun scaleDown(bitmap: PolyBitmap, maxWidth: Int): PolyBitmap { + if (bitmap.width <= maxWidth) return bitmap + val awt = BufferedImage(bitmap.width, bitmap.height, BufferedImage.TYPE_INT_ARGB) + val px = bitmap.pixels + val argb = IntArray(bitmap.width * bitmap.height) { i -> + val o = i * 4 + (px[o + 3].toInt() and 0xFF shl 24) or (px[o].toInt() and 0xFF shl 16) or (px[o + 1].toInt() and 0xFF shl 8) or (px[o + 2].toInt() and 0xFF) + } + awt.setRGB(0, 0, bitmap.width, bitmap.height, argb, 0, bitmap.width) + return PolyBitmap.fromAwt(scaleDown(awt, maxWidth)) + } + + private fun scaleDown(image: BufferedImage, maxWidth: Int): BufferedImage { + if (image.width <= maxWidth) return image + val height = (image.height * (maxWidth.toDouble() / image.width)).toInt().coerceAtLeast(1) + val out = BufferedImage(maxWidth, height, BufferedImage.TYPE_INT_ARGB) + val g = out.createGraphics() + try { + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR) + g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY) + g.drawImage(image, 0, 0, maxWidth, height, null) + } finally { + g.dispose() + } + return out + } +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Interaction.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Interaction.kt new file mode 100644 index 000000000..91ae628cc --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Interaction.kt @@ -0,0 +1,119 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.layout.layout +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.a11y.A11yRole +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.api.ui.v1.a11y.a11y +import org.polyfrost.oneconfig.api.ui.v1.internal.componentsBridge +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme + +@ExperimentalOneConfigUiApi +public enum class UiSound { + Click, + Open, + Close, + SliderTick, +} + +@ExperimentalOneConfigUiApi +public fun playUiSound(sound: UiSound) { + runCatching { componentsBridge().playSound(sound) } +} + +private const val OVERLAY_FADE_MILLIS = 120 + +@ExperimentalOneConfigUiApi +public fun Modifier.oneConfigClickable( + label: String?, + onClick: () -> Unit, + role: A11yRole = A11yRole.Button, + enabled: Boolean = true, + selected: Boolean? = null, + checked: Boolean? = null, + shape: Shape? = null, + indication: Boolean = true, + sound: UiSound? = UiSound.Click, + interactionSource: MutableInteractionSource? = null, +): Modifier = composed { + val source = interactionSource ?: remember { MutableInteractionSource() } + val theme = OneConfigTheme.current + val outline = shape ?: theme.buttonShape + val latest by rememberUpdatedState(onClick) + val latestSound by rememberUpdatedState(sound) + a11y(label, role, selected, checked, outline, enabled) + .then(if (enabled) Modifier.pointerHoverIcon(PointerIcon.Hand) else Modifier) + .then(if (indication && enabled) Modifier.stateOverlay(source, outline) else Modifier) + .then(if (enabled) componentsBridge().keepSearchFocus(Modifier) else Modifier) + .clickable(interactionSource = source, indication = null, enabled = enabled) { + latestSound?.let(::playUiSound) + latest() + } +} + +private fun Modifier.stateOverlay(source: MutableInteractionSource, shape: Shape): Modifier = composed { + val theme = OneConfigTheme.current + val hovered by source.collectIsHoveredAsState() + val pressed by source.collectIsPressedAsState() + val target = when { + pressed -> theme.pressedOverlayAlpha + hovered -> theme.hoverOverlayAlpha + else -> 0f + } + val overlay by animateFloatAsState(target, Motion.spec(tween(OVERLAY_FADE_MILLIS)), label = "stateOverlay") + val color = theme.textColor + drawBehind { + if (overlay > 0f) drawOutline(shape.createOutline(size, layoutDirection, this), color.copy(alpha = overlay * color.alpha)) + } +} + +@ExperimentalOneConfigUiApi +public class HoverState( + public val interactionSource: MutableInteractionSource, + public val hovered: Boolean, +) + +@ExperimentalOneConfigUiApi +@Composable +public fun rememberHoverState(): HoverState { + val source = remember { MutableInteractionSource() } + val hovered by source.collectIsHoveredAsState() + return HoverState(source, hovered) +} + +private const val HIDDEN_FADE_MILLIS = 150 + +@ExperimentalOneConfigUiApi +public fun Modifier.hiddenUntilHover(visible: Boolean): Modifier = composed { + val alpha by animateFloatAsState(if (visible) 1f else 0f, Motion.spec(tween(HIDDEN_FADE_MILLIS)), label = "hiddenUntilHover") + val placed = visible || alpha > 0f + focusProperties { canFocus = visible } + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height) { + if (placed) placeable.place(0, 0) + } + } + .alpha(alpha) +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/KeybindField.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/KeybindField.kt new file mode 100644 index 000000000..ad95563f0 --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/KeybindField.kt @@ -0,0 +1,265 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class, InternalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.key.utf16CodePoint +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.polyfrost.oneconfig.api.platform.v1.Platform +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.InternalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.a11y.A11yRole +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.api.ui.v1.internal.keybindRecordingBridge +import org.polyfrost.oneconfig.api.ui.v1.keybind.KeyModifiers +import org.polyfrost.oneconfig.api.ui.v1.keybind.KeybindManager +import org.polyfrost.oneconfig.api.ui.v1.keybind.OneConfigKeybind +import org.polyfrost.oneconfig.api.ui.v1.keybind.TextInputFocus +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme + +private val KEY_ICON_SIZE = 16.dp +private const val FADE_MILLIS = 150 +private const val RECORDING_FILL_ALPHA = 0.2f +private const val HOVER_FILL_ALPHA = 0.8f +private const val HOVER_BORDER_ALPHA = 0.2f + +@ExperimentalOneConfigUiApi +@Composable +public fun KeybindField( + keybind: OneConfigKeybind?, + onChange: (OneConfigKeybind?) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + conflict: Boolean? = null, + singleKey: Boolean = false, + interactionSource: MutableInteractionSource? = null, +) { + val theme = OneConfigTheme.current + val accent = OneConfigTheme.accent + val latestKeybind by rememberUpdatedState(keybind) + val latestOnChange by rememberUpdatedState(onChange) + val source = interactionSource ?: remember { MutableInteractionSource() } + val hovered by source.collectIsHoveredAsState() + val focusRequester = remember { FocusRequester() } + var recording by remember { mutableStateOf(false) } + val recordedKeys = remember { mutableStateListOf() } + val recordedMouse = remember { mutableStateListOf() } + val heldKeys = remember { HashSet() } + val heldMouse = remember { HashSet() } + val typingToken = remember { Any() } + + fun emit(keys: IntArray?, mouse: IntArray?, mods: Byte) { + val current = latestKeybind + val result = if (keys == null && mouse == null) { + current?.copyWith(null, null, KeyModifiers.NONE) + } else { + current?.copyWith(keys, mouse, mods) ?: OneConfigKeybind(keys, mouse, mods, 0L) { true } + } + recording = false + latestOnChange(result) + } + + fun clear() = emit(null, null, KeyModifiers.NONE) + + fun commitIfReleased() { + if (heldKeys.isNotEmpty() || heldMouse.isNotEmpty()) return + if (recordedKeys.isEmpty() && recordedMouse.isEmpty()) return + val mouse = recordedMouse.toIntArray().takeIf { it.isNotEmpty() } + val (mods, keys) = splitModifiers(recordedKeys) + if (keys.isEmpty() && mouse == null) emit(recordedKeys.toIntArray(), null, KeyModifiers.NONE) + else emit(keys.takeIf { it.isNotEmpty() }, mouse, mods) + } + + LaunchedEffect(enabled) { if (!enabled) recording = false } + + LaunchedEffect(recording) { + if (recording) { + recordedKeys.clear() + recordedMouse.clear() + heldKeys.clear() + heldMouse.clear() + runCatching { focusRequester.requestFocus() } + } + } + + if (recording) EscapeCloses { clear() } + + DisposableEffect(recording) { + if (!recording) return@DisposableEffect onDispose { } + val onEscape: () -> Unit = { clear() } + val onMouse: (Int, Boolean) -> Unit = { button, pressed -> + if (pressed && singleKey) { + emit(null, intArrayOf(button), KeyModifiers.NONE) + } else if (pressed) { + if (button !in recordedMouse) recordedMouse.add(button) + heldMouse.add(button) + } else { + heldMouse.remove(button) + commitIfReleased() + } + } + val bridge = runCatching { keybindRecordingBridge() }.getOrNull() + TextInputFocus.acquire(typingToken) + bridge?.startRecording(onEscape, onMouse) + onDispose { + bridge?.stopRecording(onEscape, onMouse) + TextInputFocus.release(typingToken) + } + } + + val showConflict = !recording && (conflict ?: (keybind != null && KeybindManager.findConflicts(keybind).any { it.action !== keybind.action })) + val fill by animateColorAsState( + when { + recording -> accent.copy(alpha = RECORDING_FILL_ALPHA) + hovered && enabled -> theme.componentBackground.copy(alpha = theme.componentBackground.alpha * HOVER_FILL_ALPHA) + else -> theme.componentBackground + }, + Motion.spec(tween(FADE_MILLIS)), + label = "keybindFill", + ) + val border by animateColorAsState( + when { + recording -> accent + showConflict -> theme.error + hovered && enabled -> theme.textColor.copy(alpha = HOVER_BORDER_ALPHA) + else -> theme.borderColor + }, + Motion.spec(tween(FADE_MILLIS)), + label = "keybindBorder", + ) + val content by animateColorAsState( + when { + recording -> accent + showConflict -> theme.error + else -> theme.textColor + }, + Motion.spec(tween(FADE_MILLIS)), + label = "keybindText", + ) + + val shown = when { + recording && recordedKeys.isEmpty() && recordedMouse.isEmpty() -> formControlString("oneconfig.keybind.recording", "Press keys...") + recording -> (recordedKeys.map(OneConfigKeybind::keyName) + recordedMouse.map(::mouseButtonName)).joinToString(" + ") + keybind != null && keybind.isBound -> keybind.displayName() + else -> formControlString("oneconfig.keybind.none", "None") + } + val shape = theme.shapes.field + val typography = theme.typography + + Row( + modifier = modifier + .graphicsLayer { alpha = if (enabled) 1f else theme.disabledAlpha } + .heightIn(min = ControlSize.Regular.height) + .onPreviewKeyEvent { event -> recording && handleRecordingKey(event, recordedKeys, heldKeys, singleKey, ::clear, ::commitIfReleased) { code -> emit(intArrayOf(code), null, KeyModifiers.NONE) } } + .focusRequester(focusRequester) + .formFrame(shape, theme.accentBrush, fill = { fill }, border = { border }) + .oneConfigClickable( + shown, + { if (!recording) recording = true }, + role = A11yRole.Button, + enabled = enabled, + shape = shape, + indication = false, + interactionSource = source, + ) + .padding(horizontal = theme.spacing.l, vertical = theme.spacing.xs), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(theme.spacing.m), + ) { + Icon("keyboard", null, Modifier.size(KEY_ICON_SIZE), tint = content) + Text( + shown, + Modifier, + content, + OneConfigTheme.typography.body.copy(fontSize = typography.label.fontSize, fontWeight = typography.label.fontWeight ?: FontWeight.Normal), + Int.MAX_VALUE, + TextOverflow.Clip, + textAlign = TextAlign.Start, + ) + } +} + +private fun handleRecordingKey( + event: KeyEvent, + recordedKeys: MutableList, + heldKeys: MutableSet, + singleKey: Boolean, + clear: () -> Unit, + commitIfReleased: () -> Unit, + commitSingle: (Int) -> Unit, +): Boolean { + if (event.awtKeyEventId() == java.awt.event.KeyEvent.KEY_TYPED) return true + return when (event.type) { + KeyEventType.KeyDown -> { + if (event.key == Key.Escape || ((event.key == Key.Backspace || event.key == Key.Delete) && recordedKeys.isEmpty())) { + clear() + return true + } + val keyCode = event.utf16CodePoint + if (keyCode <= 0) return true + if (singleKey) { + commitSingle(keyCode) + return true + } + if (keyCode !in recordedKeys) recordedKeys.add(keyCode) + heldKeys.add(keyCode) + true + } + KeyEventType.KeyUp -> { + heldKeys.remove(event.utf16CodePoint) + commitIfReleased() + true + } + else -> false + } +} + +private fun splitModifiers(codes: List): Pair { + var mods = KeyModifiers.NONE + val keys = ArrayList(codes.size) + for (code in codes) { + val bit = KeyModifiers.of(code) + if (bit != KeyModifiers.NONE) mods = (mods.toInt() or bit.toInt()).toByte() else keys += code + } + return mods to keys.toIntArray() +} + +private fun mouseButtonName(button: Int): String = + runCatching { Platform.compatibility().keys().mouseName(button) }.getOrNull() ?: button.toString() + +private fun KeyEvent.awtKeyEventId(): Int? = runCatching { + val internal = nativeKeyEvent + val field = internal.javaClass.getDeclaredField("nativeEvent").apply { isAccessible = true } + (field.get(internal) as? java.awt.event.KeyEvent)?.id +}.getOrNull() diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/KitShared.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/KitShared.kt index 5cb635816..d0b4872e9 100644 --- a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/KitShared.kt +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/KitShared.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntSize @@ -16,10 +15,6 @@ import androidx.compose.ui.window.PopupPositionProvider import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi import org.polyfrost.oneconfig.api.ui.v1.a11y.notFocusable -internal val DangerColor = Color(0xFFE35B5B) - -internal const val DISABLED_ALPHA = 0.5f - @Composable internal fun Modifier.swallowClicks(): Modifier = notFocusable().clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/LayoutPieces.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/LayoutPieces.kt new file mode 100644 index 000000000..3ec540102 --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/LayoutPieces.kt @@ -0,0 +1,214 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.foundation.VerticalScrollbar +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.rememberScrollbarAdapter +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigUITheme +import org.polyfrost.oneconfig.api.ui.v1.theme.legibleOn + +@ExperimentalOneConfigUiApi +public enum class BadgeTone { + Neutral, + + Accent, + + Success, + + Warning, + + Danger, +} + +private const val BADGE_FILL_ALPHA = 0.16f +private const val NEUTRAL_FILL_ALPHA = 0.08f +private val BADGE_DOT = 6.dp + +@ExperimentalOneConfigUiApi +@Composable +public fun Badge(text: String, modifier: Modifier = Modifier, tone: BadgeTone = BadgeTone.Neutral, overImage: Boolean = false) { + val theme = OneConfigTheme.current + val spacing = theme.spacing + val shape = theme.shapes.pill + val toneColor = badgeToneColor(theme, tone, OneConfigTheme.accent) + val fill = when { + overImage -> theme.onMediaShadow + tone == BadgeTone.Neutral -> theme.textColor.copy(alpha = NEUTRAL_FILL_ALPHA) + else -> toneColor.copy(alpha = BADGE_FILL_ALPHA) + } + val content = if (overImage) theme.onMedia else toneColor + Row( + modifier + .clip(shape) + .background(fill, shape) + .padding(horizontal = spacing.s, vertical = spacing.xxs), + horizontalArrangement = Arrangement.spacedBy(spacing.xs), + verticalAlignment = Alignment.CenterVertically, + ) { + if (overImage && tone != BadgeTone.Neutral) { + Box(Modifier.size(BADGE_DOT).background(toneColor, theme.shapes.circle)) + } + LayoutText(text, theme.typography.caption.merge(fontWeight = FontWeight.Medium), content, maxLines = 1) + } +} + +private fun badgeToneColor(theme: OneConfigUITheme, tone: BadgeTone, accent: Color): Color = when (tone) { + BadgeTone.Neutral -> theme.mutedText + BadgeTone.Accent -> accent.legibleOn(theme.pageSurface) + BadgeTone.Success -> theme.success + BadgeTone.Warning -> theme.warning + BadgeTone.Danger -> theme.error +} + +@ExperimentalOneConfigUiApi +@Composable +public fun SectionHeader( + text: String, + modifier: Modifier = Modifier, + eyebrow: Boolean = true, + trailing: (@Composable () -> Unit)? = null, +) { + val theme = OneConfigTheme.current + Row( + modifier.fillMaxWidth().padding(vertical = theme.spacing.xs), + horizontalArrangement = Arrangement.spacedBy(theme.spacing.m), + verticalAlignment = Alignment.CenterVertically, + ) { + val heading = Modifier.weight(1f).semantics { heading() } + if (eyebrow) { + LayoutText(text.uppercase(), theme.typography.eyebrow, theme.mutedText, heading, maxLines = 1) + } else { + LayoutText(text, theme.typography.heading, theme.textColor, heading, maxLines = 1) + } + trailing?.invoke() + } +} + +@ExperimentalOneConfigUiApi +@Composable +public fun Divider(modifier: Modifier = Modifier, vertical: Boolean = false) { + val color = OneConfigTheme.current.borderColor + Box( + if (vertical) modifier.fillMaxHeight().width(1.dp).background(color) + else modifier.fillMaxWidth().height(1.dp).background(color), + ) +} + +@ExperimentalOneConfigUiApi +@Composable +public fun KeyValueRow(label: String, value: String, modifier: Modifier = Modifier) { + val theme = OneConfigTheme.current + Row( + modifier.fillMaxWidth().semantics(mergeDescendants = true) {}, + horizontalArrangement = Arrangement.spacedBy(theme.spacing.l), + verticalAlignment = Alignment.CenterVertically, + ) { + LayoutText(label, theme.typography.small, theme.mutedText, maxLines = 1) + LayoutText(value, theme.typography.small, theme.textColor, Modifier.weight(1f), maxLines = 1, align = TextAlign.End) + } +} + +@ExperimentalOneConfigUiApi +@Composable +public fun PageScaffold( + modifier: Modifier = Modifier, + toolbar: (@Composable RowScope.() -> Unit)? = null, + sidePanel: (@Composable () -> Unit)? = null, + sidePanelWidth: Dp = 320.dp, + content: @Composable () -> Unit, +) { + val spacing = OneConfigTheme.current.spacing + Column(modifier.fillMaxSize()) { + if (toolbar != null) { + Row( + Modifier + .fillMaxWidth() + .heightIn(min = ControlSize.Large.height) + .padding(horizontal = spacing.pageGutter, vertical = spacing.m), + horizontalArrangement = Arrangement.spacedBy(spacing.m), + verticalAlignment = Alignment.CenterVertically, + content = toolbar, + ) + } + Row(Modifier.fillMaxWidth().weight(1f)) { + Box(Modifier.weight(1f).fillMaxHeight()) { content() } + if (sidePanel != null) { + Divider(vertical = true) + Box(Modifier.width(sidePanelWidth).fillMaxHeight()) { sidePanel() } + } + } + } +} + +@ExperimentalOneConfigUiApi +@Composable +public fun CenteredPanel( + modifier: Modifier = Modifier, + width: Dp = 650.dp, + scrollable: Boolean = true, + content: @Composable ColumnScope.() -> Unit, +) { + val theme = OneConfigTheme.current + val spacing = theme.spacing + val shape = theme.shapes.large + Box(modifier.fillMaxSize().padding(spacing.xl), contentAlignment = Alignment.Center) { + Box( + Modifier + .widthIn(max = width) + .fillMaxWidth() + .clip(shape) + .background(theme.popupBackground, shape) + .border(1.dp, theme.borderColor, shape), + ) { + val scroll = rememberScrollState() + Column( + Modifier + .fillMaxWidth() + .then(if (scrollable) Modifier.verticalScroll(scroll) else Modifier) + .padding(spacing.xxl), + verticalArrangement = Arrangement.spacedBy(spacing.l), + content = content, + ) + if (scrollable) { + Box(Modifier.matchParentSize()) { + VerticalScrollbar( + rememberScrollbarAdapter(scroll), + Modifier.align(Alignment.CenterEnd).fillMaxHeight().padding(vertical = spacing.m, horizontal = spacing.xxs), + ) + } + } + } + } +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/LayoutSupport.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/LayoutSupport.kt new file mode 100644 index 000000000..ed1ec03c9 --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/LayoutSupport.kt @@ -0,0 +1,39 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import org.polyfrost.oneconfig.api.platform.v1.Platform +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi + +internal fun layoutString(key: String, fallback: String, vararg args: Any?): String = + runCatching { + val i18n = Platform.i18n() + if (i18n.hasTranslation(key)) i18n.translateString(key, *args) else null + }.getOrNull() ?: if (args.isEmpty()) fallback else fallback.format(*args) + +@Composable +internal fun LayoutText( + text: String, + style: TextStyle, + color: Color, + modifier: Modifier = Modifier, + maxLines: Int = Int.MAX_VALUE, + align: TextAlign = TextAlign.Unspecified, + softWrap: Boolean = true, +) { + BasicText( + text = text, + modifier = modifier, + style = style.merge(color = color, textAlign = align), + overflow = TextOverflow.Ellipsis, + softWrap = softWrap, + maxLines = maxLines, + ) +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/LinkText.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/LinkText.kt new file mode 100644 index 000000000..59403bdf9 --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/LinkText.kt @@ -0,0 +1,140 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import java.net.URI +import org.apache.logging.log4j.LogManager +import org.polyfrost.oneconfig.api.platform.v1.DesktopHelper +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.a11y.A11yRole +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme +import org.polyfrost.oneconfig.api.ui.v1.theme.legibleOn + +private const val LINK_FADE_MILLIS = 120 +private const val LINK_REST_ALPHA = 0.85f +private val LINK_ICON_SIZE = 12.dp + +private val LOGGER = LogManager.getLogger("OneConfig/UI") + +@ExperimentalOneConfigUiApi +@Composable +public fun LinkText( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + icon: String? = null, + enabled: Boolean = true, + interactionSource: MutableInteractionSource? = null, +) { + val glyph: (@Composable (Color) -> Unit)? = if (icon == null) null else { { color -> Icon(icon, null, Modifier.size(LINK_ICON_SIZE), tint = color) } } + LinkTextFrame(text, onClick, modifier, enabled, interactionSource, glyph) +} + +@ExperimentalOneConfigUiApi +@Composable +public fun LinkText( + text: String, + onClick: () -> Unit, + icon: Painter, + modifier: Modifier = Modifier, + enabled: Boolean = true, + interactionSource: MutableInteractionSource? = null, +) { + LinkTextFrame(text, onClick, modifier, enabled, interactionSource) { color -> + Image(icon, null, Modifier.size(LINK_ICON_SIZE), colorFilter = ColorFilter.tint(color)) + } +} + +@ExperimentalOneConfigUiApi +@Composable +public fun LinkText( + text: String, + url: String, + modifier: Modifier = Modifier, + icon: String? = null, + enabled: Boolean = true, + interactionSource: MutableInteractionSource? = null, +) { + LinkText(text, { openUrl(url) }, modifier, icon, enabled, interactionSource) +} + +@ExperimentalOneConfigUiApi +@Composable +public fun LinkText( + text: String, + url: String, + icon: Painter, + modifier: Modifier = Modifier, + enabled: Boolean = true, + interactionSource: MutableInteractionSource? = null, +) { + LinkText(text, { openUrl(url) }, icon, modifier, enabled, interactionSource) +} + +@Composable +private fun LinkTextFrame( + text: String, + onClick: () -> Unit, + modifier: Modifier, + enabled: Boolean, + interactionSource: MutableInteractionSource?, + glyph: (@Composable (Color) -> Unit)?, +) { + val theme = OneConfigTheme.current + val source = interactionSource ?: remember { MutableInteractionSource() } + val hovered by source.collectIsHoveredAsState() + val active = hovered && enabled + val base = OneConfigTheme.accent.legibleOn(theme.pageSurface) + val color by animateColorAsState(if (active) base else base.copy(alpha = base.alpha * LINK_REST_ALPHA), Motion.spec(tween(LINK_FADE_MILLIS))) + Row( + modifier = modifier + .graphicsLayer { alpha = if (enabled) 1f else theme.disabledAlpha } + .oneConfigClickable(text, onClick, role = A11yRole.Link, enabled = enabled, shape = theme.shapes.small, indication = false, interactionSource = source), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text, + Modifier.drawBehind { + if (!active) return@drawBehind + val y = size.height - 1.dp.toPx() + drawLine(color, Offset(0f, y), Offset(size.width, y), 1.dp.toPx()) + }, + color, + OneConfigTheme.typography.body.copy(fontSize = 13.sp, fontWeight = FontWeight.Normal), + 1, + TextOverflow.Ellipsis, + ) + glyph?.invoke(color) + } +} + +private fun openUrl(url: String) { + val opened = runCatching { DesktopHelper.browse(URI(url)) }.getOrElse { false } + if (!opened) LOGGER.warn("Could not open {} in a browser", url) +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Menu.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Menu.kt index 7ae342f35..5b606104f 100644 --- a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Menu.kt +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Menu.kt @@ -1,3 +1,5 @@ +@file:JvmMultifileClass +@file:JvmName("MenuKt") @file:OptIn(ExperimentalOneConfigUiApi::class) package org.polyfrost.oneconfig.api.ui.v1.components @@ -5,12 +7,12 @@ package org.polyfrost.oneconfig.api.ui.v1.components import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.focusable import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope @@ -22,45 +24,46 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue -import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.PointerEvent -import androidx.compose.ui.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.PointerIcon -import androidx.compose.ui.input.pointer.isPrimaryPressed -import androidx.compose.ui.input.pointer.isSecondaryPressed -import androidx.compose.ui.input.pointer.pointerHoverIcon -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.LayoutCoordinates -import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.BaselineShift -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.isSpecified import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.a11y.A11y import org.polyfrost.oneconfig.api.ui.v1.a11y.A11yRole -import org.polyfrost.oneconfig.api.ui.v1.a11y.a11y +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.api.ui.v1.a11y.isKeyboardMode +import org.polyfrost.oneconfig.api.ui.v1.a11y.notFocusable import org.polyfrost.oneconfig.api.ui.v1.internal.componentsBridge import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme import org.polyfrost.oneconfig.api.ui.v1.theme.concentric -import kotlin.math.roundToInt @ExperimentalOneConfigUiApi public enum class MenuItemSize(internal val height: Dp, internal val textSize: TextUnit, internal val iconSize: Dp, internal val inset: Dp) { @@ -69,33 +72,101 @@ public enum class MenuItemSize(internal val height: Dp, internal val textSize: T } @ExperimentalOneConfigUiApi -public class MenuAction( - public val label: String, - public val icon: String? = null, - public val detail: String? = null, - public val danger: Boolean = false, - public val enabled: Boolean = true, - public val onClick: () -> Unit, -) +public object MenuDefaults { + public val size: MenuItemSize = MenuItemSize.Compact + + public val minWidth: Dp = 170.dp + + public val maxWidth: Dp = 280.dp + + public val contentPadding: Dp = 4.dp + + public val itemSpacing: Dp = 2.dp + + public val tooltipMaxWidth: Dp = 320.dp + + public val submenuGap: Dp = 2.dp + + public const val HOVER_ALPHA: Float = 0.10f -private val MENU_INSET = 4.dp -private val MENU_MIN_WIDTH = 170.dp -private val MENU_MAX_WIDTH = 280.dp -private val MENU_TOOLTIP_WIDTH = 320.dp -private const val HOVER_ALPHA = 0.10f + public const val SUBMENU_DELAY_MILLIS: Long = 200L + + public const val TYPE_AHEAD_RESET_MILLIS: Long = 700L + + public val shape: Shape + @Composable get() = OneConfigTheme.current.popupShape + + public val itemShape: Shape + @Composable get() = OneConfigTheme.current.popupShape.concentric(contentPadding) + + @Composable + public fun contentColor(style: ControlStyle): Color { + val theme = OneConfigTheme.current + return when (style) { + ControlStyle.Danger, ControlStyle.DangerFilled -> theme.error + else -> theme.textColor + } + } +} +@ExperimentalOneConfigUiApi +@Composable +public fun MenuItem( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + icon: String? = null, + painter: Painter? = null, + detail: String? = null, + style: ControlStyle = ControlStyle.Ghost, + size: MenuItemSize = MenuDefaults.size, + enabled: Boolean = true, + selected: Boolean = false, + checked: Boolean? = null, + shortcutHint: String? = null, + badge: String? = null, + tooltip: String? = null, + danger: Boolean = false, + leading: (@Composable () -> Unit)? = null, + trailing: (@Composable () -> Unit)? = null, + interactionSource: MutableInteractionSource? = null, +) { + MenuItemRow( + label = label, + onClick = onClick, + modifier = modifier, + icon = icon, + painter = painter, + detail = detail, + style = if (danger) ControlStyle.Danger else style, + size = size, + enabled = enabled, + selected = selected, + checked = checked, + shortcutHint = shortcutHint, + badge = badge, + tooltip = tooltip, + leading = leading, + trailing = trailing, + interactionSource = interactionSource, + ) +} + +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) @ExperimentalOneConfigUiApi @Composable public fun MenuItem(label: String, onClick: () -> Unit) { - MenuItem(label, null, null, MenuItemSize.Compact, false, false, true, null, Modifier, null, onClick) + MenuItem(label, onClick, Modifier) } +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) @ExperimentalOneConfigUiApi @Composable public fun MenuItem(label: String, icon: String?, onClick: () -> Unit) { - MenuItem(label, icon, null, MenuItemSize.Compact, false, false, true, null, Modifier, null, onClick) + MenuItem(label, onClick, Modifier, icon = icon) } +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) @ExperimentalOneConfigUiApi @Composable public fun MenuItem( @@ -110,165 +181,239 @@ public fun MenuItem( modifier: Modifier, leading: (@Composable () -> Unit)?, onClick: () -> Unit, +) { + MenuItem( + label = label, + onClick = onClick, + modifier = modifier, + icon = icon, + detail = detail, + size = size, + enabled = enabled, + selected = selected, + tooltip = tooltip, + danger = danger, + leading = leading, + ) +} + +@Composable +internal fun MenuItemRow( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + icon: String? = null, + painter: Painter? = null, + detail: String? = null, + style: ControlStyle = ControlStyle.Ghost, + size: MenuItemSize = MenuDefaults.size, + enabled: Boolean = true, + selected: Boolean = false, + checked: Boolean? = null, + shortcutHint: String? = null, + badge: String? = null, + tooltip: String? = null, + leading: (@Composable () -> Unit)? = null, + trailing: (@Composable () -> Unit)? = null, + interactionSource: MutableInteractionSource? = null, + radio: Boolean = false, + reserveCheck: Boolean = false, + reserveIcon: Boolean = false, + submenu: (@Composable (MenuNavItem) -> Unit)? = null, + styledLabel: AnnotatedString? = null, ) { val theme = OneConfigTheme.current val accent = OneConfigTheme.accent - val source = remember { MutableInteractionSource() } + val level = LocalMenuLevel.current + val source = interactionSource ?: remember { MutableInteractionSource() } val hovered by source.collectIsHoveredAsState() + val keyboard = isKeyboardMode() + val submenuLevel = if (submenu != null) level else null + val opensSubmenu = submenuLevel != null + val navHolder = remember { arrayOfNulls(1) } + val click: () -> Unit = { + val item = navHolder[0] + if (submenuLevel != null && item != null) submenuLevel.toggle(item, false) else onClick() + } + val navItem = rememberMenuNavItem(level, label, enabled, opensSubmenu) { + playUiSound(UiSound.Click) + click() + } + navHolder[0] = navItem + MenuNavHover(level, navItem, hovered) + val highlighted = navItem != null && level?.highlighted === navItem + val submenuOpen = navItem != null && level?.openChild === navItem + val active = if (enabled) hovered || highlighted || submenuOpen else highlighted && keyboard val base = when { selected -> theme.accentTextColor - danger -> DangerColor - else -> theme.textColor + else -> MenuDefaults.contentColor(style) } val color = if (enabled) base else theme.textColorSecondary val background by animateColorAsState( when { selected -> accent - enabled && hovered -> base.copy(alpha = HOVER_ALPHA) + active -> base.copy(alpha = MenuDefaults.HOVER_ALPHA) else -> base.copy(alpha = 0f) }, - tween(120), + Motion.spec(tween(120)), ) - val shape = theme.popupShape.concentric(MENU_INSET) + val shape = MenuDefaults.itemShape val spoken = if (detail == null) label else "$label. $detail" - Box(modifier) { - if (tooltip != null && hovered) TooltipPopup(tooltip, TooltipPlacement.AboveCenter, MENU_TOOLTIP_WIDTH) + val role = if (radio) A11yRole.RadioButton else A11yRole.MenuItem + LaunchedEffect(highlighted, keyboard) { + if (highlighted && keyboard) A11y.focused(spoken, role, selected.takeIf { it }, checked) + } + val wantTooltip = tooltip != null && (hovered || highlighted && keyboard) + var tooltipShown by remember { mutableStateOf(false) } + LaunchedEffect(wantTooltip) { + if (wantTooltip) { + delay(TooltipDefaults.delayMillis) + tooltipShown = true + } else { + tooltipShown = false + } + } + Box(modifier.menuNavPosition(navItem)) { + if (tooltipShown && tooltip != null) TooltipPopup(tooltip, Modifier, TooltipPlacement.AboveCenter, MenuDefaults.tooltipMaxWidth) Row( modifier = Modifier .fillMaxWidth() .heightIn(min = size.height) .clip(shape) .background(background, shape) + .then(if (highlighted && keyboard) Modifier.border(1.dp, theme.focusRingColor, shape) else Modifier) .hoverable(source) - .then( - if (enabled) { - Modifier.a11y(spoken, A11yRole.Button, selected.takeIf { it }, null, shape, true) - .onClick(source, onClick) - .pointerHoverIcon(PointerIcon.Hand) - } else { - Modifier.graphicsLayer { alpha = DISABLED_ALPHA } - }, + .then(if (level != null) Modifier.notFocusable() else Modifier) + .oneConfigClickable( + label = spoken, + onClick = click, + role = role, + enabled = enabled, + selected = selected.takeIf { it }, + checked = checked, + shape = shape, + indication = false, + interactionSource = source, ) + .then(if (enabled) Modifier else Modifier.graphicsLayer { alpha = theme.disabledAlpha }) .padding(horizontal = size.inset, vertical = if (detail == null) 0.dp else 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(size.inset), ) { - if (leading != null) leading() else if (icon != null) Icon(icon, color, Modifier.size(size.iconSize)) + if (checked != null || reserveCheck) { + Box(Modifier.size(size.iconSize), contentAlignment = Alignment.Center) { + if (checked == true) { + if (radio) Box(Modifier.size(size.iconSize / 2.5f).background(color, theme.shapes.circle)) + else Icon("tick", null, Modifier.size(size.iconSize), color) + } + } + } + when { + leading != null -> leading() + icon != null -> Icon(icon, null, Modifier.size(size.iconSize), color) + painter != null -> Icon(painter, null, Modifier.size(size.iconSize), color) + reserveIcon -> Box(Modifier.size(size.iconSize)) + } Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text(label, Modifier, color, size.textSize, null, BaselineShift.None, FontWeight.Normal, TextAlign.Start, 1, TextOverflow.Ellipsis) + val labelStyle = theme.typography.body.copy(fontSize = size.textSize, fontWeight = FontWeight.Normal) + if (styledLabel != null) Text(styledLabel, Modifier, color, labelStyle, 1, TextOverflow.Ellipsis) + else Text(label, Modifier, color, labelStyle, 1, TextOverflow.Ellipsis) if (detail != null) { - Text(detail, Modifier, theme.textColorSecondary, 11.sp, null, BaselineShift.None, FontWeight.Normal, TextAlign.Start, Int.MAX_VALUE, TextOverflow.Clip) + Text(detail, Modifier, theme.textColorSecondary, theme.typography.caption) } } + if (badge != null) Badge(badge) + if (shortcutHint != null) { + Text(shortcutHint, Modifier, theme.textColorSecondary, theme.typography.caption, 1, TextOverflow.Clip, softWrap = false) + } + trailing?.invoke() + if (opensSubmenu) { + val rtl = LocalLayoutDirection.current == LayoutDirection.Rtl + Icon(if (rtl) "left-arrow" else "right-arrow", null, Modifier.size(size.iconSize), theme.textColorSecondary) + } + } + if (submenu != null && navItem != null && level?.openChild === navItem) { + Box(Modifier.matchParentSize().outset(MenuDefaults.contentPadding)) { submenu(navItem) } } } } @ExperimentalOneConfigUiApi @Composable -public fun MenuSurface(modifier: Modifier, content: @Composable ColumnScope.() -> Unit) { - Column( - modifier = componentsBridge().popupSurface( - modifier.widthIn(min = MENU_MIN_WIDTH, max = MENU_MAX_WIDTH).width(IntrinsicSize.Max), - OneConfigTheme.current.popupShape, - ).verticalScroll(rememberScrollState()).padding(MENU_INSET), - verticalArrangement = Arrangement.spacedBy(2.dp), - content = content, - ) +public fun MenuSurface( + modifier: Modifier = Modifier, + maxHeight: Dp = Dp.Unspecified, + content: @Composable ColumnScope.() -> Unit, +) { + val controller = remember { MenuNavController() } + val focus = remember { FocusRequester() } + val rtl = LocalLayoutDirection.current == LayoutDirection.Rtl + LaunchedEffect(controller) { + runCatching { focus.requestFocus() } + if (A11y.keyboardMode) { + withFrameNanos { } + controller.root.highlightFirst() + } + } + MenuHoverIntentEffect(controller.root) + CompositionLocalProvider(LocalMenuLevel provides controller.root) { + MenuPanel( + modifier + .onKeyEvent { controller.onKey(it, rtl) } + .focusRequester(focus) + .focusable(), + maxHeight, + content, + ) + } } +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) @ExperimentalOneConfigUiApi @Composable -public fun MenuList(items: List, onPicked: () -> Unit) { - MenuList(items, MenuItemSize.Compact, onPicked) +public fun MenuSurface(modifier: Modifier, content: @Composable ColumnScope.() -> Unit) { + MenuSurface(modifier, Dp.Unspecified, content) } -@ExperimentalOneConfigUiApi @Composable -public fun MenuList(items: List, size: MenuItemSize, onPicked: () -> Unit) { - MenuSurface(Modifier) { - items.forEach { item -> - MenuItem(item.label, item.icon, item.detail, size, item.danger, false, item.enabled, null, Modifier, null) { - onPicked() - item.onClick() - } - } - } +internal fun MenuPanel(modifier: Modifier, maxHeight: Dp, content: @Composable ColumnScope.() -> Unit) { + val padding = MenuDefaults.contentPadding + Column( + modifier = componentsBridge().popupSurface( + modifier + .widthIn(min = MenuDefaults.minWidth, max = MenuDefaults.maxWidth) + .width(IntrinsicSize.Max) + .then(if (maxHeight.isSpecified) Modifier.heightIn(max = maxHeight) else Modifier), + MenuDefaults.shape, + ).verticalScroll(rememberScrollState()).padding(padding), + verticalArrangement = Arrangement.spacedBy(MenuDefaults.itemSpacing), + content = content, + ) } +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) @ExperimentalOneConfigUiApi @Composable -public fun ContextMenu(expanded: Boolean, offset: IntOffset, onDismiss: () -> Unit, content: @Composable ColumnScope.() -> Unit) { - if (!expanded) return - EscapeCloses(onDismiss) - val provider = remember(offset) { PointPosition(offset, true) } - componentsBridge().Popup(provider, onDismiss, true) { - MenuSurface(Modifier, content) - } +public fun MenuList(items: List, onPicked: () -> Unit) { + MenuList(items, onPicked, Modifier, MenuItemSize.Compact) } +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) @ExperimentalOneConfigUiApi @Composable -public fun ContextMenu(expanded: Boolean, offset: IntOffset, onDismiss: () -> Unit, items: List) { - if (!expanded) return - EscapeCloses(onDismiss) - val provider = remember(offset) { PointPosition(offset, true) } - componentsBridge().Popup(provider, onDismiss, true) { - MenuList(items, onDismiss) - } -} - -private class OpenContextMenu(val at: IntOffset, val items: List) - -private class ContextMenuState { - var open by mutableStateOf(null) -} - -private class CoordinatesHolder { - var coordinates: LayoutCoordinates? = null +public fun MenuList(items: List, size: MenuItemSize, onPicked: () -> Unit) { + MenuList(items, onPicked, Modifier, size) } -private val LocalContextMenu = staticCompositionLocalOf { null } - @ExperimentalOneConfigUiApi @Composable -public fun ContextMenuHost(content: @Composable () -> Unit) { - val state = remember { ContextMenuState() } - CompositionLocalProvider(LocalContextMenu provides state, content = content) - val menu = state.open ?: return - key(menu) { - val close = { if (state.open === menu) state.open = null } - EscapeCloses(close) - val provider = remember(menu.at) { PointPosition(menu.at, false) } - componentsBridge().Popup(provider, close, true) { - MenuList(menu.items, close) - } +public fun MenuList( + entries: List, + onSelect: () -> Unit, + modifier: Modifier = Modifier, + size: MenuItemSize = MenuItemSize.Compact, +) { + MenuSurface(modifier) { + MenuEntryItems(entries, onSelect, size) } } - -@ExperimentalOneConfigUiApi -@Composable -public fun Modifier.contextMenu(items: () -> List): Modifier { - val state = LocalContextMenu.current ?: return this - val latest by rememberUpdatedState(items) - val holder = remember { CoordinatesHolder() } - return this - .onGloballyPositioned { holder.coordinates = it } - .pointerInput(state) { - awaitPointerEventScope { - while (true) { - val event = awaitPointerEvent(PointerEventPass.Main) - if (event.type != PointerEventType.Press || !event.secondaryPress()) continue - val change = event.changes.firstOrNull()?.takeUnless { it.isConsumed } ?: continue - val coordinates = holder.coordinates?.takeIf { it.isAttached } ?: continue - val list = latest() - if (list.isEmpty()) continue - change.consume() - val point = coordinates.localToWindow(change.position) - state.open = OpenContextMenu(IntOffset(point.x.roundToInt(), point.y.roundToInt()), list) - } - } - } -} - -private fun PointerEvent.secondaryPress(): Boolean = buttons.isSecondaryPressed && !buttons.isPrimaryPressed diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/MenuContributors.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/MenuContributors.kt new file mode 100644 index 000000000..8ae743acb --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/MenuContributors.kt @@ -0,0 +1,193 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import java.lang.reflect.Array as ReflectArray +import java.util.Locale +import java.util.concurrent.CopyOnWriteArrayList +import org.apache.logging.log4j.LogManager +import org.polyfrost.compose.render.PolyColor +import org.polyfrost.oneconfig.api.config.v1.Property +import org.polyfrost.oneconfig.api.config.v1.serialize.ObjectSerializer +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.OptionWidgets +import org.polyfrost.oneconfig.api.ui.v1.keybind.OneConfigKeybind +import org.polyfrost.oneconfig.utils.v1.ClipboardHelper +import org.polyfrost.oneconfig.utils.v1.WrappingUtils + +private val CONTRIBUTOR_LOGGER = LogManager.getLogger("OneConfig/MenuContributors") + +@ExperimentalOneConfigUiApi +public object OptionMenuContributors { + private val contributors = CopyOnWriteArrayList<(Property<*>) -> List>() + + @JvmStatic + public fun register(contributor: (Property<*>) -> List) { + contributors.addIfAbsent(contributor) + } + + @JvmStatic + public fun unregister(contributor: (Property<*>) -> List): Boolean = contributors.remove(contributor) + + @JvmStatic + public fun entriesFor(property: Property<*>): List = collectEntries(contributors, property) + + @JvmStatic + @JvmOverloads + public fun builtInEntries(property: Property<*>, onReset: (() -> Unit)? = null): List { + val locked = property.display == Property.Display.DISABLED + val value = runCatching { property.get() }.getOrNull() + val resettable = onReset != null || (value !is OneConfigKeybind && hasDefault(property, HashSet())) + return buildList { + add( + MenuAction( + kitString("oneconfig.menu.option.reset", "Reset to default"), + { if (onReset != null) onReset() else resetToDefault(property) }, + icon = "refresh", + enabled = !locked && resettable, + ), + ) + if (value is PolyColor) { + add(MenuAction(kitString("oneconfig.menu.option.copy_hex", "Copy hex"), { ClipboardHelper.setString(colorHex(value)) }, icon = "copy")) + val pasted = ClipboardHelper.getString()?.let(::parseColor) + add( + MenuAction( + kitString("oneconfig.menu.option.paste_hex", "Paste hex"), + { if (pasted != null) writeValue(property, pasted) }, + icon = "clipboard", + enabled = !locked && pasted != null, + ), + ) + return@buildList + } + val codec = codecFor(property.type) ?: return@buildList + val copied = value?.let(codec.format) + add( + MenuAction( + kitString("oneconfig.menu.option.copy_value", "Copy value"), + { if (copied != null) ClipboardHelper.setString(copied) }, + icon = "copy", + enabled = copied != null, + ), + ) + val pasted = ClipboardHelper.getString()?.let(codec.parse) + add( + MenuAction( + kitString("oneconfig.menu.option.paste_value", "Paste value"), + { if (pasted != null) writeValue(property, pasted) }, + icon = "clipboard", + enabled = !locked && pasted != null && pasted != value, + ), + ) + } + } +} + +@ExperimentalOneConfigUiApi +public object ModCardMenuContributors { + private val contributors = CopyOnWriteArrayList<(String) -> List>() + + @JvmStatic + public fun register(contributor: (modId: String) -> List) { + contributors.addIfAbsent(contributor) + } + + @JvmStatic + public fun unregister(contributor: (modId: String) -> List): Boolean = contributors.remove(contributor) + + @JvmStatic + public fun entriesFor(modId: String): List = collectEntries(contributors, modId) +} + +private fun collectEntries(contributors: List<(T) -> List>, target: T): List { + val result = ArrayList() + for (contributor in contributors) { + val entries = try { + contributor(target) + } catch (t: Throwable) { + CONTRIBUTOR_LOGGER.warn("A menu contributor failed for {}", target, t) + continue + } + if (entries.isEmpty()) continue + if (result.isNotEmpty()) result += MenuSeparator + result += entries + } + return result +} + +private class ValueCodec(val format: (Any) -> String?, val parse: (String) -> Any?) + +private fun codecFor(type: Class<*>?): ValueCodec? { + if (type == null) return null + if (type.isEnum) { + val constants = type.enumConstants ?: return null + return ValueCodec({ (it as Enum<*>).name }) { text -> + val wanted = text.trim() + constants.firstOrNull { (it as Enum<*>).name.equals(wanted, ignoreCase = true) } + } + } + return when (type.kotlin.javaObjectType) { + Boolean::class.javaObjectType -> ValueCodec({ it.toString() }) { text -> + when (text.trim().lowercase(Locale.ROOT)) { + "true", "on", "yes", "1" -> true + "false", "off", "no", "0" -> false + else -> null + } + } + Int::class.javaObjectType -> ValueCodec({ it.toString() }) { it.trim().toIntOrNull() } + Long::class.javaObjectType -> ValueCodec({ it.toString() }) { it.trim().toLongOrNull() } + Short::class.javaObjectType -> ValueCodec({ it.toString() }) { it.trim().toShortOrNull() } + Byte::class.javaObjectType -> ValueCodec({ it.toString() }) { it.trim().toByteOrNull() } + Float::class.javaObjectType -> ValueCodec({ it.toString() }) { it.trim().toFloatOrNull()?.takeIf(Float::isFinite) } + Double::class.javaObjectType -> ValueCodec({ it.toString() }) { it.trim().toDoubleOrNull()?.takeIf(Double::isFinite) } + String::class.java -> ValueCodec({ it as String }) { it } + else -> null + } +} + +private fun colorHex(color: PolyColor): String { + val argb = color.rawArgb + return if (argb ushr 24 == 0xFF) "#%06X".format(argb and 0xFFFFFF) else "#%08X".format(argb.toLong() and 0xFFFFFFFFL) +} + +private fun parseColor(text: String): PolyColor? = runCatching { PolyColor.hex(text.trim()) }.getOrNull() + +@Suppress("UNCHECKED_CAST") +private fun writeValue(property: Property<*>, value: Any) { + (property as Property).set(value) + OptionWidgets.refresh(property) +} + +private fun hasDefault(property: Property<*>, seen: MutableSet>): Boolean = + seen.add(property) && (property.getMetadata("default") != null || + property.getMetadata>>("resetWith")?.any { hasDefault(it, seen) } == true) + +private fun resetToDefault(property: Property<*>, seen: MutableSet> = HashSet()) { + if (!seen.add(property)) return + val default = property.getMetadata("default") + if (default != null) { + @Suppress("UNCHECKED_CAST") + (property as Property).setReferential(copyOf(default)) + } + val linked = property.getMetadata>>("resetWith") + linked?.forEach { resetToDefault(it, seen) } + if (default != null || linked != null) OptionWidgets.refresh(property) +} + +@Suppress("UNCHECKED_CAST") +private fun copyOf(value: Any): Any = when { + WrappingUtils.isSimpleClass(value.javaClass) -> value + value is PolyColor -> PolyColor(value.rawArgb, value.chroma, value.chromaSpeed).also { + it.gradientEnabled = value.gradientEnabled + it.gradientArgb = value.gradientArgb + } + value.javaClass.isArray -> ReflectArray.newInstance(value.javaClass.componentType, ReflectArray.getLength(value)).also { + System.arraycopy(value, 0, it, 0, ReflectArray.getLength(value)) + } + value is List<*> -> ArrayList(value) + value is Set<*> -> LinkedHashSet(value) + value is Map<*, *> -> LinkedHashMap(value) + else -> runCatching { + (ObjectSerializer.INSTANCE.serialize(value, false, false) as? Map)?.let(ObjectSerializer.INSTANCE::deserialize) + }.getOrNull() ?: value +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/MenuEntries.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/MenuEntries.kt new file mode 100644 index 000000000..928f67ac8 --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/MenuEntries.kt @@ -0,0 +1,93 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.page.PageText +import org.polyfrost.oneconfig.api.ui.v1.page.translate + +@ExperimentalOneConfigUiApi +public sealed interface MenuEntry + +@ExperimentalOneConfigUiApi +public class MenuAction( + label: PageText, + public val onClick: () -> Unit, + public val icon: String? = null, + public val painter: Painter? = null, + public val detail: String? = null, + style: ControlStyle = ControlStyle.Ghost, + public val enabled: Boolean = true, + public val disabledReason: String? = null, + public val checked: Boolean? = null, + public val shortcutHint: String? = null, + public val badge: String? = null, + public val size: MenuItemSize = MenuItemSize.Compact, + public val leading: (@Composable () -> Unit)? = null, + public val children: List = emptyList(), + danger: Boolean = false, +) : MenuEntry { + public constructor( + label: String, + onClick: () -> Unit, + icon: String? = null, + painter: Painter? = null, + detail: String? = null, + style: ControlStyle = ControlStyle.Ghost, + enabled: Boolean = true, + disabledReason: String? = null, + checked: Boolean? = null, + shortcutHint: String? = null, + badge: String? = null, + size: MenuItemSize = MenuItemSize.Compact, + leading: (@Composable () -> Unit)? = null, + children: List = emptyList(), + danger: Boolean = false, + ) : this( + PageText.literal(label), onClick, icon, painter, detail, style, enabled, disabledReason, checked, + shortcutHint, badge, size, leading, children, danger, + ) + + public val labelText: PageText = label + + public val label: String get() = labelText.translate() + + public val style: ControlStyle = if (danger) ControlStyle.Danger else style + + public val danger: Boolean get() = style == ControlStyle.Danger || style == ControlStyle.DangerFilled + + @Deprecated( + "Use the constructor that takes onClick second", + ReplaceWith("MenuAction(label, onClick, icon = icon, detail = detail, enabled = enabled, danger = danger)"), + ) + public constructor( + label: String, + icon: String? = null, + detail: String? = null, + danger: Boolean = false, + enabled: Boolean = true, + onClick: () -> Unit, + ) : this(label, onClick, icon = icon, detail = detail, enabled = enabled, danger = danger) +} + +@ExperimentalOneConfigUiApi +public data object MenuSeparator : MenuEntry + +@ExperimentalOneConfigUiApi +public class MenuHeading(public val text: String) : MenuEntry + +@ExperimentalOneConfigUiApi +public class MenuRadioGroup( + public val options: List, + public val selected: T, + public val label: (T) -> String, + public val onSelect: (T) -> Unit, +) : MenuEntry + +@ExperimentalOneConfigUiApi +public class MenuIconRow(public val items: List) : MenuEntry + +@ExperimentalOneConfigUiApi +public class MenuCustomItem(public val content: @Composable () -> Unit) : MenuEntry diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/MenuNavigation.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/MenuNavigation.kt new file mode 100644 index 000000000..ef1ca0673 --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/MenuNavigation.kt @@ -0,0 +1,321 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.awtEventOrNull +import androidx.compose.ui.composed +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isAltPressed +import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.isMetaPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.type +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow +import kotlinx.coroutines.delay +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import kotlin.math.abs +import kotlin.math.roundToInt + +internal val LocalMenuLevel = staticCompositionLocalOf { null } + +private const val UNPLACED = Int.MIN_VALUE + +internal class MenuNavItem { + var label: String = "" + var enabled: Boolean = true + var hasSubmenu: Boolean = false + var activate: () -> Unit = {} + var top: Int = UNPLACED + var left: Int = 0 + var seq: Int = 0 + var revealRequest by mutableIntStateOf(0) +} + +internal class MenuNavLevel(val controller: MenuNavController, val parent: MenuNavLevel?) { + private val registered = ArrayList() + private var nextSeq = 0 + + var highlighted by mutableStateOf(null) + var openChild by mutableStateOf(null) + var hoverIntent by mutableStateOf(null) + var child: MenuNavLevel? = null + + fun register(item: MenuNavItem) { + item.seq = nextSeq++ + registered += item + } + + fun unregister(item: MenuNavItem) { + registered -= item + if (highlighted === item) highlighted = null + if (openChild === item) openChild = null + if (hoverIntent === item) hoverIntent = null + } + + fun ordered(): List = + if (registered.all { it.top != UNPLACED }) registered.sortedWith(compareBy({ it.top }, { it.left }, { it.seq })) + else registered.sortedBy { it.seq } + + fun contains(level: MenuNavLevel): Boolean = generateSequence(level) { it.parent }.any { it === this } + + fun hover(item: MenuNavItem) { + controller.active = this + highlighted = item + hoverIntent = item + parent?.holdOpen() + } + + fun unhover(item: MenuNavItem) { + if (highlighted === item && openChild !== item) highlighted = null + } + + private fun holdOpen() { + val open = openChild ?: return + highlighted = open + hoverIntent = open + parent?.holdOpen() + } + + fun open(item: MenuNavItem, focusFirst: Boolean) { + val shown = child + if (openChild === item && shown != null) { + if (focusFirst) shown.highlightFirst() + return + } + controller.focusFirstOf = if (focusFirst) item else null + highlighted = item + hoverIntent = item + openChild = item + controller.active = this + } + + fun toggle(item: MenuNavItem, focusFirst: Boolean) { + if (openChild === item) closeChild() else open(item, focusFirst) + } + + fun closeChild() { + val open = openChild ?: return + openChild = null + highlighted = open + hoverIntent = open + controller.active = this + } + + fun deepestOpen(): MenuNavLevel { + val next = child + return if (openChild != null && next != null) next.deepestOpen() else this + } + + fun highlightFirst() { + val first = ordered().firstOrNull() + highlighted = first + controller.active = this + first?.let { it.revealRequest++ } + } + + fun select(item: MenuNavItem?) { + controller.active = this + if (item == null) return + highlighted = item + hoverIntent = null + if (openChild != null && openChild !== item) openChild = null + item.revealRequest++ + } +} + +internal class MenuNavController { + val root = MenuNavLevel(this, null) + var active: MenuNavLevel = root + var focusFirstOf: MenuNavItem? = null + private val typed = StringBuilder() + private var typedAt = 0L + + fun onKey(event: KeyEvent, rtl: Boolean): Boolean { + typedChar(event)?.let { return typeAhead(it) } + if (event.type != KeyEventType.KeyDown) return false + if (event.isCtrlPressed || event.isAltPressed || event.isMetaPressed) return false + val level = active + val forward = if (rtl) Key.DirectionLeft else Key.DirectionRight + val back = if (rtl) Key.DirectionRight else Key.DirectionLeft + return when (event.key) { + Key.DirectionDown -> stepRow(level, 1) + Key.DirectionUp -> stepRow(level, -1) + Key.MoveHome, Key.PageUp -> { + level.select(level.ordered().firstOrNull()) + true + } + Key.MoveEnd, Key.PageDown -> { + level.select(level.ordered().lastOrNull()) + true + } + Key.Enter, Key.NumPadEnter, Key.Spacebar -> activate(level) + forward -> forward(level) + back -> back(level) + Key.Escape -> { + val deepest = root.deepestOpen() + val parent = deepest.parent ?: return false + parent.closeChild() + true + } + else -> false + } + } + + private fun rows(level: MenuNavLevel): List> { + val rows = ArrayList>() + for (item in level.ordered()) { + val last = rows.lastOrNull() + if (last != null && item.top != UNPLACED && last.first().top == item.top) last += item else rows += mutableListOf(item) + } + return rows + } + + private fun stepRow(level: MenuNavLevel, direction: Int): Boolean { + val rows = rows(level) + if (rows.isEmpty()) return true + val current = level.highlighted + val at = rows.indexOfFirst { row -> row.any { it === current } } + if (at < 0) { + level.select(if (direction > 0) rows.first().first() else rows.last().first()) + return true + } + val target = rows[(at + direction).mod(rows.size)] + level.select(target.minByOrNull { abs(it.left - (current?.left ?: 0)) }) + return true + } + + private fun stepInRow(level: MenuNavLevel, direction: Int): Boolean { + val current = level.highlighted ?: return false + val row = rows(level).firstOrNull { row -> row.any { it === current } } ?: return false + val target = row.getOrNull(row.indexOf(current) + direction) ?: return false + level.select(target) + return true + } + + private fun forward(level: MenuNavLevel): Boolean { + if (stepInRow(level, 1)) return true + val current = level.highlighted ?: return true + if (current.hasSubmenu && current.enabled) level.open(current, true) + return true + } + + private fun back(level: MenuNavLevel): Boolean { + if (stepInRow(level, -1)) return true + level.parent?.closeChild() + return true + } + + private fun activate(level: MenuNavLevel): Boolean { + val current = level.highlighted ?: return true + if (!current.enabled) return true + if (current.hasSubmenu) level.open(current, true) else current.activate() + return true + } + + private fun typedChar(event: KeyEvent): Char? { + val native = event.awtEventOrNull ?: return null + if (native.id != java.awt.event.KeyEvent.KEY_TYPED) return null + val char = native.keyChar + if (char == java.awt.event.KeyEvent.CHAR_UNDEFINED || char.isISOControl()) return null + if (event.isCtrlPressed || event.isAltPressed || event.isMetaPressed) return null + return char + } + + private fun typeAhead(char: Char): Boolean { + val now = System.currentTimeMillis() + if (now - typedAt > MenuDefaults.TYPE_AHEAD_RESET_MILLIS) typed.setLength(0) + if (typed.isEmpty() && char.isWhitespace()) return false + typedAt = now + typed.append(char) + val level = active + val items = level.ordered() + if (items.isEmpty()) return true + val repeated = typed.all { it.equals(typed[0], ignoreCase = true) } + val prefix = if (repeated) typed[0].toString() else typed.toString() + val at = items.indexOfFirst { it === level.highlighted } + val start = if (repeated || at < 0) at + 1 else at + val match = items.indices + .map { items[(start + it).mod(items.size)] } + .firstOrNull { it.label.trimStart().startsWith(prefix, ignoreCase = true) } + if (match != null) level.select(match) + return true + } +} + +@Composable +internal fun rememberMenuNavItem( + level: MenuNavLevel?, + label: String, + enabled: Boolean, + submenu: Boolean, + activate: () -> Unit, +): MenuNavItem? { + if (level == null) return null + val item = remember(level) { MenuNavItem() } + val latest by rememberUpdatedState(activate) + SideEffect { + item.label = label + item.enabled = enabled + item.hasSubmenu = submenu + item.activate = { latest() } + } + DisposableEffect(level, item) { + level.register(item) + onDispose { level.unregister(item) } + } + return item +} + +@Composable +internal fun MenuNavHover(level: MenuNavLevel?, item: MenuNavItem?, hovered: Boolean) { + if (level == null || item == null) return + val seen = remember(item) { booleanArrayOf(false) } + LaunchedEffect(item, hovered) { + if (hovered) level.hover(item) else if (seen[0]) level.unhover(item) + seen[0] = hovered + } +} + +@Composable +internal fun MenuHoverIntentEffect(level: MenuNavLevel) { + val intent = level.hoverIntent + LaunchedEffect(level, intent) { + if (intent == null || intent === level.openChild) return@LaunchedEffect + delay(MenuDefaults.SUBMENU_DELAY_MILLIS) + level.openChild = intent.takeIf { it.hasSubmenu && it.enabled } + } +} + +internal fun Modifier.menuNavPosition(item: MenuNavItem?): Modifier = + if (item == null) this + else composed { + val requester = remember { BringIntoViewRequester() } + val reveal = item.revealRequest + LaunchedEffect(item, reveal) { + if (reveal != 0) requester.bringIntoView() + } + Modifier + .bringIntoViewRequester(requester) + .onGloballyPositioned { + val at = it.positionInWindow() + item.top = at.y.roundToInt() + item.left = at.x.roundToInt() + } + } diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/MenuRendering.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/MenuRendering.kt new file mode 100644 index 000000000..c6814d1ef --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/MenuRendering.kt @@ -0,0 +1,212 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layout +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.a11y.A11y +import org.polyfrost.oneconfig.api.ui.v1.a11y.A11yRole +import org.polyfrost.oneconfig.api.ui.v1.a11y.isKeyboardMode +import org.polyfrost.oneconfig.api.ui.v1.a11y.notFocusable +import org.polyfrost.oneconfig.api.ui.v1.internal.componentsBridge +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme + +@Composable +internal fun MenuEntryItems(entries: List, onSelect: () -> Unit, size: MenuItemSize) { + val reserveCheck = entries.any { it is MenuAction && it.checked != null || it is MenuRadioGroup<*> } + val reserveIcon = entries.any { it is MenuAction && (it.icon != null || it.painter != null || it.leading != null) } + entries.forEach { entry -> + when (entry) { + is MenuAction -> MenuActionRow(entry, onSelect, size, reserveCheck, reserveIcon) + is MenuSeparator -> Divider(Modifier.padding(vertical = MenuDefaults.itemSpacing)) + is MenuHeading -> MenuHeadingText(entry.text, size) + is MenuRadioGroup<*> -> MenuRadioRows(entry, onSelect, size, reserveIcon) + is MenuIconRow -> MenuIconRowItems(entry, onSelect) + is MenuCustomItem -> entry.content() + } + } +} + +@Composable +private fun MenuActionRow(action: MenuAction, onSelect: () -> Unit, size: MenuItemSize, reserveCheck: Boolean, reserveIcon: Boolean) { + val level = LocalMenuLevel.current + val itemSize = maxOf(action.size, size) + val submenu: (@Composable (MenuNavItem) -> Unit)? = + if (action.children.isEmpty() || level == null) null + else { owner -> SubmenuPopup(level, owner, action.children, onSelect, size) } + MenuItemRow( + label = action.label, + onClick = { + onSelect() + action.onClick() + }, + icon = action.icon, + painter = action.painter, + detail = action.detail, + style = action.style, + size = itemSize, + enabled = action.enabled, + checked = action.checked, + shortcutHint = action.shortcutHint, + badge = action.badge, + tooltip = action.disabledReason?.takeIf { !action.enabled }, + leading = action.leading, + reserveCheck = reserveCheck, + reserveIcon = reserveIcon, + submenu = submenu, + ) +} + +@Composable +private fun MenuHeadingText(text: String, size: MenuItemSize) { + val theme = OneConfigTheme.current + Text( + text, + Modifier + .fillMaxWidth() + .padding(start = size.inset, end = size.inset, top = 6.dp, bottom = 2.dp) + .semantics { heading() }, + theme.mutedText, + theme.typography.caption.copy(fontWeight = FontWeight.Medium), + 1, + TextOverflow.Ellipsis, + ) +} + +@Composable +private fun MenuRadioRows(group: MenuRadioGroup, onSelect: () -> Unit, size: MenuItemSize, reserveIcon: Boolean) { + group.options.forEach { option -> + MenuItemRow( + label = group.label(option), + onClick = { + onSelect() + group.onSelect(option) + }, + size = size, + checked = option == group.selected, + radio = true, + reserveIcon = reserveIcon, + ) + } +} + +@Composable +private fun MenuIconRowItems(row: MenuIconRow, onSelect: () -> Unit) { + Row( + Modifier.fillMaxWidth().padding(horizontal = MenuDefaults.contentPadding, vertical = MenuDefaults.itemSpacing), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + row.items.forEach { MenuIconButton(it, onSelect) } + } +} + +@Composable +private fun MenuIconButton(action: MenuAction, onSelect: () -> Unit) { + val theme = OneConfigTheme.current + val level = LocalMenuLevel.current + val source = remember { MutableInteractionSource() } + val hovered by source.collectIsHoveredAsState() + val keyboard = isKeyboardMode() + val click = { + onSelect() + action.onClick() + } + val nav = rememberMenuNavItem(level, action.label, action.enabled, false) { + playUiSound(UiSound.Click) + click() + } + MenuNavHover(level, nav, hovered) + val highlighted = nav != null && level?.highlighted === nav + val tooltip = action.disabledReason?.takeIf { !action.enabled } ?: action.label + LaunchedEffect(highlighted, keyboard) { + if (highlighted && keyboard) A11y.focused(tooltip, A11yRole.Button, null, null) + } + val ringShape = if (action.painter != null || action.icon != null) IconButtonDefaults.shape(IconButtonStyle.Ghost) else ButtonDefaults.shape + val ring = if (highlighted && keyboard) Modifier.border(1.dp, theme.focusRingColor, ringShape) else Modifier + Box(Modifier.menuNavPosition(nav).then(ring)) { + val modifier = if (level != null) Modifier.notFocusable() else Modifier + when { + action.painter != null -> IconButton( + action.painter, action.label, click, modifier, + enabled = action.enabled, size = ControlSize.Compact, danger = action.danger, + tooltip = tooltip, interactionSource = source, + ) + action.icon != null -> IconButton( + action.icon, action.label, click, modifier, + enabled = action.enabled, size = ControlSize.Compact, danger = action.danger, + tooltip = tooltip, interactionSource = source, + ) + else -> TextButton( + action.label, click, modifier, + style = if (action.danger) ControlStyle.Danger else ControlStyle.Ghost, size = ControlSize.Compact, + enabled = action.enabled, leading = action.leading, interactionSource = source, + ) + } + if (rememberTooltipShown(highlighted && keyboard)) TooltipPopup(tooltip, Modifier, TooltipPlacement.BelowCenter) + } +} + +@Composable +private fun SubmenuPopup( + parent: MenuNavLevel, + owner: MenuNavItem, + entries: List, + onSelect: () -> Unit, + size: MenuItemSize, +) { + val controller = parent.controller + val level = remember(parent, owner) { MenuNavLevel(controller, parent) } + DisposableEffect(level) { + parent.child = level + onDispose { + if (parent.child === level) parent.child = null + if (level.contains(controller.active)) controller.active = parent + } + } + EscapeCloses { parent.closeChild() } + LaunchedEffect(level) { + if (controller.focusFirstOf !== owner) return@LaunchedEffect + controller.focusFirstOf = null + withFrameNanos { } + level.highlightFirst() + } + MenuHoverIntentEffect(level) + componentsBridge().Popup(rememberAnchoredPosition(Placement.End, MenuDefaults.submenuGap), null, false) { + CompositionLocalProvider(LocalMenuLevel provides level) { + MenuPanel(Modifier, popupMaxHeight()) { + MenuEntryItems(entries, onSelect, size) + } + } + } +} + +internal fun Modifier.outset(by: Dp): Modifier = layout { measurable, constraints -> + val px = by.roundToPx() + val width = constraints.maxWidth + val height = constraints.maxHeight + val placeable = measurable.measure(Constraints.fixed(width + px * 2, height + px * 2)) + layout(width, height) { placeable.place(-px, -px) } +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/MoreButton.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/MoreButton.kt new file mode 100644 index 000000000..5aefd6eed --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/MoreButton.kt @@ -0,0 +1,35 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi + +@ExperimentalOneConfigUiApi +public object MoreButtonDefaults { + public val contentDescription: String + @Composable get() = kitString("oneconfig.ui.more_actions", "More actions") +} + +@ExperimentalOneConfigUiApi +@Composable +public fun MoreButton( + entries: List, + modifier: Modifier = Modifier, + contentDescription: String = MoreButtonDefaults.contentDescription, + style: IconButtonStyle = IconButtonStyle.Ghost, + size: ControlSize = ControlSize.Compact, +) { + var expanded by remember { mutableStateOf(false) } + val trigger = rememberPopupTrigger() + Box(modifier) { + IconButton("dots-vertical", contentDescription, { expanded = !expanded }, Modifier.popupTrigger(trigger), style = style, size = size, selected = expanded) + TriggeredDropdownMenu(expanded, { expanded = false }, entries, trigger, placement = Placement.BelowEnd) + } +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/NotificationsCenterPanel.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/NotificationsCenterPanel.kt new file mode 100644 index 000000000..4524ff0a4 --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/NotificationsCenterPanel.kt @@ -0,0 +1,14 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.internal.feedbackBridge + +@ExperimentalOneConfigUiApi +@Composable +public fun NotificationsCenterPanel(modifier: Modifier = Modifier) { + feedbackBridge()?.NotificationsCenter(modifier) +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/NumberField.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/NumberField.kt new file mode 100644 index 000000000..e1ff17766 --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/NumberField.kt @@ -0,0 +1,335 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isShiftPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import java.math.BigDecimal +import java.math.RoundingMode +import java.util.Locale +import kotlin.math.pow +import kotlin.math.roundToLong +import org.polyfrost.oneconfig.api.platform.v1.Platform +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme + +private val NUMBER_FIELD_WIDTH = 87.dp +private val NUMBER_FIELD_START_PADDING = 8.dp +private val NUMBER_FIELD_END_PADDING = 2.dp +private const val FREE_DECIMALS = 4 +private const val SHIFT_STEP_MULTIPLIER = 10f +private const val ARROW_FADE_MILLIS = 150 + +@ExperimentalOneConfigUiApi +@Composable +public fun NumberField( + value: Float, + onValueChange: (Float) -> Unit, + modifier: Modifier = Modifier, + range: ClosedFloatingPointRange? = null, + step: Float = 1f, + decimals: Int = 0, + roundToDecimals: Boolean = true, + unit: String? = null, + format: ((Float) -> String)? = null, + enabled: Boolean = true, + size: ControlSize = ControlSize.Regular, + label: String? = null, + interactionSource: MutableInteractionSource? = null, +) { + val theme = OneConfigTheme.current + val source = interactionSource ?: remember { MutableInteractionSource() } + val focused by source.collectIsFocusedAsState() + val focusManager = LocalFocusManager.current + val latestChange by rememberUpdatedState(onValueChange) + var field by remember { mutableStateOf(TextFieldValue(formatNumber(value, decimals, roundToDecimals, step))) } + var edited by remember { mutableStateOf(false) } + + fun normalise(v: Float): Float { + val rounded = if (roundToDecimals) { + val factor = 10.0.pow(decimals.coerceAtLeast(0)) + ((v * factor).roundToLong() / factor).toFloat() + } else { + v + } + return range?.let { rounded.coerceIn(it) } ?: rounded + } + + fun show(v: Float) { + val text = formatNumber(v, decimals, roundToDecimals, step) + field = TextFieldValue(text, TextRange(text.length)) + } + + fun commitTyped(): Float { + if (!edited) return value + edited = false + val result = NumberExpression(field.text).evaluate()?.toFloat()?.takeIf { it.isFinite() } + if (result == null) { + show(value) + return value + } + val next = normalise(result) + show(next) + if (next != value) latestChange(next) + return next + } + + fun stepBy(delta: Float, from: Float) { + if (!enabled) return + edited = false + val next = normalise(from + delta) + show(next) + if (next != from) latestChange(next) + } + + val external = formatNumber(value, decimals, roundToDecimals, step) + LaunchedEffect(external, focused) { + if (!focused && !edited && field.text != external) field = TextFieldValue(external, TextRange(external.length)) + } + val formatted = if (format != null && !focused && !edited) runCatching { format(value) }.getOrNull() else null + val shown = when { + formatted != null -> TextFieldValue(formatted, TextRange(formatted.length)) + !focused && !edited && field.text != external -> TextFieldValue(external, TextRange(external.length)) + else -> field + } + val canIncrease = range == null || value < range.endInclusive + val canDecrease = range == null || value > range.start + val unitSuffix = rememberUnitSuffix(unit) + val shape = TextFieldDefaults.shape + val frameAlpha = if (enabled) 1f else theme.disabledAlpha + val fill = theme.componentBackground + val border by animateColorAsState( + if (focused) TextFieldDefaults.focusBorder else TextFieldDefaults.border, + Motion.spec(tween(ARROW_FADE_MILLIS)), + ) + + Row( + modifier + .width(NUMBER_FIELD_WIDTH) + .heightIn(min = size.height) + .background(fill.copy(alpha = fill.alpha * frameAlpha), shape) + .border(1.dp, border.copy(alpha = border.alpha * frameAlpha), shape) + .padding(start = NUMBER_FIELD_START_PADDING, end = NUMBER_FIELD_END_PADDING), + verticalAlignment = Alignment.CenterVertically, + ) { + FieldCore( + shown, + { next -> + if (formatted != null) return@FieldCore + val filtered = next.text.filter { it.isDigit() || it in ".+-*/() " } + if (filtered != field.text) edited = true + field = if (filtered == next.text) next else TextFieldValue(filtered, TextRange(filtered.length)) + filtered.takeIf { text -> text.all { it.isDigit() || it == '.' } }?.toFloatOrNull()?.let { typed -> + val clamped = range?.let { typed.coerceIn(it) } ?: typed + if (clamped != value) latestChange(clamped) + } + }, + Modifier + .weight(1f) + .onFocusChanged { if (!it.isFocused) commitTyped() } + .onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyDown || !enabled) return@onPreviewKeyEvent false + val multiplier = if (event.isShiftPressed) SHIFT_STEP_MULTIPLIER else 1f + when (event.key) { + Key.DirectionUp -> { + stepBy(step * multiplier, commitTyped()) + true + } + Key.DirectionDown -> { + stepBy(-step * multiplier, commitTyped()) + true + } + Key.Enter, Key.NumPadEnter -> { + commitTyped() + focusManager.clearFocus() + true + } + else -> false + } + }, + null, + enabled, + false, + true, + size, + label, + null, + null, + null, + false, + Int.MAX_VALUE, + false, + TextUnit.Unspecified, + null, + null, + source, + transformation = if (formatted != null) null else unitSuffix, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + Column { + StepArrow("up", numberString("oneconfig.ui.number.increase", "Increase"), enabled && canIncrease) { + stepBy(step, commitTyped()) + } + StepArrow("down", numberString("oneconfig.ui.number.decrease", "Decrease"), enabled && canDecrease) { + stepBy(-step, commitTyped()) + } + } + } +} + +@Composable +private fun StepArrow(icon: String, label: String, enabled: Boolean, onClick: () -> Unit) { + val theme = OneConfigTheme.current + val source = remember { MutableInteractionSource() } + val hovered by source.collectIsHoveredAsState() + val color by animateColorAsState( + when { + !enabled -> theme.textColorSecondary.copy(alpha = theme.disabledAlpha) + hovered -> theme.textColor + else -> theme.textColorSecondary + }, + Motion.spec(tween(ARROW_FADE_MILLIS)), + ) + Box( + modifier = Modifier + .size(18.dp, 13.dp) + .oneConfigClickable(label, onClick, enabled = enabled, shape = theme.shapes.small, indication = false, interactionSource = source), + contentAlignment = Alignment.Center, + ) { + Icon(icon, null, Modifier.size(14.dp), tint = color) + } +} + +@Suppress("DEPRECATION") +@Composable +private fun rememberUnitSuffix(unit: String?): VisualTransformation { + val color = OneConfigTheme.current.textColorSecondary + return remember(unit, color) { + if (unit == null) { + VisualTransformation.None + } else { + VisualTransformation { typed -> + val shown = AnnotatedString.Builder(typed).apply { + pushStyle(SpanStyle(color = color)) + append(unit) + pop() + }.toAnnotatedString() + TransformedText( + shown, + object : OffsetMapping { + override fun originalToTransformed(offset: Int): Int = offset + override fun transformedToOriginal(offset: Int): Int = offset.coerceAtMost(typed.length) + }, + ) + } + } + } +} + +private fun formatNumber(value: Float, decimals: Int, round: Boolean, step: Float): String { + if (round) return if (decimals <= 0) value.roundToLong().toString() else String.format(Locale.ROOT, "%.${decimals}f", value) + if (!value.isFinite()) return value.toString() + val minimum = decimals.coerceAtLeast(0) + val stepScale = if (step > 0f && step.isFinite()) BigDecimal(step.toString()).stripTrailingZeros().scale() else 0 + val exact = BigDecimal(value.toString()) + .setScale(maxOf(minimum, FREE_DECIMALS, stepScale), RoundingMode.HALF_UP) + .stripTrailingZeros() + return exact.setScale(maxOf(minimum, exact.scale(), 0)).toPlainString() +} + +private fun numberString(key: String, fallback: String): String = + runCatching { Platform.i18n().translateString(key) }.getOrNull()?.takeIf { it != key } ?: fallback + +private class NumberExpression(private val src: String) { + private var pos = 0 + + fun evaluate(): Double? { + val v = expression() ?: return null + return if (peek() == null) v else null + } + + private fun peek(): Char? { + while (pos < src.length && src[pos] == ' ') pos++ + return src.getOrNull(pos) + } + + private fun expression(): Double? { + var v = term() ?: return null + while (true) { + when (peek()) { + '+' -> { pos++; v += term() ?: return null } + '-' -> { pos++; v -= term() ?: return null } + else -> return v + } + } + } + + private fun term(): Double? { + var v = factor() ?: return null + while (true) { + when (peek()) { + '*' -> { pos++; v *= factor() ?: return null } + '/' -> { pos++; v /= factor() ?: return null } + else -> return v + } + } + } + + private fun factor(): Double? { + when (peek()) { + '-' -> { pos++; return factor()?.let { -it } } + '+' -> { pos++; return factor() } + '(' -> { + pos++ + val v = expression() ?: return null + if (peek() != ')') return null + pos++ + return v + } + } + val start = pos + while (pos < src.length && (src[pos].isDigit() || src[pos] == '.')) pos++ + return src.substring(start, pos).toDoubleOrNull() + } +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/OptionWidth.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/OptionWidth.kt index bcd8e00d4..6850d32f5 100644 --- a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/OptionWidth.kt +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/OptionWidth.kt @@ -5,6 +5,11 @@ import androidx.compose.ui.unit.Dp import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi import org.polyfrost.oneconfig.api.ui.v1.internal.componentsBridge +@Deprecated("Returns a placeholder width outside an option row. Use currentOptionWidthOrNull.", ReplaceWith("currentOptionWidthOrNull")) @ExperimentalOneConfigUiApi public val currentOptionWidth: Dp @Composable get() = componentsBridge().optionWidth() + +@ExperimentalOneConfigUiApi +public val currentOptionWidthOrNull: Dp? + @Composable get() = componentsBridge().optionWidthOrNull() diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/PlayerHead.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/PlayerHead.kt index e3f6febef..8ebcab815 100644 --- a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/PlayerHead.kt +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/PlayerHead.kt @@ -4,6 +4,7 @@ package org.polyfrost.oneconfig.api.ui.v1.components import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape @@ -17,15 +18,24 @@ import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.BaselineShift import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.constrainHeight +import androidx.compose.ui.unit.constrainWidth import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.dp import java.util.UUID +import org.polyfrost.compose.render.PolyBitmap import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi import org.polyfrost.oneconfig.api.ui.v1.internal.playerHeadBridge +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme @ExperimentalOneConfigUiApi public object PlayerHeads { @@ -34,38 +44,105 @@ public object PlayerHeads { @JvmStatic public fun defaultFace(id: UUID): ImageBitmap? = runCatching { playerHeadBridge()?.defaultFace(id) }.getOrNull() -} -@ExperimentalOneConfigUiApi -@Composable -public fun PlayerHead(playerId: UUID, size: Dp, modifier: Modifier) { - PlayerHead(playerId, size, modifier, null) + @ExperimentalOneConfigUiApi + @JvmStatic + public fun bitmap(playerId: UUID?): PolyBitmap? = + playerId?.let { id -> runCatching { playerHeadBridge()?.bitmap(id) }.getOrNull() } } @ExperimentalOneConfigUiApi @Composable -public fun PlayerHead(playerId: UUID, size: Dp, modifier: Modifier, shape: Shape?) { - val clip = shape ?: RoundedCornerShape(size / 4) - val observed = remember(playerId) { runCatching { playerHeadBridge()?.observe(playerId) }.getOrNull() } +public fun PlayerHead( + playerId: UUID?, + size: Dp, + modifier: Modifier = Modifier, + name: String? = null, + shape: Shape = OneConfigTheme.shapes.small, + contentDescription: String? = name, +) { + val described = modifier + .size(size) + .clip(shape) + .then( + if (contentDescription == null) Modifier + else Modifier.semantics { + this.contentDescription = contentDescription + role = Role.Image + }, + ) + val observed = remember(playerId) { playerId?.let { id -> runCatching { playerHeadBridge()?.observe(id) }.getOrNull() } } val face = observed?.value if (face != null) { - Image(BitmapPainter(face, filterQuality = FilterQuality.None), null, modifier.size(size).clip(clip)) + Image(BitmapPainter(face, filterQuality = FilterQuality.None), null, described) return } - Box(modifier.size(size).clip(clip).background(placeholderColor(playerId), clip), contentAlignment = Alignment.Center) { + val theme = OneConfigTheme.current + val initial = name?.firstOrNull { it.isLetterOrDigit() }?.uppercase() + ?: playerId?.toString()?.take(1)?.uppercase() + ?: "?" + val fontSize = with(LocalDensity.current) { (size * 0.4f).toSp() } + Box(described.background(placeholderColor(playerId, name), shape), contentAlignment = Alignment.Center) { Text( - playerId.toString().take(1).uppercase(), - Modifier, - Color.White, - (size.value * 0.4f).sp, - null, - BaselineShift.None, - FontWeight.Bold, - TextAlign.Center, - 1, - TextOverflow.Clip, + initial, + color = theme.onMedia, + style = theme.typography.body.copy(fontSize = fontSize, fontWeight = FontWeight.Bold), + maxLines = 1, + textAlign = TextAlign.Center, ) } } -private fun placeholderColor(id: UUID): Color = Color.hsv(id.hashCode().mod(360).toFloat(), 0.45f, 0.55f) +@ExperimentalOneConfigUiApi +@Composable +public fun PlayerHeadStack(ids: List, size: Dp, modifier: Modifier = Modifier, max: Int = 4) { + val theme = OneConfigTheme.current + val shape = theme.shapes.small + val shown = ids.take(max.coerceAtLeast(1)) + val extra = ids.size - shown.size + val ring = theme.pageSurface + Layout( + content = { + for (id in shown) PlayerHead(id, size, Modifier.border(1.5.dp, ring, shape), name = null, shape = shape) + if (extra > 0) { + Box( + Modifier + .size(size) + .clip(shape) + .background(theme.componentBackground, shape) + .border(1.5.dp, ring, shape), + contentAlignment = Alignment.Center, + ) { + val fontSize = with(LocalDensity.current) { (size * 0.36f).toSp() } + Text("+$extra", color = theme.textColorSecondary, style = theme.typography.small.copy(fontSize = fontSize), maxLines = 1) + } + } + }, + modifier = modifier, + ) { measurables, constraints -> + val side = size.roundToPx() + val step = side - (size / 4).roundToPx() + val placeables = measurables.map { it.measure(Constraints.fixed(side, side)) } + val width = if (placeables.isEmpty()) 0 else step * (placeables.size - 1) + side + layout(constraints.constrainWidth(width), constraints.constrainHeight(side)) { + placeables.forEachIndexed { index, placeable -> placeable.place(step * index, 0) } + } + } +} + +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) +@ExperimentalOneConfigUiApi +@Composable +public fun PlayerHead(playerId: UUID, size: Dp, modifier: Modifier, shape: Shape?) { + PlayerHead(playerId, size, modifier, null, shape ?: RoundedCornerShape(size / 4)) +} + +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) +@ExperimentalOneConfigUiApi +@Composable +public fun PlayerHead(playerId: UUID, size: Dp, modifier: Modifier) { + PlayerHead(playerId = playerId, size = size, modifier = modifier, name = null, shape = RoundedCornerShape(size / 4)) +} + +private fun placeholderColor(id: UUID?, name: String?): Color = + Color.hsv(((id?.hashCode() ?: name?.hashCode()) ?: 0).mod(360).toFloat(), 0.45f, 0.55f) diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/PopupButton.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/PopupButton.kt index 0c1efc994..d7b3bed02 100644 --- a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/PopupButton.kt +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/PopupButton.kt @@ -2,49 +2,114 @@ package org.polyfrost.oneconfig.api.ui.v1.components +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntRect -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.PopupPositionProvider import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi import org.polyfrost.oneconfig.api.ui.v1.a11y.Announce -import org.polyfrost.oneconfig.api.ui.v1.internal.componentsBridge -import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme private val POPUP_GAP = 6.dp -private class AnchoredPosition(private val alignment: Alignment.Horizontal, private val gap: Int) : PopupPositionProvider { - override fun calculatePosition(anchorBounds: IntRect, windowSize: IntSize, layoutDirection: LayoutDirection, popupContentSize: IntSize): IntOffset { - val x = anchorBounds.left + alignment.align(popupContentSize.width, anchorBounds.width, layoutDirection) - val below = anchorBounds.bottom + gap - val above = anchorBounds.top - gap - popupContentSize.height - val y = if (below + popupContentSize.height <= windowSize.height || above < 0) below else above - return IntOffset( - x.coerceIn(0, (windowSize.width - popupContentSize.width).coerceAtLeast(0)), - y.coerceIn(0, (windowSize.height - popupContentSize.height).coerceAtLeast(0)), +@ExperimentalOneConfigUiApi +@Composable +public fun AnchoredPopupButton( + icon: String, + contentDescription: String, + modifier: Modifier = Modifier, + placement: Placement = Placement.BelowEnd, + gap: Dp = POPUP_GAP, + enabled: Boolean = true, + style: IconButtonStyle = IconButtonStyle.Ghost, + size: ControlSize = ControlSize.Regular, + interactionSource: MutableInteractionSource? = null, + content: @Composable (close: () -> Unit) -> Unit, +) { + PopupButtonFrame(contentDescription, modifier, placement, gap, enabled, content) { open, toggle, triggerModifier -> + IconButton( + icon, + contentDescription, + toggle, + triggerModifier, + enabled = enabled, + style = style, + size = size, + selected = open, + tooltip = if (open) null else contentDescription, + interactionSource = interactionSource, ) } } +@ExperimentalOneConfigUiApi +@Composable +public fun AnchoredPopupButton( + painter: Painter, + contentDescription: String, + modifier: Modifier = Modifier, + placement: Placement = Placement.BelowEnd, + gap: Dp = POPUP_GAP, + enabled: Boolean = true, + style: IconButtonStyle = IconButtonStyle.Ghost, + size: ControlSize = ControlSize.Regular, + interactionSource: MutableInteractionSource? = null, + content: @Composable (close: () -> Unit) -> Unit, +) { + PopupButtonFrame(contentDescription, modifier, placement, gap, enabled, content) { open, toggle, triggerModifier -> + IconButton( + painter, + contentDescription, + toggle, + triggerModifier, + enabled = enabled, + style = style, + size = size, + selected = open, + tooltip = if (open) null else contentDescription, + interactionSource = interactionSource, + ) + } +} + +@Composable +private fun PopupButtonFrame( + contentDescription: String, + modifier: Modifier, + placement: Placement, + gap: Dp, + enabled: Boolean, + content: @Composable (close: () -> Unit) -> Unit, + button: @Composable (open: Boolean, toggle: () -> Unit, triggerModifier: Modifier) -> Unit, +) { + var open by remember { mutableStateOf(false) } + val trigger = rememberPopupTrigger() + LaunchedEffect(enabled) { if (!enabled) open = false } + Box(modifier) { + button(open, { open = !open }, Modifier.popupTrigger(trigger)) + AnchoredPopup(open, { open = false }, placement, gap, trigger = trigger) { + Announce(contentDescription) + content { open = false } + } + } +} + +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) @ExperimentalOneConfigUiApi @Composable public fun AnchoredPopupButton(icon: String, tooltip: String, content: @Composable (close: () -> Unit) -> Unit) { - AnchoredPopupButton(icon, tooltip, Modifier, Alignment.End, POPUP_GAP, content) + AnchoredPopupButton(icon, tooltip, Modifier, content = content) } +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) @ExperimentalOneConfigUiApi @Composable public fun AnchoredPopupButton( @@ -55,26 +120,10 @@ public fun AnchoredPopupButton( gap: Dp, content: @Composable (close: () -> Unit) -> Unit, ) { - var open by remember { mutableStateOf(false) } - val gapPx = with(LocalDensity.current) { gap.roundToPx() } - val provider = remember(alignment, gapPx) { AnchoredPosition(alignment, gapPx) } - Box(modifier) { - IconButton( - if (open) null else tooltip, - tooltip, - Modifier, - null, - TooltipPlacement.BelowCenter, - { open = !open }, - ) { - Icon(icon, OneConfigTheme.current.textColor, Modifier.size(16.dp)) - } - if (open) { - EscapeCloses { open = false } - componentsBridge().Popup(provider, { open = false }, true) { - Announce(tooltip) - content { open = false } - } - } + val placement = when (alignment) { + Alignment.Start -> Placement.BelowStart + Alignment.CenterHorizontally -> Placement.BelowCenter + else -> Placement.BelowEnd } + AnchoredPopupButton(icon, tooltip, modifier, placement, gap, content = content) } diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/ProgressBar.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/ProgressBar.kt new file mode 100644 index 000000000..7917b81fa --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/ProgressBar.kt @@ -0,0 +1,105 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlin.math.roundToInt +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.a11y.A11yRole +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.api.ui.v1.a11y.a11y +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme +import org.polyfrost.oneconfig.api.ui.v1.theme.ThemeStyle + +@ExperimentalOneConfigUiApi +public object ProgressBarDefaults { + public const val INDETERMINATE_MILLIS: Int = 1200 + + public fun thickness(size: ControlSize): Dp = when (size) { + ControlSize.Compact -> 4.dp + ControlSize.Regular -> 6.dp + ControlSize.Large -> 8.dp + } +} + +private const val TRACK_ALPHA = 0.35f +private const val SEGMENT_FRACTION = 0.4f +private const val REDUCED_FILL_ALPHA = 0.5f +private const val PROGRESS_MILLIS = 250 + +@ExperimentalOneConfigUiApi +@Composable +public fun ProgressBar(progress: Float?, modifier: Modifier = Modifier, size: ControlSize = ControlSize.Compact) { + val theme = OneConfigTheme.current + val shape = theme.shapes.pill + val rounded = theme.style != ThemeStyle.Pixel + val track = theme.controlTrackColor.let { it.copy(alpha = it.alpha * TRACK_ALPHA) } + val brush = theme.accentBrush + val value = progress?.coerceIn(0f, 1f) + val shown by animateFloatAsState(value ?: 0f, Motion.spec(tween(PROGRESS_MILLIS)), label = "progress") + val reduced = Motion.reduced + val sweep: State? = if (value == null && !reduced) { + rememberInfiniteTransition(label = "progressSweep").animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(ProgressBarDefaults.INDETERMINATE_MILLIS, easing = LinearEasing)), + label = "progressSweep", + ) + } else { + null + } + val label = if (value == null) { + layoutString("oneconfig.ui.loading", "Loading") + } else { + "${(value * 100f).roundToInt()}%" + } + Box( + Modifier + .then(modifier) + .fillMaxWidth() + .height(ProgressBarDefaults.thickness(size)) + .a11y(label, A11yRole.ProgressBar, null, null, shape, false) + .semantics { + progressBarRangeInfo = if (value == null) ProgressBarRangeInfo.Indeterminate else ProgressBarRangeInfo(value, 0f..1f) + } + .clip(shape) + .drawBehind { + val bar = this.size + val corner = if (rounded) CornerRadius(bar.height / 2f) else CornerRadius.Zero + drawRoundRect(track, cornerRadius = corner) + when { + value != null -> if (shown > 0f) { + drawRoundRect(brush, size = Size(bar.width * shown, bar.height), cornerRadius = corner) + } + sweep == null -> drawRoundRect(brush, cornerRadius = corner, alpha = REDUCED_FILL_ALPHA) + else -> { + val segment = bar.width * SEGMENT_FRACTION + val start = sweep.value * (bar.width + segment) - segment + drawRoundRect(brush, Offset(start, 0f), Size(segment, bar.height), corner) + } + } + }, + ) +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/RadioGroup.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/RadioGroup.kt new file mode 100644 index 000000000..2a4eb69c7 --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/RadioGroup.kt @@ -0,0 +1,217 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.animateIntOffsetAsState +import androidx.compose.animation.core.animateIntSizeAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.onPlaced +import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.semantics.selectableGroup +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import kotlin.math.roundToInt +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.a11y.A11yRole +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme +import org.polyfrost.oneconfig.api.ui.v1.theme.concentric + +private val GROUP_PADDING = 3.dp +private val ITEM_MIN_HEIGHT = ControlSize.Regular.height - GROUP_PADDING * 2 +private const val FADE_MILLIS = 150 + +private class RadioSlot { + var offset by mutableStateOf(IntOffset.Zero) + var size by mutableStateOf(IntSize.Zero) +} + +@ExperimentalOneConfigUiApi +@Composable +public fun RadioGroup( + selected: T, + options: List, + onSelect: (T) -> Unit, + modifier: Modifier = Modifier, + label: (T) -> String = { it.toString() }, + orientation: Orientation = Orientation.Vertical, + enabled: Boolean = true, + interactionSource: MutableInteractionSource? = null, + styledLabel: ((T) -> AnnotatedString)? = null, +) { + val theme = OneConfigTheme.current + val groupShape = theme.shapes.field + val itemShape = groupShape.concentric(GROUP_PADDING) + val vertical = orientation == Orientation.Vertical + val slots = remember(options.size) { List(options.size) { RadioSlot() } } + val requesters = remember(options.size) { List(options.size) { FocusRequester() } } + var focusedIndex by remember(options.size) { mutableIntStateOf(-1) } + val selectedIndex = options.indexOf(selected) + + fun move(by: Int): Boolean { + if (!enabled || options.isEmpty()) return false + val from = if (focusedIndex in options.indices) focusedIndex else selectedIndex.coerceAtLeast(0) + val to = (from + by).mod(options.size) + onSelect(options[to]) + runCatching { requesters[to].requestFocus() } + return true + } + + Box( + modifier = modifier + .graphicsLayer { alpha = if (enabled) 1f else theme.disabledAlpha } + .groupInteractions(interactionSource, enabled) + .border(1.dp, theme.borderColor, groupShape) + .clip(groupShape) + .background(theme.componentBackground) + .semantics { selectableGroup() } + .onKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onKeyEvent false + when (event.key) { + Key.DirectionLeft, Key.DirectionUp -> move(-1) + Key.DirectionRight, Key.DirectionDown -> move(1) + else -> false + } + }, + ) { + Box(Modifier.padding(GROUP_PADDING)) { + val target = slots.getOrNull(selectedIndex) + if (target != null && target.size.width > 0) { + val offset by animateIntOffsetAsState( + target.offset, + Motion.finiteSpec(spring(stiffness = Spring.StiffnessMediumLow, visibilityThreshold = IntOffset.VisibilityThreshold)), + label = "radioIndicatorOffset", + ) + val size by animateIntSizeAsState( + target.size, + Motion.finiteSpec(spring(stiffness = Spring.StiffnessMediumLow, visibilityThreshold = IntSize.VisibilityThreshold)), + label = "radioIndicatorSize", + ) + Box( + Modifier + .layout { measurable, _ -> + val placeable = measurable.measure(Constraints.fixed(size.width, size.height)) + layout(0, 0) { placeable.place(offset.x, offset.y) } + } + .clip(itemShape) + .background(OneConfigTheme.accent), + ) + } + val items: @Composable (Modifier) -> Unit = { itemModifier -> + options.forEachIndexed { index, option -> + RadioItem( + text = label(option), + styled = styledLabel?.invoke(option), + selected = index == selectedIndex, + enabled = enabled, + shape = itemShape, + vertical = vertical, + slot = slots[index], + modifier = itemModifier + .focusRequester(requesters[index]) + .onFocusChanged { if (it.isFocused) focusedIndex = index else if (focusedIndex == index) focusedIndex = -1 }, + onClick = { onSelect(option) }, + ) + } + } + if (vertical) { + Column(Modifier.width(IntrinsicSize.Max)) { items(Modifier.fillMaxWidth()) } + } else { + Row(verticalAlignment = Alignment.CenterVertically) { items(Modifier) } + } + } + } +} + +@Composable +private fun RadioItem( + text: String, + styled: AnnotatedString?, + selected: Boolean, + enabled: Boolean, + shape: Shape, + vertical: Boolean, + slot: RadioSlot, + modifier: Modifier, + onClick: () -> Unit, +) { + val theme = OneConfigTheme.current + val style = theme.typography.small + val source = remember { MutableInteractionSource() } + val hovered by source.collectIsHoveredAsState() + val color by animateColorAsState( + if (selected || (hovered && enabled)) theme.textColor else theme.textColorSecondary, + Motion.spec(tween(FADE_MILLIS)), + label = "radioItemText", + ) + Box( + modifier = modifier + .onPlaced { coordinates -> + val position = coordinates.positionInParent() + slot.offset = IntOffset(position.x.roundToInt(), position.y.roundToInt()) + slot.size = coordinates.size + } + .oneConfigClickable( + text, + onClick, + role = A11yRole.RadioButton, + enabled = enabled, + selected = selected, + shape = shape, + indication = false, + interactionSource = source, + ) + .heightIn(min = ITEM_MIN_HEIGHT) + .padding(horizontal = theme.spacing.m, vertical = theme.spacing.xs), + contentAlignment = if (vertical) Alignment.CenterStart else Alignment.Center, + ) { + val textStyle = OneConfigTheme.typography.body.copy(fontSize = style.fontSize, fontWeight = style.fontWeight ?: FontWeight.Normal) + val align = if (vertical) TextAlign.Start else TextAlign.Center + if (styled != null) Text(styled, Modifier, color, textStyle, 1, TextOverflow.Ellipsis, textAlign = align) + else Text(text, Modifier, color, textStyle, 1, TextOverflow.Ellipsis, textAlign = align) + } +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Reorder.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Reorder.kt index c42cacb59..41937aae3 100644 --- a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Reorder.kt +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Reorder.kt @@ -201,27 +201,7 @@ public fun Modifier.reorderItem(state: ReorderState, key: Any, index: Int, enabl state.place(key, it) } .then( - if (!enabled) { - Modifier - } else { - Modifier.pointerInput(state, key) { - awaitEachGesture { - val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Main) - if (down.isConsumed) return@awaitEachGesture - val crossed = awaitSlop(down.id, down.position) ?: return@awaitEachGesture - val coordinates = holder.coordinates?.takeIf { it.isAttached } ?: return@awaitEachGesture - if (!state.start(key, state.main(coordinates.localToRoot(down.position)))) return@awaitEachGesture - try { - state.moveTo(state.main(coordinates.localToRoot(crossed))) - follow(down.id) { at -> - holder.coordinates?.takeIf { it.isAttached }?.let { state.moveTo(state.main(it.localToRoot(at))) } - } - } finally { - state.end() - } - } - } - }, + if (!enabled) Modifier else Modifier.reorderDrag(state, key, holder), ) .zIndex(if (dragging) 1f else 0f) .graphicsLayer { @@ -230,6 +210,32 @@ public fun Modifier.reorderItem(state: ReorderState, key: Any, index: Int, enabl } } +@ExperimentalOneConfigUiApi +public fun Modifier.reorderHandle(state: ReorderState, key: Any, enabled: Boolean = true): Modifier = if (!enabled) this else composed { + val holder = remember { ReorderCoordinates() } + this + .onGloballyPositioned { holder.coordinates = it } + .reorderDrag(state, key, holder) +} + +private fun Modifier.reorderDrag(state: ReorderState, key: Any, holder: ReorderCoordinates): Modifier = pointerInput(state, key) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Main) + if (down.isConsumed) return@awaitEachGesture + val crossed = awaitSlop(down.id, down.position) ?: return@awaitEachGesture + val coordinates = holder.coordinates?.takeIf { it.isAttached } ?: return@awaitEachGesture + if (!state.start(key, state.main(coordinates.localToRoot(down.position)))) return@awaitEachGesture + try { + state.moveTo(state.main(coordinates.localToRoot(crossed))) + follow(down.id) { at -> + holder.coordinates?.takeIf { it.isAttached }?.let { state.moveTo(state.main(it.localToRoot(at))) } + } + } finally { + state.end() + } + } +} + private class ReorderCoordinates { var coordinates: LayoutCoordinates? = null } diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Scrolling.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Scrolling.kt new file mode 100644 index 000000000..3966deb31 --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Scrolling.kt @@ -0,0 +1,202 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.foundation.HorizontalScrollbar +import androidx.compose.foundation.LocalScrollbarStyle +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.ScrollbarStyle +import androidx.compose.foundation.VerticalScrollbar +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.ScrollableState +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.rememberScrollbarAdapter +import androidx.compose.foundation.v2.ScrollbarAdapter +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.internal.glassBridge +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme + +private const val SCROLLBAR_IDLE_ALPHA = 0.40f +private const val SCROLLBAR_HOVER_ALPHA = 0.70f + +private val MaskOpaque = Color.Black + +private val ScrollContainerFade = 24.dp + +@ExperimentalOneConfigUiApi +public fun Modifier.smoothWheelScroll(state: ScrollableState, orientation: Orientation = Orientation.Vertical): Modifier = + glassBridge()?.wheelScroll(this, state, orientation) ?: this + +@ExperimentalOneConfigUiApi +@Composable +public fun ScrollContainer( + state: ScrollState = rememberScrollState(), + modifier: Modifier = Modifier, + orientation: Orientation = Orientation.Vertical, + scrollbar: Boolean = true, + fadingEdges: Boolean = false, + content: @Composable () -> Unit, +) { + val vertical = orientation == Orientation.Vertical + val spacing = OneConfigTheme.current.spacing + val style = themedScrollbarStyle() + val gutter = if (scrollbar) style.thickness + spacing.xs * 2 else 0.dp + Box(modifier.smoothWheelScroll(state, orientation), propagateMinConstraints = true) { + val viewport = Modifier + .then(if (fadingEdges) Modifier.fadingEdgesMask(state, ScrollContainerFade, orientation) else Modifier) + .then(if (vertical) Modifier.verticalScroll(state) else Modifier.horizontalScroll(state)) + if (vertical) { + Column(viewport.padding(end = gutter)) { content() } + } else { + Row(viewport.padding(bottom = gutter)) { content() } + } + if (scrollbar) { + Box(Modifier.matchParentSize()) { + ThemedScrollbar( + rememberScrollbarAdapter(state), + horizontal = !vertical, + reverse = false, + style = style, + modifier = if (vertical) { + Modifier.align(Alignment.CenterEnd).fillMaxHeight().padding(end = spacing.xs) + } else { + Modifier.align(Alignment.BottomCenter).fillMaxWidth().padding(bottom = spacing.xs) + }, + ) + } + } + } +} + +@ExperimentalOneConfigUiApi +@Composable +public fun Scrollbar(state: LazyListState, modifier: Modifier = Modifier) { + val horizontal by remember(state) { derivedStateOf { state.layoutInfo.orientation == Orientation.Horizontal } } + val reverse by remember(state) { derivedStateOf { state.layoutInfo.reverseLayout } } + ThemedScrollbar(rememberScrollbarAdapter(state), horizontal, reverse, themedScrollbarStyle(), modifier) +} + +@ExperimentalOneConfigUiApi +@Composable +public fun Scrollbar(state: LazyGridState, modifier: Modifier = Modifier) { + val horizontal by remember(state) { derivedStateOf { state.layoutInfo.orientation == Orientation.Horizontal } } + val reverse by remember(state) { derivedStateOf { state.layoutInfo.reverseLayout } } + ThemedScrollbar(rememberScrollbarAdapter(state), horizontal, reverse, themedScrollbarStyle(), modifier) +} + +@ExperimentalOneConfigUiApi +public fun Modifier.fadingEdgesMask(state: ScrollableState, length: Dp = 24.dp): Modifier = + fadingEdgesMask(state, length, null) + +internal fun Modifier.fadingEdgesMask(state: ScrollableState, length: Dp, orientation: Orientation?): Modifier = + graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + val vertical = (orientation ?: orientationOf(state)) == Orientation.Vertical + val extent = if (vertical) size.height else size.width + val edge = length.toPx().coerceAtMost(extent / 2f) + if (edge <= 0f) return@drawWithContent + val start = (backwardDistance(state) / edge).coerceIn(0f, 1f) + val end = (forwardDistance(state, vertical) / edge).coerceIn(0f, 1f) + if (start == 0f && end == 0f) return@drawWithContent + val stops = arrayOf( + 0f to MaskOpaque.copy(alpha = 1f - start), + edge / extent to MaskOpaque, + 1f - edge / extent to MaskOpaque, + 1f to MaskOpaque.copy(alpha = 1f - end), + ) + val brush = if (vertical) Brush.verticalGradient(*stops) else Brush.horizontalGradient(*stops) + drawRect(brush, blendMode = BlendMode.DstIn) + } + +@Composable +internal fun themedScrollbarStyle(): ScrollbarStyle { + val base = LocalScrollbarStyle.current + val color = OneConfigTheme.current.textColorSecondary + return remember(base, color) { + ScrollbarStyle( + minimalHeight = base.minimalHeight, + thickness = base.thickness, + shape = base.shape, + hoverDurationMillis = base.hoverDurationMillis, + unhoverColor = color.copy(alpha = SCROLLBAR_IDLE_ALPHA), + hoverColor = color.copy(alpha = SCROLLBAR_HOVER_ALPHA), + ) + } +} + +@Composable +private fun ThemedScrollbar(adapter: ScrollbarAdapter, horizontal: Boolean, reverse: Boolean, style: ScrollbarStyle, modifier: Modifier) { + if (horizontal) { + HorizontalScrollbar(adapter, modifier, reverseLayout = reverse, style = style) + } else { + VerticalScrollbar(adapter, modifier, reverseLayout = reverse, style = style) + } +} + +private fun orientationOf(state: ScrollableState): Orientation = when (state) { + is LazyListState -> state.layoutInfo.orientation + is LazyGridState -> state.layoutInfo.orientation + else -> Orientation.Vertical +} + +private fun backwardDistance(state: ScrollableState): Float { + if (!state.canScrollBackward) return 0f + return when (state) { + is ScrollState -> state.value.toFloat() + is LazyListState -> if (state.firstVisibleItemIndex > 0) Float.POSITIVE_INFINITY else state.firstVisibleItemScrollOffset.toFloat() + is LazyGridState -> if (state.firstVisibleItemIndex > 0) Float.POSITIVE_INFINITY else state.firstVisibleItemScrollOffset.toFloat() + else -> Float.POSITIVE_INFINITY + } +} + +private fun forwardDistance(state: ScrollableState, vertical: Boolean): Float { + if (!state.canScrollForward) return 0f + return when (state) { + is ScrollState -> (state.maxValue - state.value).toFloat() + is LazyListState -> { + val info = state.layoutInfo + val last = info.visibleItemsInfo.lastOrNull() + if (last == null || last.index < info.totalItemsCount - 1) { + Float.POSITIVE_INFINITY + } else { + (last.offset + last.size + info.afterContentPadding - info.viewportEndOffset).toFloat() + } + } + is LazyGridState -> { + val info = state.layoutInfo + val items = info.visibleItemsInfo + if (items.isEmpty() || items.maxOf { it.index } < info.totalItemsCount - 1) { + Float.POSITIVE_INFINITY + } else { + val end = items.maxOf { if (vertical) it.offset.y + it.size.height else it.offset.x + it.size.width } + (end + info.afterContentPadding - info.viewportEndOffset).toFloat() + } + } + else -> Float.POSITIVE_INFINITY + } +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/SearchField.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/SearchField.kt new file mode 100644 index 000000000..1ee773659 --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/SearchField.kt @@ -0,0 +1,162 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import org.polyfrost.oneconfig.api.platform.v1.Platform +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme + +private val CLEAR_BUTTON_SIZE = 22.dp + +@ExperimentalOneConfigUiApi +@Composable +public fun SearchField( + query: String, + onQueryChange: (String) -> Unit, + modifier: Modifier = Modifier, + placeholder: String? = null, + onSubmit: (() -> Unit)? = null, + clearable: Boolean = true, + size: ControlSize = ControlSize.Large, + enabled: Boolean = true, + focusRequester: FocusRequester? = null, + interactionSource: MutableInteractionSource? = null, + onDismiss: (() -> Unit)? = null, + hidePlaceholderWhenFocused: Boolean = false, +) { + var local by remember { mutableStateOf(TextFieldValue(query, TextRange(query.length))) } + val shown = if (local.text == query) { + local + } else { + TextFieldValue(query, TextRange(local.selection.start.coerceAtMost(query.length), local.selection.end.coerceAtMost(query.length))) + } + SearchFieldCore( + shown, + { next -> + local = next + if (next.text != query) onQueryChange(next.text) + }, + { onQueryChange("") }, + modifier, + placeholder, + onSubmit, + clearable, + size, + enabled, + focusRequester, + interactionSource, + onDismiss, + hidePlaceholderWhenFocused, + ) +} + +@ExperimentalOneConfigUiApi +@Composable +public fun SearchField( + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + modifier: Modifier = Modifier, + placeholder: String? = null, + onSubmit: (() -> Unit)? = null, + clearable: Boolean = true, + size: ControlSize = ControlSize.Large, + enabled: Boolean = true, + focusRequester: FocusRequester? = null, + interactionSource: MutableInteractionSource? = null, + onDismiss: (() -> Unit)? = null, + hidePlaceholderWhenFocused: Boolean = false, +) { + SearchFieldCore( + value, + onValueChange, + { onValueChange(TextFieldValue()) }, + modifier, + placeholder, + onSubmit, + clearable, + size, + enabled, + focusRequester, + interactionSource, + onDismiss, + hidePlaceholderWhenFocused, + ) +} + +@Composable +private fun SearchFieldCore( + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + onClear: () -> Unit, + modifier: Modifier, + placeholder: String?, + onSubmit: (() -> Unit)?, + clearable: Boolean, + size: ControlSize, + enabled: Boolean, + focusRequester: FocusRequester?, + interactionSource: MutableInteractionSource?, + onDismiss: (() -> Unit)?, + hidePlaceholderWhenFocused: Boolean, +) { + val shownPlaceholder = placeholder ?: remember { searchString("oneconfig.header.search", "Search...") } + val clearLabel = remember { searchString("oneconfig.ui.search.clear", "Clear search") } + val trailing: (@Composable () -> Unit)? = if (clearable && (value.text.isNotEmpty() || onDismiss != null)) { + { + IconButton( + "close", + clearLabel, + { + onClear() + onDismiss?.invoke() + }, + Modifier.size(CLEAR_BUTTON_SIZE), + enabled = enabled, + size = ControlSize.Compact, + tooltip = null, + ) + } + } else { + null + } + FieldCore( + value, + onValueChange, + modifier, + shownPlaceholder, + enabled, + false, + true, + size, + shownPlaceholder, + "search", + null, + trailing, + false, + Int.MAX_VALUE, + true, + TextUnit.Unspecified, + focusRequester, + onSubmit, + interactionSource, + fill = OneConfigTheme.current.chipBackground, + glass = true, + hidePlaceholderWhenFocused = hidePlaceholderWhenFocused, + ) +} + +private fun searchString(key: String, fallback: String): String = + runCatching { Platform.i18n().translateString(key) }.getOrNull()?.takeIf { it != key } ?: fallback diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Select.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Select.kt new file mode 100644 index 000000000..53054b434 --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Select.kt @@ -0,0 +1,238 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.a11y.A11yRole +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme + +@ExperimentalOneConfigUiApi +public object SelectDefaults { + public val minWidth: Dp = 160.dp + + public val horizontalPadding: Dp = 12.dp + + public const val MAX_NAMED: Int = 2 +} + +private const val FADE_MILLIS = 150 +private const val EXPANDED_TINT_ALPHA = 0.2f + +@ExperimentalOneConfigUiApi +@Composable +public fun Select( + selected: T, + options: List, + onSelect: (T) -> Unit, + modifier: Modifier = Modifier, + label: (T) -> String = { it.toString() }, + enabled: Boolean = true, + placeholder: String? = null, + size: ControlSize = ControlSize.Regular, + interactionSource: MutableInteractionSource? = null, + styledLabel: ((T) -> AnnotatedString)? = null, +) { + var expanded by remember { mutableStateOf(false) } + val trigger = rememberPopupTrigger() + val isSelected = options.contains(selected) + val current = if (isSelected) label(selected) else null + SelectField( + text = current ?: placeholder ?: kitString("oneconfig.ui.select.placeholder", "Select"), + styled = if (isSelected && styledLabel != null) styledLabel(selected) else null, + muted = current == null, + expanded = expanded, + onExpandedChange = { expanded = it }, + modifier = modifier, + enabled = enabled, + size = size, + trigger = trigger, + interactionSource = interactionSource, + ) { + if (styledLabel == null) { + val entries = options.map { option -> + MenuAction(label(option), onClick = { onSelect(option) }, checked = option == selected) + } + TriggeredDropdownMenu(expanded, { expanded = false }, entries, trigger, matchAnchorWidth = true) + } else { + TriggeredDropdownMenu(expanded, { expanded = false }, trigger, matchAnchorWidth = true) { + options.forEach { option -> + val on = option == selected + MenuItemRow( + label = label(option), + onClick = { + expanded = false + onSelect(option) + }, + size = MenuItemSize.Compact, + checked = on, + reserveCheck = true, + styledLabel = styledLabel(option), + ) + } + } + } + } +} + +@ExperimentalOneConfigUiApi +@Composable +public fun MultiSelect( + selected: Set, + options: List, + onChange: (Set) -> Unit, + modifier: Modifier = Modifier, + label: (T) -> String = { it.toString() }, + enabled: Boolean = true, + interactionSource: MutableInteractionSource? = null, + styledLabel: ((T) -> AnnotatedString)? = null, +) { + var expanded by remember { mutableStateOf(false) } + val trigger = rememberPopupTrigger() + val chosen = options.filter { it in selected } + val summary = when { + chosen.isEmpty() -> null + chosen.size <= SelectDefaults.MAX_NAMED -> chosen.joinToString(", ") { label(it) } + else -> kitString("oneconfig.ui.select.count", "${chosen.size} selected", chosen.size) + } + val styledSummary = if (styledLabel != null && chosen.isNotEmpty() && chosen.size <= SelectDefaults.MAX_NAMED) { + buildAnnotatedString { + chosen.forEachIndexed { index, option -> + if (index > 0) append(", ") + append(styledLabel(option)) + } + } + } else { + null + } + SelectField( + text = summary ?: kitString("oneconfig.ui.select.none", "None"), + styled = styledSummary, + muted = summary == null, + expanded = expanded, + onExpandedChange = { expanded = it }, + modifier = modifier, + enabled = enabled, + size = ControlSize.Regular, + trigger = trigger, + interactionSource = interactionSource, + ) { + TriggeredDropdownMenu(expanded, { expanded = false }, trigger, matchAnchorWidth = true) { + options.forEach { option -> + val on = option in selected + MenuItemRow( + label = label(option), + onClick = { onChange(if (on) selected - option else selected + option) }, + selected = on, + styledLabel = styledLabel?.invoke(option), + ) + } + } + } +} + +@Composable +private fun SelectField( + text: String, + styled: AnnotatedString?, + muted: Boolean, + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + modifier: Modifier, + enabled: Boolean, + size: ControlSize, + trigger: PopupTrigger, + interactionSource: MutableInteractionSource?, + menu: @Composable () -> Unit, +) { + LaunchedEffect(enabled) { if (!enabled) onExpandedChange(false) } + val theme = OneConfigTheme.current + val accent = OneConfigTheme.accent + val shape = theme.shapes.field + val source = interactionSource ?: remember { MutableInteractionSource() } + val hovered by source.collectIsHoveredAsState() + val fill by animateColorAsState( + if (expanded) accent.copy(alpha = EXPANDED_TINT_ALPHA).compositeOver(theme.componentBackground) else theme.componentBackground, + Motion.spec(tween(FADE_MILLIS)), + ) + val border by animateColorAsState(if (expanded) accent else theme.borderColor, Motion.spec(tween(FADE_MILLIS))) + val content by animateColorAsState( + when { + muted -> theme.mutedText + hovered || expanded -> theme.textColor + else -> theme.textColorSecondary + }, + Motion.spec(tween(FADE_MILLIS)), + ) + val chevron by animateFloatAsState(if (expanded) 0f else 180f, Motion.spec(tween(FADE_MILLIS))) + Box(modifier, propagateMinConstraints = true) { + Row( + modifier = Modifier + .popupTrigger(trigger) + .onKeyEvent { event -> + val open = enabled && !expanded && event.type == KeyEventType.KeyDown && event.key == Key.DirectionDown + if (open) onExpandedChange(true) + open + } + .defaultMinSize(minWidth = SelectDefaults.minWidth) + .graphicsLayer { alpha = if (enabled) 1f else theme.disabledAlpha } + .heightIn(min = size.height) + .glassFrame(shape, { fill }, { border }, hover = enabled) + .oneConfigClickable( + text, + { onExpandedChange(!expanded) }, + role = A11yRole.Option, + enabled = enabled, + shape = shape, + indication = !glassAvailable, + interactionSource = source, + ) + .padding(horizontal = SelectDefaults.horizontalPadding), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + val textModifier = Modifier.weight(1f, fill = false).padding(end = ButtonDefaults.contentSpacing) + val textStyle = theme.typography.body.copy(fontSize = ButtonDefaults.fontSize(size)) + if (styled != null) { + Text(styled, textModifier, Color.Unspecified, textStyle, 1, TextOverflow.Ellipsis, colorProducer = { content }) + } else { + Text(text, textModifier, Color.Unspecified, textStyle, 1, TextOverflow.Ellipsis, colorProducer = { content }) + } + Icon("up", null, Modifier.size(ButtonDefaults.iconSize(size)).graphicsLayer { rotationZ = chevron }, tintProducer = { content }) + } + menu() + } +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Selection.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Selection.kt new file mode 100644 index 000000000..23895e339 --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Selection.kt @@ -0,0 +1,132 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.inset +import androidx.compose.ui.unit.dp +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.a11y.A11yRole +import org.polyfrost.oneconfig.api.ui.v1.a11y.Announce +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme + +private const val SELECT_MILLIS = 150 +private const val ROW_SELECTED_ALPHA = 0.2f +private const val CARD_SELECTED_FILL_ALPHA = 0.08f +private val CARD_RING_WIDTH = 2.dp + +@ExperimentalOneConfigUiApi +public fun Modifier.selectableRow( + selected: Boolean, + onClick: () -> Unit, + label: String, + shape: Shape? = null, + enabled: Boolean = true, +): Modifier = composed { + val theme = OneConfigTheme.current + val outline = shape ?: theme.shapes.medium + val brush = theme.accentBrush + val amount by animateFloatAsState(if (selected) 1f else 0f, Motion.spec(tween(SELECT_MILLIS)), label = "selectableRow") + alpha(if (enabled) 1f else theme.disabledAlpha) + .drawWithContent { + if (amount > 0f) drawOutline(outline.createOutline(size, layoutDirection, this), brush, amount * ROW_SELECTED_ALPHA) + drawContent() + } + .oneConfigClickable(label, onClick, A11yRole.Option, enabled, selected = selected, shape = outline) +} + +@ExperimentalOneConfigUiApi +public fun Modifier.selectableCard( + selected: Boolean, + onClick: () -> Unit, + label: String, + shape: Shape? = null, + enabled: Boolean = true, +): Modifier = composed { + val theme = OneConfigTheme.current + val outline = shape ?: theme.shapes.large + val brush = theme.accentBrush + val amount by animateFloatAsState(if (selected) 1f else 0f, Motion.spec(tween(SELECT_MILLIS)), label = "selectableCard") + alpha(if (enabled) 1f else theme.disabledAlpha) + .drawWithContent { + if (amount > 0f) drawOutline(outline.createOutline(size, layoutDirection, this), brush, amount * CARD_SELECTED_FILL_ALPHA) + drawContent() + if (amount > 0f) { + val ring = CARD_RING_WIDTH.toPx() + inset(ring / 2f) { + drawOutline(outline.createOutline(size, layoutDirection, this), brush, amount, Stroke(ring)) + } + } + } + .oneConfigClickable(label, onClick, A11yRole.Option, enabled, selected = selected, shape = outline) +} + +@ExperimentalOneConfigUiApi +@Composable +public fun SelectionBar( + count: Int, + onClear: () -> Unit, + modifier: Modifier = Modifier, + actions: @Composable RowScope.() -> Unit, +) { + val theme = OneConfigTheme.current + val spacing = theme.spacing + val shape = theme.shapes.large + val lastCount = remember { IntArray(1) } + if (count > 0) lastCount[0] = count + val label = layoutString("oneconfig.ui.selection.count", "%s selected", lastCount[0]) + if (count > 0) { + EscapeCloses(onClear) + Announce(label) + } + AnimatedVisibility( + visible = count > 0, + modifier = modifier, + enter = Motion.enter(fadeIn(tween(SELECT_MILLIS)) + slideInVertically(tween(SELECT_MILLIS)) { it / 2 }), + exit = Motion.exit(fadeOut(tween(SELECT_MILLIS)) + slideOutVertically(tween(SELECT_MILLIS)) { it / 2 }), + label = "selectionBar", + ) { + Row( + Modifier + .heightIn(min = ControlSize.Large.height + spacing.m * 2) + .clip(shape) + .background(theme.popupBackground, shape) + .border(1.dp, theme.borderColor, shape) + .padding(start = spacing.xl, end = spacing.s, top = spacing.s, bottom = spacing.s), + horizontalArrangement = Arrangement.spacedBy(spacing.m), + verticalAlignment = Alignment.CenterVertically, + ) { + LayoutText(label, theme.typography.label, theme.textColor, Modifier.padding(end = spacing.m), maxLines = 1) + actions() + Divider(Modifier.height(ControlSize.Compact.height), vertical = true) + IconButton("close", layoutString("oneconfig.ui.selection.clear", "Clear selection"), onClear) + } + } +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Slider.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Slider.kt new file mode 100644 index 000000000..f4f618eb0 --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Slider.kt @@ -0,0 +1,259 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.DragInteraction +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.setProgress +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import kotlin.math.abs +import kotlin.math.roundToInt +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.a11y.A11y +import org.polyfrost.oneconfig.api.ui.v1.a11y.A11yRole +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.api.ui.v1.a11y.a11y +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme + +private val THUMB_SIZE = 19.dp +private val TRACK_HEIGHT = 5.dp +private const val TICK_INTERVAL_MILLIS = 70L +private const val KEY_STEPS_WITHOUT_STEP = 100 +private const val PAGE_STEPS = 10 + +@ExperimentalOneConfigUiApi +@Composable +public fun Slider( + value: Float, + onValueChange: (Float) -> Unit, + modifier: Modifier = Modifier, + range: ClosedFloatingPointRange = 0f..1f, + step: Float = 0f, + enabled: Boolean = true, + label: String? = null, + valueText: ((Float) -> String)? = null, + interactionSource: MutableInteractionSource? = null, + onValueChangeFinished: (() -> Unit)? = null, +) { + val theme = OneConfigTheme.current + val typography = theme.typography + val min = range.start + val max = range.endInclusive + val span = max - min + val latestChange by rememberUpdatedState(onValueChange) + val latestFinished by rememberUpdatedState(onValueChangeFinished) + val latestValue by rememberUpdatedState(value) + val source = interactionSource ?: remember { MutableInteractionSource() } + var trackWidthPx by remember { mutableFloatStateOf(0f) } + val fraction = animateFloatAsState( + if (span > 0f) ((value - min) / span).coerceIn(0f, 1f) else 0f, + Motion.spec(spring()), + label = "sliderFraction", + ) + + fun snap(raw: Float): Float { + val clamped = raw.coerceIn(min, max) + if (step <= 0f) return clamped + return (min + ((clamped - min) / step).roundToInt() * step).coerceIn(min, max) + } + + fun describe(v: Float): String = valueText?.invoke(v) ?: formatSliderValue(v) + + val keyStep = if (step > 0f) step else span / KEY_STEPS_WITHOUT_STEP + val spoken = describe(value) + val shape = theme.checkBoxShape + + Column( + modifier = modifier.graphicsLayer { alpha = if (enabled) 1f else theme.disabledAlpha }, + verticalArrangement = Arrangement.spacedBy(theme.spacing.s), + ) { + if (label != null || valueText != null) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text( + label ?: "", + Modifier.weight(1f), + theme.textColor, + OneConfigTheme.typography.body.copy(fontSize = typography.label.fontSize, fontWeight = typography.label.fontWeight ?: FontWeight.Normal), + 1, + TextOverflow.Ellipsis, + textAlign = TextAlign.Start, + ) + if (valueText != null) { + Text( + valueText(value), + Modifier, + theme.textColorSecondary, + OneConfigTheme.typography.body.copy(fontSize = typography.small.fontSize, fontWeight = typography.small.fontWeight ?: FontWeight.Normal), + 1, + TextOverflow.Clip, + textAlign = TextAlign.End, + ) + } + } + } + Box( + modifier = Modifier + .fillMaxWidth() + .height(THUMB_SIZE) + .onSizeChanged { trackWidthPx = it.width.toFloat() } + .semantics { + stateDescription = spoken + progressBarRangeInfo = ProgressBarRangeInfo(value.coerceIn(min, max), min..max, if (step > 0f && span > 0f) (span / step).roundToInt() - 1 else 0) + if (enabled) { + setProgress { target -> + val snapped = snap(target) + if (snapped != latestValue) latestChange(snapped) + latestFinished?.invoke() + true + } + } else { + disabled() + } + } + .a11y(label ?: spoken, A11yRole.Slider, null, null, shape, true) + .onKeyEvent { event -> + if (!enabled || event.type != KeyEventType.KeyDown || span <= 0f) return@onKeyEvent false + val current = latestValue + val next = when (event.key) { + Key.DirectionLeft, Key.DirectionDown -> current - keyStep + Key.DirectionRight, Key.DirectionUp -> current + keyStep + Key.PageDown -> current - keyStep * PAGE_STEPS + Key.PageUp -> current + keyStep * PAGE_STEPS + Key.MoveHome -> min + Key.MoveEnd -> max + else -> return@onKeyEvent false + } + val snapped = snap(next) + if (snapped != current) { + latestChange(snapped) + playUiSound(UiSound.SliderTick) + A11y.announce(describe(snapped)) + } + latestFinished?.invoke() + true + } + .focusable(enabled, source) + .hoverable(source, enabled) + .then(if (enabled) Modifier.pointerHoverIcon(PointerIcon.Hand) else Modifier) + .pointerInput(enabled, min, max, step, source) { + if (!enabled) return@pointerInput + val thumbPx = THUMB_SIZE.toPx() + var lastTickValue = Float.NaN + var lastTickAt = 0L + fun emit(x: Float) { + val usable = (trackWidthPx - thumbPx).coerceAtLeast(1f) + val newValue = snap(min + ((x - thumbPx / 2f) / usable).coerceIn(0f, 1f) * span) + if (newValue != lastTickValue) { + lastTickValue = newValue + val now = System.currentTimeMillis() + if (now - lastTickAt >= TICK_INTERVAL_MILLIS) { + lastTickAt = now + playUiSound(UiSound.SliderTick) + } + } + if (newValue != latestValue) latestChange(newValue) + } + awaitEachGesture { + val down = awaitFirstDown() + val drag = DragInteraction.Start() + source.tryEmit(drag) + try { + emit(down.position.x) + do { + val event = awaitPointerEvent() + event.changes.forEach { change -> + if (change.pressed) { + emit(change.position.x) + change.consume() + } + } + } while (event.changes.any { it.pressed }) + } finally { + source.tryEmit(DragInteraction.Stop(drag)) + } + latestFinished?.invoke() + } + }, + ) { + Box( + Modifier + .fillMaxWidth() + .height(TRACK_HEIGHT) + .align(Alignment.CenterStart) + .clip(shape) + .background(theme.controlTrackColor), + ) + Box( + Modifier + .layout { measurable, constraints -> + val w = (constraints.maxWidth * fraction.value).roundToInt().coerceIn(constraints.minWidth, constraints.maxWidth) + val placeable = measurable.measure(constraints.copy(minWidth = w, maxWidth = w)) + layout(w, placeable.height) { placeable.place(0, 0) } + } + .height(TRACK_HEIGHT) + .align(Alignment.CenterStart) + .clip(shape) + .background(theme.accentBrush), + ) + Box( + Modifier + .align(Alignment.CenterStart) + .offset { IntOffset((fraction.value * (trackWidthPx - THUMB_SIZE.toPx())).roundToInt(), 0) } + .size(THUMB_SIZE) + .background(theme.controlThumbColor, theme.circleShape), + ) + } + } +} + +private const val WHOLE_EPSILON = 0.0001f + +private fun formatSliderValue(value: Float): String { + val whole = value.roundToInt() + if (abs(value - whole) < WHOLE_EPSILON) return whole.toString() + return "%.2f".format(value).trimEnd('0').trimEnd('.', ',') +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/SplitButton.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/SplitButton.kt new file mode 100644 index 000000000..d2a890a0d --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/SplitButton.kt @@ -0,0 +1,114 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme + +private const val FADE_MILLIS = 150 + +@ExperimentalOneConfigUiApi +public object SplitButtonDefaults { + public val chevronPadding: Dp = 8.dp + + public val divider: Dp = 1.dp +} + +@ExperimentalOneConfigUiApi +@Composable +public fun SplitButton( + text: String, + onClick: () -> Unit, + menu: List, + modifier: Modifier = Modifier, + style: ControlStyle = ControlStyle.Primary, + size: ControlSize = ControlSize.Regular, + enabled: Boolean = true, +) { + var expanded by remember { mutableStateOf(false) } + LaunchedEffect(enabled) { if (!enabled) expanded = false } + val trigger = rememberPopupTrigger() + Box(modifier) { + Row(Modifier.height(IntrinsicSize.Min), horizontalArrangement = Arrangement.spacedBy(SplitButtonDefaults.divider)) { + TextButton(text, onClick, Modifier.fillMaxHeight(), style, size, enabled) + SplitChevron(expanded, { expanded = !expanded }, style, size, enabled, Modifier.popupTrigger(trigger)) + } + TriggeredDropdownMenu(expanded, { expanded = false }, menu, trigger, placement = Placement.BelowEnd) + } +} + +@Composable +private fun SplitChevron(expanded: Boolean, onToggle: () -> Unit, style: ControlStyle, size: ControlSize, enabled: Boolean, modifier: Modifier) { + val theme = OneConfigTheme.current + val source = remember { MutableInteractionSource() } + val hovered by source.collectIsHoveredAsState() + val active = (hovered || expanded) && enabled + val fill by animateColorAsState( + when (style) { + ControlStyle.Primary, ControlStyle.Ghost -> Color.Transparent + ControlStyle.DangerFilled -> theme.error.copy(alpha = if (active) ButtonDefaults.FILLED_HOVER_ALPHA else 1f) + ControlStyle.Danger -> theme.error.copy(alpha = if (active) ButtonDefaults.DANGER_HOVER_ALPHA else ButtonDefaults.DANGER_REST_ALPHA) + ControlStyle.Secondary -> theme.componentBackground + }, + Motion.spec(tween(FADE_MILLIS)), + ) + val accentAlpha by animateFloatAsState( + when { + style != ControlStyle.Primary -> 0f + active -> ButtonDefaults.FILLED_HOVER_ALPHA + else -> 1f + }, + Motion.spec(tween(FADE_MILLIS)), + ) + val border by animateColorAsState( + when { + style != ControlStyle.Secondary -> Color.Transparent + active -> theme.textColorSecondary + else -> theme.borderColor + }, + Motion.spec(tween(FADE_MILLIS)), + ) + val chevron by animateFloatAsState(if (expanded) 0f else 180f, Motion.spec(tween(FADE_MILLIS))) + val shape = ButtonDefaults.shape + val ghost = style == ControlStyle.Ghost + val label = kitString("oneconfig.ui.splitbutton.more", "More options") + Row( + modifier = modifier + .graphicsLayer { alpha = if (enabled) 1f else theme.disabledAlpha } + .heightIn(min = size.height) + .fillMaxHeight() + .glassFrame(shape, { fill }, { border }, { accentAlpha }, hover = enabled && !ghost, glass = !ghost) + .oneConfigClickable(label, onToggle, enabled = enabled, selected = expanded, shape = shape, indication = ghost || !glassAvailable, interactionSource = source) + .padding(horizontal = SplitButtonDefaults.chevronPadding), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon("up", null, Modifier.size(ButtonDefaults.iconSize(size)).graphicsLayer { rotationZ = chevron }, ButtonDefaults.contentColor(style)) + } +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/StatusText.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/StatusText.kt new file mode 100644 index 000000000..fd7658f67 --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/StatusText.kt @@ -0,0 +1,87 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import kotlinx.coroutines.delay +import org.polyfrost.oneconfig.api.notifications.v1.NotificationType +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.a11y.Announce +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme + +private const val STATUS_FADE_MILLIS = 150 + +@Stable +@ExperimentalOneConfigUiApi +public class StatusMessage internal constructor() { + public var text: String? by mutableStateOf(null) + private set + + public var type: NotificationType by mutableStateOf(NotificationType.INFO) + private set + + internal var serial: Int by mutableIntStateOf(0) + private set + + public fun show(text: String, type: NotificationType = NotificationType.INFO) { + this.text = text + this.type = type + serial++ + } + + public fun clear() { + text = null + serial++ + } +} + +@ExperimentalOneConfigUiApi +@Composable +public fun rememberStatusMessage(durationMillis: Long = 2000L): StatusMessage { + val state = remember { StatusMessage() } + val serial = state.serial + LaunchedEffect(state, serial, durationMillis) { + if (state.text != null && durationMillis > 0L) { + delay(durationMillis) + state.clear() + } + } + return state +} + +@ExperimentalOneConfigUiApi +@Composable +public fun StatusText(state: StatusMessage, modifier: Modifier = Modifier) { + val theme = OneConfigTheme.current + val current = state.text + val last = remember { arrayOf("" to NotificationType.INFO) } + if (current != null) last[0] = current to state.type + val (text, type) = last[0] + val alpha by animateFloatAsState(if (current != null) 1f else 0f, Motion.spec(tween(STATUS_FADE_MILLIS)), label = "statusText") + Announce(current) + val color = if (type == NotificationType.INFO) theme.textColorSecondary else toastColor(type) + Text( + text, + modifier.graphicsLayer { this.alpha = alpha }.semantics { liveRegion = LiveRegionMode.Polite }, + color, + theme.typography.small, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Surfaces.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Surfaces.kt new file mode 100644 index 000000000..45347ade5 --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Surfaces.kt @@ -0,0 +1,95 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.dp +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.internal.glassBridge +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigUITheme +import org.polyfrost.oneconfig.api.ui.v1.theme.ThemeStyle + +@ExperimentalOneConfigUiApi +public enum class SurfaceKind { + Panel, + + Popup, + + Card, + + Field, +} + +private val PanelElevation = 16.dp +private val PopupElevation = 12.dp + +@ExperimentalOneConfigUiApi +@Composable +public fun Modifier.themedSurface(kind: SurfaceKind = SurfaceKind.Panel, shape: Shape? = null, selected: Boolean = false): Modifier { + val theme = OneConfigTheme.current + val surfaceShape = shape ?: theme.surfaceShape(kind) + val fill = theme.surfaceFill(kind) + val floating = kind == SurfaceKind.Panel || kind == SurfaceKind.Popup + val bridge = glassBridge() + val shadowed = if (floating && theme.shadowEnabled) { + Modifier.shadow( + if (kind == SurfaceKind.Panel) PanelElevation else PopupElevation, + surfaceShape, + clip = false, + ambientColor = theme.shadowColor, + spotColor = theme.shadowColor, + ) + } else { + Modifier + } + val treated = when { + bridge == null -> this.then(shadowed) + theme.style == ThemeStyle.Glass -> bridge.liquidGlass(this.then(shadowed), surfaceShape, floating) + floating && fill.alpha < 1f -> bridge.backdropBlur(this.then(shadowed), surfaceShape) + else -> this.then(shadowed) + } + val surface = treated.clip(surfaceShape).background(fill, surfaceShape) + return if (selected) { + surface.border(1.dp, theme.accentBrush, surfaceShape) + } else { + surface.border(1.dp, theme.borderColor, surfaceShape) + } +} + +@ExperimentalOneConfigUiApi +@Composable +public fun Modifier.liquidGlass(shape: Shape, frosted: Boolean = true): Modifier = + glassBridge()?.liquidGlass(this, shape, frosted) ?: this + +@ExperimentalOneConfigUiApi +@Composable +public fun ProvideThemeVariant(dark: Boolean, keepAccent: Boolean = true, content: @Composable () -> Unit) { + val bridge = glassBridge() + if (bridge == null) { + content() + return + } + bridge.ProvideThemeVariant(dark, keepAccent, content) +} + +private fun OneConfigUITheme.surfaceShape(kind: SurfaceKind): Shape = when (kind) { + SurfaceKind.Panel -> backgroundShape + SurfaceKind.Popup -> popupShape + SurfaceKind.Card -> shapes.large + SurfaceKind.Field -> shapes.field +} + +private fun OneConfigUITheme.surfaceFill(kind: SurfaceKind): Color = when (kind) { + SurfaceKind.Panel -> pageBackground + SurfaceKind.Popup -> popupBackground + SurfaceKind.Card -> modCardBackground + SurfaceKind.Field -> componentBackground +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Switch.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Switch.kt new file mode 100644 index 000000000..84ded1d25 --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Switch.kt @@ -0,0 +1,193 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.layout +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import kotlin.math.roundToInt +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.a11y.A11yRole +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme + +private val TRACK_WIDTH = 42.dp +private val TRACK_HEIGHT = 21.dp +private val THUMB_INSET = 3.dp +private const val TRACK_HOVER_ALPHA = 0.15f +private const val FADE_MILLIS = 150 + +@ExperimentalOneConfigUiApi +@Composable +public fun Switch( + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + interactionSource: MutableInteractionSource? = null, +) { + val theme = OneConfigTheme.current + val source = interactionSource ?: remember { MutableInteractionSource() } + val hovered by source.collectIsHoveredAsState() + val stateLabel = if (checked) formOnLabel() else formOffLabel() + Row( + modifier = modifier.graphicsLayer { alpha = if (enabled) 1f else theme.disabledAlpha }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(theme.spacing.m), + ) { + SwitchStateLabel(stateLabel) + SwitchTrack( + checked, + hovered && enabled, + Modifier.oneConfigClickable( + stateLabel, + { onCheckedChange(!checked) }, + role = A11yRole.Switch, + enabled = enabled, + checked = checked, + shape = theme.circleShape, + indication = false, + interactionSource = source, + ), + ) + } +} + +@ExperimentalOneConfigUiApi +@Composable +public fun SwitchRow( + title: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + description: String? = null, + enabled: Boolean = true, + interactionSource: MutableInteractionSource? = null, +) { + val theme = OneConfigTheme.current + val typography = theme.typography + val spacing = theme.spacing + val source = interactionSource ?: remember { MutableInteractionSource() } + val hovered by source.collectIsHoveredAsState() + val shape = theme.buttonShape + Row( + modifier = modifier + .graphicsLayer { alpha = if (enabled) 1f else theme.disabledAlpha } + .heightIn(min = ControlSize.Regular.height) + .clip(shape) + .oneConfigClickable( + title, + { onCheckedChange(!checked) }, + role = A11yRole.Switch, + enabled = enabled, + checked = checked, + shape = shape, + interactionSource = source, + ) + .padding(horizontal = spacing.m, vertical = spacing.s), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spacing.l), + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(spacing.xxs)) { + Text( + title, + Modifier, + theme.textColor, + OneConfigTheme.typography.body.copy(fontSize = typography.body.fontSize, fontWeight = typography.body.fontWeight ?: FontWeight.Normal), + Int.MAX_VALUE, + TextOverflow.Clip, + textAlign = TextAlign.Start, + ) + if (description != null) { + Text( + description, + Modifier, + theme.textColorSecondary, + OneConfigTheme.typography.body.copy(fontSize = typography.small.fontSize, fontWeight = typography.small.fontWeight ?: FontWeight.Normal), + Int.MAX_VALUE, + TextOverflow.Clip, + textAlign = TextAlign.Start, + ) + } + } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(spacing.m)) { + SwitchStateLabel(if (checked) formOnLabel() else formOffLabel()) + SwitchTrack(checked, hovered && enabled, Modifier) + } + } +} + +@Composable +private fun SwitchStateLabel(text: String) { + val theme = OneConfigTheme.current + val style = theme.typography.small + Text( + text, + Modifier, + theme.textColorSecondary, + OneConfigTheme.typography.body.copy(fontSize = style.fontSize, fontWeight = style.fontWeight ?: FontWeight.Normal), + 1, + TextOverflow.Clip, + textAlign = TextAlign.Start, + ) +} + +@Composable +private fun SwitchTrack(checked: Boolean, hovered: Boolean, modifier: Modifier) { + val theme = OneConfigTheme.current + val shape = theme.circleShape + val accentAlpha = animateFloatAsState(if (checked) 1f else 0f, Motion.spec(tween(FADE_MILLIS)), label = "switchAccent") + val thumbProgress = animateFloatAsState(if (checked) 1f else 0f, Motion.spec(spring()), label = "switchThumb") + val border = animateColorAsState( + if (hovered) theme.textColor.copy(alpha = TRACK_HOVER_ALPHA) else Color.Transparent, + Motion.spec(tween(FADE_MILLIS)), + label = "switchBorder", + ) + Box( + modifier = modifier + .size(TRACK_WIDTH, TRACK_HEIGHT) + .clip(shape) + .background(theme.controlTrackColor) + .formFrame(shape, theme.accentBrush, accentAlpha = { accentAlpha.value }, border = { border.value }) + .padding(THUMB_INSET), + ) { + Box( + modifier = Modifier + .layout { measurable, constraints -> + val thumb = measurable.measure(constraints) + layout(constraints.maxWidth, thumb.height) { + thumb.place(((constraints.maxWidth - thumb.width) * thumbProgress.value).roundToInt(), 0) + } + } + .fillMaxHeight() + .aspectRatio(1f) + .background(theme.controlThumbColor, shape), + ) + } +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Tabs.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Tabs.kt new file mode 100644 index 000000000..f731e2052 --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Tabs.kt @@ -0,0 +1,314 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.a11y.A11yRole +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.api.ui.v1.internal.componentsBridge +import org.polyfrost.oneconfig.api.ui.v1.page.PageText +import org.polyfrost.oneconfig.api.ui.v1.page.resolve +import org.polyfrost.oneconfig.api.ui.v1.page.translate +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme + +private const val TAB_FADE_MILLIS = 150 +private const val SEGMENT_SELECTED_ALPHA = 0.1f +private val SEGMENT_INSET = 3.dp +private val UNDERLINE_WIDTH = 2.dp + +@ExperimentalOneConfigUiApi +public enum class TabStyle { + Pill, + + Underline, + + Segmented, +} + +@ExperimentalOneConfigUiApi +public class TabItem( + public val value: T, + label: PageText, + public val icon: String? = null, + public val enabled: Boolean = true, + public val badge: String? = null, + public val iconPainter: Painter? = null, + public val size: ControlSize? = null, + public val interactionSource: MutableInteractionSource? = null, + public val tooltip: String? = null, +) { + public constructor( + value: T, + label: String, + icon: String? = null, + enabled: Boolean = true, + badge: String? = null, + iconPainter: Painter? = null, + size: ControlSize? = null, + interactionSource: MutableInteractionSource? = null, + tooltip: String? = null, + ) : this(value, PageText.literal(label), icon, enabled, badge, iconPainter, size, interactionSource, tooltip) + + @Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) + public constructor(value: T, label: String) : this(value, PageText.literal(label), null, true, null, null, null, null, null) + + public val labelText: PageText = label + + public val label: String get() = labelText.translate() +} + +@ExperimentalOneConfigUiApi +@Composable +public fun TabRow( + tabs: List>, + selected: T, + onSelect: (T) -> Unit, + modifier: Modifier = Modifier, + style: TabStyle = TabStyle.Pill, + size: ControlSize = ControlSize.Regular, + fill: Boolean = true, + enabled: Boolean = true, + interactionSource: MutableInteractionSource? = null, + tooltipPlacement: TooltipPlacement = TooltipDefaults.placement, +) { + val rowModifier = modifier.groupInteractions(interactionSource, enabled).then(if (fill) Modifier.fillMaxWidth() else Modifier) + when (style) { + TabStyle.Pill -> Row(rowModifier, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + tabs.forEach { tab -> + TabHost(tab, fill, false, tooltipPlacement) { host -> + PillTab(tab, tab.value == selected, enabled && tab.enabled, tab.size ?: size, host) { onSelect(tab.value) } + } + } + } + TabStyle.Underline -> { + val hairline = OneConfigTheme.current.borderColor + Row( + rowModifier.drawBehind { + val y = this.size.height - 0.5.dp.toPx() + drawLine(hairline, Offset(0f, y), Offset(this.size.width, y), 1.dp.toPx()) + }, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + tabs.forEach { tab -> + TabHost(tab, fill, false, tooltipPlacement) { host -> + UnderlineTab(tab, tab.value == selected, enabled && tab.enabled, tab.size ?: size, host) { onSelect(tab.value) } + } + } + } + } + TabStyle.Segmented -> { + val theme = OneConfigTheme.current + val track = theme.componentBackground + val border = theme.borderColor + Row( + rowModifier + .height(size.height) + .glassFrame(theme.buttonShape, { track }, { border }, hover = false) + .padding(SEGMENT_INSET), + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + tabs.forEach { tab -> + TabHost(tab, fill, true, tooltipPlacement) { host -> + SegmentTab(tab, tab.value == selected, enabled && tab.enabled, tab.size ?: size, host) { onSelect(tab.value) } + } + } + } + } + } +} + +@ExperimentalOneConfigUiApi +@Composable +public fun SegmentedChips( + options: List, + selected: T, + onSelect: (T) -> Unit, + modifier: Modifier = Modifier, + label: (T) -> String = { it.toString() }, + enabled: Boolean = true, + size: ControlSize = ChipDefaults.size, +) { + Row(modifier, horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + options.forEach { option -> + Chip(label(option), option == selected, { onSelect(option) }, enabled = enabled, size = size, role = A11yRole.Tab) + } + } +} + +private fun RowScope.tabWeight(fill: Boolean): Modifier = if (fill) Modifier.weight(1f) else Modifier + +@Composable +private fun RowScope.TabHost(tab: TabItem<*>, fill: Boolean, tall: Boolean, placement: TooltipPlacement, content: @Composable (Modifier) -> Unit) { + val tooltip = tab.tooltip + if (tooltip.isNullOrEmpty()) { + content(tabWeight(fill)) + return + } + val outer = tabWeight(fill).then(if (tall) Modifier.fillMaxHeight() else Modifier) + TooltipBox(tooltip, outer, placement) { + content(if (fill) Modifier.fillMaxWidth() else Modifier) + } +} + +@Composable +private fun tabLabel(tab: TabItem<*>): String { + val label = tab.labelText.resolve().ifEmpty { tab.tooltip.orEmpty() } + return if (tab.badge.isNullOrEmpty()) label else "$label, ${tab.badge}" +} + +@Composable +private fun PillTab(tab: TabItem<*>, selected: Boolean, enabled: Boolean, size: ControlSize, modifier: Modifier, onClick: () -> Unit) { + val theme = OneConfigTheme.current + val source = tab.interactionSource ?: remember { MutableInteractionSource() } + val hovered by source.collectIsHoveredAsState() + val active = hovered && enabled + val fill by animateColorAsState(if (selected) Color.Transparent else theme.componentBackground, Motion.spec(tween(TAB_FADE_MILLIS))) + val accentAlpha by animateFloatAsState(if (selected) 1f else 0f, Motion.spec(tween(TAB_FADE_MILLIS))) + val border by animateColorAsState( + when { + selected -> Color.Transparent + active -> theme.textColorSecondary + else -> theme.borderColor + }, + Motion.spec(tween(TAB_FADE_MILLIS)), + ) + val shape = theme.buttonShape + Box( + modifier = modifier + .graphicsLayer { alpha = if (enabled) 1f else theme.disabledAlpha } + .heightIn(min = size.height) + .glassFrame(shape, { fill }, { border }, { accentAlpha }, hover = enabled) + .oneConfigClickable(tabLabel(tab), onClick, role = A11yRole.Tab, enabled = enabled, selected = selected, shape = shape, indication = !glassAvailable, interactionSource = source) + .padding(horizontal = 14.dp), + contentAlignment = Alignment.Center, + ) { + TabContent(tab, if (selected) theme.accentTextColor else theme.textColor, size) + } +} + +@Composable +private fun UnderlineTab(tab: TabItem<*>, selected: Boolean, enabled: Boolean, size: ControlSize, modifier: Modifier, onClick: () -> Unit) { + val theme = OneConfigTheme.current + val accent = OneConfigTheme.accent + val source = tab.interactionSource ?: remember { MutableInteractionSource() } + val hovered by source.collectIsHoveredAsState() + val content by animateColorAsState( + if (selected || hovered && enabled) theme.textColor else theme.textColorSecondary, + Motion.spec(tween(TAB_FADE_MILLIS)), + ) + val indicator by animateFloatAsState(if (selected) 1f else 0f, Motion.spec(tween(TAB_FADE_MILLIS))) + Box( + modifier = modifier + .graphicsLayer { alpha = if (enabled) 1f else theme.disabledAlpha } + .height(size.height) + .oneConfigClickable(tabLabel(tab), onClick, role = A11yRole.Tab, enabled = enabled, selected = selected, shape = theme.shapes.small, indication = false, interactionSource = source) + .drawBehind { + if (indicator <= 0f) return@drawBehind + val stroke = UNDERLINE_WIDTH.toPx() + val y = this.size.height - stroke / 2f + drawLine(accent.copy(alpha = accent.alpha * indicator), Offset(0f, y), Offset(this.size.width, y), stroke) + } + .padding(horizontal = 12.dp), + contentAlignment = Alignment.Center, + ) { + TabContent(tab, content, size) + } +} + +@Composable +private fun SegmentTab(tab: TabItem<*>, selected: Boolean, enabled: Boolean, size: ControlSize, modifier: Modifier, onClick: () -> Unit) { + val theme = OneConfigTheme.current + val source = tab.interactionSource ?: remember { MutableInteractionSource() } + val hovered by source.collectIsHoveredAsState() + val fill by animateColorAsState( + if (selected) theme.textColor.copy(alpha = SEGMENT_SELECTED_ALPHA) else Color.Transparent, + Motion.spec(tween(TAB_FADE_MILLIS)), + ) + val content by animateColorAsState( + if (selected || hovered && enabled) theme.textColor else theme.textColorSecondary, + Motion.spec(tween(TAB_FADE_MILLIS)), + ) + val shape = componentsBridge().concentric(theme.buttonShape, SEGMENT_INSET, 0.dp) + Box( + modifier = modifier + .fillMaxHeight() + .graphicsLayer { alpha = if (enabled) 1f else theme.disabledAlpha } + .glassFrame(shape, { fill }, { Color.Transparent }, hover = enabled, glass = false, pull = 0.75.dp) + .oneConfigClickable(tabLabel(tab), onClick, role = A11yRole.Tab, enabled = enabled, selected = selected, shape = shape, indication = !glassAvailable, interactionSource = source) + .padding(horizontal = 12.dp), + contentAlignment = Alignment.Center, + ) { + TabContent(tab, content, size) + } +} + +@Composable +private fun TabContent(tab: TabItem<*>, color: Color, size: ControlSize) { + val iconSize: Dp = if (size == ControlSize.Compact) 14.dp else 16.dp + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + val painter = tab.iconPainter + when { + painter != null -> Image(painter, null, Modifier.size(iconSize), colorFilter = ColorFilter.tint(color)) + tab.icon != null -> Icon(tab.icon, null, Modifier.size(iconSize), tint = color) + } + val label = tab.labelText.resolve() + if (label.isNotEmpty()) { + Text(label, Modifier, color, OneConfigTheme.typography.body.copy(fontSize = if (size == ControlSize.Compact) 13.sp else 14.sp, fontWeight = FontWeight.Normal), 1, TextOverflow.Ellipsis, textAlign = TextAlign.Center) + } + tab.badge?.let { TabBadge(it) } + } +} + +@Composable +private fun TabBadge(text: String) { + val theme = OneConfigTheme.current + val accent = OneConfigTheme.accent + if (text.isEmpty()) { + Box(Modifier.size(8.dp).background(accent, theme.shapes.circle)) + return + } + Box( + Modifier + .defaultMinSize(minWidth = 16.dp, minHeight = 16.dp) + .background(accent, theme.shapes.pill) + .padding(horizontal = 5.dp), + contentAlignment = Alignment.Center, + ) { + Text(text, Modifier, theme.accentTextColor, OneConfigTheme.typography.body.copy(fontSize = 10.sp, fontWeight = FontWeight.Medium), 1, TextOverflow.Clip, textAlign = TextAlign.Center) + } +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Text.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Text.kt index 6f24c65cd..16425186c 100644 --- a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Text.kt +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Text.kt @@ -1,35 +1,104 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + package org.polyfrost.oneconfig.api.ui.v1.components +import androidx.compose.foundation.layout.Column import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorProducer +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.BaselineShift import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.sp +import java.util.concurrent.ConcurrentHashMap +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.ComponentLike +import org.apache.logging.log4j.LogManager +import org.polyfrost.oneconfig.api.platform.v1.Platform import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi import org.polyfrost.oneconfig.api.ui.v1.internal.componentsBridge +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme + +private val LOGGER = LogManager.getLogger("OneConfig/UI") + +private val warnedTextTypes = ConcurrentHashMap.newKeySet>() @ExperimentalOneConfigUiApi @Composable -public fun Text(text: Any, modifier: Modifier, color: Color) { - Text(text, modifier, color, 14.sp, null, BaselineShift.None, FontWeight.Normal, TextAlign.Start, Int.MAX_VALUE, TextOverflow.Clip) +public fun Text( + text: String, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + style: TextStyle = OneConfigTheme.typography.body, + maxLines: Int = Int.MAX_VALUE, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + textAlign: TextAlign? = null, + colorProducer: ColorProducer? = null, + onTextLayout: ((TextLayoutResult) -> Unit)? = null, +) { + componentsBridge().Text(text, modifier, color, style, maxLines, overflow, softWrap, textAlign, colorProducer, onTextLayout) } @ExperimentalOneConfigUiApi @Composable -public fun Text(text: Any, modifier: Modifier, color: Color, fontSize: TextUnit) { - Text(text, modifier, color, fontSize, null, BaselineShift.None, FontWeight.Normal, TextAlign.Start, Int.MAX_VALUE, TextOverflow.Clip) +public fun Text( + text: AnnotatedString, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + style: TextStyle = OneConfigTheme.typography.body, + maxLines: Int = Int.MAX_VALUE, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + textAlign: TextAlign? = null, + colorProducer: ColorProducer? = null, + onTextLayout: ((TextLayoutResult) -> Unit)? = null, +) { + componentsBridge().Text(text, modifier, color, style, maxLines, overflow, softWrap, textAlign, colorProducer, onTextLayout) } @ExperimentalOneConfigUiApi @Composable -public fun Text(text: Any, modifier: Modifier, color: Color, fontSize: TextUnit, fontWeight: FontWeight) { - Text(text, modifier, color, fontSize, null, BaselineShift.None, fontWeight, TextAlign.Start, Int.MAX_VALUE, TextOverflow.Clip) +public fun Text( + text: Component, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + style: TextStyle = OneConfigTheme.typography.body, + maxLines: Int = Int.MAX_VALUE, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + textAlign: TextAlign? = null, + colorProducer: ColorProducer? = null, + onTextLayout: ((TextLayoutResult) -> Unit)? = null, +) { + componentsBridge().Text(text, modifier, color, style, maxLines, overflow, softWrap, textAlign, colorProducer, onTextLayout) } +@ExperimentalOneConfigUiApi +@Composable +public fun Text( + text: List, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + style: TextStyle = OneConfigTheme.typography.body, + maxLines: Int = Int.MAX_VALUE, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + textAlign: TextAlign? = null, + colorProducer: ColorProducer? = null, +) { + Column(modifier) { + for (line in text) Text(line, Modifier, color, style, maxLines, overflow, softWrap, textAlign, colorProducer) + } +} + +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) @ExperimentalOneConfigUiApi @Composable public fun Text( @@ -44,5 +113,57 @@ public fun Text( maxLines: Int, overflow: TextOverflow, ) { - componentsBridge().Text(text, modifier, color, fontSize, lineHeight, shift, fontWeight, textAlign, maxLines, overflow) + val style = OneConfigTheme.typography.body.copy( + fontSize = fontSize, + fontWeight = fontWeight, + lineHeight = lineHeight ?: TextUnit.Unspecified, + baselineShift = shift, + ) + AnyText(text, modifier, color, style, textAlign, maxLines, overflow) +} + +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) +@ExperimentalOneConfigUiApi +@Composable +public fun Text(text: Any, modifier: Modifier, color: Color) { + AnyText(text, modifier, color, OneConfigTheme.typography.body.copy(fontSize = 14.sp, fontWeight = FontWeight.Normal), TextAlign.Start, Int.MAX_VALUE, TextOverflow.Clip) +} + +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) +@ExperimentalOneConfigUiApi +@Composable +public fun Text(text: Any, modifier: Modifier, color: Color, fontSize: TextUnit) { + AnyText(text, modifier, color, OneConfigTheme.typography.body.copy(fontSize = fontSize, fontWeight = FontWeight.Normal), TextAlign.Start, Int.MAX_VALUE, TextOverflow.Clip) +} + +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) +@ExperimentalOneConfigUiApi +@Composable +public fun Text(text: Any, modifier: Modifier, color: Color, fontSize: TextUnit, fontWeight: FontWeight) { + AnyText(text, modifier, color, OneConfigTheme.typography.body.copy(fontSize = fontSize, fontWeight = fontWeight), TextAlign.Start, Int.MAX_VALUE, TextOverflow.Clip) +} + +@Composable +private fun AnyText( + text: Any, + modifier: Modifier, + color: Color, + style: TextStyle, + textAlign: TextAlign, + maxLines: Int, + overflow: TextOverflow, +) { + when (val value = runCatching { Platform.compatibility().wrapPlatformComponent(text) }.getOrNull() ?: text) { + is String -> Text(value, modifier, color, style, maxLines, overflow, textAlign = textAlign) + is AnnotatedString -> Text(value, modifier, color, style, maxLines, overflow, textAlign = textAlign) + is Component -> Text(value, modifier, color, style, maxLines, overflow, textAlign = textAlign) + is ComponentLike -> Text(value.asComponent(), modifier, color, style, maxLines, overflow, textAlign = textAlign) + is Iterable<*> -> Column(modifier) { + for (line in value) if (line != null) AnyText(line, Modifier, color, style, textAlign, maxLines, overflow) + } + else -> { + if (warnedTextTypes.add(value.javaClass)) LOGGER.warn("Text does not support {}; drawing its toString() instead", value.javaClass.name) + Text(value.toString(), modifier, color, style, maxLines, overflow, textAlign = textAlign) + } + } } diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/TextButton.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/TextButton.kt index bdcece0e5..20d539eff 100644 --- a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/TextButton.kt +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/TextButton.kt @@ -3,13 +3,13 @@ package org.polyfrost.oneconfig.api.ui.v1.components import androidx.compose.animation.animateColorAsState -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.hoverable +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -18,23 +18,19 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.lerp -import androidx.compose.ui.input.pointer.PointerIcon -import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.BaselineShift import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi -import org.polyfrost.oneconfig.api.ui.v1.a11y.A11yRole -import org.polyfrost.oneconfig.api.ui.v1.a11y.a11y +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme +@Deprecated("Use ControlStyle", ReplaceWith("ControlStyle", "org.polyfrost.oneconfig.api.ui.v1.components.ControlStyle")) @ExperimentalOneConfigUiApi public enum class ButtonStyle { Primary, @@ -43,72 +39,181 @@ public enum class ButtonStyle { DangerFilled, } -private const val FILLED_HOVER_LIFT = 0.1f -private const val DANGER_REST_ALPHA = 0.12f -private const val DANGER_HOVER_ALPHA = 0.2f +@Suppress("DEPRECATION") +internal fun ButtonStyle.toControlStyle(): ControlStyle = when (this) { + ButtonStyle.Primary -> ControlStyle.Primary + ButtonStyle.Secondary -> ControlStyle.Secondary + ButtonStyle.Danger -> ControlStyle.Danger + ButtonStyle.DangerFilled -> ControlStyle.DangerFilled +} + +private const val FADE_MILLIS = 150 @ExperimentalOneConfigUiApi @Composable -public fun TextButton(label: String, modifier: Modifier, onClick: () -> Unit) { - TextButton(label, null, modifier, ButtonStyle.Secondary, true, onClick) +public fun TextButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + style: ControlStyle = ControlStyle.Secondary, + size: ControlSize = ControlSize.Regular, + enabled: Boolean = true, + fillWidth: Boolean = false, + leading: (@Composable () -> Unit)? = null, + trailing: (@Composable () -> Unit)? = null, + interactionSource: MutableInteractionSource? = null, +) { + TextButtonContent(text, null, onClick, modifier, style, size, enabled, fillWidth, leading, trailing, interactionSource) } @ExperimentalOneConfigUiApi @Composable -public fun TextButton(label: String, modifier: Modifier, style: ButtonStyle, onClick: () -> Unit) { - TextButton(label, null, modifier, style, true, onClick) +public fun TextButton( + text: AnnotatedString, + onClick: () -> Unit, + modifier: Modifier = Modifier, + style: ControlStyle = ControlStyle.Secondary, + size: ControlSize = ControlSize.Regular, + enabled: Boolean = true, + fillWidth: Boolean = false, + leading: (@Composable () -> Unit)? = null, + trailing: (@Composable () -> Unit)? = null, + interactionSource: MutableInteractionSource? = null, +) { + TextButtonContent(text.text, text, onClick, modifier, style, size, enabled, fillWidth, leading, trailing, interactionSource) } -@ExperimentalOneConfigUiApi @Composable -public fun TextButton(label: String, icon: String?, modifier: Modifier, style: ButtonStyle, enabled: Boolean, onClick: () -> Unit) { +private fun TextButtonContent( + text: String, + styled: AnnotatedString?, + onClick: () -> Unit, + modifier: Modifier, + style: ControlStyle, + size: ControlSize, + enabled: Boolean, + fillWidth: Boolean, + leading: (@Composable () -> Unit)?, + trailing: (@Composable () -> Unit)?, + interactionSource: MutableInteractionSource?, +) { val theme = OneConfigTheme.current - val source = remember { MutableInteractionSource() } + val source = interactionSource ?: remember { MutableInteractionSource() } val hovered by source.collectIsHoveredAsState() - val fill = when (style) { - ButtonStyle.Primary -> OneConfigTheme.accent - ButtonStyle.DangerFilled -> DangerColor - ButtonStyle.Danger -> DangerColor.copy(alpha = if (hovered && enabled) DANGER_HOVER_ALPHA else DANGER_REST_ALPHA) - ButtonStyle.Secondary -> theme.componentBackground - } - val content = when (style) { - ButtonStyle.Primary -> theme.accentTextColor - ButtonStyle.DangerFilled -> Color.White - ButtonStyle.Danger -> DangerColor - ButtonStyle.Secondary -> theme.textColor - } - val lifted = style == ButtonStyle.Primary || style == ButtonStyle.DangerFilled - val background by animateColorAsState(if (lifted && hovered && enabled) lerp(fill, Color.White, FILLED_HOVER_LIFT) else fill) + val active = hovered && enabled + val fill by animateColorAsState( + when (style) { + ControlStyle.Primary, ControlStyle.Ghost -> Color.Transparent + ControlStyle.DangerFilled -> theme.error.copy(alpha = if (active) ButtonDefaults.FILLED_HOVER_ALPHA else 1f) + ControlStyle.Danger -> theme.error.copy(alpha = if (active) ButtonDefaults.DANGER_HOVER_ALPHA else ButtonDefaults.DANGER_REST_ALPHA) + ControlStyle.Secondary -> theme.componentBackground + }, + Motion.spec(tween(FADE_MILLIS)), + ) + val accentAlpha by animateFloatAsState( + when { + style != ControlStyle.Primary -> 0f + active -> ButtonDefaults.FILLED_HOVER_ALPHA + else -> 1f + }, + Motion.spec(tween(FADE_MILLIS)), + ) val border by animateColorAsState( when { - style != ButtonStyle.Secondary -> background - hovered && enabled -> theme.textColorSecondary + style != ControlStyle.Secondary -> Color.Transparent + active -> theme.textColorSecondary else -> theme.borderColor }, + Motion.spec(tween(FADE_MILLIS)), ) - val shape = theme.buttonShape + val content = ButtonDefaults.contentColor(style) + val shape = ButtonDefaults.shape + val ghost = style == ControlStyle.Ghost Row( modifier = modifier - .graphicsLayer { alpha = if (enabled) 1f else DISABLED_ALPHA } - .heightIn(min = 32.dp) - .clip(shape) - .background(background, shape) - .border(1.dp, border, shape) - .hoverable(source) - .then( - if (enabled) { - Modifier.a11y(label, A11yRole.Button, null, null, shape, true) - .onClick(source, onClick) - .pointerHoverIcon(PointerIcon.Hand) - } else { - Modifier - }, - ) - .padding(horizontal = 16.dp, vertical = 6.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + .then(if (fillWidth) Modifier.fillMaxWidth() else Modifier) + .graphicsLayer { alpha = if (enabled) 1f else theme.disabledAlpha } + .heightIn(min = size.height) + .glassFrame(shape, { fill }, { border }, { accentAlpha }, hover = enabled && !ghost, glass = !ghost) + .oneConfigClickable(text, onClick, enabled = enabled, shape = shape, indication = ghost || !glassAvailable, interactionSource = source) + .padding(horizontal = ButtonDefaults.horizontalPadding(size), vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(ButtonDefaults.contentSpacing, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically, ) { - if (icon != null) Icon(icon, content, Modifier.size(16.dp)) - Text(label, Modifier, content, 13.sp, null, BaselineShift.None, FontWeight.Normal, TextAlign.Center, 1, TextOverflow.Ellipsis) + leading?.invoke() + val labelStyle = OneConfigTheme.typography.body.copy(fontSize = ButtonDefaults.fontSize(size), fontWeight = FontWeight.Normal) + if (styled != null) Text(styled, Modifier, content, labelStyle, 1, TextOverflow.Ellipsis, textAlign = TextAlign.Center) + else Text(text, Modifier, content, labelStyle, 1, TextOverflow.Ellipsis, textAlign = TextAlign.Center) + trailing?.invoke() } } + +@ExperimentalOneConfigUiApi +@Composable +public fun TextButton( + text: String, + icon: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + style: ControlStyle = ControlStyle.Secondary, + size: ControlSize = ControlSize.Regular, + enabled: Boolean = true, + fillWidth: Boolean = false, + trailing: (@Composable () -> Unit)? = null, + interactionSource: MutableInteractionSource? = null, +) { + val tint = ButtonDefaults.contentColor(style) + TextButton( + text, onClick, modifier, style, size, enabled, fillWidth, + leading = { Icon(icon, null, Modifier.size(ButtonDefaults.iconSize(size)), tint) }, + trailing = trailing, + interactionSource = interactionSource, + ) +} + +@ExperimentalOneConfigUiApi +@Composable +public fun TextButton( + text: AnnotatedString, + icon: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + style: ControlStyle = ControlStyle.Secondary, + size: ControlSize = ControlSize.Regular, + enabled: Boolean = true, + fillWidth: Boolean = false, + trailing: (@Composable () -> Unit)? = null, + interactionSource: MutableInteractionSource? = null, +) { + val tint = ButtonDefaults.contentColor(style) + TextButton( + text, onClick, modifier, style, size, enabled, fillWidth, + leading = { Icon(icon, null, Modifier.size(ButtonDefaults.iconSize(size)), tint) }, + trailing = trailing, + interactionSource = interactionSource, + ) +} + +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) +@ExperimentalOneConfigUiApi +@Composable +public fun TextButton(label: String, modifier: Modifier, onClick: () -> Unit) { + TextButton(label, onClick, modifier) +} + +@Suppress("DEPRECATION") +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) +@ExperimentalOneConfigUiApi +@Composable +public fun TextButton(label: String, modifier: Modifier, style: ButtonStyle, onClick: () -> Unit) { + TextButton(label, onClick, modifier, style.toControlStyle()) +} + +@Suppress("DEPRECATION") +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) +@ExperimentalOneConfigUiApi +@Composable +public fun TextButton(label: String, icon: String?, modifier: Modifier, style: ButtonStyle, enabled: Boolean, onClick: () -> Unit) { + if (icon != null) TextButton(label, icon, onClick, modifier, style.toControlStyle(), enabled = enabled) + else TextButton(label, onClick, modifier, style.toControlStyle(), enabled = enabled) +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/TextContextMenu.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/TextContextMenu.kt new file mode 100644 index 000000000..e4aa462b1 --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/TextContextMenu.kt @@ -0,0 +1,50 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class, ExperimentalFoundationApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.text.LocalTextContextMenu +import androidx.compose.foundation.text.TextContextMenu +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import java.util.Locale +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import androidx.compose.foundation.ContextMenuState as FoundationContextMenuState + +@ExperimentalOneConfigUiApi +@Composable +public fun ProvideOneConfigTextContextMenu(content: @Composable () -> Unit) { + CompositionLocalProvider(LocalTextContextMenu provides OneConfigTextContextMenu, content = content) +} + +private object OneConfigTextContextMenu : TextContextMenu { + @Composable + override fun Area(textManager: TextContextMenu.TextManager, state: FoundationContextMenuState, content: @Composable () -> Unit) { + val latest by rememberUpdatedState(textManager) + val entries = remember { { textMenuEntries(latest) } } + val outer = LocalContextMenuHost.current + val local = remember { ContextMenuHostState() } + Box(Modifier.contextMenuTrigger(outer ?: local, null, entries), propagateMinConstraints = true) { + content() + if (outer == null) ContextMenuLayer(local) + } + } +} + +private fun textMenuEntries(textManager: TextContextMenu.TextManager): List = buildList { + textManager.cut?.let { add(textAction("oneconfig.menu.text.cut", "Cut", "scissors", "X", it)) } + textManager.copy?.let { add(textAction("oneconfig.menu.text.copy", "Copy", "copy", "C", it)) } + textManager.paste?.let { add(textAction("oneconfig.menu.text.paste", "Paste", "clipboard", "V", it)) } + textManager.selectAll?.let { add(textAction("oneconfig.menu.text.select_all", "Select all", "select-all", "A", it)) } +} + +private fun textAction(key: String, fallback: String, icon: String, letter: String, action: TextContextMenu.Action): MenuAction = + MenuAction(kitString(key, fallback), action.execute, icon = icon, enabled = action.enabled, shortcutHint = "$SHORTCUT_PREFIX$letter") + +private val SHORTCUT_PREFIX: String = + if (System.getProperty("os.name", "").lowercase(Locale.ROOT).contains("mac")) "Cmd+" else "Ctrl+" diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/TextField.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/TextField.kt index 8506f1daa..fe2d89675 100644 --- a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/TextField.kt +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/TextField.kt @@ -3,6 +3,7 @@ package org.polyfrost.oneconfig.api.ui.v1.components import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.interaction.MutableInteractionSource @@ -12,9 +13,11 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -25,6 +28,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.key.Key @@ -41,27 +46,140 @@ import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.BaselineShift import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.isSpecified import androidx.compose.ui.unit.sp import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi import org.polyfrost.oneconfig.api.ui.v1.a11y.A11y +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion import org.polyfrost.oneconfig.api.ui.v1.a11y.a11yField import org.polyfrost.oneconfig.api.ui.v1.keybind.trackTextInputFocus import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme +private const val FIELD_FADE_MILLIS = 150 + +@ExperimentalOneConfigUiApi +public object TextFieldDefaults { + public val size: ControlSize = ControlSize.Regular + + public val horizontalPadding: Dp = 12.dp + + public val contentSpacing: Dp = 10.dp + + public val fill: Color + @Composable get() = OneConfigTheme.current.modCardBackground + + public val border: Color + @Composable get() = OneConfigTheme.current.borderColor + + public val focusBorder: Color + @Composable get() = OneConfigTheme.accent + + public val errorBorder: Color + @Composable get() = OneConfigTheme.current.error + + public val shape: Shape + @Composable get() = OneConfigTheme.current.shapes.field + + public fun fontSize(size: ControlSize): TextUnit = if (size == ControlSize.Compact) 13.sp else 14.sp + + public fun iconSize(size: ControlSize): Dp = if (size == ControlSize.Compact) 14.dp else 16.dp +} + +@Suppress("DEPRECATION") +@ExperimentalOneConfigUiApi +@Composable +public fun TextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + placeholder: String? = null, + enabled: Boolean = true, + invalid: Boolean = false, + singleLine: Boolean = true, + size: ControlSize = TextFieldDefaults.size, + label: String? = null, + leadingIcon: String? = null, + leading: (@Composable () -> Unit)? = null, + trailing: (@Composable () -> Unit)? = null, + masked: Boolean = false, + maxLength: Int = Int.MAX_VALUE, + framed: Boolean = true, + fontSize: TextUnit = TextUnit.Unspecified, + focusRequester: FocusRequester? = null, + onSubmit: (() -> Unit)? = null, + interactionSource: MutableInteractionSource? = null, + visualTransformation: VisualTransformation? = null, + hidePlaceholderWhenFocused: Boolean = false, +) { + var local by remember { mutableStateOf(TextFieldValue(value, TextRange(value.length))) } + val shown = if (local.text == value) { + local + } else { + TextFieldValue(value, TextRange(local.selection.start.coerceAtMost(value.length), local.selection.end.coerceAtMost(value.length))) + } + FieldCore( + shown, + { next -> + local = next + if (next.text != value) onValueChange(next.text) + }, + modifier, placeholder, enabled, invalid, singleLine, size, label, leadingIcon, leading, trailing, masked, maxLength, framed, + fontSize, focusRequester, onSubmit, interactionSource, + transformation = visualTransformation, hidePlaceholderWhenFocused = hidePlaceholderWhenFocused, + ) +} + +@Suppress("DEPRECATION") +@ExperimentalOneConfigUiApi +@Composable +public fun TextField( + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + modifier: Modifier = Modifier, + placeholder: String? = null, + enabled: Boolean = true, + invalid: Boolean = false, + singleLine: Boolean = true, + size: ControlSize = TextFieldDefaults.size, + label: String? = null, + leadingIcon: String? = null, + leading: (@Composable () -> Unit)? = null, + trailing: (@Composable () -> Unit)? = null, + masked: Boolean = false, + maxLength: Int = Int.MAX_VALUE, + framed: Boolean = true, + fontSize: TextUnit = TextUnit.Unspecified, + focusRequester: FocusRequester? = null, + onSubmit: (() -> Unit)? = null, + interactionSource: MutableInteractionSource? = null, + visualTransformation: VisualTransformation? = null, + hidePlaceholderWhenFocused: Boolean = false, +) { + FieldCore( + value, onValueChange, modifier, placeholder, enabled, invalid, singleLine, size, label, leadingIcon, leading, trailing, masked, + maxLength, framed, fontSize, focusRequester, onSubmit, interactionSource, + transformation = visualTransformation, hidePlaceholderWhenFocused = hidePlaceholderWhenFocused, + ) +} + +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) @ExperimentalOneConfigUiApi @Composable public fun TextField(value: String, onValueChange: (String) -> Unit, modifier: Modifier) { - TextField(value, onValueChange, modifier, null, null, Int.MAX_VALUE, false) + TextField(value, onValueChange, modifier, placeholder = null) } +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) @ExperimentalOneConfigUiApi @Composable public fun TextField(value: String, onValueChange: (String) -> Unit, modifier: Modifier, placeholder: String?) { - TextField(value, onValueChange, modifier, placeholder, null, Int.MAX_VALUE, false) + TextField(value, onValueChange, modifier, placeholder, enabled = true) } +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) @ExperimentalOneConfigUiApi @Composable public fun TextField( @@ -73,12 +191,10 @@ public fun TextField( maxLength: Int, invalid: Boolean, ) { - TextField( - value, onValueChange, modifier, placeholder, leadingIcon, maxLength, invalid, - true, true, true, 14.sp, null, false, null, null, null, null, - ) + TextField(value, onValueChange, modifier, placeholder, invalid = invalid, leadingIcon = leadingIcon, maxLength = maxLength) } +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) @ExperimentalOneConfigUiApi @Composable public fun TextField( @@ -100,23 +216,13 @@ public fun TextField( trailing: (@Composable () -> Unit)?, onSubmit: (() -> Unit)?, ) { - var local by remember { mutableStateOf(TextFieldValue(value, TextRange(value.length))) } - val shown = if (local.text == value) { - local - } else { - TextFieldValue(value, TextRange(local.selection.start.coerceAtMost(value.length), local.selection.end.coerceAtMost(value.length))) - } - FieldCore( - shown, - { next -> - local = next - if (next.text != value) onValueChange(next.text) - }, - modifier, placeholder, leadingIcon, maxLength, invalid, enabled, singleLine, framed, fontSize, label, masked, - focusRequester, interactionSource, trailing, onSubmit, + TextField( + value, onValueChange, modifier, placeholder, enabled, invalid, singleLine, TextFieldDefaults.size, label, leadingIcon, null, trailing, + masked, maxLength, framed, fontSize, focusRequester, onSubmit, interactionSource, ) } +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) @ExperimentalOneConfigUiApi @Composable public fun TextField( @@ -125,12 +231,10 @@ public fun TextField( modifier: Modifier, placeholder: String?, ) { - TextField( - value, onValueChange, modifier, placeholder, null, Int.MAX_VALUE, false, - true, true, true, 14.sp, null, false, null, null, null, null, - ) + TextField(value, onValueChange, modifier, placeholder, enabled = true) } +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) @ExperimentalOneConfigUiApi @Composable public fun TextField( @@ -152,34 +256,43 @@ public fun TextField( trailing: (@Composable () -> Unit)?, onSubmit: (() -> Unit)?, ) { - FieldCore( - value, onValueChange, modifier, placeholder, leadingIcon, maxLength, invalid, enabled, singleLine, framed, fontSize, label, masked, - focusRequester, interactionSource, trailing, onSubmit, + TextField( + value, onValueChange, modifier, placeholder, enabled, invalid, singleLine, TextFieldDefaults.size, label, leadingIcon, null, trailing, + masked, maxLength, framed, fontSize, focusRequester, onSubmit, interactionSource, ) } +@Suppress("DEPRECATION") @Composable -private fun FieldCore( +internal fun FieldCore( value: TextFieldValue, onValueChange: (TextFieldValue) -> Unit, modifier: Modifier, placeholder: String?, - leadingIcon: String?, - maxLength: Int, - invalid: Boolean, enabled: Boolean, + invalid: Boolean, singleLine: Boolean, - framed: Boolean, - fontSize: TextUnit, + size: ControlSize, label: String?, + leadingIcon: String?, + leading: (@Composable () -> Unit)?, + trailing: (@Composable () -> Unit)?, masked: Boolean, + maxLength: Int, + framed: Boolean, + fontSize: TextUnit, focusRequester: FocusRequester?, - interactionSource: MutableInteractionSource?, - trailing: (@Composable () -> Unit)?, onSubmit: (() -> Unit)?, + interactionSource: MutableInteractionSource?, + fill: Color? = null, + glass: Boolean = false, + transformation: VisualTransformation? = null, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + hidePlaceholderWhenFocused: Boolean = false, ) { val source = interactionSource ?: remember { MutableInteractionSource() } - val style = rememberFieldStyle(fontSize) + val textSize = if (fontSize.isSpecified) fontSize else TextFieldDefaults.fontSize(size) + val style = rememberFieldStyle(textSize) val focused by source.collectIsFocusedAsState() val previous = remember { arrayOf(value) } LaunchedEffect(value) { @@ -191,15 +304,19 @@ private fun FieldCore( BasicTextField( value = value, onValueChange = { next -> onValueChange(next.limitedFrom(value, maxLength)) }, - modifier = fieldModifier(modifier, source, invalid, enabled, singleLine, framed, spokenLabel(label ?: placeholder, value.text, masked), focusRequester, onSubmit), + modifier = fieldModifier( + modifier, source, invalid, enabled, singleLine, framed, fill ?: TextFieldDefaults.fill, glass, + spokenLabel(label ?: placeholder, value.text, masked), focusRequester, onSubmit, + ), enabled = enabled, singleLine = singleLine, textStyle = style, cursorBrush = SolidColor(OneConfigTheme.accent), - visualTransformation = maskFor(masked), + visualTransformation = transformation ?: maskFor(masked), + keyboardOptions = keyboardOptions, interactionSource = source, decorationBox = { inner -> - FieldDecoration(value.text.isEmpty(), framed, placeholder, leadingIcon, singleLine, fontSize, source, trailing, inner) + FieldDecoration(value.text.isEmpty(), hidePlaceholderWhenFocused, framed, placeholder, leadingIcon, leading, singleLine, size, textSize, source, trailing, inner) }, ) } @@ -304,6 +421,8 @@ private fun fieldModifier( enabled: Boolean, singleLine: Boolean, framed: Boolean, + fill: Color, + glass: Boolean, label: String?, focusRequester: FocusRequester?, onSubmit: (() -> Unit)?, @@ -312,12 +431,18 @@ private fun fieldModifier( val focused by source.collectIsFocusedAsState() val border by animateColorAsState( when { - invalid -> DangerColor - focused -> OneConfigTheme.accent - else -> theme.borderColor + invalid -> TextFieldDefaults.errorBorder + focused -> TextFieldDefaults.focusBorder + else -> TextFieldDefaults.border }, + Motion.spec(tween(FIELD_FADE_MILLIS)), ) - val shape = theme.sideBarNavigationEntryShape + val shape = TextFieldDefaults.shape + val frame = when { + !framed -> Modifier + glass -> Modifier.glassFrame(shape, { fill }, { border }, hover = false) + else -> Modifier.background(fill, shape).border(1.dp, border, shape) + } return modifier .trackTextInputFocus() .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) @@ -333,23 +458,20 @@ private fun fieldModifier( Modifier }, ) - .graphicsLayer { alpha = if (enabled) 1f else DISABLED_ALPHA } - .then( - if (framed) { - Modifier.background(theme.modCardBackground, shape).border(1.dp, border, shape) - } else { - Modifier - }, - ) + .graphicsLayer { alpha = if (enabled) 1f else theme.disabledAlpha } + .then(frame) } @Composable private fun FieldDecoration( empty: Boolean, + hidePlaceholderWhenFocused: Boolean, framed: Boolean, placeholder: String?, leadingIcon: String?, + leading: (@Composable () -> Unit)?, singleLine: Boolean, + size: ControlSize, fontSize: TextUnit, source: MutableInteractionSource, trailing: (@Composable () -> Unit)?, @@ -358,14 +480,23 @@ private fun FieldDecoration( val theme = OneConfigTheme.current val hovered by source.collectIsHoveredAsState() val focused by source.collectIsFocusedAsState() - val iconColor by animateColorAsState(if (hovered || focused) theme.textColor else theme.textColorSecondary) + val iconColor by animateColorAsState(if (hovered || focused) theme.textColor else theme.textColorSecondary, Motion.spec(tween(FIELD_FADE_MILLIS))) Row( - modifier = if (framed) Modifier.padding(horizontal = 12.dp, vertical = 7.dp) else Modifier, + modifier = if (framed) { + Modifier + .defaultMinSize(minHeight = size.height) + .padding(horizontal = TextFieldDefaults.horizontalPadding, vertical = if (singleLine) 4.dp else 7.dp) + } else { + Modifier + }, verticalAlignment = if (singleLine) Alignment.CenterVertically else Alignment.Top, - horizontalArrangement = Arrangement.spacedBy(10.dp), + horizontalArrangement = Arrangement.spacedBy(TextFieldDefaults.contentSpacing), ) { - if (leadingIcon != null) Icon(leadingIcon, iconColor, Modifier.size(18.dp)) - FieldText(empty, placeholder, singleLine, fontSize, inner) + when { + leading != null -> leading() + leadingIcon != null -> Icon(leadingIcon, null, Modifier.size(TextFieldDefaults.iconSize(size)), iconColor) + } + FieldText(empty && !(hidePlaceholderWhenFocused && focused), placeholder, singleLine, fontSize, inner) trailing?.invoke() } } @@ -384,11 +515,7 @@ private fun RowScope.FieldText( placeholder, Modifier, OneConfigTheme.current.textColorSecondary, - fontSize, - null, - BaselineShift.None, - FontWeight.Normal, - TextAlign.Start, + OneConfigTheme.typography.body.copy(fontSize = fontSize, fontWeight = FontWeight.Normal), if (singleLine) 1 else Int.MAX_VALUE, TextOverflow.Ellipsis, ) diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/TextPromptDialog.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/TextPromptDialog.kt new file mode 100644 index 000000000..9615b280d --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/TextPromptDialog.kt @@ -0,0 +1,72 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme + +@ExperimentalOneConfigUiApi +@Composable +public fun TextPromptDialog( + title: String, + initial: String, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + confirmLabel: String = DialogDefaults.saveLabel, + validate: (String) -> String? = { null }, +) { + val theme = OneConfigTheme.current + var value by remember { mutableStateOf(initial) } + var edited by remember { mutableStateOf(false) } + val problem = validate(value) + val latestConfirm by rememberUpdatedState(onConfirm) + val submit = { if (validate(value) == null) latestConfirm(value) } + val focus = remember { FocusRequester() } + LaunchedEffect(focus) { runCatching { focus.requestFocus() } } + ModalDialog( + onDismiss, + modifier, + title = { DialogTitle(title) }, + buttons = { + TextButton(DialogDefaults.cancelLabel, onDismiss) + TextButton(confirmLabel, submit, style = ControlStyle.Primary, enabled = problem == null) + }, + ) { + TextField( + value = value, + onValueChange = { + value = it + edited = true + }, + modifier = Modifier.fillMaxWidth(), + placeholder = null, + leadingIcon = null, + maxLength = Int.MAX_VALUE, + invalid = edited && problem != null, + enabled = true, + singleLine = true, + framed = true, + fontSize = theme.typography.body.fontSize, + label = title, + masked = false, + focusRequester = focus, + interactionSource = null, + trailing = null, + onSubmit = submit, + ) + if (edited && problem != null) { + Text(problem, Modifier, theme.error, theme.typography.small) + } + } +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Toasts.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Toasts.kt new file mode 100644 index 000000000..20c46b34f --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Toasts.kt @@ -0,0 +1,181 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.PopupPositionProvider +import java.util.concurrent.atomic.AtomicLong +import kotlinx.coroutines.delay +import org.polyfrost.oneconfig.api.notifications.v1.NotificationType +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.a11y.Announce +import org.polyfrost.oneconfig.api.ui.v1.a11y.Motion +import org.polyfrost.oneconfig.api.ui.v1.internal.componentsBridge +import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigTheme + +@ExperimentalOneConfigUiApi +public object Toasts { + internal class Toast(val text: String, val type: NotificationType, durationMillis: Long) { + val id: Long = nextId.incrementAndGet() + val persistent: Boolean = durationMillis <= 0L + var remainingMillis: Long = durationMillis + val visibility: MutableTransitionState = MutableTransitionState(false).apply { targetState = true } + + fun close() { + visibility.targetState = false + } + } + + private val nextId = AtomicLong() + + internal val queue = mutableStateListOf() + + internal val hosts = HostStack() + + @JvmStatic + public fun show(text: String, type: NotificationType = NotificationType.INFO, durationMillis: Long = 4000L) { + val toast = Toast(text, type, durationMillis) + mutateFeedbackState { queue += toast } + } + + internal fun remove(toast: Toast) { + mutateFeedbackState { queue -= toast } + } +} + +@ExperimentalOneConfigUiApi +public object ToastDefaults { + public const val maxVisible: Int = 3 + + public val maxWidth: Dp = 360.dp + + public val margin: Dp = 16.dp + + public val alignment: Alignment = Alignment.TopCenter +} + +@ExperimentalOneConfigUiApi +@Composable +public fun ToastHost(modifier: Modifier = Modifier, alignment: Alignment = ToastDefaults.alignment) { + val slot = Toasts.hosts.rememberSlot(fallback = false, depth = feedbackDepth()) + ToastHostLayer(slot, modifier, alignment) +} + +@Composable +internal fun ToastHostLayer(slot: Any, modifier: Modifier, alignment: Alignment) { + val shown = Toasts.queue.take(ToastDefaults.maxVisible) + if (shown.isEmpty()) return + if (!Toasts.hosts.isActive(slot)) return + val marginPx = with(LocalDensity.current) { ToastDefaults.margin.roundToPx() } + val provider = remember(alignment, marginPx) { WindowAlignedPosition(alignment, marginPx) } + val fromBottom = alignment == Alignment.BottomStart || alignment == Alignment.BottomCenter || alignment == Alignment.BottomEnd + val horizontal = when (alignment) { + Alignment.TopStart, Alignment.CenterStart, Alignment.BottomStart -> Alignment.Start + Alignment.TopEnd, Alignment.CenterEnd, Alignment.BottomEnd -> Alignment.End + else -> Alignment.CenterHorizontally + } + componentsBridge().Popup(provider, null, false) { + val hover = rememberHoverState() + Column( + modifier = modifier.widthIn(max = ToastDefaults.maxWidth).hoverable(hover.interactionSource), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = horizontal, + ) { + for (toast in if (fromBottom) shown.asReversed() else shown) { + key(toast.id) { ToastItem(toast, hover.hovered) } + } + } + } +} + +@Composable +private fun ToastItem(toast: Toasts.Toast, paused: Boolean) { + val state = toast.visibility + if (state.isIdle && !state.targetState) { + LaunchedEffect(toast) { Toasts.remove(toast) } + } + LaunchedEffect(toast, paused) { + if (paused || toast.persistent || !toast.visibility.targetState) return@LaunchedEffect + val start = System.currentTimeMillis() + try { + delay(toast.remainingMillis.coerceAtLeast(0L)) + } finally { + toast.remainingMillis -= System.currentTimeMillis() - start + } + toast.close() + } + Announce(toast.text) + AnimatedVisibility( + visibleState = state, + enter = Motion.enter(fadeIn() + expandVertically(expandFrom = Alignment.Top)), + exit = Motion.exit(fadeOut() + shrinkVertically(shrinkTowards = Alignment.Top)), + ) { + ToastCard(toast) + } +} + +@Composable +private fun ToastCard(toast: Toasts.Toast) { + val theme = OneConfigTheme.current + Row( + modifier = componentsBridge().popupSurface(Modifier, theme.popupShape) + .semantics { liveRegion = LiveRegionMode.Polite } + .padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(toast.type.iconName, null, Modifier.size(16.dp), toastColor(toast.type)) + Text(toast.text, Modifier.weight(1f, fill = false).padding(vertical = 6.dp), theme.textColor, theme.typography.label) + IconButton("close", kitString("oneconfig.ui.toast.dismiss", "Dismiss"), toast::close, size = ControlSize.Compact, tooltip = null) + } +} + +@Composable +internal fun toastColor(type: NotificationType): Color { + val theme = OneConfigTheme.current + return when { + type == NotificationType.SUCCESS -> theme.success + type == NotificationType.ERROR -> theme.error + type.name == "WARNING" -> theme.warning + type == NotificationType.PROGRESS -> OneConfigTheme.accent + else -> theme.info + } +} + +private class WindowAlignedPosition(private val alignment: Alignment, private val margin: Int) : PopupPositionProvider { + override fun calculatePosition(anchorBounds: IntRect, windowSize: IntSize, layoutDirection: LayoutDirection, popupContentSize: IntSize): IntOffset { + val space = IntSize((windowSize.width - margin * 2).coerceAtLeast(0), (windowSize.height - margin * 2).coerceAtLeast(0)) + val placed = alignment.align(popupContentSize, space, layoutDirection) + return IntOffset(placed.x + margin, placed.y + margin) + } +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Tooltip.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Tooltip.kt index 098c48622..854063b68 100644 --- a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Tooltip.kt +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/components/Tooltip.kt @@ -7,12 +7,20 @@ import androidx.compose.ui.unit.DpOffset import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi import org.polyfrost.oneconfig.api.ui.v1.internal.componentsBridge +@Deprecated( + "Use TooltipBox, which also shows on keyboard focus and flips when there is no room", + ReplaceWith("TooltipBox(text, modifier, content = content)", "org.polyfrost.oneconfig.api.ui.v1.components.TooltipBox"), +) @ExperimentalOneConfigUiApi @Composable public fun Tooltip(text: @Composable () -> Unit, modifier: Modifier, anchor: Alignment, content: @Composable () -> Unit) { componentsBridge().Tooltip(text, modifier, anchor, anchor, DpOffset.Zero, content) } +@Deprecated( + "Use TooltipBox, which also shows on keyboard focus and flips when there is no room", + ReplaceWith("TooltipBox(text, modifier, content = content)", "org.polyfrost.oneconfig.api.ui.v1.components.TooltipBox"), +) @ExperimentalOneConfigUiApi @Composable public fun Tooltip( diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/config/PropertyState.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/config/PropertyState.kt new file mode 100644 index 000000000..d3708702f --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/config/PropertyState.kt @@ -0,0 +1,69 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) +@file:JvmName("PropertyStates") + +package org.polyfrost.oneconfig.api.ui.v1.config + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.MutableIntState +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import java.util.function.Predicate +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.map +import org.polyfrost.oneconfig.api.config.v1.Property +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi + +@ExperimentalOneConfigUiApi +@Composable +public fun Property.collectAsState(): State { + val property = this + val state = remember(System.identityHashCode(property)) { PropertyValueState(property, mutableIntStateOf(0)) } + DisposableEffect(state) { + if (property.get() !== state.lastRead) state.invalidate() + val callback = Predicate { + state.invalidate() + false + } + property.addCallback(callback) + onDispose { property.removeCallback(callback) } + } + return state +} + +private class PropertyValueState( + private val property: Property, + private val version: MutableIntState, +) : State { + var lastRead: T? = null + private set + + override val value: T? + get() { + version.intValue + return property.get().also { lastRead = it } + } + + fun invalidate() { + version.intValue++ + } +} + +@ExperimentalOneConfigUiApi +public fun Property.asFlow(): Flow { + val property = this + return callbackFlow { + val callback = Predicate { + trySend(Unit) + false + } + property.addCallback(callback) + trySend(Unit) + awaitClose { property.removeCallback(callback) } + }.buffer(Channel.CONFLATED).map { property.get() } +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIComponentsBridge.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIComponentsBridge.kt index 43dc07702..7a0adda3a 100644 --- a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIComponentsBridge.kt +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIComponentsBridge.kt @@ -6,19 +6,26 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorProducer import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.BaselineShift +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpOffset -import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.window.PopupPositionProvider +import net.kyori.adventure.text.Component import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.InternalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.components.ColorPickerModel +import org.polyfrost.oneconfig.api.ui.v1.components.UiSound import org.polyfrost.oneconfig.api.ui.v1.theme.OneConfigUITheme +import org.polyfrost.oneconfig.api.ui.v1.theme.ThemeBuilder @ExperimentalOneConfigUiApi +@InternalOneConfigUiApi public interface OneConfigUIComponentsBridge { @Composable public fun currentTheme(): OneConfigUITheme @@ -27,7 +34,19 @@ public interface OneConfigUIComponentsBridge { public fun accent(): Color @Composable - public fun ProvideTheme(content: @Composable () -> Unit) + public fun ProvideTheme(theme: OneConfigUITheme?, scaleToUi: Boolean, content: @Composable () -> Unit) + + @Composable + public fun themeShape(radius: Dp): Shape + + @Composable + public fun themeIcon(name: String): String + + public fun customTheme(base: OneConfigUITheme, id: String, name: String, builder: ThemeBuilder): OneConfigUITheme + + public fun playSound(sound: UiSound) + + public fun keepSearchFocus(modifier: Modifier): Modifier public fun polyfrostedDark(): OneConfigUITheme @@ -51,20 +70,48 @@ public interface OneConfigUIComponentsBridge { public fun concentric(shape: Shape, inset: Dp, minRadius: Dp): Shape @Composable - public fun Icon(iconName: String, color: Color, modifier: Modifier, fitAspectRatio: Boolean) + public fun Icon(iconName: String, color: Color, modifier: Modifier, fitAspectRatio: Boolean, tint: (() -> Color)?) + + @Composable + public fun Text( + text: String, + modifier: Modifier, + color: Color, + style: TextStyle, + maxLines: Int, + overflow: TextOverflow, + softWrap: Boolean, + textAlign: TextAlign?, + colorProducer: ColorProducer?, + onTextLayout: ((TextLayoutResult) -> Unit)?, + ) + + @Composable + public fun Text( + text: AnnotatedString, + modifier: Modifier, + color: Color, + style: TextStyle, + maxLines: Int, + overflow: TextOverflow, + softWrap: Boolean, + textAlign: TextAlign?, + colorProducer: ColorProducer?, + onTextLayout: ((TextLayoutResult) -> Unit)?, + ) @Composable public fun Text( - text: Any, + text: Component, modifier: Modifier, color: Color, - fontSize: TextUnit, - lineHeight: TextUnit?, - shift: BaselineShift, - fontWeight: FontWeight, - textAlign: TextAlign, + style: TextStyle, maxLines: Int, overflow: TextOverflow, + softWrap: Boolean, + textAlign: TextAlign?, + colorProducer: ColorProducer?, + onTextLayout: ((TextLayoutResult) -> Unit)?, ) @Composable @@ -109,10 +156,19 @@ public interface OneConfigUIComponentsBridge { @Composable public fun fadingEdges(modifier: Modifier, scrollState: ScrollState, color: Color, length: Dp): Modifier - public fun createColorPicker(initialColor: Color, hasAlpha: Boolean): Any + public fun createColorPicker(initialColor: Color, hasAlpha: Boolean, chroma: Boolean, chromaSpeed: Float, gradient: Color?): ColorPickerModel + + public fun updateColorPicker(model: ColorPickerModel, color: Color, chroma: Boolean, chromaSpeed: Float, gradient: Color?) @Composable - public fun ColorPickerPopup(model: Any, onColorChanged: (Color) -> Unit, onClose: () -> Unit, chromaCapable: Boolean) + public fun ColorPickerPanel( + model: ColorPickerModel, + onColorChanged: (Color) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier, + chromaCapable: Boolean, + gradientCapable: Boolean, + ) public fun colorToHex(color: Color): String @@ -121,6 +177,12 @@ public interface OneConfigUIComponentsBridge { @Composable public fun pageSearchQuery(): String + + @Composable + public fun optionWidthOrNull(): Dp? + + @Composable + public fun pageSearchQueryOrNull(): String? } @ExperimentalOneConfigUiApi diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIExtensionsBridge.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIExtensionsBridge.kt index e336211c6..df8d79ac7 100644 --- a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIExtensionsBridge.kt +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIExtensionsBridge.kt @@ -2,8 +2,10 @@ package org.polyfrost.oneconfig.api.ui.v1.internal import org.polyfrost.oneconfig.api.config.v1.Property import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.InternalOneConfigUiApi @ExperimentalOneConfigUiApi +@InternalOneConfigUiApi public interface OneConfigUIExtensionsBridge { public fun modOrder(): List diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIFeedbackBridge.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIFeedbackBridge.kt new file mode 100644 index 000000000..13ba852a6 --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIFeedbackBridge.kt @@ -0,0 +1,21 @@ +package org.polyfrost.oneconfig.api.ui.v1.internal + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.InternalOneConfigUiApi + +@ExperimentalOneConfigUiApi +@InternalOneConfigUiApi +public interface OneConfigUIFeedbackBridge { + @Composable + public fun NotificationsCenter(modifier: Modifier) +} + +@ExperimentalOneConfigUiApi +private val loadedFeedbackBridge: OneConfigUIFeedbackBridge? by lazy { + loadBridge(OneConfigUIFeedbackBridge::class.java, "the notification centre panel stays empty") +} + +@ExperimentalOneConfigUiApi +internal fun feedbackBridge(): OneConfigUIFeedbackBridge? = loadedFeedbackBridge diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIGlassBridge.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIGlassBridge.kt new file mode 100644 index 000000000..10fa0603e --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIGlassBridge.kt @@ -0,0 +1,42 @@ +package org.polyfrost.oneconfig.api.ui.v1.internal + +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.ScrollableState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.InternalOneConfigUiApi + +@ExperimentalOneConfigUiApi +@InternalOneConfigUiApi +public interface OneConfigUIGlassBridge { + @Composable + public fun glassHover(modifier: Modifier, shape: Shape, pull: Dp): Modifier + + @Composable + public fun liquidGlass(modifier: Modifier, shape: Shape): Modifier + + public fun animatedFrame(modifier: Modifier, shape: Shape, fill: () -> Color, accentAlpha: () -> Float, border: () -> Color): Modifier + + @Composable + public fun liquidGlass(modifier: Modifier, shape: Shape, frosted: Boolean): Modifier + + @Composable + public fun backdropBlur(modifier: Modifier, shape: Shape): Modifier + + public fun wheelScroll(modifier: Modifier, state: ScrollableState, orientation: Orientation): Modifier + + @Composable + public fun ProvideThemeVariant(dark: Boolean, keepAccent: Boolean, content: @Composable () -> Unit) +} + +@ExperimentalOneConfigUiApi +private val loadedGlassBridge: OneConfigUIGlassBridge? by lazy { + loadBridge(OneConfigUIGlassBridge::class.java, "kit controls draw without the shell's glass") +} + +@ExperimentalOneConfigUiApi +internal fun glassBridge(): OneConfigUIGlassBridge? = loadedGlassBridge diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIKeybindBridge.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIKeybindBridge.kt index b205a8932..b2e5e4291 100644 --- a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIKeybindBridge.kt +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIKeybindBridge.kt @@ -1,15 +1,18 @@ package org.polyfrost.oneconfig.api.ui.v1.internal import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.InternalOneConfigUiApi import org.polyfrost.oneconfig.api.ui.v1.keybind.ConfigKeybind import org.polyfrost.oneconfig.api.ui.v1.keybind.OneConfigKeybind @ExperimentalOneConfigUiApi +@InternalOneConfigUiApi public interface OneConfigUIKeybindBridge { public fun configKeybinds(): List } @ExperimentalOneConfigUiApi +@InternalOneConfigUiApi public object KeybindInterop { @JvmStatic public fun configKeybind(keybind: OneConfigKeybind, title: String, path: String, modId: String, modTitle: String): ConfigKeybind = diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIKeybindRecordingBridge.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIKeybindRecordingBridge.kt new file mode 100644 index 000000000..2acca31f7 --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIKeybindRecordingBridge.kt @@ -0,0 +1,22 @@ +package org.polyfrost.oneconfig.api.ui.v1.internal + +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.InternalOneConfigUiApi + +@ExperimentalOneConfigUiApi +@InternalOneConfigUiApi +public interface OneConfigUIKeybindRecordingBridge { + public fun startRecording(onEscape: () -> Unit, onMouse: (button: Int, pressed: Boolean) -> Unit) + + public fun stopRecording(onEscape: () -> Unit, onMouse: (button: Int, pressed: Boolean) -> Unit) + + public val isRecording: Boolean +} + +@ExperimentalOneConfigUiApi +private val loadedKeybindRecordingBridge: OneConfigUIKeybindRecordingBridge? by lazy { + loadBridge(OneConfigUIKeybindRecordingBridge::class.java, "keybind fields cannot record mouse buttons and Escape may close the screen while recording") +} + +@ExperimentalOneConfigUiApi +internal fun keybindRecordingBridge(): OneConfigUIKeybindRecordingBridge? = loadedKeybindRecordingBridge diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIPlayerHeadBridge.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIPlayerHeadBridge.kt index 1db286c83..ce2641f43 100644 --- a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIPlayerHeadBridge.kt +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUIPlayerHeadBridge.kt @@ -3,15 +3,20 @@ package org.polyfrost.oneconfig.api.ui.v1.internal import androidx.compose.runtime.State import androidx.compose.ui.graphics.ImageBitmap import java.util.UUID +import org.polyfrost.compose.render.PolyBitmap import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.InternalOneConfigUiApi @ExperimentalOneConfigUiApi +@InternalOneConfigUiApi public interface OneConfigUIPlayerHeadBridge { public fun face(id: UUID): ImageBitmap? public fun observe(id: UUID): State public fun defaultFace(id: UUID): ImageBitmap? + + public fun bitmap(id: UUID): PolyBitmap? } @ExperimentalOneConfigUiApi diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUISearchBridge.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUISearchBridge.kt index ffd3e1628..4f5f8fc7a 100644 --- a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUISearchBridge.kt +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/internal/OneConfigUISearchBridge.kt @@ -1,12 +1,14 @@ package org.polyfrost.oneconfig.api.ui.v1.internal import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.InternalOneConfigUiApi import org.polyfrost.oneconfig.api.ui.v1.search.SearchDocument import org.polyfrost.oneconfig.api.ui.v1.search.SearchMetadata import org.polyfrost.oneconfig.api.ui.v1.search.SearchProvider import org.polyfrost.oneconfig.api.ui.v1.search.SearchScope @ExperimentalOneConfigUiApi +@InternalOneConfigUiApi public interface OneConfigUISearchBridge { public fun register(provider: SearchProvider) @@ -18,6 +20,7 @@ public interface OneConfigUISearchBridge { } @ExperimentalOneConfigUiApi +@InternalOneConfigUiApi public object SearchInterop { @JvmStatic public fun scope(key: String): SearchScope = SearchScope(key) diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/keybind/BindNotInScreen.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/keybind/BindNotInScreen.kt index b6fbef914..8f55b5334 100644 --- a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/keybind/BindNotInScreen.kt +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/keybind/BindNotInScreen.kt @@ -1,6 +1,7 @@ package org.polyfrost.oneconfig.api.ui.v1.keybind import org.polyfrost.oneconfig.api.platform.v1.Platform +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi public class BindNotInScreen( keyCodes: IntArray?, @@ -13,11 +14,13 @@ public class BindNotInScreen( return super.test(downKeys, downMouse, currentMods) && Platform.screen().current() == null } + @OptIn(ExperimentalOneConfigUiApi::class) override fun copyWith(keyCodes: IntArray?, mouseBtns: IntArray?, mods: Byte): OneConfigKeybind = BindNotInScreen(keyCodes, mouseBtns, mods, durationNanos, action).also { it.name = name it.category = category it.defaultKeybind = defaultKeybind it.firesWhileTyping = firesWhileTyping + it.mirrorToVanilla = mirrorToVanilla } } diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/keybind/KeybindHelper.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/keybind/KeybindHelper.kt index 5f4814390..82130b95d 100644 --- a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/keybind/KeybindHelper.kt +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/keybind/KeybindHelper.kt @@ -2,6 +2,7 @@ package org.polyfrost.oneconfig.api.ui.v1.keybind import java.util.function.Consumer import kotlin.experimental.or +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi public class KeybindHelper { private val keyCodes = mutableListOf() @@ -13,6 +14,7 @@ public class KeybindHelper { private var action: ((Boolean) -> Boolean)? = null private var name: String? = null private var category: String? = null + private var mirrorToVanilla = true public fun key(vararg codes: Int): KeybindHelper = apply { keyCodes.addAll(codes.asList()) } public fun mouse(vararg btns: Int): KeybindHelper = apply { mouseBtns.addAll(btns.asList()) } @@ -26,6 +28,8 @@ public class KeybindHelper { * Defaults to "OneConfig" */ public fun category(category: String): KeybindHelper = apply { this.category = category } + @ExperimentalOneConfigUiApi + public fun mirrorToVanilla(mirror: Boolean): KeybindHelper = apply { mirrorToVanilla = mirror } public fun shift(): KeybindHelper = apply { mods = mods or KeyModifiers.SHIFT } public fun ctrl(): KeybindHelper = apply { mods = mods or KeyModifiers.CTRL } public fun alt(): KeybindHelper = apply { mods = mods or KeyModifiers.ALT } @@ -46,6 +50,7 @@ public class KeybindHelper { * Config-backed (`@Keybind`) binds exist only to be bound by the user and are polled by the owning code * so they build with a no-op action */ + @OptIn(ExperimentalOneConfigUiApi::class) public fun build(): OneConfigKeybind { val fn = action ?: NO_OP val keys = keyCodes.toIntArray().takeIf { it.isNotEmpty() } @@ -55,6 +60,7 @@ public class KeybindHelper { bind.name = name bind.category = category bind.firesWhileTyping = firesWhileTyping + bind.mirrorToVanilla = mirrorToVanilla return bind } diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/keybind/KeybindManager.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/keybind/KeybindManager.kt index 27cc7c125..821ef31bf 100644 --- a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/keybind/KeybindManager.kt +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/keybind/KeybindManager.kt @@ -87,10 +87,11 @@ public object KeybindManager { } } + @OptIn(ExperimentalOneConfigUiApi::class) @JvmStatic public fun register(bind: OneConfigKeybind): OneConfigKeybind { if (bind !in binds) binds.add(bind) - bridge?.register(bind) + if (bind.mirrorToVanilla) bridge?.register(bind) return bind } @@ -110,9 +111,11 @@ public object KeybindManager { * Use after mutating a keybind's [OneConfigKeybind.name]/[OneConfigKeybind.category] so the vanilla * mapping picks up the new metadata */ + @OptIn(ExperimentalOneConfigUiApi::class) @JvmStatic public fun refreshMinecraftBinding(bind: OneConfigKeybind) { - if (bind in binds) bridge?.register(bind) + if (bind !in binds) return + if (bind.mirrorToVanilla) bridge?.register(bind) else bridge?.unregister(bind) } /** @@ -123,12 +126,14 @@ public object KeybindManager { * * Nothing happens if [old] was never registered */ + @OptIn(ExperimentalOneConfigUiApi::class) @JvmStatic public fun replace(old: OneConfigKeybind?, new: OneConfigKeybind): OneConfigKeybind { if (old != null && new !== old) { if (new.name == null) new.name = old.name if (new.category == null) new.category = old.category if (new.defaultKeybind == null) new.defaultKeybind = old.defaultKeybind + if (!old.mirrorToVanilla) new.mirrorToVanilla = false } if (old === new) { bridge?.sync(new) @@ -238,12 +243,14 @@ public object KeybindManager { @JvmStatic public fun builder(): KeybindHelper = KeybindHelper() + @OptIn(ExperimentalOneConfigUiApi::class) private fun checkBinds() { val typing = TextInputFocus.isTyping + val captured = isInputCaptured() for (bind in binds) { @Suppress("SENSELESS_COMPARISON") if (bind.action == null) continue - val triggered = (!typing || bind.firesWhileTyping) && bind.test(downKeys, downMouse, mods) + val triggered = !captured && (!typing || bind.firesWhileTyping) && bind.test(downKeys, downMouse, mods) val wasActive = bind in activeBinds try { if (triggered && !wasActive) { diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/keybind/KeybindMetadata.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/keybind/KeybindMetadata.kt new file mode 100644 index 000000000..49c9396aa --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/keybind/KeybindMetadata.kt @@ -0,0 +1,10 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.keybind + +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi + +@ExperimentalOneConfigUiApi +public object KeybindMetadata { + public const val NO_MIRROR: String = "oc_no_mc_mirror" +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/keybind/OneConfigKeybind.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/keybind/OneConfigKeybind.kt index 0dc0ec782..40b365ff6 100644 --- a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/keybind/OneConfigKeybind.kt +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/keybind/OneConfigKeybind.kt @@ -2,6 +2,7 @@ package org.polyfrost.oneconfig.api.ui.v1.keybind import kotlin.experimental.and import org.polyfrost.oneconfig.api.platform.v1.Platform +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi import org.polyfrost.oneconfig.utils.v1.OverwriteMergeable public open class OneConfigKeybind( @@ -47,6 +48,15 @@ public open class OneConfigKeybind( @Transient public var firesWhileTyping: Boolean = false + @ExperimentalOneConfigUiApi + @Transient + public var mirrorToVanilla: Boolean = true + set(value) { + if (field == value) return + field = value + KeybindManager.refreshMinecraftBinding(this) + } + /** * Serialized key inputs that could not be resolved on this Minecraft version. * @@ -84,12 +94,14 @@ public open class OneConfigKeybind( * * Preserves the action and duration and subtype and Controls-menu metadata */ + @OptIn(ExperimentalOneConfigUiApi::class) public open fun copyWith(keyCodes: IntArray?, mouseBtns: IntArray?, mods: Byte): OneConfigKeybind = OneConfigKeybind(keyCodes, mouseBtns, mods, durationNanos, action).also { it.name = name it.category = category it.defaultKeybind = defaultKeybind it.firesWhileTyping = firesWhileTyping + it.mirrorToVanilla = mirrorToVanilla } /** diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/page/OneConfigDestination.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/page/OneConfigDestination.kt new file mode 100644 index 000000000..f5c5cebc2 --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/page/OneConfigDestination.kt @@ -0,0 +1,17 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + +package org.polyfrost.oneconfig.api.ui.v1.page + +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi + +@ExperimentalOneConfigUiApi +public sealed interface OneConfigDestination { + @ExperimentalOneConfigUiApi + public data class Page(public val id: String) : OneConfigDestination + + @ExperimentalOneConfigUiApi + public data class ModConfig(public val target: ModConfigTarget) : OneConfigDestination + + @ExperimentalOneConfigUiApi + public data class Custom(public val page: OneConfigPage) : OneConfigDestination +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/page/OneConfigPages.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/page/OneConfigPages.kt index 5eaabdfb7..3d612fb81 100644 --- a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/page/OneConfigPages.kt +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/page/OneConfigPages.kt @@ -20,6 +20,13 @@ public object OneConfigPages { } } + @JvmStatic + public fun registerIfAbsent(page: OneConfigPage): Boolean = synchronized(lock) { + if (pages.any { it.id == page.id }) return@synchronized false + Snapshot.withMutableSnapshot { pages.add(page) } + true + } + @JvmStatic public fun unregister(id: String): Boolean = synchronized(lock) { Snapshot.withMutableSnapshot { pages.removeAll { it.id == id } } } diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/page/PageSearchQuery.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/page/PageSearchQuery.kt index 39117d80e..6639b8dae 100644 --- a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/page/PageSearchQuery.kt +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/page/PageSearchQuery.kt @@ -4,6 +4,11 @@ import androidx.compose.runtime.Composable import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi import org.polyfrost.oneconfig.api.ui.v1.internal.componentsBridge +@Deprecated("Returns an empty query outside a OneConfig page. Use currentPageSearchQueryOrNull.", ReplaceWith("currentPageSearchQueryOrNull")) @ExperimentalOneConfigUiApi public val currentPageSearchQuery: String @Composable get() = componentsBridge().pageSearchQuery() + +@ExperimentalOneConfigUiApi +public val currentPageSearchQueryOrNull: String? + @Composable get() = componentsBridge().pageSearchQueryOrNull() diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/page/PageText.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/page/PageText.kt index d48df1e30..e42e5776b 100644 --- a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/page/PageText.kt +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/page/PageText.kt @@ -1,5 +1,9 @@ +@file:OptIn(ExperimentalOneConfigUiApi::class) + package org.polyfrost.oneconfig.api.ui.v1.page +import androidx.compose.runtime.Composable +import org.polyfrost.oneconfig.api.platform.v1.Platform import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi @ExperimentalOneConfigUiApi @@ -22,3 +26,15 @@ public class PageText internal constructor(public val key: String?, public val f public fun literal(text: String): PageText = PageText(null, text) } } + +@ExperimentalOneConfigUiApi +@Composable +public fun PageText.resolve(): String = translate() + +internal fun PageText.translate(): String { + val key = key ?: return fallback + return runCatching { + val i18n = Platform.i18n() + if (i18n.hasTranslation(key)) i18n.translateString(key) else null + }.getOrNull() ?: fallback +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/theme/OneConfigTheme.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/theme/OneConfigTheme.kt index 727f06214..391a11953 100644 --- a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/theme/OneConfigTheme.kt +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/theme/OneConfigTheme.kt @@ -1,11 +1,18 @@ package org.polyfrost.oneconfig.api.ui.v1.theme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.components.ContextMenuRoot +import org.polyfrost.oneconfig.api.ui.v1.components.FeedbackHosts +import org.polyfrost.oneconfig.api.ui.v1.components.ProvideOneConfigTextContextMenu import org.polyfrost.oneconfig.api.ui.v1.internal.componentsBridge @ExperimentalOneConfigUiApi @@ -39,6 +46,15 @@ public object OneConfigTheme { public val current: OneConfigUITheme @Composable get() = componentsBridge().currentTheme() + public val typography: OneConfigTypography + @Composable get() = current.typography + + public val shapes: OneConfigShapes + @Composable get() = current.shapes + + public val spacing: OneConfigSpacing + @Composable get() = current.spacing + public val accent: Color @Composable get() = componentsBridge().accent() @@ -63,6 +79,18 @@ public object OneConfigTheme { componentsBridge().ensureThemeLoaded() } + @Composable + public fun shape(radius: Dp): Shape = componentsBridge().themeShape(radius) + + @Composable + public fun icon(name: String): String = componentsBridge().themeIcon(name) + + @JvmStatic + public fun custom(base: OneConfigUITheme, id: String, name: String, block: ThemeBuilder.() -> Unit): OneConfigUITheme { + require(id.isNotBlank()) { "A custom theme needs a non-blank id" } + return componentsBridge().customTheme(base, id, name, ThemeBuilder().apply(block)) + } + @JvmStatic public var logoOverride: String? by mutableStateOf(null) private set @@ -73,8 +101,34 @@ public object OneConfigTheme { } } +@ExperimentalOneConfigUiApi +@Composable +public fun ProvideOneConfigTheme( + theme: OneConfigUITheme? = null, + scaleToUi: Boolean = false, + content: @Composable () -> Unit, +) { + val nested = LocalThemeProvided.current + if (nested && theme == null && !scaleToUi) { + content() + return + } + componentsBridge().ProvideTheme(theme, scaleToUi) { + if (nested) { + content() + } else { + CompositionLocalProvider(LocalThemeProvided provides true) { + ProvideOneConfigTextContextMenu { ContextMenuRoot { FeedbackHosts(content) } } + } + } + } +} + +private val LocalThemeProvided = staticCompositionLocalOf { false } + +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) @ExperimentalOneConfigUiApi @Composable public fun ProvideOneConfigTheme(content: @Composable () -> Unit) { - componentsBridge().ProvideTheme(content) + ProvideOneConfigTheme(null, false, content) } diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/theme/OneConfigUITheme.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/theme/OneConfigUITheme.kt index c6a684c75..4c3370728 100644 --- a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/theme/OneConfigUITheme.kt +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/theme/OneConfigUITheme.kt @@ -1,12 +1,19 @@ package org.polyfrost.oneconfig.api.ui.v1.theme +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.text.font.FontFamily import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi +import org.polyfrost.oneconfig.api.ui.v1.InternalOneConfigUiApi @ExperimentalOneConfigUiApi -public abstract class OneConfigUITheme { +public abstract class OneConfigUITheme @InternalOneConfigUiApi constructor() { public abstract val name: String public abstract val pageBackground: Color @@ -37,4 +44,64 @@ public abstract class OneConfigUITheme { public abstract val fontFamily: FontFamily public abstract val logoPath: String + + public open val style: ThemeStyle get() = ThemeStyle.Flat + + public open val isLight: Boolean get() = textColor.luminance() < 0.5f + + public val pageSurface: Color get() = pageBackground.compositeOver(if (isLight) Color.White else Color.Black) + + public open val error: Color get() = Color(0xFFE35B5B).legibleOn(pageSurface) + + public open val warning: Color get() = Color(0xFFFFAB1D).legibleOn(pageSurface) + + public open val success: Color get() = Color(0xFF3CAF77).legibleOn(pageSurface) + + public open val info: Color get() = Color(0xFF5B9BE3).legibleOn(pageSurface) + + public open val onError: Color get() = readableOn(error) + + public open val onSuccess: Color get() = readableOn(success) + + public open val mutedText: Color + get() = if (textColorSecondary.contrastAgainst(pageSurface) >= 4.5f) textColorSecondary else textColor + + public open val scrimColor: Color get() = Color.Black.copy(alpha = if (isLight) 0.35f else 0.5f) + + public open val focusRingColor: Color get() = textColor + + public open val disabledAlpha: Float get() = 0.5f + + public open val hoverOverlayAlpha: Float get() = if (isLight) 0.06f else 0.08f + + public open val pressedOverlayAlpha: Float get() = if (isLight) 0.12f else 0.14f + + public open val controlTrackColor: Color get() = Color(0xFF74777F) + + public open val accentBrush: Brush get() = SolidColor(accentTextColor) + + public open val onMedia: Color get() = Color.White + + public open val onMediaShadow: Color get() = Color.Black.copy(alpha = 0.6f) + + public open val checkerLight: Color get() = if (isLight) Color(0xFFFFFFFF) else Color(0xFF9A9A9A) + + public open val checkerDark: Color get() = if (isLight) Color(0xFFD0D0D0) else Color(0xFF6A6A6A) + + public open val shapes: OneConfigShapes + get() = OneConfigShapes( + small = checkBoxShape, + medium = buttonShape, + large = modCardShape, + field = sideBarNavigationEntryShape, + pill = if (style == ThemeStyle.Pixel) RectangleShape else RoundedCornerShape(percent = 50), + circle = circleShape, + ) + + public open val typography: OneConfigTypography get() = OneConfigTypography(fontFamily) + + public open val spacing: OneConfigSpacing get() = OneConfigSpacing.Default + + private fun readableOn(fill: Color): Color = + if (Color.White.contrastAgainst(fill) >= Color.Black.contrastAgainst(fill)) Color.White else Color.Black } diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/theme/ThemeBuilder.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/theme/ThemeBuilder.kt new file mode 100644 index 000000000..049eda2e9 --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/theme/ThemeBuilder.kt @@ -0,0 +1,23 @@ +package org.polyfrost.oneconfig.api.ui.v1.theme + +import androidx.compose.ui.graphics.Color +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi + +@ExperimentalOneConfigUiApi +public class ThemeBuilder internal constructor() { + public var pageBackground: Color? = null + public var sidebarBackground: Color? = null + public var chipBackground: Color? = null + public var modCardBackground: Color? = null + public var componentBackground: Color? = null + public var popupBackground: Color? = null + public var borderColor: Color? = null + public var textColor: Color? = null + public var textColorSecondary: Color? = null + public var accentTextColor: Color? = null + public var shadowColor: Color? = null + public var controlThumbColor: Color? = null + public var favoriteColor: Color? = null + public var controlTrackColor: Color? = null + public var shadowEnabled: Boolean? = null +} diff --git a/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/theme/ThemeTokens.kt b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/theme/ThemeTokens.kt new file mode 100644 index 000000000..265937564 --- /dev/null +++ b/modules/ui/src/main/kotlin/org/polyfrost/oneconfig/api/ui/v1/theme/ThemeTokens.kt @@ -0,0 +1,94 @@ +package org.polyfrost.oneconfig.api.ui.v1.theme + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.polyfrost.oneconfig.api.ui.v1.ExperimentalOneConfigUiApi + +@ExperimentalOneConfigUiApi +public enum class ThemeStyle { + Flat, + + Glass, + + Pixel, +} + +@ExperimentalOneConfigUiApi +public class OneConfigShapes( + public val small: Shape, + public val medium: Shape, + public val large: Shape, + public val field: Shape, + public val pill: Shape, + public val circle: Shape, +) + +@ExperimentalOneConfigUiApi +public open class OneConfigTypography( + public val title: TextStyle, + public val heading: TextStyle, + public val body: TextStyle, + public val label: TextStyle, + public val small: TextStyle, + public val caption: TextStyle, + public val eyebrow: TextStyle, + public val mono: TextStyle, +) { + public constructor(fontFamily: FontFamily) : this( + title = TextStyle(fontFamily = fontFamily, fontSize = 18.sp, fontWeight = FontWeight.SemiBold), + heading = TextStyle(fontFamily = fontFamily, fontSize = 15.sp, fontWeight = FontWeight.SemiBold), + body = TextStyle(fontFamily = fontFamily, fontSize = 14.sp, fontWeight = FontWeight.Normal), + label = TextStyle(fontFamily = fontFamily, fontSize = 13.sp, fontWeight = FontWeight.Medium), + small = TextStyle(fontFamily = fontFamily, fontSize = 12.sp, fontWeight = FontWeight.Normal), + caption = TextStyle(fontFamily = fontFamily, fontSize = 11.sp, fontWeight = FontWeight.Normal), + eyebrow = TextStyle(fontFamily = fontFamily, fontSize = 11.sp, fontWeight = FontWeight.Medium, letterSpacing = 1.5.sp), + mono = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 12.sp, fontWeight = FontWeight.Normal), + ) +} + +@ExperimentalOneConfigUiApi +public class OneConfigSpacing( + public val xxs: Dp = 2.dp, + public val xs: Dp = 4.dp, + public val s: Dp = 6.dp, + public val m: Dp = 8.dp, + public val l: Dp = 12.dp, + public val xl: Dp = 16.dp, + public val xxl: Dp = 24.dp, + public val pageGutter: Dp = 25.dp, +) { + public companion object { + @JvmField + public val Default: OneConfigSpacing = OneConfigSpacing() + } +} + +private const val CONTRAST_STEPS = 20 + +@ExperimentalOneConfigUiApi +public fun Color.contrastAgainst(other: Color): Float { + val front = if (alpha < 1f) compositeOver(other) else this + val a = front.luminance() + 0.05f + val b = other.luminance() + 0.05f + return if (a > b) a / b else b / a +} + +@ExperimentalOneConfigUiApi +public fun Color.legibleOn(surface: Color, minContrast: Float = 4.5f): Color { + if (contrastAgainst(surface) >= minContrast) return this + val target = if (surface.luminance() > 0.5f) Color.Black else Color.White + for (step in 1..CONTRAST_STEPS) { + val candidate = lerp(this, target.copy(alpha = alpha), step / CONTRAST_STEPS.toFloat()) + if (candidate.contrastAgainst(surface) >= minContrast) return candidate + } + return target.copy(alpha = alpha) +} diff --git a/modules/utils/api/utils.api b/modules/utils/api/utils.api index 799b9abfe..7634f3e85 100644 --- a/modules/utils/api/utils.api +++ b/modules/utils/api/utils.api @@ -220,6 +220,20 @@ public final class org/polyfrost/oneconfig/utils/v1/Deprecator { public static fun markDeprecated ()V } +public final class org/polyfrost/oneconfig/utils/v1/Formatters { + public static final field INSTANCE Lorg/polyfrost/oneconfig/utils/v1/Formatters; + public static final fun count (I)Ljava/lang/String; + public static final fun count (J)Ljava/lang/String; + public static final fun relativeTime (Ljava/time/Instant;)Ljava/lang/String; + public static final fun relativeTime (Ljava/time/Instant;Ljava/time/Instant;)Ljava/lang/String; + public static synthetic fun relativeTime$default (Ljava/time/Instant;Ljava/time/Instant;ILjava/lang/Object;)Ljava/lang/String; + public static final fun size (J)Ljava/lang/String; + public static final fun time (Ljava/time/Instant;)Ljava/lang/String; + public static final fun time (Ljava/time/Instant;Ljava/time/ZoneId;)Ljava/lang/String; + public static final fun time (Ljava/time/Instant;Ljava/time/ZoneId;Ljava/time/Instant;)Ljava/lang/String; + public static synthetic fun time$default (Ljava/time/Instant;Ljava/time/ZoneId;Ljava/time/Instant;ILjava/lang/Object;)Ljava/lang/String; +} + public final class org/polyfrost/oneconfig/utils/v1/IOUtils { public static fun getFileChecksum (Ljava/nio/file/Path;)Ljava/lang/String; } @@ -281,6 +295,15 @@ public final class org/polyfrost/oneconfig/utils/v1/NetworkUtils { public static fun downloadFile (Ljava/lang/String;Ljava/nio/file/Path;Ljava/lang/String;IZ)Z public static fun getString (Ljava/lang/String;)Ljava/lang/String; public static fun getString (Ljava/lang/String;Ljava/lang/String;IZ)Ljava/lang/String; + public static fun post (Ljava/lang/String;[BLjava/lang/String;)Ljava/lang/String; + public static fun post (Ljava/lang/String;[BLjava/lang/String;Ljava/util/Map;)Ljava/lang/String; + public static fun post (Ljava/lang/String;[BLjava/lang/String;Ljava/util/Map;I)Ljava/lang/String; + public static fun postAsync (Ljava/lang/String;[BLjava/lang/String;)Ljava/util/concurrent/CompletableFuture; + public static fun postAsync (Ljava/lang/String;[BLjava/lang/String;Ljava/util/Map;)Ljava/util/concurrent/CompletableFuture; + public static fun postAsync (Ljava/lang/String;[BLjava/lang/String;Ljava/util/Map;I)Ljava/util/concurrent/CompletableFuture; + public static fun postJson (Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String; + public static fun postJson (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;)Ljava/lang/String; + public static fun postJson (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;I)Ljava/lang/String; public static fun setupConnection (Ljava/lang/String;)Ljava/io/InputStream; public static fun setupConnection (Ljava/lang/String;Ljava/lang/String;IZ)Ljava/io/InputStream; } diff --git a/modules/utils/src/main/java/org/polyfrost/oneconfig/utils/v1/NetworkUtils.java b/modules/utils/src/main/java/org/polyfrost/oneconfig/utils/v1/NetworkUtils.java index 2f169d230..5a022bdf3 100644 --- a/modules/utils/src/main/java/org/polyfrost/oneconfig/utils/v1/NetworkUtils.java +++ b/modules/utils/src/main/java/org/polyfrost/oneconfig/utils/v1/NetworkUtils.java @@ -28,9 +28,11 @@ import java.io.BufferedInputStream; import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; +import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.URI; import java.net.URL; @@ -38,9 +40,13 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.CompletableFuture; import java.util.zip.GZIPInputStream; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Nullable; import org.polyfrost.oneconfig.api.platform.v1.DesktopHelper; @@ -90,6 +96,102 @@ public static String getString(String url) { return getString(url, DEF_AGENT, 5000, false); } + @Nullable + @ApiStatus.Experimental + @SuppressWarnings("deprecation") + public static String post(String url, byte[] body, String contentType, Map headers, int timeoutMillis) { + HttpURLConnection connection = null; + try { + connection = (HttpURLConnection) new URL(url).openConnection(); + connection.setRequestMethod("POST"); + connection.setUseCaches(false); + connection.setDoOutput(true); + connection.setReadTimeout(timeoutMillis); + connection.setConnectTimeout(timeoutMillis); + connection.setRequestProperty("User-Agent", DEF_AGENT); + connection.setRequestProperty("Content-Type", contentType); + for (Map.Entry header : headers.entrySet()) { + connection.setRequestProperty(header.getKey(), header.getValue()); + } + connection.setFixedLengthStreamingMode(body.length); + try (OutputStream out = connection.getOutputStream()) { + out.write(body); + } + int status = connection.getResponseCode(); + if (status < 200 || status >= 300) { + InputStream error = connection.getErrorStream(); + String message = error == null ? "" : readBody(connection, error); + LOGGER.error("POST to {} failed with HTTP {}: {}", url, status, message); + return null; + } + return readBody(connection, connection.getInputStream()); + } catch (Exception e) { + LOGGER.error("Failed to POST to {}", url, e); + return null; + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + + @Nullable + @ApiStatus.Experimental + public static String post(String url, byte[] body, String contentType, Map headers) { + return post(url, body, contentType, headers, 10000); + } + + @Nullable + @ApiStatus.Experimental + public static String post(String url, byte[] body, String contentType) { + return post(url, body, contentType, Collections.emptyMap(), 10000); + } + + @Nullable + @ApiStatus.Experimental + public static String postJson(String url, String json, Map headers, int timeoutMillis) { + return post(url, json.getBytes(StandardCharsets.UTF_8), "application/json; charset=utf-8", headers, timeoutMillis); + } + + @Nullable + @ApiStatus.Experimental + public static String postJson(String url, String json, Map headers) { + return postJson(url, json, headers, 10000); + } + + @Nullable + @ApiStatus.Experimental + public static String postJson(String url, String json) { + return postJson(url, json, Collections.emptyMap(), 10000); + } + + @ApiStatus.Experimental + public static CompletableFuture<@Nullable String> postAsync(String url, byte[] body, String contentType, Map headers, int timeoutMillis) { + return CompletableFuture.supplyAsync(() -> post(url, body, contentType, headers, timeoutMillis), Multithreading.getExecutor()); + } + + @ApiStatus.Experimental + public static CompletableFuture<@Nullable String> postAsync(String url, byte[] body, String contentType, Map headers) { + return postAsync(url, body, contentType, headers, 10000); + } + + @ApiStatus.Experimental + public static CompletableFuture<@Nullable String> postAsync(String url, byte[] body, String contentType) { + return postAsync(url, body, contentType, Collections.emptyMap(), 10000); + } + + private static String readBody(HttpURLConnection connection, InputStream raw) throws IOException { + try (InputStream stream = "gzip".equalsIgnoreCase(connection.getContentEncoding()) ? new GZIPInputStream(raw) : raw) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + int read; + while ((read = stream.read(buffer)) != -1) { + out.write(buffer, 0, read); + } + return new String(out.toByteArray(), StandardCharsets.UTF_8); + } + } + /** * Downloads a path from a URL * diff --git a/modules/utils/src/main/kotlin/org/polyfrost/oneconfig/utils/v1/Formatters.kt b/modules/utils/src/main/kotlin/org/polyfrost/oneconfig/utils/v1/Formatters.kt new file mode 100644 index 000000000..ea19e508d --- /dev/null +++ b/modules/utils/src/main/kotlin/org/polyfrost/oneconfig/utils/v1/Formatters.kt @@ -0,0 +1,161 @@ +package org.polyfrost.oneconfig.utils.v1 + +import java.math.RoundingMode +import java.text.DecimalFormat +import java.text.DecimalFormatSymbols +import java.text.NumberFormat +import java.time.Duration +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Locale +import kotlin.math.abs +import org.jetbrains.annotations.ApiStatus +import org.polyfrost.oneconfig.api.platform.v1.Platform + +@ApiStatus.Experimental +public object Formatters { + private const val TIME = "oneconfig.format.time." + private const val COUNT = "oneconfig.format.count." + private const val SIZE = "oneconfig.format.size." + + private val english: Map = mapOf( + "${TIME}just_now" to "just now", + "${TIME}moment" to "in a moment", + "${TIME}seconds_ago" to "%s seconds ago", + "${TIME}minute_ago" to "%s minute ago", + "${TIME}minutes_ago" to "%s minutes ago", + "${TIME}hour_ago" to "%s hour ago", + "${TIME}hours_ago" to "%s hours ago", + "${TIME}day_ago" to "%s day ago", + "${TIME}days_ago" to "%s days ago", + "${TIME}month_ago" to "%s month ago", + "${TIME}months_ago" to "%s months ago", + "${TIME}year_ago" to "%s year ago", + "${TIME}years_ago" to "%s years ago", + "${TIME}in_seconds" to "in %s seconds", + "${TIME}in_minute" to "in %s minute", + "${TIME}in_minutes" to "in %s minutes", + "${TIME}in_hour" to "in %s hour", + "${TIME}in_hours" to "in %s hours", + "${TIME}in_day" to "in %s day", + "${TIME}in_days" to "in %s days", + "${TIME}in_month" to "in %s month", + "${TIME}in_months" to "in %s months", + "${TIME}in_year" to "in %s year", + "${TIME}in_years" to "in %s years", + "${TIME}today_at" to "Today, %s", + "${TIME}yesterday_at" to "Yesterday, %s", + "${COUNT}thousand" to "%sK", + "${COUNT}million" to "%sM", + "${COUNT}billion" to "%sB", + "${COUNT}trillion" to "%sT", + "${SIZE}bytes" to "%s B", + "${SIZE}kilobytes" to "%s KB", + "${SIZE}megabytes" to "%s MB", + "${SIZE}gigabytes" to "%s GB", + "${SIZE}terabytes" to "%s TB", + ) + + @Volatile + private var cachedLocale: Pair? = null + + @JvmStatic + @JvmOverloads + public fun relativeTime(instant: Instant, now: Instant = Instant.now()): String { + val elapsed = Duration.between(instant, now).seconds + val future = elapsed < 0 + val seconds = abs(elapsed) + if (seconds < 10) return translate(if (future) "${TIME}moment" else "${TIME}just_now") + val days = seconds / 86_400 + return when { + seconds < 60 -> relative(future, "second", seconds) + seconds < 3_600 -> relative(future, "minute", seconds / 60) + seconds < 86_400 -> relative(future, "hour", seconds / 3_600) + days < 30 -> relative(future, "day", days) + days < 365 -> relative(future, "month", days / 30) + else -> relative(future, "year", days / 365) + } + } + + @JvmStatic + @JvmOverloads + public fun time(instant: Instant, zone: ZoneId = ZoneId.systemDefault(), now: Instant = Instant.now()): String { + val locale = locale() + val dateTime = instant.atZone(zone) + val today = now.atZone(zone).toLocalDate() + val clock = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale).format(dateTime) + val text = when (dateTime.toLocalDate()) { + today -> translate("${TIME}today_at", clock) + today.minusDays(1) -> translate("${TIME}yesterday_at", clock) + else -> DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT).withLocale(locale).format(dateTime) + } + return text.replace(' ', ' ') + } + + @JvmStatic + public fun count(n: Long): String { + val magnitude = abs(n.toDouble()) + return when { + magnitude < 1e3 -> NumberFormat.getIntegerInstance(locale()).format(n) + magnitude < 1e6 -> translate("${COUNT}thousand", scaled(n / 1e3)) + magnitude < 1e9 -> translate("${COUNT}million", scaled(n / 1e6)) + magnitude < 1e12 -> translate("${COUNT}billion", scaled(n / 1e9)) + else -> translate("${COUNT}trillion", scaled(n / 1e12)) + } + } + + @JvmStatic + public fun count(n: Int): String = count(n.toLong()) + + @JvmStatic + public fun size(bytes: Long): String { + val magnitude = abs(bytes.toDouble()) + return when { + magnitude < KIB -> translate("${SIZE}bytes", NumberFormat.getIntegerInstance(locale()).format(bytes)) + magnitude < MIB -> translate("${SIZE}kilobytes", scaled(bytes / KIB)) + magnitude < GIB -> translate("${SIZE}megabytes", scaled(bytes / MIB)) + magnitude < TIB -> translate("${SIZE}gigabytes", scaled(bytes / GIB)) + else -> translate("${SIZE}terabytes", scaled(bytes / TIB)) + } + } + + private const val KIB = 1024.0 + private const val MIB = KIB * 1024.0 + private const val GIB = MIB * 1024.0 + private const val TIB = GIB * 1024.0 + + private fun relative(future: Boolean, unit: String, amount: Long): String { + val plural = amount != 1L + val key = if (future) { + "${TIME}in_$unit${if (plural) "s" else ""}" + } else { + "${TIME}$unit${if (plural) "s" else ""}_ago" + } + return translate(key, NumberFormat.getIntegerInstance(locale()).format(amount)) + } + + private fun scaled(value: Double): String { + val pattern = if (abs(value) < 100.0) "#,##0.#" else "#,##0" + val format = DecimalFormat(pattern, DecimalFormatSymbols.getInstance(locale())) + format.roundingMode = RoundingMode.DOWN + return format.format(value) + } + + private fun translate(key: String, vararg args: Any): String { + val translated = runCatching { Platform.i18n().translateString(key, *args) }.getOrNull() + if (translated != null && translated != key) return translated + val fallback = english[key] ?: return key + return if (args.isEmpty()) fallback else String.format(locale(), fallback, *args) + } + + private fun locale(): Locale { + val code = runCatching { Platform.i18n().translateString("language.code") }.getOrNull() + if (code.isNullOrBlank() || code == "language.code") return Locale.getDefault() + cachedLocale?.let { (cachedCode, locale) -> if (cachedCode == code) return locale } + val locale = Locale.forLanguageTag(code.replace('_', '-')) + cachedLocale = code to locale + return locale + } +}