/* * Episteme Reader - A native Android document reader. * Copyright (C) 2026 Episteme * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . * * mail: epistemereader@gmail.com */ package com.aryan.reader.pdf import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures 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.Spacer 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.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf 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.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.clipPath import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.aryan.reader.BrightnessSlider import com.aryan.reader.ColorComparePill import com.aryan.reader.HexInput import com.aryan.reader.RgbInputColumn import com.aryan.reader.SpectrumBox import kotlin.math.roundToInt @OptIn(ExperimentalMaterial3Api::class) @Composable fun ToolSettingsPopup( selectedTool: InkType, activeToolThickness: Float, fountainPenColor: Color, markerColor: Color, pencilColor: Color, highlighterColor: Color, highlighterRoundColor: Color, activePalette: List, onToolTypeChanged: (InkType) -> Unit, onColorChanged: (Color) -> Unit, onThicknessChanged: (Float) -> Unit, onPaletteChange: (List) -> Unit, modifier: Modifier = Modifier, isHighlighterSnapEnabled: Boolean = false, onSnapToggle: (Boolean) -> Unit = {} ) { val isHighlighter = selectedTool == InkType.HIGHLIGHTER || selectedTool == InkType.HIGHLIGHTER_ROUND val isEraser = selectedTool == InkType.ERASER val activeColor = when (selectedTool) { InkType.FOUNTAIN_PEN -> fountainPenColor InkType.PEN -> markerColor InkType.PENCIL -> pencilColor InkType.HIGHLIGHTER -> highlighterColor InkType.HIGHLIGHTER_ROUND -> highlighterRoundColor InkType.ERASER -> Color.White else -> markerColor } val currentAlpha = activeColor.alpha val safeOnColorChanged: (Color) -> Unit = { newColor -> if (isHighlighter) { onColorChanged(newColor.copy(alpha = currentAlpha)) } else { onColorChanged(newColor) } } val thicknessRange = when { isHighlighter -> 0.01f..0.06f isEraser -> 0.01f..0.1f else -> 0.001f..0.015f } @Suppress("UnusedExpression") if (isHighlighter) 0.005f else 0.001f var showColorPicker by remember { mutableStateOf(false) } var colorPickerSlotIndex by remember { mutableIntStateOf(-1) } val currentOnColorChanged by rememberUpdatedState(safeOnColorChanged) val selectedPaletteIndex = remember(activePalette, activeColor, isHighlighter) { activePalette.indexOfFirst { paletteColor -> if (isHighlighter) { paletteColor.copy(alpha = 1f) == activeColor.copy(alpha = 1f) } else { paletteColor == activeColor } } } val circleSize = 28.dp Surface( modifier = modifier .width(360.dp) .padding(12.dp), shape = RoundedCornerShape(28.dp), color = Color(0xFF1E1E1E), shadowElevation = 12.dp, tonalElevation = 0.dp ) { Column( modifier = Modifier.padding(20.dp), horizontalAlignment = Alignment.CenterHorizontally ) { if (isEraser) { Box( modifier = Modifier .fillMaxWidth() .height(125.dp), contentAlignment = Alignment.Center ) { val radiusDp = (activeToolThickness * 1000).coerceIn(10f, 100f).dp Box( modifier = Modifier.size(radiusDp), contentAlignment = Alignment.Center ) { Canvas(modifier = Modifier.fillMaxSize()) { drawCircle( color = Color.White.copy(alpha = 0.3f), radius = size.width / 2 ) drawCircle( color = Color.White, radius = size.width / 2, style = Stroke(width = 2.dp.toPx()) ) } } } } else { Box( modifier = Modifier .fillMaxWidth() .height(125.dp), contentAlignment = Alignment.BottomCenter ) { Row( horizontalArrangement = Arrangement.spacedBy(28.dp), verticalAlignment = Alignment.Bottom ) { if (isHighlighter) { PenItem( type = PenType.HIGHLIGHTER, forcedInkType = InkType.HIGHLIGHTER, color = highlighterColor.copy(alpha = 1f), inkColor = highlighterColor, isSelected = selectedTool == InkType.HIGHLIGHTER, strokeWidth = activeToolThickness, onClick = { onToolTypeChanged(InkType.HIGHLIGHTER) }, isSnappingEnabled = isHighlighterSnapEnabled ) PenItem( type = PenType.HIGHLIGHTER_ROUND, forcedInkType = InkType.HIGHLIGHTER_ROUND, color = highlighterRoundColor.copy(alpha = 1f), inkColor = highlighterRoundColor, isSelected = selectedTool == InkType.HIGHLIGHTER_ROUND, strokeWidth = activeToolThickness, onClick = { onToolTypeChanged(InkType.HIGHLIGHTER_ROUND) }, isSnappingEnabled = isHighlighterSnapEnabled ) } else { PenItem( type = PenType.FOUNTAIN_PEN, forcedInkType = InkType.FOUNTAIN_PEN, color = fountainPenColor, isSelected = selectedTool == InkType.FOUNTAIN_PEN, strokeWidth = activeToolThickness, onClick = { onToolTypeChanged(InkType.FOUNTAIN_PEN) } ) PenItem( type = PenType.MARKER, forcedInkType = InkType.PEN, color = markerColor, isSelected = selectedTool == InkType.PEN, strokeWidth = activeToolThickness, onClick = { onToolTypeChanged(InkType.PEN) } ) PenItem( type = PenType.PENCIL, forcedInkType = InkType.PENCIL, color = pencilColor, isSelected = selectedTool == InkType.PENCIL, strokeWidth = activeToolThickness, onClick = { onToolTypeChanged(InkType.PENCIL) } ) } } } } Spacer(Modifier.height(16.dp)) if (isHighlighter) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { Text( text = "Straight Line", color = Color.White, style = MaterialTheme.typography.bodyMedium ) Switch( checked = isHighlighterSnapEnabled, onCheckedChange = onSnapToggle, colors = SwitchDefaults.colors( checkedThumbColor = Color.White, checkedTrackColor = activeColor.copy(alpha = 1f), uncheckedThumbColor = Color.Gray, uncheckedTrackColor = Color(0xFF424242) ), modifier = Modifier.scale(0.8f) ) } Spacer(Modifier.height(16.dp)) } // THICKNESS SLIDER StyledPropertySlider( value = activeToolThickness, onValueChange = onThicknessChanged, valueRange = thicknessRange, isOpacity = false, trackColor = Color(0xFF424242), thumbColor = Color(0xFF757575), activeColor = if (isEraser) Color.White else activeColor ) // Darkness (Opacity) Slider for Highlighters if (isHighlighter) { Spacer(Modifier.height(16.dp)) StyledPropertySlider( value = currentAlpha, onValueChange = { newAlpha -> onColorChanged(activeColor.copy(alpha = newAlpha)) }, valueRange = 0.1f..1.0f, isOpacity = true, trackColor = activeColor.copy(alpha = 1f), thumbColor = activeColor.copy(alpha = 1f), activeColor = activeColor ) } if (!isEraser) { Spacer(Modifier.height(16.dp)) // Reduced spacing // --- Color Palette --- Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { Row( modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.SpaceBetween ) { activePalette.take(6).forEachIndexed { index, color -> val isSelected = index == selectedPaletteIndex Box( contentAlignment = Alignment.Center, modifier = Modifier .size(circleSize) .testTag("Palette_Item_$index") .pointerInput(color) { detectTapGestures( onTap = { currentOnColorChanged(color) }, onLongPress = { colorPickerSlotIndex = index showColorPicker = true } ) } ) { Canvas(modifier = Modifier.fillMaxSize()) { drawCircle(color = color.copy(alpha = 1f)) if (isSelected) { drawCircle( color = Color.White, radius = size.minDimension / 2, style = Stroke(width = 2.dp.toPx()) ) } } } } } Spacer(Modifier.width(16.dp)) // Divider Box( modifier = Modifier .width(1.dp) .height(circleSize) .background(Color.White.copy(alpha = 0.15f)) ) Spacer(Modifier.width(16.dp)) // Spectrum / Color Wheel Button val rainbowColors = listOf( Color.Red, Color.Magenta, Color.Blue, Color.Cyan, Color.Green, Color.Yellow, Color.Red ) Box( contentAlignment = Alignment.Center, modifier = Modifier .size(circleSize) .clip(CircleShape) .background(Brush.sweepGradient(rainbowColors)) .clickable { if (selectedPaletteIndex != -1) { colorPickerSlotIndex = selectedPaletteIndex showColorPicker = true } } ) {} } } } } if (showColorPicker && colorPickerSlotIndex != -1) { val initialColor = activePalette.getOrElse(colorPickerSlotIndex) { Color.Black } ColorPickerDialog( initialColor = initialColor, onDismiss = { showColorPicker = false }, onColorSelected = { newColor -> val mutableList = activePalette.toMutableList() if (colorPickerSlotIndex in mutableList.indices) { mutableList[colorPickerSlotIndex] = newColor onPaletteChange(mutableList) if (isHighlighter) { onColorChanged(newColor.copy(alpha = currentAlpha)) } else { onColorChanged(newColor) } } showColorPicker = false } ) } } @Composable private fun ColorPickerDialog( initialColor: Color, onDismiss: () -> Unit, onColorSelected: (Color) -> Unit ) { val lockedInitialColor = remember { initialColor } val initialHsv = remember(initialColor) { val hsv = FloatArray(3) android.graphics.Color.colorToHSV(initialColor.toArgb(), hsv) hsv } var hue by remember { mutableFloatStateOf(initialHsv[0]) } var saturation by remember { mutableFloatStateOf(initialHsv[1]) } var value by remember { mutableFloatStateOf(initialHsv[2]) } val alpha = 1.0f val currentColor by remember { derivedStateOf { val hsv = floatArrayOf(hue, saturation, value) val argb = android.graphics.Color.HSVToColor((alpha * 255).toInt(), hsv) Color(argb) } } fun updateFromColor(color: Color) { val hsv = FloatArray(3) android.graphics.Color.colorToHSV(color.toArgb(), hsv) hue = hsv[0] saturation = hsv[1] value = hsv[2] } Dialog( onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false) ) { Surface( shape = RoundedCornerShape(24.dp), color = Color(0xFF2C2C2C), modifier = Modifier .fillMaxWidth(0.85f) .padding(8.dp) ) { Column( modifier = Modifier.padding(20.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Box( modifier = Modifier .background(Color(0xFF3E3E3E), RoundedCornerShape(16.dp)) .padding(horizontal = 24.dp, vertical = 8.dp) ) { Text( text = "Spectrum", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = Color.White ) } Spacer(Modifier.height(20.dp)) SpectrumBox( hue = hue, saturation = saturation, currentColor = currentColor, onHueSatChanged = { h, s -> hue = h saturation = s }, modifier = Modifier .fillMaxWidth() .height(220.dp) ) Spacer(Modifier.height(20.dp)) BrightnessSlider( hue = hue, saturation = saturation, value = value, onValueChanged = { value = it }, modifier = Modifier .fillMaxWidth() .height(24.dp) .clip(RoundedCornerShape(12.dp)) ) Spacer(Modifier.height(24.dp)) Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(12.dp) ) { ColorComparePill( oldColor = lockedInitialColor, newColor = currentColor, modifier = Modifier .width(64.dp) .height(36.dp) ) Column( modifier = Modifier.weight(1.6f), horizontalAlignment = Alignment.CenterHorizontally ) { Text( "Hex", color = Color.Gray, fontSize = 12.sp, maxLines = 1 ) Spacer(Modifier.height(4.dp)) HexInput( color = currentColor, onHexChanged = { updateFromColor(it) } ) } Row( modifier = Modifier.weight(2.4f), horizontalArrangement = Arrangement.spacedBy(6.dp) ) { RgbInputColumn( label = "Red", value = currentColor.red, onValueChange = { r -> updateFromColor(currentColor.copy(red = r)) }, modifier = Modifier.weight(1f) ) RgbInputColumn( label = "Green", value = currentColor.green, onValueChange = { g -> updateFromColor(currentColor.copy(green = g)) }, modifier = Modifier.weight(1f) ) RgbInputColumn( label = "Blue", value = currentColor.blue, onValueChange = { b -> updateFromColor(currentColor.copy(blue = b)) }, modifier = Modifier.weight(1f) ) } } Spacer(Modifier.height(24.dp)) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically ) { TextButton(onClick = onDismiss) { Text("Cancel", color = Color.Gray) } Spacer(Modifier.width(8.dp)) Button( onClick = { onColorSelected(currentColor) }, colors = ButtonDefaults.buttonColors( containerColor = Color.White, contentColor = Color.Black ) ) { Text("Done") } } } } } } @Composable private fun PenItem( type: PenType, color: Color, isSelected: Boolean, strokeWidth: Float, onClick: () -> Unit, forcedInkType: InkType? = null, inkColor: Color? = null, isSnappingEnabled: Boolean = false ) { val scale by animateFloatAsState( targetValue = if (isSelected) 1.15f else 0.9f, label = "scale" ) Box( modifier = Modifier .width(44.dp) .height(100.dp) .scale(scale) .testTag("SettingsItem_${type.name}") .semantics { this.selected = isSelected } .clickable(onClick = onClick), contentAlignment = Alignment.BottomCenter ) { PenIcon( color = color, inkColor = inkColor, type = type, isSelected = isSelected, strokeWidth = strokeWidth, forcedInkType = forcedInkType, modifier = Modifier.fillMaxSize(), isSnappingEnabled = isSnappingEnabled ) } } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun StyledPropertySlider( value: Float, onValueChange: (Float) -> Unit, valueRange: ClosedFloatingPointRange, isOpacity: Boolean, trackColor: Color, thumbColor: Color, activeColor: Color ) { val displayValue = remember(value, valueRange) { val fraction = (value - valueRange.start) / (valueRange.endInclusive - valueRange.start) (fraction * 100).roundToInt().coerceIn(1, 100) } val onePercentDelta = (valueRange.endInclusive - valueRange.start) / 100f val canDecrease = value > valueRange.start + 0.0001f val canIncrease = value < valueRange.endInclusive - 0.0001f Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth() ) { // Minus Button Box( modifier = Modifier .size(32.dp) .testTag("Property_Minus") .clickable(enabled = canDecrease) { val newValue = (value - onePercentDelta).coerceAtLeast(valueRange.start) onValueChange(newValue) }, contentAlignment = Alignment.Center ) { Text( text = "—", color = if (canDecrease) Color.White else Color.White.copy(alpha = 0.3f), fontSize = 18.sp, fontWeight = FontWeight.Bold ) } Spacer(modifier = Modifier.width(4.dp)) // Custom Slider Box(modifier = Modifier.weight(1f)) { Slider( value = value, onValueChange = onValueChange, valueRange = valueRange, colors = SliderDefaults.colors( thumbColor = Color.Transparent, activeTrackColor = Color.Transparent, inactiveTrackColor = Color.Transparent ), modifier = Modifier.height(32.dp), thumb = { Surface( shape = CircleShape, color = thumbColor, modifier = Modifier .size(26.dp) .padding(2.dp), shadowElevation = 4.dp, border = if (isOpacity) null else androidx.compose.foundation.BorderStroke(1.dp, Color.Gray) ) { Box(contentAlignment = Alignment.Center) { Text( text = displayValue.toString(), color = Color.White, fontSize = 10.sp, fontWeight = FontWeight.Bold ) } } }, track = { _ -> Canvas( modifier = Modifier .fillMaxWidth() .height(16.dp) ) { val trackHeight = size.height val cornerRadius = CornerRadius(trackHeight / 2) if (isOpacity) { drawRoundRect( color = Color.Gray, size = size, cornerRadius = cornerRadius ) val clipPath = androidx.compose.ui.graphics.Path().apply { addRoundRect( androidx.compose.ui.geometry.RoundRect( rect = androidx.compose.ui.geometry.Rect(Offset.Zero, size), cornerRadius = cornerRadius ) ) } clipPath(clipPath) { val boxSize = 12f val columns = (size.width / boxSize).toInt() + 1 val rows = (size.height / boxSize).toInt() + 1 for (col in 0 until columns) { for (row in 0 until rows) { val color = if ((col + row) % 2 == 0) Color(0xFF555555) else Color(0xFF333333) drawRect( color = color, topLeft = Offset(col * boxSize, row * boxSize), size = androidx.compose.ui.geometry.Size(boxSize, boxSize) ) } } drawRect(color = activeColor) } } else { drawRoundRect( color = trackColor.copy(alpha = 0.5f), size = size, cornerRadius = cornerRadius ) val dotRadius = 1.5.dp.toPx() val padding = trackHeight / 2 val availableWidth = size.width - (padding * 2) val dotCount = 8 val spacing = availableWidth / (dotCount - 1) for (i in 0 until dotCount) { drawCircle( color = Color.White.copy(alpha = 0.2f), radius = dotRadius, center = Offset(padding + (i * spacing), size.height / 2) ) } } } } ) } Spacer(modifier = Modifier.width(4.dp)) // Plus Button Box( modifier = Modifier .size(32.dp) .testTag("Property_Plus") .clickable(enabled = canIncrease) { val newValue = (value + onePercentDelta).coerceAtMost(valueRange.endInclusive) onValueChange(newValue) }, contentAlignment = Alignment.Center ) { Text( text = "+", color = if (canIncrease) Color.White else Color.White.copy(alpha = 0.3f), fontSize = 22.sp, fontWeight = FontWeight.Normal ) } } }