diff --git a/tools/idea-plugin/CHANGELOG.md b/tools/idea-plugin/CHANGELOG.md index 2a46e190c..39bb24cfb 100644 --- a/tools/idea-plugin/CHANGELOG.md +++ b/tools/idea-plugin/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - [Web Import] Add `Font Awesome` icons provider +- [Web Import] Add SVG customization controls for standard icon providers, including size, rotation, flips, and persistent custom/recent colors ## 1.4.0 - 2026-03-15 diff --git a/tools/idea-plugin/build.gradle.kts b/tools/idea-plugin/build.gradle.kts index 66525fa6c..5e312ed7f 100644 --- a/tools/idea-plugin/build.gradle.kts +++ b/tools/idea-plugin/build.gradle.kts @@ -50,6 +50,7 @@ dependencies { implementation(projects.sdk.intellij.psi.iconpack) implementation(projects.sdk.intellij.psi.imagevector) implementation(projects.sdk.ir.core) + implementation(projects.sdk.parser.common) implementation(projects.sdk.ir.compose) implementation(projects.sdk.ir.util) implementation(projects.sdk.ir.xml) diff --git a/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/service/PersistentSettings.kt b/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/service/PersistentSettings.kt index e50a46895..9b6c98d7a 100644 --- a/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/service/PersistentSettings.kt +++ b/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/service/PersistentSettings.kt @@ -14,7 +14,7 @@ import io.github.composegears.valkyrie.ui.screen.webimport.material.domain.model import io.github.composegears.valkyrie.ui.screen.webimport.material.domain.model.font.MaterialFontSettings.Companion.DEFAULT_GRADE import io.github.composegears.valkyrie.ui.screen.webimport.material.domain.model.font.MaterialFontSettings.Companion.DEFAULT_OPTICAL_SIZE import io.github.composegears.valkyrie.ui.screen.webimport.material.domain.model.font.MaterialFontSettings.Companion.DEFAULT_WEIGHT -import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.SizeSettings.Companion.DEFAULT_SIZE +import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.SvgImportSettings.Companion.DEFAULT_SIZE @State(name = "Valkyrie.Settings", storages = [Storage("valkyrie_settings.xml")]) class PersistentSettings : SimplePersistentStateComponent(ValkyrieState()) { @@ -64,18 +64,28 @@ class PersistentSettings : SimplePersistentStateComponent(Valkyri // Lucide var lucideSize: Int by property(DEFAULT_SIZE) + var lucideLastCustomColor: String? by string() + var lucideRecentColors: String? by string() // Bootstrap var bootstrapSize: Int by property(DEFAULT_SIZE) + var bootstrapLastCustomColor: String? by string() + var bootstrapRecentColors: String? by string() // Remix var remixSize: Int by property(DEFAULT_SIZE) + var remixLastCustomColor: String? by string() + var remixRecentColors: String? by string() // BoxIcons var boxiconsSize: Int by property(DEFAULT_SIZE) + var boxiconsLastCustomColor: String? by string() + var boxiconsRecentColors: String? by string() // Font Awesome var fontAwesomeSize: Int by property(DEFAULT_SIZE) + var fontAwesomeLastCustomColor: String? by string() + var fontAwesomeRecentColors: String? by string() } companion object { diff --git a/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/settings/InMemorySettings.kt b/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/settings/InMemorySettings.kt index e50b9298e..94d173f39 100644 --- a/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/settings/InMemorySettings.kt +++ b/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/settings/InMemorySettings.kt @@ -13,7 +13,7 @@ import io.github.composegears.valkyrie.ui.screen.webimport.material.domain.model import io.github.composegears.valkyrie.ui.screen.webimport.material.domain.model.font.MaterialFontSettings.Companion.DEFAULT_GRADE import io.github.composegears.valkyrie.ui.screen.webimport.material.domain.model.font.MaterialFontSettings.Companion.DEFAULT_OPTICAL_SIZE import io.github.composegears.valkyrie.ui.screen.webimport.material.domain.model.font.MaterialFontSettings.Companion.DEFAULT_WEIGHT -import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.SizeSettings.Companion.DEFAULT_SIZE +import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.SvgImportSettings.Companion.DEFAULT_SIZE import java.util.Collections.emptyList import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -66,10 +66,20 @@ class InMemorySettings(project: Project) { materialFontGrade = DEFAULT_GRADE materialFontOpticalSize = DEFAULT_OPTICAL_SIZE lucideSize = DEFAULT_SIZE + lucideLastCustomColor = "" + lucideRecentColors = "" bootstrapSize = DEFAULT_SIZE + bootstrapLastCustomColor = "" + bootstrapRecentColors = "" remixSize = DEFAULT_SIZE + remixLastCustomColor = "" + remixRecentColors = "" boxiconsSize = DEFAULT_SIZE + boxiconsLastCustomColor = "" + boxiconsRecentColors = "" fontAwesomeSize = DEFAULT_SIZE + fontAwesomeLastCustomColor = "" + fontAwesomeRecentColors = "" } fun updateUIState(uiState: SavedState) { @@ -136,3 +146,14 @@ fun PersistentSettings.ValkyrieState.updateNestedPack(packs: List) { fun PersistentSettings.ValkyrieState.updateOutputFormat(format: OutputFormat) { outputFormat = format.key } + +fun String?.toStringList(): List { + return orEmpty() + .split(",") + .map(String::trim) + .filter(String::isNotEmpty) +} + +fun List.toPersistedString(): String { + return joinToString(separator = ",") +} diff --git a/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/common/model/IconSettings.kt b/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/common/model/IconSettings.kt index 96475d553..777e618fe 100644 --- a/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/common/model/IconSettings.kt +++ b/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/common/model/IconSettings.kt @@ -2,7 +2,7 @@ package io.github.composegears.valkyrie.ui.screen.webimport.common.model /** * Common interface for icon settings that can be applied during web import. - * Implemented by both standard providers (SizeSettings) and Material (FontSettings). + * Implemented by both standard providers (SvgImportSettings) and Material (FontSettings). */ interface IconSettings { val isModified: Boolean diff --git a/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/common/ui/IconSizeCustomization.kt b/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/common/ui/IconSizeCustomization.kt index 476fc50f9..642499be9 100644 --- a/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/common/ui/IconSizeCustomization.kt +++ b/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/common/ui/IconSizeCustomization.kt @@ -21,7 +21,7 @@ import io.github.composegears.valkyrie.jewel.tooling.PreviewTheme import io.github.composegears.valkyrie.sdk.compose.foundation.layout.CenterVerticalRow import io.github.composegears.valkyrie.sdk.compose.foundation.layout.WeightSpacer import io.github.composegears.valkyrie.sdk.compose.foundation.rememberMutableState -import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.SizeSettings +import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.SvgImportSettings import io.github.composegears.valkyrie.util.stringResource import kotlin.math.roundToInt import org.jetbrains.compose.ui.tooling.preview.Preview @@ -43,8 +43,8 @@ import org.jetbrains.jewel.ui.component.styling.LocalGroupHeaderStyle */ @Composable fun IconSizeCustomization( - settings: SizeSettings, - onSettingsChange: (SizeSettings) -> Unit, + settings: SvgImportSettings, + onSettingsChange: (SvgImportSettings) -> Unit, onClose: () -> Unit, sizeLabel: String, modifier: Modifier = Modifier, @@ -55,8 +55,8 @@ fun IconSizeCustomization( CustomizationToolbar( onClose = onClose, onReset = { - size = SizeSettings.DEFAULT_SIZE.toFloat() - onSettingsChange(SizeSettings()) + size = SvgImportSettings.DEFAULT_SIZE.toFloat() + onSettingsChange(SvgImportSettings()) }, isModified = settings.isModified, ) @@ -77,7 +77,7 @@ fun IconSizeCustomization( size = it onSettingsChange(settings.copy(size = size.roundToInt())) }, - valueRange = SizeSettings.MIN_SIZE.toFloat()..SizeSettings.MAX_SIZE.toFloat(), + valueRange = SvgImportSettings.MIN_SIZE.toFloat()..SvgImportSettings.MAX_SIZE.toFloat(), ) } } @@ -87,7 +87,7 @@ fun IconSizeCustomization( @Preview @Composable private fun IconSizeCustomizationPreview() = PreviewTheme(alignment = Alignment.TopEnd) { - var settings by rememberMutableState { SizeSettings() } + var settings by rememberMutableState { SvgImportSettings() } IconSizeCustomization( modifier = Modifier diff --git a/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/common/ui/SidePanel.kt b/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/common/ui/SidePanel.kt index a8f8ebe66..9ea182c42 100644 --- a/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/common/ui/SidePanel.kt +++ b/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/common/ui/SidePanel.kt @@ -53,7 +53,7 @@ fun SidePanel( ) Box( modifier = Modifier - .widthIn(max = 250.dp) + .widthIn(max = 320.dp) .fillMaxHeight() .clip(RoundedCornerShape(topStart = 12.dp, bottomStart = 12.dp)) .background(JewelTheme.globalColors.borders.normal), diff --git a/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/common/ui/SvgCustomizationPanel.kt b/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/common/ui/SvgCustomizationPanel.kt new file mode 100644 index 000000000..720b40a5f --- /dev/null +++ b/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/common/ui/SvgCustomizationPanel.kt @@ -0,0 +1,302 @@ +package io.github.composegears.valkyrie.ui.screen.webimport.common.ui + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.dp +import com.intellij.ui.ColorChooserService +import io.github.composegears.valkyrie.jewel.HorizontalDivider +import io.github.composegears.valkyrie.jewel.settings.DropdownSettingsRow +import io.github.composegears.valkyrie.jewel.tooling.PreviewTheme +import io.github.composegears.valkyrie.sdk.compose.foundation.layout.CenterVerticalRow +import io.github.composegears.valkyrie.sdk.compose.foundation.layout.WeightSpacer +import io.github.composegears.valkyrie.sdk.compose.foundation.rememberMutableState +import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.SvgCustomizationCapabilities +import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.SvgImportSettings +import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.SvgImportSettings.Companion.MAX_SIZE +import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.SvgImportSettings.Companion.MIN_SIZE +import io.github.composegears.valkyrie.util.stringResource +import java.awt.Color as AwtColor +import kotlin.math.roundToInt +import org.jetbrains.compose.ui.tooling.preview.Preview +import org.jetbrains.jewel.foundation.ExperimentalJewelApi +import org.jetbrains.jewel.foundation.LocalComponent +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.CheckboxRow +import org.jetbrains.jewel.ui.component.HorizontallyScrollableContainer +import org.jetbrains.jewel.ui.component.InfoText +import org.jetbrains.jewel.ui.component.OutlinedButton +import org.jetbrains.jewel.ui.component.Slider +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.component.VerticallyScrollableContainer +import org.jetbrains.jewel.ui.component.styling.LocalGroupHeaderStyle +import org.jetbrains.jewel.ui.util.fromArgbHexStringOrNull +import org.jetbrains.jewel.ui.util.toArgbHexString + +private val rotationOptions = listOf(0, 90, 180, 270) +private const val RECENT_COLOR_SLOTS = 4 +private val presetColors = listOf("#FFFFFF") + +private val defaultPickerColor = Color(0xFF68A9E0) + +private fun Color.toAwtColor(): AwtColor = AwtColor( + (red * 255).roundToInt(), + (green * 255).roundToInt(), + (blue * 255).roundToInt(), +) + +private fun AwtColor.toRgbHexString(): String = toArgbHexString() + .removePrefix("#") + .takeLast(6) + .uppercase() + .let { "#$it" } + +private fun String.toComposeColorOrNull(): Color? = Color.fromArgbHexStringOrNull(this) + +@OptIn(ExperimentalJewelApi::class) +@Composable +fun SvgCustomizationPanel( + settings: SvgImportSettings, + recentColors: List, + lastCustomColor: String?, + capabilities: SvgCustomizationCapabilities, + onSettingsChange: (SvgImportSettings) -> Unit, + onPickCustomColor: (String) -> Unit, + onResetCustomization: () -> Unit, + onClose: () -> Unit, + modifier: Modifier = Modifier, +) { + val noneLabel = stringResource("web.import.font.customize.none") + val customLabel = stringResource("web.import.font.customize.color.custom") + val originalLabel = stringResource("web.import.font.customize.color.original") + val component = LocalComponent.current + val customColor = lastCustomColor?.toComposeColorOrNull() + + Column(modifier = modifier) { + CustomizationToolbar( + onClose = onClose, + onReset = onResetCustomization, + isModified = settings.isModified, + ) + HorizontalDivider(color = LocalGroupHeaderStyle.current.colors.divider) + VerticallyScrollableContainer { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (capabilities.supportsColor) { + CenterVerticalRow(modifier = Modifier.padding(horizontal = 4.dp)) { + Text(text = stringResource("web.import.font.customize.color")) + WeightSpacer() + InfoText(text = settings.color ?: originalLabel) + } + HorizontallyScrollableContainer { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ColorSwatch( + label = noneLabel, + color = null, + selected = settings.color == null, + isNoneSwatch = true, + onClick = { onSettingsChange(settings.copy(color = null)) }, + ) + presetColors.forEach { hex -> + ColorSwatch( + label = hex, + color = Color.fromArgbHexStringOrNull(hex) ?: defaultPickerColor, + selected = settings.color == hex, + onClick = { onSettingsChange(settings.copy(color = hex)) }, + ) + } + repeat(RECENT_COLOR_SLOTS) { index -> + val recentColorHex = recentColors.getOrNull(index) + ColorSwatch( + label = "", + color = recentColorHex?.toComposeColorOrNull(), + selected = recentColorHex != null && settings.color == recentColorHex, + isPlaceholder = recentColorHex == null, + onClick = { + recentColorHex?.let { onSettingsChange(settings.copy(color = it)) } + }, + ) + } + } + } + OutlinedButton( + onClick = { + val chosenColor = ColorChooserService.getInstance().showDialog( + parent = component, + caption = customLabel, + preselectedColor = ( + settings.color?.let(Color::fromArgbHexStringOrNull) ?: customColor + ?: defaultPickerColor + ) + .toAwtColor(), + enableOpacity = false, + ) + chosenColor?.let { + onPickCustomColor(it.toRgbHexString()) + } + }, + ) { + Text(text = customLabel) + } + } + + var size by rememberMutableState(settings.size) { settings.size.toFloat() } + CenterVerticalRow(modifier = Modifier.padding(horizontal = 4.dp)) { + Text(text = stringResource("web.import.font.customize.size")) + WeightSpacer() + InfoText(text = stringResource("web.import.font.customize.px.suffix", size.roundToInt())) + } + Slider( + value = size, + onValueChange = { + size = it + onSettingsChange(settings.copy(size = size.roundToInt())) + }, + valueRange = MIN_SIZE.toFloat()..MAX_SIZE.toFloat(), + ) + + if (capabilities.supportsRotation) { + DropdownSettingsRow( + text = stringResource("web.import.font.customize.rotate"), + current = settings.rotation, + items = rotationOptions, + onSelectItem = { onSettingsChange(settings.copy(rotation = it)) }, + comboxModifier = Modifier.width(90.dp), + transform = { + when (it) { + 0 -> noneLabel + else -> "$it${'\u00B0'}" + } + }, + ) + } + + if (capabilities.supportsFlip) { + Text(text = stringResource("web.import.font.customize.flip")) + CheckboxRow( + checked = settings.flipHorizontally, + onCheckedChange = { + onSettingsChange(settings.copy(flipHorizontally = it)) + }, + ) { + Text(text = stringResource("web.import.font.customize.flip.horizontal")) + } + CheckboxRow( + checked = settings.flipVertically, + onCheckedChange = { + onSettingsChange(settings.copy(flipVertically = it)) + }, + ) { + Text(text = stringResource("web.import.font.customize.flip.vertical")) + } + } + } + } + } +} + +@Composable +private fun ColorSwatch( + label: String, + color: Color?, + selected: Boolean, + isPlaceholder: Boolean = false, + isNoneSwatch: Boolean = false, + onClick: () -> Unit, +) { + val colors = JewelTheme.globalColors + val borderColor = with(colors) { + when { + selected -> text.normal + isPlaceholder -> borders.normal.copy(alpha = 0.12f) + else -> borders.normal + } + } + val backgroundColor = when { + color != null -> color + isPlaceholder -> colors.panelBackground.copy(alpha = 0.38f) + else -> colors.panelBackground + } + + Box( + modifier = Modifier + .size(32.dp) + .clip(RoundedCornerShape(10.dp)) + .background(backgroundColor) + .border(width = 2.dp, color = borderColor, shape = RoundedCornerShape(10.dp)) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + if (isNoneSwatch) { + val glyphColor = if (selected) colors.text.normal else colors.text.normal.copy(alpha = 0.8f) + NoneGlyph(color = glyphColor) + } else if (color == null && !isPlaceholder) { + Text(text = label.take(1)) + } + } +} + +@Composable +private fun NoneGlyph(color: Color) { + Canvas(modifier = Modifier.size(15.dp)) { + val strokeWidth = 1.6.dp.toPx() + drawCircle( + color = color, + style = Stroke(width = strokeWidth), + ) + drawLine( + color = color, + start = Offset(x = size.width * 0.18f, y = size.height * 0.18f), + end = Offset(x = size.width * 0.82f, y = size.height * 0.82f), + strokeWidth = strokeWidth, + cap = StrokeCap.Round, + ) + } +} + +@Preview +@Composable +private fun SvgCustomizationPanelPreview() = PreviewTheme(alignment = Alignment.TopEnd) { + var settings by rememberMutableState { SvgImportSettings(color = "#4F8FBA", rotation = 90) } + + SvgCustomizationPanel( + modifier = Modifier + .width(320.dp) + .fillMaxHeight() + .clip(RoundedCornerShape(topStart = 12.dp, bottomStart = 12.dp)) + .background(JewelTheme.globalColors.borders.normal), + settings = settings, + recentColors = listOf("#4F8FBA", "#1F8A70"), + lastCustomColor = "#4F8FBA", + capabilities = SvgCustomizationCapabilities(), + onClose = {}, + onSettingsChange = { settings = it }, + onPickCustomColor = { settings = settings.copy(color = it) }, + onResetCustomization = { settings = SvgImportSettings() }, + ) +} diff --git a/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/bootstrap/domain/BootstrapUseCase.kt b/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/bootstrap/domain/BootstrapUseCase.kt index ac8aa9bff..3300bad39 100644 --- a/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/bootstrap/domain/BootstrapUseCase.kt +++ b/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/bootstrap/domain/BootstrapUseCase.kt @@ -1,14 +1,14 @@ package io.github.composegears.valkyrie.ui.screen.webimport.standard.bootstrap.domain import io.github.composegears.valkyrie.settings.InMemorySettings +import io.github.composegears.valkyrie.settings.toPersistedString +import io.github.composegears.valkyrie.settings.toStringList import io.github.composegears.valkyrie.ui.screen.webimport.common.model.FontByteArray import io.github.composegears.valkyrie.ui.screen.webimport.standard.bootstrap.data.BootstrapRepository import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.domain.StandardIconProvider -import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.domain.SvgSizeCustomizer import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.domain.inferCategoryFromTags import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.domain.toDisplayName import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.IconStyle -import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.SizeSettings import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.StandardIcon import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.StandardIconConfig import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.toStandardIconConfig @@ -22,6 +22,8 @@ class BootstrapUseCase( override val stateKey: String = "bootstrap" override val fontAlias: String = "bootstrap-icons" override val persistentSize: Int = inMemorySettings.readState { bootstrapSize } + override val persistentLastCustomColor: String? = inMemorySettings.readState { bootstrapLastCustomColor }?.ifBlank { null } + override val persistentRecentColors: List = inMemorySettings.readState { bootstrapRecentColors }.toStringList() override fun updatePersistentSize(value: Int) { inMemorySettings.update { @@ -29,6 +31,13 @@ class BootstrapUseCase( } } + override fun updatePersistentCustomColors(lastCustomColor: String?, recentColors: List) { + inMemorySettings.update { + bootstrapLastCustomColor = lastCustomColor.orEmpty() + bootstrapRecentColors = recentColors.toPersistedString() + } + } + override suspend fun loadConfig(): StandardIconConfig { val codepoints = repository.loadCodepoints() @@ -49,8 +58,5 @@ class BootstrapUseCase( return FontByteArray(repository.loadFontBytes()) } - override suspend fun downloadSvg(icon: StandardIcon, settings: SizeSettings): String { - val rawSvg = repository.downloadSvg(icon.name) - return SvgSizeCustomizer.applySettings(rawSvg, settings) - } + override suspend fun loadSvg(icon: StandardIcon): String = repository.downloadSvg(icon.name) } diff --git a/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/boxicons/domain/BoxIconsUseCase.kt b/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/boxicons/domain/BoxIconsUseCase.kt index 51464e50a..155bcbdb1 100644 --- a/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/boxicons/domain/BoxIconsUseCase.kt +++ b/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/boxicons/domain/BoxIconsUseCase.kt @@ -1,14 +1,14 @@ package io.github.composegears.valkyrie.ui.screen.webimport.standard.boxicons.domain import io.github.composegears.valkyrie.settings.InMemorySettings +import io.github.composegears.valkyrie.settings.toPersistedString +import io.github.composegears.valkyrie.settings.toStringList import io.github.composegears.valkyrie.ui.screen.webimport.common.model.FontByteArray import io.github.composegears.valkyrie.ui.screen.webimport.standard.boxicons.data.BoxIconsRepository import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.domain.StandardIconProvider -import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.domain.SvgSizeCustomizer import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.domain.inferCategoryFromTags import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.domain.toDisplayName import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.IconStyle -import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.SizeSettings import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.StandardIcon import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.StandardIconConfig import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.toStandardIconConfig @@ -22,6 +22,8 @@ class BoxIconsUseCase( override val stateKey: String = "boxicons" override val fontAlias: String = "boxicons" override val persistentSize: Int = inMemorySettings.readState { boxiconsSize } + override val persistentLastCustomColor: String? = inMemorySettings.readState { boxiconsLastCustomColor }?.ifBlank { null } + override val persistentRecentColors: List = inMemorySettings.readState { boxiconsRecentColors }.toStringList() override fun updatePersistentSize(value: Int) { inMemorySettings.update { @@ -29,6 +31,13 @@ class BoxIconsUseCase( } } + override fun updatePersistentCustomColors(lastCustomColor: String?, recentColors: List) { + inMemorySettings.update { + boxiconsLastCustomColor = lastCustomColor.orEmpty() + boxiconsRecentColors = recentColors.toPersistedString() + } + } + override suspend fun loadConfig(): StandardIconConfig { val icons = repository.loadCodepoints().map { entry -> val style = entry.key.toStyle() @@ -52,10 +61,7 @@ class BoxIconsUseCase( return FontByteArray(repository.loadFontBytes()) } - override suspend fun downloadSvg(icon: StandardIcon, settings: SizeSettings): String { - val rawSvg = repository.downloadSvg(icon.name) - return SvgSizeCustomizer.applySettings(rawSvg, settings) - } + override suspend fun loadSvg(icon: StandardIcon): String = repository.downloadSvg(icon.name) } private enum class BoxIconsStyle(val prefix: String, val value: IconStyle) { diff --git a/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/common/StandardIconViewModel.kt b/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/common/StandardIconViewModel.kt index f8427c7b9..300556467 100644 --- a/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/common/StandardIconViewModel.kt +++ b/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/common/StandardIconViewModel.kt @@ -13,9 +13,10 @@ import io.github.composegears.valkyrie.ui.screen.webimport.common.model.GridItem import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.domain.StandardIconProvider import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.IconStyle import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.InferredCategory -import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.SizeSettings import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.StandardIcon import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.StandardIconConfig +import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.SvgCustomizationCapabilities +import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.SvgImportSettings import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.util.filterByCategory import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -102,7 +103,10 @@ class StandardIconViewModel( style = selectedStyle, searchQuery = "", ), - settings = SizeSettings(size = provider.persistentSize), + customizationCapabilities = provider.customizationCapabilities, + settings = SvgImportSettings(size = provider.persistentSize), + lastCustomColor = provider.persistentLastCustomColor, + recentColors = provider.persistentRecentColors, selectedStyle = selectedStyle, ) downloadFont(selectedStyle) @@ -145,7 +149,8 @@ class StandardIconViewModel( val currentState = stateRecord.value.safeAs() ?: return@launch runCatching { - val svgContent = provider.downloadSvg(icon, currentState.settings) + val latestSettings = stateRecord.value.safeAs()?.settings ?: currentState.settings + val svgContent = provider.downloadSvg(icon, latestSettings) _events.send( StandardIconEvent.IconDownloaded( @@ -210,7 +215,7 @@ class StandardIconViewModel( } } - fun updateSettings(settings: SizeSettings) { + fun updateSettings(settings: SvgImportSettings) { viewModelScope.launch(Dispatchers.Default) { provider.updatePersistentSize(settings.size) updateSuccess { state -> @@ -219,6 +224,50 @@ class StandardIconViewModel( } } + fun selectCustomColor(color: String) { + viewModelScope.launch(Dispatchers.Default) { + val currentState = stateRecord.value.safeAs() ?: return@launch + val updatedRecentColors = buildList { + add(color) + currentState.recentColors + .asSequence() + .filter { it != color } + .forEach(::add) + }.take(4) + + provider.updatePersistentCustomColors( + lastCustomColor = color, + recentColors = updatedRecentColors, + ) + + updateSuccess { state -> + state.copy( + settings = state.settings.copy(color = color), + lastCustomColor = color, + recentColors = updatedRecentColors, + ) + } + } + } + + fun resetCustomization() { + viewModelScope.launch(Dispatchers.Default) { + provider.updatePersistentSize(SvgImportSettings.DEFAULT_SIZE) + provider.updatePersistentCustomColors( + lastCustomColor = null, + recentColors = emptyList(), + ) + + updateSuccess { state -> + state.copy( + settings = SvgImportSettings(), + lastCustomColor = null, + recentColors = emptyList(), + ) + } + } + } + private fun prefetchStyleFonts(styles: List, selectedStyleId: String?) { prefetchJob?.cancel() prefetchJob = viewModelScope.launch { @@ -264,10 +313,13 @@ sealed interface StandardState { data class Success( val config: StandardIconConfig, val gridItems: List = emptyList(), + val customizationCapabilities: SvgCustomizationCapabilities = SvgCustomizationCapabilities(), val selectedCategory: InferredCategory = InferredCategory.All, val selectedStyle: IconStyle? = null, val searchQuery: String = "", - val settings: SizeSettings = SizeSettings(), + val settings: SvgImportSettings = SvgImportSettings(), + val lastCustomColor: String? = null, + val recentColors: List = emptyList(), val fontByteArray: FontByteArray? = null, ) : StandardState diff --git a/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/common/StandardImportScreenUI.kt b/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/common/StandardImportScreenUI.kt index e0f9ac45a..a207d5bb3 100644 --- a/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/common/StandardImportScreenUI.kt +++ b/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/common/StandardImportScreenUI.kt @@ -46,13 +46,13 @@ import io.github.composegears.valkyrie.ui.screen.webimport.common.ui.CategoryHea import io.github.composegears.valkyrie.ui.screen.webimport.common.ui.IconCard import io.github.composegears.valkyrie.ui.screen.webimport.common.ui.IconGrid import io.github.composegears.valkyrie.ui.screen.webimport.common.ui.IconLoadingPlaceholder -import io.github.composegears.valkyrie.ui.screen.webimport.common.ui.IconSizeCustomization import io.github.composegears.valkyrie.ui.screen.webimport.common.ui.SidePanel +import io.github.composegears.valkyrie.ui.screen.webimport.common.ui.SvgCustomizationPanel import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.domain.StandardIconProvider import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.IconStyle import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.InferredCategory -import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.SizeSettings import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.StandardIcon +import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.SvgImportSettings import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.ui.StandardTopActions import io.github.composegears.valkyrie.util.stringResource import kotlinx.coroutines.launch @@ -91,6 +91,8 @@ internal fun StandardImportScreen( onSelectStyle = viewModel::selectStyle, onSearchQueryChange = viewModel::updateSearchQuery, onSettingsChange = viewModel::updateSettings, + onPickCustomColor = viewModel::selectCustomColor, + onResetCustomization = viewModel::resetCustomization, modifier = modifier, ) } @@ -106,7 +108,9 @@ private fun StandardImportScreenUI( onSelectCategory: (InferredCategory) -> Unit, onSelectStyle: (IconStyle) -> Unit, onSearchQueryChange: (String) -> Unit, - onSettingsChange: (SizeSettings) -> Unit, + onSettingsChange: (SvgImportSettings) -> Unit, + onPickCustomColor: (String) -> Unit, + onResetCustomization: () -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -155,6 +159,8 @@ private fun StandardImportScreenUI( onSelectStyle = onSelectStyle, onSearchQueryChange = onSearchQueryChange, onSettingsChange = onSettingsChange, + onPickCustomColor = onPickCustomColor, + onResetCustomization = onResetCustomization, ) } } @@ -171,7 +177,9 @@ private fun IconsContent( onSelectCategory: (InferredCategory) -> Unit, onSelectStyle: (IconStyle) -> Unit, onSearchQueryChange: (String) -> Unit, - onSettingsChange: (SizeSettings) -> Unit, + onSettingsChange: (SvgImportSettings) -> Unit, + onPickCustomColor: (String) -> Unit, + onResetCustomization: () -> Unit, ) { val scope = rememberCoroutineScope() @@ -284,11 +292,15 @@ private fun IconsContent( isOpen = isSidePanelOpen, onClose = { isSidePanelOpen = false }, content = { - IconSizeCustomization( + SvgCustomizationPanel( settings = state.settings, + recentColors = state.recentColors, + lastCustomColor = state.lastCustomColor, + capabilities = state.customizationCapabilities, onSettingsChange = onSettingsChange, + onPickCustomColor = onPickCustomColor, + onResetCustomization = onResetCustomization, onClose = { isSidePanelOpen = false }, - sizeLabel = stringResource("web.import.font.customize.size"), ) }, ) diff --git a/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/common/domain/StandardIconProvider.kt b/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/common/domain/StandardIconProvider.kt index 521f701bf..fbf6aeb16 100644 --- a/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/common/domain/StandardIconProvider.kt +++ b/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/common/domain/StandardIconProvider.kt @@ -3,20 +3,30 @@ package io.github.composegears.valkyrie.ui.screen.webimport.standard.common.doma import androidx.compose.ui.text.font.FontWeight import io.github.composegears.valkyrie.ui.screen.webimport.common.model.FontByteArray import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.IconStyle -import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.SizeSettings import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.StandardIcon import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.StandardIconConfig +import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.SvgCustomizationCapabilities +import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.SvgImportSettings interface StandardIconProvider { val providerName: String val stateKey: String val fontAlias: String val persistentSize: Int + val persistentLastCustomColor: String? + val persistentRecentColors: List + val customizationCapabilities: SvgCustomizationCapabilities + get() = SvgCustomizationCapabilities() fun updatePersistentSize(value: Int) + fun updatePersistentCustomColors(lastCustomColor: String?, recentColors: List) fun resolveFontWeight(style: IconStyle?): FontWeight = FontWeight.W400 suspend fun loadConfig(): StandardIconConfig suspend fun loadFontBytes(style: IconStyle? = null): FontByteArray - suspend fun downloadSvg(icon: StandardIcon, settings: SizeSettings): String + suspend fun loadSvg(icon: StandardIcon): String + + fun applySettings(svgContent: String, settings: SvgImportSettings): String = SvgImportCustomizer.applySettings(svgContent, settings) + + suspend fun downloadSvg(icon: StandardIcon, settings: SvgImportSettings): String = applySettings(loadSvg(icon), settings) } diff --git a/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/common/domain/SvgImportCustomizer.kt b/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/common/domain/SvgImportCustomizer.kt new file mode 100644 index 000000000..22f1712fe --- /dev/null +++ b/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/common/domain/SvgImportCustomizer.kt @@ -0,0 +1,103 @@ +package io.github.composegears.valkyrie.ui.screen.webimport.standard.common.domain + +import io.github.composegears.valkyrie.sdk.utils.svg.SvgDomModifier +import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.SvgImportSettings +import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.SvgImportSettings.Companion.DEFAULT_SIZE +import org.w3c.dom.Element + +/** + * Utility for applying SVG import settings to SVG content. + * Used by standard icon providers (Lucide, Bootstrap, etc.). + */ +object SvgImportCustomizer { + + private const val ATTR_WIDTH = "width" + private const val ATTR_HEIGHT = "height" + private const val ATTR_FILL = "fill" + private const val ATTR_STROKE = "stroke" + private const val ATTR_COLOR = "color" + private const val ATTR_TRANSFORM = "transform" + private const val ATTR_VIEW_BOX = "viewBox" + private const val CURRENT_COLOR = "currentColor" + + fun applySettings(svgContent: String, settings: SvgImportSettings): String { + return SvgDomModifier.modify(svgContent) { svgElement -> + svgElement.setAttribute(ATTR_WIDTH, settings.size.toString()) + svgElement.setAttribute(ATTR_HEIGHT, settings.size.toString()) + + settings.color?.let { color -> + svgElement.setAttribute(ATTR_COLOR, color) + if (!svgElement.hasAttribute(ATTR_FILL)) { + svgElement.setAttribute(ATTR_FILL, color) + } + SvgDomModifier.apply { + updateAttributeConditionally(svgElement, ATTR_FILL, CURRENT_COLOR, color) + updateAttributeConditionally(svgElement, ATTR_STROKE, CURRENT_COLOR, color) + } + } + + buildTransform(svgElement, settings)?.let { transform -> + svgElement.setAttribute(ATTR_TRANSFORM, transform) + } + } + } + + private fun buildTransform( + svgElement: Element, + settings: SvgImportSettings, + ): String? { + if ( + settings.rotation == SvgImportSettings.DEFAULT_ROTATION && + !settings.flipHorizontally && + !settings.flipVertically + ) { + return null + } + + val bounds = parseBounds(svgElement) + val transforms = buildList { + if (settings.rotation != SvgImportSettings.DEFAULT_ROTATION) { + add("rotate(${settings.rotation} ${bounds.centerX} ${bounds.centerY})") + } + if (settings.flipHorizontally) { + add("translate(${bounds.flipX} 0) scale(-1 1)") + } + if (settings.flipVertically) { + add("translate(0 ${bounds.flipY}) scale(1 -1)") + } + } + return transforms.joinToString(" ") + } + + private fun parseBounds(svgElement: Element): SvgBounds { + val viewBox = svgElement.getAttribute(ATTR_VIEW_BOX) + .split(',', ' ') + .filter { it.isNotBlank() } + .mapNotNull { it.toDoubleOrNull() } + + if (viewBox.size == 4) { + return SvgBounds( + centerX = (viewBox[0] + viewBox[2] / 2).toString(), + centerY = (viewBox[1] + viewBox[3] / 2).toString(), + flipX = (viewBox[0] * 2 + viewBox[2]).toString(), + flipY = (viewBox[1] * 2 + viewBox[3]).toString(), + ) + } + + val width = svgElement.getAttribute(ATTR_WIDTH).toDoubleOrNull() ?: DEFAULT_SIZE.toDouble() + val height = svgElement.getAttribute(ATTR_HEIGHT).toDoubleOrNull() ?: DEFAULT_SIZE.toDouble() + return SvgBounds( + centerX = (width / 2).toString(), + centerY = (height / 2).toString(), + flipX = width.toString(), + flipY = height.toString(), + ) + } + + private data class SvgBounds( + val centerX: String, + val centerY: String, + val flipX: String, + val flipY: String, + ) +} diff --git a/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/common/domain/SvgSizeCustomizer.kt b/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/common/domain/SvgSizeCustomizer.kt deleted file mode 100644 index 4214016df..000000000 --- a/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/common/domain/SvgSizeCustomizer.kt +++ /dev/null @@ -1,21 +0,0 @@ -package io.github.composegears.valkyrie.ui.screen.webimport.standard.common.domain - -import io.github.composegears.valkyrie.sdk.utils.svg.SvgDomModifier -import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.SizeSettings - -/** - * Utility for applying size settings to SVG content. - * Used by standard icon providers (Lucide, Bootstrap, etc.). - */ -object SvgSizeCustomizer { - - private const val ATTR_WIDTH = "width" - private const val ATTR_HEIGHT = "height" - - fun applySettings(svgContent: String, settings: SizeSettings): String { - return SvgDomModifier.modify(svgContent) { svgElement -> - svgElement.setAttribute(ATTR_WIDTH, settings.size.toString()) - svgElement.setAttribute(ATTR_HEIGHT, settings.size.toString()) - } - } -} diff --git a/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/common/model/SizeSettings.kt b/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/common/model/SizeSettings.kt deleted file mode 100644 index d3b33822a..000000000 --- a/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/common/model/SizeSettings.kt +++ /dev/null @@ -1,24 +0,0 @@ -package io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model - -import io.github.composegears.valkyrie.ui.screen.webimport.common.model.IconSettings - -/** - * Size settings for standard icon providers. - * Allows customization of icon dimensions (width/height). - */ -data class SizeSettings( - val size: Int = DEFAULT_SIZE, -) : IconSettings { - companion object { - const val DEFAULT_SIZE = 24 - const val MIN_SIZE = 16 - const val MAX_SIZE = 48 - } - - init { - require(size in MIN_SIZE..MAX_SIZE) { "Size must be between $MIN_SIZE and $MAX_SIZE" } - } - - override val isModified: Boolean - get() = size != DEFAULT_SIZE -} diff --git a/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/common/model/SvgCustomizationCapabilities.kt b/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/common/model/SvgCustomizationCapabilities.kt new file mode 100644 index 000000000..612f64ff9 --- /dev/null +++ b/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/common/model/SvgCustomizationCapabilities.kt @@ -0,0 +1,7 @@ +package io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model + +data class SvgCustomizationCapabilities( + val supportsColor: Boolean = true, + val supportsRotation: Boolean = true, + val supportsFlip: Boolean = true, +) diff --git a/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/common/model/SvgImportSettings.kt b/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/common/model/SvgImportSettings.kt new file mode 100644 index 000000000..11d1bed44 --- /dev/null +++ b/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/common/model/SvgImportSettings.kt @@ -0,0 +1,36 @@ +package io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model + +import io.github.composegears.valkyrie.ui.screen.webimport.common.model.IconSettings + +/** + * Shared SVG customization settings for standard icon providers. + * + * The current UI only exposes size, but the shared model also carries + * transformer-backed color and transform options used by the standard web import flow. + */ +data class SvgImportSettings( + val size: Int = DEFAULT_SIZE, + val color: String? = null, + val rotation: Int = DEFAULT_ROTATION, + val flipHorizontally: Boolean = false, + val flipVertically: Boolean = false, +) : IconSettings { + companion object { + const val DEFAULT_SIZE = 24 + const val MIN_SIZE = 16 + const val MAX_SIZE = 48 + const val DEFAULT_ROTATION = 0 + } + + init { + require(size in MIN_SIZE..MAX_SIZE) { "Size must be between $MIN_SIZE and $MAX_SIZE" } + } + + override val isModified: Boolean + get() = + size != DEFAULT_SIZE || + color != null || + rotation != DEFAULT_ROTATION || + flipHorizontally || + flipVertically +} diff --git a/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/fontawesome/domain/FontAwesomeUseCase.kt b/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/fontawesome/domain/FontAwesomeUseCase.kt index 829f39766..06e051fd9 100644 --- a/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/fontawesome/domain/FontAwesomeUseCase.kt +++ b/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/fontawesome/domain/FontAwesomeUseCase.kt @@ -2,13 +2,13 @@ package io.github.composegears.valkyrie.ui.screen.webimport.standard.fontawesome import androidx.compose.ui.text.font.FontWeight import io.github.composegears.valkyrie.settings.InMemorySettings +import io.github.composegears.valkyrie.settings.toPersistedString +import io.github.composegears.valkyrie.settings.toStringList import io.github.composegears.valkyrie.ui.screen.webimport.common.model.FontByteArray import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.domain.StandardIconProvider -import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.domain.SvgSizeCustomizer import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.domain.inferCategoryFromTags import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.domain.toDisplayName import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.IconStyle -import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.SizeSettings import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.StandardIcon import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.StandardIconConfig import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.toStandardIconConfig @@ -24,6 +24,8 @@ class FontAwesomeUseCase( override val stateKey: String = "fontawesome" override val fontAlias: String = "fontawesome" override val persistentSize: Int = inMemorySettings.readState { fontAwesomeSize } + override val persistentLastCustomColor: String? = inMemorySettings.readState { fontAwesomeLastCustomColor }?.ifBlank { null } + override val persistentRecentColors: List = inMemorySettings.readState { fontAwesomeRecentColors }.toStringList() override fun updatePersistentSize(value: Int) { inMemorySettings.update { @@ -31,6 +33,13 @@ class FontAwesomeUseCase( } } + override fun updatePersistentCustomColors(lastCustomColor: String?, recentColors: List) { + inMemorySettings.update { + fontAwesomeLastCustomColor = lastCustomColor.orEmpty() + fontAwesomeRecentColors = recentColors.toPersistedString() + } + } + override fun resolveFontWeight(style: IconStyle?): FontWeight { return if (style?.id == SOLID_STYLE_ID) FontWeight.W900 else FontWeight.W400 } @@ -66,10 +75,9 @@ class FontAwesomeUseCase( return FontByteArray(repository.loadFontBytes(styleId = styleId)) } - override suspend fun downloadSvg(icon: StandardIcon, settings: SizeSettings): String { + override suspend fun loadSvg(icon: StandardIcon): String { val styleId = icon.style?.id ?: SOLID_STYLE_ID - val rawSvg = repository.downloadSvg(iconName = icon.name, styleId = styleId) - return SvgSizeCustomizer.applySettings(rawSvg, settings) + return repository.downloadSvg(iconName = icon.name, styleId = styleId) } private fun FontAwesomeIconMetadata.toExportName(style: IconStyle, stylesCount: Int): String { diff --git a/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/lucide/domain/LucideUseCase.kt b/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/lucide/domain/LucideUseCase.kt index c1c4b7177..eea74a9e0 100644 --- a/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/lucide/domain/LucideUseCase.kt +++ b/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/lucide/domain/LucideUseCase.kt @@ -1,13 +1,13 @@ package io.github.composegears.valkyrie.ui.screen.webimport.standard.lucide.domain import io.github.composegears.valkyrie.settings.InMemorySettings +import io.github.composegears.valkyrie.settings.toPersistedString +import io.github.composegears.valkyrie.settings.toStringList import io.github.composegears.valkyrie.ui.screen.webimport.common.model.FontByteArray import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.domain.StandardIconProvider -import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.domain.SvgSizeCustomizer import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.domain.inferCategoryFromTags import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.domain.toDisplayName import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.IconStyle -import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.SizeSettings import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.StandardIcon import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.StandardIconConfig import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.toStandardIconConfig @@ -22,6 +22,8 @@ class LucideUseCase( override val stateKey: String = "lucide" override val fontAlias: String = "lucide" override val persistentSize: Int = inMemorySettings.readState { lucideSize } + override val persistentLastCustomColor: String? = inMemorySettings.readState { lucideLastCustomColor }?.ifBlank { null } + override val persistentRecentColors: List = inMemorySettings.readState { lucideRecentColors }.toStringList() override fun updatePersistentSize(value: Int) { inMemorySettings.update { @@ -29,6 +31,13 @@ class LucideUseCase( } } + override fun updatePersistentCustomColors(lastCustomColor: String?, recentColors: List) { + inMemorySettings.update { + lucideLastCustomColor = lastCustomColor.orEmpty() + lucideRecentColors = recentColors.toPersistedString() + } + } + override suspend fun loadConfig(): StandardIconConfig { val iconMetadataList = repository.loadIconList() val codepoints = repository.loadCodepoints() @@ -51,8 +60,5 @@ class LucideUseCase( return FontByteArray(repository.loadFontBytes()) } - override suspend fun downloadSvg(icon: StandardIcon, settings: SizeSettings): String { - val rawSvg = repository.downloadSvg(icon.name) - return SvgSizeCustomizer.applySettings(rawSvg, settings) - } + override suspend fun loadSvg(icon: StandardIcon): String = repository.downloadSvg(icon.name) } diff --git a/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/remix/domain/RemixUseCase.kt b/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/remix/domain/RemixUseCase.kt index 4faaa7a52..2b09a79e8 100644 --- a/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/remix/domain/RemixUseCase.kt +++ b/tools/idea-plugin/src/main/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/remix/domain/RemixUseCase.kt @@ -1,14 +1,14 @@ package io.github.composegears.valkyrie.ui.screen.webimport.standard.remix.domain import io.github.composegears.valkyrie.settings.InMemorySettings +import io.github.composegears.valkyrie.settings.toPersistedString +import io.github.composegears.valkyrie.settings.toStringList import io.github.composegears.valkyrie.ui.screen.webimport.common.model.FontByteArray import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.domain.StandardIconProvider -import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.domain.SvgSizeCustomizer import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.domain.inferCategoryFromTags import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.domain.toDisplayName import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.IconStyle import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.InferredCategory -import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.SizeSettings import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.StandardIcon import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.StandardIconConfig import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.toStandardIconConfig @@ -23,6 +23,8 @@ class RemixUseCase( override val stateKey: String = "remix" override val fontAlias: String = "remixicon" override val persistentSize: Int = inMemorySettings.readState { remixSize } + override val persistentLastCustomColor: String? = inMemorySettings.readState { remixLastCustomColor }?.ifBlank { null } + override val persistentRecentColors: List = inMemorySettings.readState { remixRecentColors }.toStringList() override fun updatePersistentSize(value: Int) { inMemorySettings.update { @@ -30,6 +32,13 @@ class RemixUseCase( } } + override fun updatePersistentCustomColors(lastCustomColor: String?, recentColors: List) { + inMemorySettings.update { + remixLastCustomColor = lastCustomColor.orEmpty() + remixRecentColors = recentColors.toPersistedString() + } + } + override suspend fun loadConfig(): StandardIconConfig { val iconMetadata = repository.loadIconList() @@ -60,8 +69,5 @@ class RemixUseCase( return FontByteArray(repository.loadFontBytes()) } - override suspend fun downloadSvg(icon: StandardIcon, settings: SizeSettings): String { - val rawSvg = repository.downloadSvg(icon.name) - return SvgSizeCustomizer.applySettings(rawSvg, settings) - } + override suspend fun loadSvg(icon: StandardIcon): String = repository.downloadSvg(icon.name) } diff --git a/tools/idea-plugin/src/main/resources/messages/Valkyrie.properties b/tools/idea-plugin/src/main/resources/messages/Valkyrie.properties index c6d8cf6b5..d052f0623 100644 --- a/tools/idea-plugin/src/main/resources/messages/Valkyrie.properties +++ b/tools/idea-plugin/src/main/resources/messages/Valkyrie.properties @@ -132,7 +132,21 @@ web.import.placeholder.empty=No icons found web.import.search.placeholder=Search icons web.import.font.customize.header=Customize web.import.font.customize.reset=Reset +web.import.font.customize.preview=Preview +web.import.font.customize.preview.empty=Select an icon to preview and import it +web.import.font.customize.preview.loading=Loading preview... +web.import.font.customize.color=Color +web.import.font.customize.color.placeholder=#4F8FBA +web.import.font.customize.color.custom=Custom color +web.import.font.customize.color.original=Original web.import.font.customize.size=Size +web.import.font.customize.rotate=Rotate +web.import.font.customize.flip=Flip +web.import.font.customize.flip.horizontal=Horizontal +web.import.font.customize.flip.vertical=Vertical +web.import.font.customize.none=None +web.import.font.customize.degrees={0}\u00B0 +web.import.font.customize.import=Import selected icon web.import.font.customize.px.suffix={0}px web.import.font.customize.material.fill=Fill web.import.font.customize.material.weight=Weight: diff --git a/tools/idea-plugin/src/test/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/common/domain/SvgImportCustomizerTest.kt b/tools/idea-plugin/src/test/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/common/domain/SvgImportCustomizerTest.kt new file mode 100644 index 000000000..0229adc4b --- /dev/null +++ b/tools/idea-plugin/src/test/kotlin/io/github/composegears/valkyrie/ui/screen/webimport/standard/common/domain/SvgImportCustomizerTest.kt @@ -0,0 +1,100 @@ +package io.github.composegears.valkyrie.ui.screen.webimport.standard.common.domain + +import assertk.assertThat +import assertk.assertions.contains +import io.github.composegears.valkyrie.ui.screen.webimport.standard.common.model.SvgImportSettings +import org.junit.jupiter.api.Test + +class SvgImportCustomizerTest { + + @Test + fun `applySettings uses viewBox center for rotation`() { + val svg = """ + + """.trimIndent() + + val result = SvgImportCustomizer.applySettings( + svgContent = svg, + settings = SvgImportSettings(rotation = 90), + ) + assertThat(result).contains("transform=\"rotate(90 22.0 32.0)\"") + } + + @Test + fun `applySettings uses viewBox bounds for horizontal flip`() { + val svg = """ + + """.trimIndent() + + val result = SvgImportCustomizer.applySettings( + svgContent = svg, + settings = SvgImportSettings(flipHorizontally = true), + ) + assertThat(result).contains("transform=\"translate(44.0 0) scale(-1 1)\"") + } + + @Test + fun `applySettings uses viewBox bounds for vertical flip`() { + val svg = """ + + """.trimIndent() + + val result = SvgImportCustomizer.applySettings( + svgContent = svg, + settings = SvgImportSettings(flipVertically = true), + ) + assertThat(result).contains("transform=\"translate(0 64.0) scale(1 -1)\"") + } + + @Test + fun `applySettings falls back to width and height when viewBox is missing`() { + val svg = """ + + """.trimIndent() + + val result = SvgImportCustomizer.applySettings( + svgContent = svg, + settings = SvgImportSettings( + rotation = 90, + flipHorizontally = true, + flipVertically = true, + ), + ) + + assertThat(result).apply { + contains("rotate(90 12.0 12.0)") + contains("translate(24.0 0) scale(-1 1)") + contains("translate(0 24.0) scale(1 -1)") + } + } + + @Test + fun `applySettings updates currentColor fill and stroke`() { + val svg = """ + + """.trimIndent() + + val result = SvgImportCustomizer.applySettings( + svgContent = svg, + settings = SvgImportSettings(color = "#6913E0"), + ) + assertThat(result).apply { + contains("color=\"#6913E0\"") + contains("fill=\"#6913E0\"") + contains("stroke=\"#6913E0\"") + } + } + + @Test + fun `applySettings sets fill when color is provided and fill is absent`() { + val svg = """ + + """.trimIndent() + + val result = SvgImportCustomizer.applySettings( + svgContent = svg, + settings = SvgImportSettings(color = "#FFFFFF"), + ) + assertThat(result).contains("fill=\"#FFFFFF\"") + } +}