diff --git a/app/src/main/java/com/droiddeck/launcher/files/FileAttributes.kt b/app/src/main/java/com/droiddeck/launcher/files/FileAttributes.kt new file mode 100644 index 000000000..60c359bf4 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/files/FileAttributes.kt @@ -0,0 +1,93 @@ +package com.droiddeck.launcher.files + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import java.io.File + +// Reading and setting a file's read-only and hidden attributes (the DOS attribute xattr). + +// ── DOS file attributes (Read-only / Hidden) ── +// The in-container file manager (wfm.exe) exposes these in its Properties dialog; this mirrors the +// same two toggles for files browsed from the app side. No root - we only touch permission bits the +// app owns (container files) and the Wine DOS-attribute xattr. + +// FILE_ATTRIBUTE_HIDDEN, as Wine encodes it in the user.DOSATTRIB extended attribute. +private const val FILE_ATTRIBUTE_HIDDEN = 0x2 + +private const val DOSATTRIB_XATTR = "user.DOSATTRIB" + +// Snapshot of a file's two toggleable attributes. hiddenSupported is false when the underlying +// filesystem can't store the DOSATTRIB xattr (the FUSE /storage volumes) - the Hidden toggle is then +// disabled while Read-only keeps working. +internal data class FileAttrState( + val readOnly: Boolean, + val hidden: Boolean, + val hiddenSupported: Boolean, +) + +// Wine (and Samba) store user.DOSATTRIB as an ASCII hex string ("0x22"), sometimes with trailing +// Samba fields after a separator. We only need the leading DOS-attribute hex, so read from the "0x" +// prefix and stop at the first non-hex character. +private fun parseDosAttrib(raw: ByteArray): Int { + val s = String(raw, Charsets.US_ASCII).trim() + val hex = if (s.startsWith("0x") || s.startsWith("0X")) s.substring(2) else s + val digits = hex.takeWhile { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' } + return digits.toIntOrNull(16) ?: 0 +} + +// Read the current read-only + Wine-hidden state. Never throws: an xattr-unsupported filesystem +// comes back hiddenSupported=false (Hidden toggle disabled), Read-only always resolves. +internal fun readFileAttrs(file: File): FileAttrState { + val path = file.absolutePath + // Read-only reflects the OWNER write bit - that's what Wine maps to FILE_ATTRIBUTE_READONLY. + val readOnly = runCatching { + (android.system.Os.stat(path).st_mode and android.system.OsConstants.S_IWUSR) == 0 + }.getOrDefault(!file.canWrite()) + + var hidden = false + var supported = true + try { + hidden = (parseDosAttrib(android.system.Os.getxattr(path, DOSATTRIB_XATTR)) and FILE_ATTRIBUTE_HIDDEN) != 0 + } catch (e: android.system.ErrnoException) { + // ENODATA: xattr namespace works, the attribute just isn't set yet -> still toggleable. + // ENOTSUP/EOPNOTSUPP/anything else: the fs can't store it -> disable the Hidden toggle. + supported = e.errno == android.system.OsConstants.ENODATA + } catch (e: Exception) { + supported = false + } + return FileAttrState(readOnly, hidden, supported) +} + +// Toggle read-only by flipping the write bits, preserving every other permission bit. Setting +// read-only clears owner/group/other write (so Wine sees FILE_ATTRIBUTE_READONLY regardless of which +// write bit it checks); clearing it restores owner write. Falls back to File.setWritable if chmod is +// somehow refused. Returns true on success. +internal fun setReadOnly(file: File, readOnly: Boolean): Boolean = runCatching { + val path = file.absolutePath + val mode = android.system.Os.stat(path).st_mode + val writeBits = android.system.OsConstants.S_IWUSR or + android.system.OsConstants.S_IWGRP or android.system.OsConstants.S_IWOTH + val newMode = if (readOnly) mode and writeBits.inv() + else mode or android.system.OsConstants.S_IWUSR + android.system.Os.chmod(path, newMode) + true +}.getOrElse { file.setWritable(!readOnly, true) } + +// Flip Wine's DOS hidden bit in user.DOSATTRIB, preserving the other DOS-attribute bits +// (archive/system/read-only) already encoded there, and synthesizing a minimal value when absent. +// Writes Wine's own "0x%x" format, which Wine reads back natively. Returns false when the fs can't +// store the xattr, so the caller can disable just the Hidden toggle. +internal fun setHidden(file: File, hidden: Boolean): Boolean = try { + val path = file.absolutePath + var attr = try { + parseDosAttrib(android.system.Os.getxattr(path, DOSATTRIB_XATTR)) + } catch (e: android.system.ErrnoException) { + if (e.errno == android.system.OsConstants.ENODATA) 0 else throw e + } + attr = if (hidden) attr or FILE_ATTRIBUTE_HIDDEN else attr and FILE_ATTRIBUTE_HIDDEN.inv() + val value = "0x%x".format(attr).toByteArray(Charsets.US_ASCII) + android.system.Os.setxattr(path, DOSATTRIB_XATTR, value, 0) + true +} catch (e: Exception) { + false +} diff --git a/app/src/main/java/com/droiddeck/launcher/files/FileManagerItems.kt b/app/src/main/java/com/droiddeck/launcher/files/FileManagerItems.kt new file mode 100644 index 000000000..054430090 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/files/FileManagerItems.kt @@ -0,0 +1,599 @@ +package com.droiddeck.launcher.files + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +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.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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Checklist +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.FileCopy +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.InsertDriveFile +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.ContentCut +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.StarBorder +import androidx.compose.material.icons.filled.PushPin +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material.icons.filled.Image +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import coil.compose.AsyncImage +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.droiddeck.launcher.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +// The File Manager's rows, tiles, favourites and context menu. + +// Semantic identity colours for the favourite-card drive badge. Intentionally NOT theme +// accent colours - they identify the storage source at a glance. Returns (background, foreground). +private fun badgeColors(loc: FavLocation): Pair { + val white = Color(0xFFFFFFFF) + return when { + loc.storage == FavStorage.INTERNAL -> Color(0xFF2E5FB0) to white // blue + loc.storage == FavStorage.SD -> Color(0xFF2E7D32) to white // green + else -> Color(0xFF555555) to white // grey + } +} + +// The context-menu item list shared by the list rows (FileItemRow) and the grid tiles +// (FileGridTile), so both open the identical menu. Gating (isDir/canRun/isInno/looksLikeArchive) is +// recomputed from [file] here - one source of truth for what each item shows. Every item dismisses +// the menu first, then runs its action. +@Composable +private fun FileContextMenuItems( + file: File, + isFavorite: Boolean, + onSelect: () -> Unit, + onRename: () -> Unit, + onCopy: () -> Unit, + onCut: () -> Unit, + onDelete: () -> Unit, + onToggleFavorite: () -> Unit, + onProperties: () -> Unit, + onDismissMenu: () -> Unit, +) { + val isDir = file.isDirectory + DropdownMenuItem( + text = { Text("Select") }, + leadingIcon = { Icon(Icons.Filled.Checklist, null, tint = MaterialTheme.colorScheme.primary) }, + onClick = { onDismissMenu(); onSelect() }, + ) + MenuItemDivider() + // Properties: basic info + Read-only / Hidden toggles, for ANY file or folder (handy for config + // files like .txt/.cfg/.ini). Kept near the top since it's a common reason to open this menu. + DropdownMenuItem( + text = { Text("Properties") }, + leadingIcon = { Icon(Icons.Filled.Info, null, tint = MaterialTheme.colorScheme.primary) }, + onClick = { onDismissMenu(); onProperties() }, + ) + MenuItemDivider() + // Favorites are directories - only folders get the pin toggle. + if (isDir) { + DropdownMenuItem( + text = { Text(if (isFavorite) "Remove from Favorites" else "Add to Favorites") }, + leadingIcon = { + Icon( + if (isFavorite) Icons.Filled.Star else Icons.Filled.StarBorder, + null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + onClick = { onToggleFavorite() }, + ) + MenuItemDivider() + } + DropdownMenuItem( + text = { Text("Rename") }, + leadingIcon = { Icon(Icons.Filled.Edit, null, tint = MaterialTheme.colorScheme.primary) }, + onClick = { onDismissMenu(); onRename() }, + ) + MenuItemDivider() + DropdownMenuItem( + text = { Text("Copy") }, + leadingIcon = { Icon(Icons.Filled.FileCopy, null, tint = MaterialTheme.colorScheme.primary) }, + onClick = { onDismissMenu(); onCopy() }, + ) + MenuItemDivider() + DropdownMenuItem( + text = { Text("Cut") }, + leadingIcon = { Icon(Icons.Filled.ContentCut, null, tint = MaterialTheme.colorScheme.primary) }, + onClick = { onDismissMenu(); onCut() }, + ) + MenuItemDivider() + DropdownMenuItem( + text = { Text("Delete") }, + leadingIcon = { Icon(Icons.Filled.Delete, null, tint = MaterialTheme.colorScheme.primary) }, + onClick = { onDismissMenu(); onDelete() }, + ) +} + +@Composable +@OptIn(ExperimentalFoundationApi::class) +internal fun FileItemRow( + file: File, + showActions: Boolean = true, + compact: Boolean = false, + selectionMode: Boolean = false, + selected: Boolean = false, + onLongPress: () -> Unit = {}, + onToggleSelect: () -> Unit = {}, + onTap: () -> Unit, + onMenu: () -> Unit, + menuExpanded: Boolean, + onDismissMenu: () -> Unit, + onSelect: () -> Unit = {}, + onCopy: () -> Unit, + onCut: () -> Unit, + onDelete: () -> Unit, + onRename: () -> Unit, + isFavorite: Boolean = false, + onToggleFavorite: () -> Unit = {}, + onProperties: () -> Unit = {}, +) { + val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) } + val isDir = file.isDirectory + val isExe = !isDir && file.name.lowercase().let { it.endsWith(".exe") || it.endsWith(".bat") || it.endsWith(".msi") || it.endsWith(".sh") } + // Image files show a real thumbnail instead of the generic file icon (handy when picking a + // wallpaper/icon). Coil sizes the decode to the 36dp slot and caches it, so scrolling stays smooth. + val isImage = !isDir && file.extension.lowercase() in IMAGE_THUMB_EXTS + + // For real PE executables, try to pull out the embedded application icon (async, off the main thread). + var exeIcon by remember(file.absolutePath) { mutableStateOf(null) } + if (!isDir && file.name.lowercase().endsWith(".exe")) { + LaunchedEffect(file.absolutePath) { + val bmp = withContext(Dispatchers.IO) { PeIconExtractor.extract(file) } + if (bmp != null) exeIcon = bmp.asImageBitmap() + } + } + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 3.dp) + .combinedClickable( + // In selection mode a tap toggles instead of opening, so you can rattle through a + // folder without long-pressing every single row. + onClick = { if (selectionMode) onToggleSelect() else onTap() }, + onLongClick = onLongPress, + ), + shape = RoundedCornerShape(10.dp), + colors = CardDefaults.cardColors( + containerColor = if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.18f) + else MaterialTheme.colorScheme.surfaceContainer, + ), + border = BorderStroke( + 1.dp, + if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline, + ), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 12.dp, vertical = if (compact) 3.dp else 8.dp), + ) { + if (selectionMode) { + androidx.compose.material3.Checkbox(checked = selected, onCheckedChange = { onToggleSelect() }) + Spacer(Modifier.width(4.dp)) + } + when { + // Show the executable's own embedded icon when we managed to extract one. + exeIcon != null -> Image( + bitmap = exeIcon!!, + contentDescription = null, + modifier = Modifier.size(if (compact) 24.dp else 36.dp), + ) + isExe -> Icon( + painter = painterResource(R.drawable.icon_menu_container), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(if (compact) 24.dp else 36.dp), + ) + isDir -> Icon( + imageVector = Icons.Filled.Folder, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(if (compact) 24.dp else 36.dp), + ) + // Real image preview. Falls back to the generic file icon while loading or on decode failure. + isImage -> AsyncImage( + model = file, + contentDescription = null, + contentScale = ContentScale.Crop, + placeholder = rememberVectorPainter(Icons.Filled.InsertDriveFile), + error = rememberVectorPainter(Icons.Filled.InsertDriveFile), + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(6.dp)), + ) + else -> Icon( + imageVector = Icons.Filled.InsertDriveFile, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(if (compact) 24.dp else 36.dp), + ) + } + Spacer(Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = file.name, + color = MaterialTheme.colorScheme.onBackground, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = buildString { + if (!isDir) append(FileOps.formatBytes(file.length())).append(" \u2022 ") + append(dateFormat.format(Date(file.lastModified()))) + }, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 12.sp, + ) + } + if (showActions) Box { + IconButton(onClick = onMenu) { + Icon(Icons.Filled.MoreVert, "Actions", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp)) + } + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = onDismissMenu, + modifier = Modifier.outlinedMenuCard(), + ) { + FileContextMenuItems( + file = file, + isFavorite = isFavorite, + onSelect = onSelect, + onRename = onRename, + onCopy = onCopy, + onCut = onCut, + onDelete = onDelete, + onToggleFavorite = onToggleFavorite, + onProperties = onProperties, + onDismissMenu = onDismissMenu, + ) + } + } + } + } +} + +// Dedicated Favorites list that replaces the file list while the star toggle is on. +// Reads the store keyed on [favTick] so it recomposes after any pin/unpin. +@Composable +internal fun FavoritesList( + currentDir: File, + favTick: Int, + onPinCurrent: () -> Unit, + onJump: (File) -> Unit, + onUnpin: (File) -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val favorites = remember(favTick) { + FavoritesStore.list(context).map(::File).filter { it.exists() } + } + val currentAlreadyPinned = remember(favTick, currentDir.absolutePath) { + FavoritesStore.isFavorite(context, currentDir.absolutePath) + } + + LazyColumn(modifier = modifier) { + item { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Text( + text = "Favorites", + color = MaterialTheme.colorScheme.onBackground, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + TextButton( + onClick = onPinCurrent, + enabled = !currentAlreadyPinned, + ) { + Icon(Icons.Filled.PushPin, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text( + text = "Pin current folder", + color = MaterialTheme.colorScheme.primary, + fontSize = 13.sp, + ) + } + } + } + + if (favorites.isEmpty()) { + item { + Box( + modifier = Modifier.fillParentMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = "No favorites yet - pin a folder with its ⋮ menu to jump back here fast.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 13.sp, + modifier = Modifier.padding(horizontal = 32.dp), + ) + } + } + } else { + items(favorites, key = { it.absolutePath }) { file -> + val loc = remember(file.absolutePath) { describeLocation(file) } + FavoriteCard( + file = file, + loc = loc, + onJump = { onJump(file) }, + onUnpin = { onUnpin(file) }, + ) + } + } + } +} + +// A single favourite - matches the FileItemRow card style (surfaceContainer + outline + +// RoundedCornerShape(10.dp)). Shows the folder name, a coloured drive badge + origin text, +// and the full display path; tapping jumps into it, the filled star unpins. +@Composable +private fun FavoriteCard( + file: File, + loc: FavLocation, + onJump: () -> Unit, + onUnpin: () -> Unit, +) { + val (badgeBg, badgeFg) = badgeColors(loc) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 3.dp) + .clickable(onClick = onJump), + shape = RoundedCornerShape(10.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) { + Icon( + imageVector = Icons.Filled.Folder, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(36.dp), + ) + Spacer(Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = file.name, + color = MaterialTheme.colorScheme.onBackground, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(2.dp)) + // Origin line: coloured drive badge + source description. + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = loc.driveLabel, + color = badgeFg, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(badgeBg) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) + Spacer(Modifier.width(6.dp)) + Text( + text = when (loc.storage) { + FavStorage.INTERNAL -> "Internal storage" + FavStorage.SD -> "SD card" + FavStorage.OTHER -> "Storage" + }, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 12.sp, + ) + } + Spacer(Modifier.height(2.dp)) + Text( + text = loc.displayPath, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + IconButton(onClick = onUnpin) { + Icon( + Icons.Filled.Star, + contentDescription = "Remove from favorites", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(22.dp), + ) + } + } + } +} + +/** + * One entry in the File Manager's grid view: a big thumbnail with the name under it. + * + * Deliberately drops size and date - at this width they truncate to noise. The grid is for + * recognising things by sight (screenshots, covers, game folders); the list stays the view for + * reading details. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun FileGridTile( + file: File, + selectionMode: Boolean, + selected: Boolean, + onLongPress: () -> Unit, + onToggleSelect: () -> Unit, + onTap: () -> Unit, + onMenu: () -> Unit, + menuExpanded: Boolean = false, + onDismissMenu: () -> Unit = {}, + isFavorite: Boolean = false, + onSelect: () -> Unit = {}, + onRename: () -> Unit = {}, + onCopy: () -> Unit = {}, + onCut: () -> Unit = {}, + onDelete: () -> Unit = {}, + onToggleFavorite: () -> Unit = {}, + onProperties: () -> Unit = {}, +) { + val isDir = file.isDirectory + val isImage = !isDir && file.extension.lowercase() in IMAGE_THUMB_EXTS + var exeIcon by remember(file.absolutePath) { mutableStateOf(null) } + if (!isDir && file.name.lowercase().endsWith(".exe")) { + LaunchedEffect(file.absolutePath) { + val bmp = withContext(Dispatchers.IO) { PeIconExtractor.extract(file) } + if (bmp != null) exeIcon = bmp.asImageBitmap() + } + } + // The tile has no ⋮ button - long-press opens this menu, anchored to the Box around the Card. + Box { + Card( + modifier = Modifier + .padding(4.dp) + .combinedClickable( + onClick = { if (selectionMode) onToggleSelect() else onTap() }, + onLongClick = onLongPress, + ), + shape = RoundedCornerShape(10.dp), + colors = CardDefaults.cardColors( + containerColor = if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.18f) + else MaterialTheme.colorScheme.surfaceContainer, + ), + border = BorderStroke( + 1.dp, + if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline, + ), + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth().padding(8.dp), + ) { + Box(modifier = Modifier.size(56.dp), contentAlignment = Alignment.Center) { + when { + exeIcon != null -> Image(bitmap = exeIcon!!, contentDescription = null, modifier = Modifier.size(48.dp)) + isDir -> Icon( + Icons.Filled.Folder, null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(48.dp), + ) + isImage -> AsyncImage( + model = file, + contentDescription = null, + contentScale = ContentScale.Crop, + placeholder = rememberVectorPainter(Icons.Filled.InsertDriveFile), + error = rememberVectorPainter(Icons.Filled.InsertDriveFile), + modifier = Modifier.size(56.dp).clip(RoundedCornerShape(6.dp)), + ) + else -> Icon( + Icons.Filled.InsertDriveFile, null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(44.dp), + ) + } + if (selectionMode) { + androidx.compose.material3.Checkbox( + checked = selected, + onCheckedChange = { onToggleSelect() }, + modifier = Modifier.align(Alignment.TopStart), + ) + } + } + Spacer(Modifier.height(4.dp)) + // Always reserve two lines (issue #475): a one-line name used to make its card shorter than + // its neighbours, so grid rows had ragged heights. Fixed min/max keeps every tile the same size. + Text( + file.name, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 12.sp, + minLines = 2, + maxLines = 2, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + overflow = TextOverflow.Ellipsis, + ) + } + } + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = onDismissMenu, + modifier = Modifier.outlinedMenuCard(), + ) { + FileContextMenuItems( + file = file, + isFavorite = isFavorite, + onSelect = onSelect, + onRename = onRename, + onCopy = onCopy, + onCut = onCut, + onDelete = onDelete, + onToggleFavorite = onToggleFavorite, + onProperties = onProperties, + onDismissMenu = onDismissMenu, + ) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/files/FileManagerModel.kt b/app/src/main/java/com/droiddeck/launcher/files/FileManagerModel.kt new file mode 100644 index 000000000..b1705146f --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/files/FileManagerModel.kt @@ -0,0 +1,101 @@ +package com.droiddeck.launcher.files + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material.icons.filled.Image +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.style.TextOverflow +import java.io.File + +// The File Manager's non-UI helpers: sorting, paths, conflict choices and favourite locations. + +/* + * Bannerlator's File Manager (ui/screens/FileManagerScreen.kt), carried over whole: the same + * browsing, grid and list, sorting, search, multi-select, copy/cut/paste with conflicts and + * progress, rename, delete, new folder, properties, favourites and the locations rail, and the + * same pick modes. What this app has no use for is not here: running a file in a Wine container, + * adding it to a container's shortcuts, the Drive C: / Drive Z: locations and the archive + * unpacker that installs into a prefix. Everything else is line for line the same so a fix in + * either app carries to the other. + */ + +/** + * What to do when a pasted item already exists at the destination. + * + * OVERWRITE and MERGE resolve to the same call - `copyWithProgress` recurses into an existing + * directory and truncates existing files - but they mean different things to the user, so both are + * offered and the wording is chosen per item type (files overwrite, folders merge). + */ +enum class ConflictChoice { OVERWRITE, MERGE, KEEP_BOTH, SKIP } + +/** + * Shortens a path from the LEFT, keeping whole segments. + * + * Compose's TextOverflow can only ellipsise the tail, which for a path throws away the part that + * matters - `/storage/emulated/0/Games/Racing/Dir…` tells you nothing about where you are. + */ +internal fun elidePathStart(path: String, max: Int): String { + if (path.length <= max) return path + val parts = path.split('/').filter { it.isNotEmpty() } + val out = StringBuilder() + for (part in parts.asReversed()) { + if (out.length + part.length + 1 > max - 2) break + out.insert(0, "/$part") + } + return if (out.isEmpty()) "…" + path.takeLast(max - 1) else "…$out" +} + +/** Folder-first ordering stays fixed across both sort directions. */ +internal fun comparatorFor(sortBy: String, desc: Boolean): Comparator { + val inner: Comparator = when (sortBy) { + "date" -> compareBy { it.lastModified() } + // Directory length() is meaningless, so folders sort by name within the size ordering + // instead of pretending to have one. + "size" -> compareBy { if (it.isDirectory) -1L else it.length() } + else -> compareBy { it.name.lowercase() } + } + val directed = if (desc) inner.reversed() else inner + return compareBy { if (it.isDirectory) 0 else 1 }.then(directed) +} + +// Image extensions that get a real thumbnail (via Coil) instead of the generic file icon. +internal val IMAGE_THUMB_EXTS = setOf("jpg", "jpeg", "png", "webp", "bmp", "gif") + +// True when [child] is [ancestor] itself or lives anywhere inside it. +internal fun isWithin(child: File, ancestor: File): Boolean { + val c = runCatching { child.canonicalPath }.getOrDefault(child.absolutePath) + val a = runCatching { ancestor.canonicalPath }.getOrDefault(ancestor.absolutePath) + return c == a || c.startsWith(a + File.separator) +} + +// ── Favorites: origin resolution ── + +enum class FavStorage { INTERNAL, SD, OTHER } + +data class FavLocation( + val storage: FavStorage, + val driveLabel: String, // "Internal", "SD card", or "Storage" + val displayPath: String // the unix absolute path +) + +// Resolve where [file] lives (storage source + a friendly label) by prefix-matching its path. +fun describeLocation(file: File): FavLocation { + val abs = file.absolutePath + + val internal = "/storage/emulated/0" + if (abs == internal || abs.startsWith("$internal/")) { + return FavLocation(FavStorage.INTERNAL, "Internal", abs) + } + + if (abs.startsWith("/storage/")) { + val name = abs.removePrefix("/storage/").substringBefore('/') + if (name.isNotEmpty() && name != "emulated" && name != "self") { + return FavLocation(FavStorage.SD, "SD card", abs) + } + } + + return FavLocation(FavStorage.OTHER, "Storage", abs) +} diff --git a/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt b/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt index 3f2a33667..ecae61e2f 100644 --- a/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt @@ -7,12 +7,7 @@ import androidx.compose.foundation.Image import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -21,7 +16,6 @@ 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.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -33,41 +27,25 @@ import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material.icons.filled.Bolt -import androidx.compose.material.icons.filled.Checklist import androidx.compose.material.icons.filled.ContentPaste import androidx.compose.material.icons.filled.CreateNewFolder import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.FileCopy import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.GridView -import androidx.compose.material.icons.filled.Info -import androidx.compose.material.icons.filled.InsertDriveFile -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.ContentCut -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.SdStorage import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Sort import androidx.compose.material.icons.filled.Star -import androidx.compose.material.icons.filled.Unarchive import androidx.compose.material.icons.filled.ViewList import androidx.compose.material.icons.filled.StarBorder -import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.filled.Storage import androidx.compose.material.icons.filled.Smartphone import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.Image import androidx.compose.material.icons.filled.SportsEsports -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -95,217 +73,22 @@ 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.ImageBitmap -import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.compose.ui.res.painterResource import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver -import coil.compose.AsyncImage -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.droiddeck.launcher.R import com.droiddeck.launcher.session.SessionPrefs import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File -import java.text.SimpleDateFormat import java.util.Date -import java.util.Locale - -/* - * Bannerlator's File Manager (ui/screens/FileManagerScreen.kt), carried over whole: the same - * browsing, grid and list, sorting, search, multi-select, copy/cut/paste with conflicts and - * progress, rename, delete, new folder, properties, favourites and the locations rail, and the - * same pick modes. What this app has no use for is not here: running a file in a Wine container, - * adding it to a container's shortcuts, the Drive C: / Drive Z: locations and the archive - * unpacker that installs into a prefix. Everything else is line for line the same so a fix in - * either app carries to the other. - */ - -/** - * What to do when a pasted item already exists at the destination. - * - * OVERWRITE and MERGE resolve to the same call - `copyWithProgress` recurses into an existing - * directory and truncates existing files - but they mean different things to the user, so both are - * offered and the wording is chosen per item type (files overwrite, folders merge). - */ -enum class ConflictChoice { OVERWRITE, MERGE, KEEP_BOTH, SKIP } - -/** - * Shortens a path from the LEFT, keeping whole segments. - * - * Compose's TextOverflow can only ellipsise the tail, which for a path throws away the part that - * matters - `/storage/emulated/0/Games/Racing/Dir…` tells you nothing about where you are. - */ -private fun elidePathStart(path: String, max: Int): String { - if (path.length <= max) return path - val parts = path.split('/').filter { it.isNotEmpty() } - val out = StringBuilder() - for (part in parts.asReversed()) { - if (out.length + part.length + 1 > max - 2) break - out.insert(0, "/$part") - } - return if (out.isEmpty()) "…" + path.takeLast(max - 1) else "…$out" -} - -/** Folder-first ordering stays fixed across both sort directions. */ -private fun comparatorFor(sortBy: String, desc: Boolean): Comparator { - val inner: Comparator = when (sortBy) { - "date" -> compareBy { it.lastModified() } - // Directory length() is meaningless, so folders sort by name within the size ordering - // instead of pretending to have one. - "size" -> compareBy { if (it.isDirectory) -1L else it.length() } - else -> compareBy { it.name.lowercase() } - } - val directed = if (desc) inner.reversed() else inner - return compareBy { if (it.isDirectory) 0 else 1 }.then(directed) -} - -// Image extensions that get a real thumbnail (via Coil) instead of the generic file icon. -private val IMAGE_THUMB_EXTS = setOf("jpg", "jpeg", "png", "webp", "bmp", "gif") - -// True when [child] is [ancestor] itself or lives anywhere inside it. -private fun isWithin(child: File, ancestor: File): Boolean { - val c = runCatching { child.canonicalPath }.getOrDefault(child.absolutePath) - val a = runCatching { ancestor.canonicalPath }.getOrDefault(ancestor.absolutePath) - return c == a || c.startsWith(a + File.separator) -} - -// ── DOS file attributes (Read-only / Hidden) ── -// The in-container file manager (wfm.exe) exposes these in its Properties dialog; this mirrors the -// same two toggles for files browsed from the app side. No root - we only touch permission bits the -// app owns (container files) and the Wine DOS-attribute xattr. - -// FILE_ATTRIBUTE_HIDDEN, as Wine encodes it in the user.DOSATTRIB extended attribute. -private const val FILE_ATTRIBUTE_HIDDEN = 0x2 -private const val DOSATTRIB_XATTR = "user.DOSATTRIB" - -// Snapshot of a file's two toggleable attributes. hiddenSupported is false when the underlying -// filesystem can't store the DOSATTRIB xattr (the FUSE /storage volumes) - the Hidden toggle is then -// disabled while Read-only keeps working. -private data class FileAttrState( - val readOnly: Boolean, - val hidden: Boolean, - val hiddenSupported: Boolean, -) - -// Wine (and Samba) store user.DOSATTRIB as an ASCII hex string ("0x22"), sometimes with trailing -// Samba fields after a separator. We only need the leading DOS-attribute hex, so read from the "0x" -// prefix and stop at the first non-hex character. -private fun parseDosAttrib(raw: ByteArray): Int { - val s = String(raw, Charsets.US_ASCII).trim() - val hex = if (s.startsWith("0x") || s.startsWith("0X")) s.substring(2) else s - val digits = hex.takeWhile { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' } - return digits.toIntOrNull(16) ?: 0 -} - -// Read the current read-only + Wine-hidden state. Never throws: an xattr-unsupported filesystem -// comes back hiddenSupported=false (Hidden toggle disabled), Read-only always resolves. -private fun readFileAttrs(file: File): FileAttrState { - val path = file.absolutePath - // Read-only reflects the OWNER write bit - that's what Wine maps to FILE_ATTRIBUTE_READONLY. - val readOnly = runCatching { - (android.system.Os.stat(path).st_mode and android.system.OsConstants.S_IWUSR) == 0 - }.getOrDefault(!file.canWrite()) - - var hidden = false - var supported = true - try { - hidden = (parseDosAttrib(android.system.Os.getxattr(path, DOSATTRIB_XATTR)) and FILE_ATTRIBUTE_HIDDEN) != 0 - } catch (e: android.system.ErrnoException) { - // ENODATA: xattr namespace works, the attribute just isn't set yet -> still toggleable. - // ENOTSUP/EOPNOTSUPP/anything else: the fs can't store it -> disable the Hidden toggle. - supported = e.errno == android.system.OsConstants.ENODATA - } catch (e: Exception) { - supported = false - } - return FileAttrState(readOnly, hidden, supported) -} - -// Toggle read-only by flipping the write bits, preserving every other permission bit. Setting -// read-only clears owner/group/other write (so Wine sees FILE_ATTRIBUTE_READONLY regardless of which -// write bit it checks); clearing it restores owner write. Falls back to File.setWritable if chmod is -// somehow refused. Returns true on success. -private fun setReadOnly(file: File, readOnly: Boolean): Boolean = runCatching { - val path = file.absolutePath - val mode = android.system.Os.stat(path).st_mode - val writeBits = android.system.OsConstants.S_IWUSR or - android.system.OsConstants.S_IWGRP or android.system.OsConstants.S_IWOTH - val newMode = if (readOnly) mode and writeBits.inv() - else mode or android.system.OsConstants.S_IWUSR - android.system.Os.chmod(path, newMode) - true -}.getOrElse { file.setWritable(!readOnly, true) } - -// Flip Wine's DOS hidden bit in user.DOSATTRIB, preserving the other DOS-attribute bits -// (archive/system/read-only) already encoded there, and synthesizing a minimal value when absent. -// Writes Wine's own "0x%x" format, which Wine reads back natively. Returns false when the fs can't -// store the xattr, so the caller can disable just the Hidden toggle. -private fun setHidden(file: File, hidden: Boolean): Boolean = try { - val path = file.absolutePath - var attr = try { - parseDosAttrib(android.system.Os.getxattr(path, DOSATTRIB_XATTR)) - } catch (e: android.system.ErrnoException) { - if (e.errno == android.system.OsConstants.ENODATA) 0 else throw e - } - attr = if (hidden) attr or FILE_ATTRIBUTE_HIDDEN else attr and FILE_ATTRIBUTE_HIDDEN.inv() - val value = "0x%x".format(attr).toByteArray(Charsets.US_ASCII) - android.system.Os.setxattr(path, DOSATTRIB_XATTR, value, 0) - true -} catch (e: Exception) { - false -} - -// ── Favorites: origin resolution ── - -enum class FavStorage { INTERNAL, SD, OTHER } - -data class FavLocation( - val storage: FavStorage, - val driveLabel: String, // "Internal", "SD card", or "Storage" - val displayPath: String // the unix absolute path -) - -// Resolve where [file] lives (storage source + a friendly label) by prefix-matching its path. -fun describeLocation(file: File): FavLocation { - val abs = file.absolutePath - - val internal = "/storage/emulated/0" - if (abs == internal || abs.startsWith("$internal/")) { - return FavLocation(FavStorage.INTERNAL, "Internal", abs) - } - - if (abs.startsWith("/storage/")) { - val name = abs.removePrefix("/storage/").substringBefore('/') - if (name.isNotEmpty() && name != "emulated" && name != "self") { - return FavLocation(FavStorage.SD, "SD card", abs) - } - } - - return FavLocation(FavStorage.OTHER, "Storage", abs) -} - -// Semantic identity colours for the favourite-card drive badge. Intentionally NOT theme -// accent colours - they identify the storage source at a glance. Returns (background, foreground). -private fun badgeColors(loc: FavLocation): Pair { - val white = Color(0xFFFFFFFF) - return when { - loc.storage == FavStorage.INTERNAL -> Color(0xFF2E5FB0) to white // blue - loc.storage == FavStorage.SD -> Color(0xFF2E7D32) to white // green - else -> Color(0xFF555555) to white // grey - } -} @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -1414,675 +1197,3 @@ fun FileManagerScreen( } // end rail + content Row } } - -// The context-menu item list shared by the list rows (FileItemRow) and the grid tiles -// (FileGridTile), so both open the identical menu. Gating (isDir/canRun/isInno/looksLikeArchive) is -// recomputed from [file] here - one source of truth for what each item shows. Every item dismisses -// the menu first, then runs its action. -@Composable -private fun FileContextMenuItems( - file: File, - isFavorite: Boolean, - onSelect: () -> Unit, - onRename: () -> Unit, - onCopy: () -> Unit, - onCut: () -> Unit, - onDelete: () -> Unit, - onToggleFavorite: () -> Unit, - onProperties: () -> Unit, - onDismissMenu: () -> Unit, -) { - val isDir = file.isDirectory - DropdownMenuItem( - text = { Text("Select") }, - leadingIcon = { Icon(Icons.Filled.Checklist, null, tint = MaterialTheme.colorScheme.primary) }, - onClick = { onDismissMenu(); onSelect() }, - ) - MenuItemDivider() - // Properties: basic info + Read-only / Hidden toggles, for ANY file or folder (handy for config - // files like .txt/.cfg/.ini). Kept near the top since it's a common reason to open this menu. - DropdownMenuItem( - text = { Text("Properties") }, - leadingIcon = { Icon(Icons.Filled.Info, null, tint = MaterialTheme.colorScheme.primary) }, - onClick = { onDismissMenu(); onProperties() }, - ) - MenuItemDivider() - // Favorites are directories - only folders get the pin toggle. - if (isDir) { - DropdownMenuItem( - text = { Text(if (isFavorite) "Remove from Favorites" else "Add to Favorites") }, - leadingIcon = { - Icon( - if (isFavorite) Icons.Filled.Star else Icons.Filled.StarBorder, - null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - onClick = { onToggleFavorite() }, - ) - MenuItemDivider() - } - DropdownMenuItem( - text = { Text("Rename") }, - leadingIcon = { Icon(Icons.Filled.Edit, null, tint = MaterialTheme.colorScheme.primary) }, - onClick = { onDismissMenu(); onRename() }, - ) - MenuItemDivider() - DropdownMenuItem( - text = { Text("Copy") }, - leadingIcon = { Icon(Icons.Filled.FileCopy, null, tint = MaterialTheme.colorScheme.primary) }, - onClick = { onDismissMenu(); onCopy() }, - ) - MenuItemDivider() - DropdownMenuItem( - text = { Text("Cut") }, - leadingIcon = { Icon(Icons.Filled.ContentCut, null, tint = MaterialTheme.colorScheme.primary) }, - onClick = { onDismissMenu(); onCut() }, - ) - MenuItemDivider() - DropdownMenuItem( - text = { Text("Delete") }, - leadingIcon = { Icon(Icons.Filled.Delete, null, tint = MaterialTheme.colorScheme.primary) }, - onClick = { onDismissMenu(); onDelete() }, - ) -} - -@Composable -@OptIn(ExperimentalFoundationApi::class) -private fun FileItemRow( - file: File, - showActions: Boolean = true, - compact: Boolean = false, - selectionMode: Boolean = false, - selected: Boolean = false, - onLongPress: () -> Unit = {}, - onToggleSelect: () -> Unit = {}, - onTap: () -> Unit, - onMenu: () -> Unit, - menuExpanded: Boolean, - onDismissMenu: () -> Unit, - onSelect: () -> Unit = {}, - onCopy: () -> Unit, - onCut: () -> Unit, - onDelete: () -> Unit, - onRename: () -> Unit, - isFavorite: Boolean = false, - onToggleFavorite: () -> Unit = {}, - onProperties: () -> Unit = {}, -) { - val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) } - val isDir = file.isDirectory - val isExe = !isDir && file.name.lowercase().let { it.endsWith(".exe") || it.endsWith(".bat") || it.endsWith(".msi") || it.endsWith(".sh") } - // Image files show a real thumbnail instead of the generic file icon (handy when picking a - // wallpaper/icon). Coil sizes the decode to the 36dp slot and caches it, so scrolling stays smooth. - val isImage = !isDir && file.extension.lowercase() in IMAGE_THUMB_EXTS - - // For real PE executables, try to pull out the embedded application icon (async, off the main thread). - var exeIcon by remember(file.absolutePath) { mutableStateOf(null) } - if (!isDir && file.name.lowercase().endsWith(".exe")) { - LaunchedEffect(file.absolutePath) { - val bmp = withContext(Dispatchers.IO) { PeIconExtractor.extract(file) } - if (bmp != null) exeIcon = bmp.asImageBitmap() - } - } - - Card( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 3.dp) - .combinedClickable( - // In selection mode a tap toggles instead of opening, so you can rattle through a - // folder without long-pressing every single row. - onClick = { if (selectionMode) onToggleSelect() else onTap() }, - onLongClick = onLongPress, - ), - shape = RoundedCornerShape(10.dp), - colors = CardDefaults.cardColors( - containerColor = if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.18f) - else MaterialTheme.colorScheme.surfaceContainer, - ), - border = BorderStroke( - 1.dp, - if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline, - ), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 12.dp, vertical = if (compact) 3.dp else 8.dp), - ) { - if (selectionMode) { - androidx.compose.material3.Checkbox(checked = selected, onCheckedChange = { onToggleSelect() }) - Spacer(Modifier.width(4.dp)) - } - when { - // Show the executable's own embedded icon when we managed to extract one. - exeIcon != null -> Image( - bitmap = exeIcon!!, - contentDescription = null, - modifier = Modifier.size(if (compact) 24.dp else 36.dp), - ) - isExe -> Icon( - painter = painterResource(R.drawable.icon_menu_container), - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(if (compact) 24.dp else 36.dp), - ) - isDir -> Icon( - imageVector = Icons.Filled.Folder, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(if (compact) 24.dp else 36.dp), - ) - // Real image preview. Falls back to the generic file icon while loading or on decode failure. - isImage -> AsyncImage( - model = file, - contentDescription = null, - contentScale = ContentScale.Crop, - placeholder = rememberVectorPainter(Icons.Filled.InsertDriveFile), - error = rememberVectorPainter(Icons.Filled.InsertDriveFile), - modifier = Modifier - .size(36.dp) - .clip(RoundedCornerShape(6.dp)), - ) - else -> Icon( - imageVector = Icons.Filled.InsertDriveFile, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(if (compact) 24.dp else 36.dp), - ) - } - Spacer(Modifier.width(10.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = file.name, - color = MaterialTheme.colorScheme.onBackground, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = buildString { - if (!isDir) append(FileOps.formatBytes(file.length())).append(" \u2022 ") - append(dateFormat.format(Date(file.lastModified()))) - }, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontSize = 12.sp, - ) - } - if (showActions) Box { - IconButton(onClick = onMenu) { - Icon(Icons.Filled.MoreVert, "Actions", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp)) - } - DropdownMenu( - expanded = menuExpanded, - onDismissRequest = onDismissMenu, - modifier = Modifier.outlinedMenuCard(), - ) { - FileContextMenuItems( - file = file, - isFavorite = isFavorite, - onSelect = onSelect, - onRename = onRename, - onCopy = onCopy, - onCut = onCut, - onDelete = onDelete, - onToggleFavorite = onToggleFavorite, - onProperties = onProperties, - onDismissMenu = onDismissMenu, - ) - } - } - } - } -} - -// Dedicated Favorites list that replaces the file list while the star toggle is on. -// Reads the store keyed on [favTick] so it recomposes after any pin/unpin. -@Composable -private fun FavoritesList( - currentDir: File, - favTick: Int, - onPinCurrent: () -> Unit, - onJump: (File) -> Unit, - onUnpin: (File) -> Unit, - modifier: Modifier = Modifier, -) { - val context = LocalContext.current - val favorites = remember(favTick) { - FavoritesStore.list(context).map(::File).filter { it.exists() } - } - val currentAlreadyPinned = remember(favTick, currentDir.absolutePath) { - FavoritesStore.isFavorite(context, currentDir.absolutePath) - } - - LazyColumn(modifier = modifier) { - item { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - ) { - Text( - text = "Favorites", - color = MaterialTheme.colorScheme.onBackground, - fontSize = 15.sp, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.weight(1f), - ) - TextButton( - onClick = onPinCurrent, - enabled = !currentAlreadyPinned, - ) { - Icon(Icons.Filled.PushPin, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp)) - Spacer(Modifier.width(6.dp)) - Text( - text = "Pin current folder", - color = MaterialTheme.colorScheme.primary, - fontSize = 13.sp, - ) - } - } - } - - if (favorites.isEmpty()) { - item { - Box( - modifier = Modifier.fillParentMaxSize(), - contentAlignment = Alignment.Center, - ) { - Text( - text = "No favorites yet - pin a folder with its ⋮ menu to jump back here fast.", - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontSize = 13.sp, - modifier = Modifier.padding(horizontal = 32.dp), - ) - } - } - } else { - items(favorites, key = { it.absolutePath }) { file -> - val loc = remember(file.absolutePath) { describeLocation(file) } - FavoriteCard( - file = file, - loc = loc, - onJump = { onJump(file) }, - onUnpin = { onUnpin(file) }, - ) - } - } - } -} - -// A single favourite - matches the FileItemRow card style (surfaceContainer + outline + -// RoundedCornerShape(10.dp)). Shows the folder name, a coloured drive badge + origin text, -// and the full display path; tapping jumps into it, the filled star unpins. -@Composable -private fun FavoriteCard( - file: File, - loc: FavLocation, - onJump: () -> Unit, - onUnpin: () -> Unit, -) { - val (badgeBg, badgeFg) = badgeColors(loc) - Card( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 3.dp) - .clickable(onClick = onJump), - shape = RoundedCornerShape(10.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - ) { - Icon( - imageVector = Icons.Filled.Folder, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(36.dp), - ) - Spacer(Modifier.width(10.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = file.name, - color = MaterialTheme.colorScheme.onBackground, - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Spacer(Modifier.height(2.dp)) - // Origin line: coloured drive badge + source description. - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = loc.driveLabel, - color = badgeFg, - fontSize = 11.sp, - fontWeight = FontWeight.Bold, - modifier = Modifier - .clip(RoundedCornerShape(4.dp)) - .background(badgeBg) - .padding(horizontal = 6.dp, vertical = 2.dp), - ) - Spacer(Modifier.width(6.dp)) - Text( - text = when (loc.storage) { - FavStorage.INTERNAL -> "Internal storage" - FavStorage.SD -> "SD card" - FavStorage.OTHER -> "Storage" - }, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontSize = 12.sp, - ) - } - Spacer(Modifier.height(2.dp)) - Text( - text = loc.displayPath, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - IconButton(onClick = onUnpin) { - Icon( - Icons.Filled.Star, - contentDescription = "Remove from favorites", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(22.dp), - ) - } - } - } -} - -/** - * One entry in the File Manager's grid view: a big thumbnail with the name under it. - * - * Deliberately drops size and date - at this width they truncate to noise. The grid is for - * recognising things by sight (screenshots, covers, game folders); the list stays the view for - * reading details. - */ -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun FileGridTile( - file: File, - selectionMode: Boolean, - selected: Boolean, - onLongPress: () -> Unit, - onToggleSelect: () -> Unit, - onTap: () -> Unit, - onMenu: () -> Unit, - menuExpanded: Boolean = false, - onDismissMenu: () -> Unit = {}, - isFavorite: Boolean = false, - onSelect: () -> Unit = {}, - onRename: () -> Unit = {}, - onCopy: () -> Unit = {}, - onCut: () -> Unit = {}, - onDelete: () -> Unit = {}, - onToggleFavorite: () -> Unit = {}, - onProperties: () -> Unit = {}, -) { - val isDir = file.isDirectory - val isImage = !isDir && file.extension.lowercase() in IMAGE_THUMB_EXTS - var exeIcon by remember(file.absolutePath) { mutableStateOf(null) } - if (!isDir && file.name.lowercase().endsWith(".exe")) { - LaunchedEffect(file.absolutePath) { - val bmp = withContext(Dispatchers.IO) { PeIconExtractor.extract(file) } - if (bmp != null) exeIcon = bmp.asImageBitmap() - } - } - // The tile has no ⋮ button - long-press opens this menu, anchored to the Box around the Card. - Box { - Card( - modifier = Modifier - .padding(4.dp) - .combinedClickable( - onClick = { if (selectionMode) onToggleSelect() else onTap() }, - onLongClick = onLongPress, - ), - shape = RoundedCornerShape(10.dp), - colors = CardDefaults.cardColors( - containerColor = if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.18f) - else MaterialTheme.colorScheme.surfaceContainer, - ), - border = BorderStroke( - 1.dp, - if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline, - ), - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxWidth().padding(8.dp), - ) { - Box(modifier = Modifier.size(56.dp), contentAlignment = Alignment.Center) { - when { - exeIcon != null -> Image(bitmap = exeIcon!!, contentDescription = null, modifier = Modifier.size(48.dp)) - isDir -> Icon( - Icons.Filled.Folder, null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(48.dp), - ) - isImage -> AsyncImage( - model = file, - contentDescription = null, - contentScale = ContentScale.Crop, - placeholder = rememberVectorPainter(Icons.Filled.InsertDriveFile), - error = rememberVectorPainter(Icons.Filled.InsertDriveFile), - modifier = Modifier.size(56.dp).clip(RoundedCornerShape(6.dp)), - ) - else -> Icon( - Icons.Filled.InsertDriveFile, null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(44.dp), - ) - } - if (selectionMode) { - androidx.compose.material3.Checkbox( - checked = selected, - onCheckedChange = { onToggleSelect() }, - modifier = Modifier.align(Alignment.TopStart), - ) - } - } - Spacer(Modifier.height(4.dp)) - // Always reserve two lines (issue #475): a one-line name used to make its card shorter than - // its neighbours, so grid rows had ragged heights. Fixed min/max keeps every tile the same size. - Text( - file.name, - color = MaterialTheme.colorScheme.onSurface, - fontSize = 12.sp, - minLines = 2, - maxLines = 2, - textAlign = androidx.compose.ui.text.style.TextAlign.Center, - overflow = TextOverflow.Ellipsis, - ) - } - } - DropdownMenu( - expanded = menuExpanded, - onDismissRequest = onDismissMenu, - modifier = Modifier.outlinedMenuCard(), - ) { - FileContextMenuItems( - file = file, - isFavorite = isFavorite, - onSelect = onSelect, - onRename = onRename, - onCopy = onCopy, - onCut = onCut, - onDelete = onDelete, - onToggleFavorite = onToggleFavorite, - onProperties = onProperties, - onDismissMenu = onDismissMenu, - ) - } - } -} - -/** - * Properties sheet for a single file or folder: basic info plus the two Windows/Wine file attributes - * the in-container file manager (wfm.exe) also exposes - Read-only and Hidden. Each toggle applies - * immediately (off the main thread) and refreshes the listing via [onChanged]. - * - * State is keyed on the file so it always reflects the entry it was opened for. On a filesystem that - * can't store Wine's DOSATTRIB xattr (the FUSE /storage volumes) only the Hidden toggle is disabled; - * Read-only keeps working everywhere. - */ -@Composable -private fun FilePropertiesDialog( - file: File, - onDismiss: () -> Unit, - onChanged: () -> Unit, -) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) } - var attrs by remember(file.absolutePath) { mutableStateOf(null) } - // Guards against a second toggle landing while the first is still being applied off-thread. - var busy by remember(file.absolutePath) { mutableStateOf(false) } - LaunchedEffect(file.absolutePath) { - attrs = withContext(Dispatchers.IO) { readFileAttrs(file) } - } - - OutlinedAlertDialog( - onDismissRequest = onDismiss, - title = { Text("Properties") }, - text = { - Column(modifier = Modifier.verticalScroll(rememberScrollState())) { - // ── Basic info ── - Text( - text = file.name, - color = MaterialTheme.colorScheme.onBackground, - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - Spacer(Modifier.height(6.dp)) - PropertyLine("Location", file.parent ?: "-") - PropertyLine( - "Type", - if (file.isDirectory) "Folder" - else file.extension.uppercase().let { if (it.isBlank()) "File" else "$it file" }, - ) - if (!file.isDirectory) PropertyLine("Size", FileOps.formatBytes(file.length())) - PropertyLine("Modified", dateFormat.format(Date(file.lastModified()))) - - Spacer(Modifier.height(10.dp)) - HorizontalDivider(color = MaterialTheme.colorScheme.outline) - Spacer(Modifier.height(6.dp)) - - val state = attrs - // ── Read-only ── checked when the owner can't write (Wine's FILE_ATTRIBUTE_READONLY). - AttributeToggleRow( - label = "Read-only", - description = "Stops games (and Wine) from overwriting or deleting this file.", - checked = state?.readOnly == true, - enabled = state != null && !busy, - onToggle = { want -> - busy = true - scope.launch { - val ok = withContext(Dispatchers.IO) { setReadOnly(file, want) } - busy = false - if (ok) { - attrs = attrs?.copy(readOnly = want) - onChanged() - } else { - Toast.makeText(context, "Couldn't change Read-only", Toast.LENGTH_SHORT).show() - } - } - }, - ) - Spacer(Modifier.height(2.dp)) - // ── Hidden ── Wine's DOS hidden bit in the user.DOSATTRIB xattr. - val hiddenSupported = state?.hiddenSupported == true - AttributeToggleRow( - label = "Hidden", - description = if (state != null && !hiddenSupported) - "This storage can't store the hidden flag." - else "Marks the file hidden in Windows (Wine's hidden attribute).", - checked = state?.hidden == true, - enabled = state != null && hiddenSupported && !busy, - onToggle = { want -> - busy = true - scope.launch { - val ok = withContext(Dispatchers.IO) { setHidden(file, want) } - busy = false - if (ok) { - attrs = attrs?.copy(hidden = want) - onChanged() - } else { - // The write failed after all - disable the toggle rather than lie. - attrs = attrs?.copy(hiddenSupported = false) - Toast.makeText(context, "Couldn't change Hidden on this storage", Toast.LENGTH_SHORT).show() - } - } - }, - ) - } - }, - confirmButton = { TextButton(onClick = onDismiss) { Text("Done") } }, - ) -} - -// One "label: value" line in the Properties info block. -@Composable -private fun PropertyLine(label: String, value: String) { - Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) { - Text( - text = label, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontSize = 12.sp, - modifier = Modifier.width(76.dp), - ) - Text( - text = value, - color = MaterialTheme.colorScheme.onSurface, - fontSize = 12.sp, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - } -} - -// A labelled attribute switch with a one-line description, used by the Properties sheet. -@Composable -private fun AttributeToggleRow( - label: String, - description: String, - checked: Boolean, - enabled: Boolean, - onToggle: (Boolean) -> Unit, -) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = label, - color = MaterialTheme.colorScheme.onBackground, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - ) - Text( - text = description, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontSize = 12.sp, - ) - } - Spacer(Modifier.width(8.dp)) - androidx.compose.material3.Switch( - checked = checked, - enabled = enabled, - onCheckedChange = onToggle, - ) - } -} diff --git a/app/src/main/java/com/droiddeck/launcher/files/FilePropertiesDialog.kt b/app/src/main/java/com/droiddeck/launcher/files/FilePropertiesDialog.kt new file mode 100644 index 000000000..8c302b853 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/files/FilePropertiesDialog.kt @@ -0,0 +1,202 @@ +package com.droiddeck.launcher.files + +import android.widget.Toast +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +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 java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +// The File Manager's properties dialog. + +/** + * Properties sheet for a single file or folder: basic info plus the two Windows/Wine file attributes + * the in-container file manager (wfm.exe) also exposes - Read-only and Hidden. Each toggle applies + * immediately (off the main thread) and refreshes the listing via [onChanged]. + * + * State is keyed on the file so it always reflects the entry it was opened for. On a filesystem that + * can't store Wine's DOSATTRIB xattr (the FUSE /storage volumes) only the Hidden toggle is disabled; + * Read-only keeps working everywhere. + */ +@Composable +internal fun FilePropertiesDialog( + file: File, + onDismiss: () -> Unit, + onChanged: () -> Unit, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) } + var attrs by remember(file.absolutePath) { mutableStateOf(null) } + // Guards against a second toggle landing while the first is still being applied off-thread. + var busy by remember(file.absolutePath) { mutableStateOf(false) } + LaunchedEffect(file.absolutePath) { + attrs = withContext(Dispatchers.IO) { readFileAttrs(file) } + } + + OutlinedAlertDialog( + onDismissRequest = onDismiss, + title = { Text("Properties") }, + text = { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + // ── Basic info ── + Text( + text = file.name, + color = MaterialTheme.colorScheme.onBackground, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(6.dp)) + PropertyLine("Location", file.parent ?: "-") + PropertyLine( + "Type", + if (file.isDirectory) "Folder" + else file.extension.uppercase().let { if (it.isBlank()) "File" else "$it file" }, + ) + if (!file.isDirectory) PropertyLine("Size", FileOps.formatBytes(file.length())) + PropertyLine("Modified", dateFormat.format(Date(file.lastModified()))) + + Spacer(Modifier.height(10.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.outline) + Spacer(Modifier.height(6.dp)) + + val state = attrs + // ── Read-only ── checked when the owner can't write (Wine's FILE_ATTRIBUTE_READONLY). + AttributeToggleRow( + label = "Read-only", + description = "Stops games (and Wine) from overwriting or deleting this file.", + checked = state?.readOnly == true, + enabled = state != null && !busy, + onToggle = { want -> + busy = true + scope.launch { + val ok = withContext(Dispatchers.IO) { setReadOnly(file, want) } + busy = false + if (ok) { + attrs = attrs?.copy(readOnly = want) + onChanged() + } else { + Toast.makeText(context, "Couldn't change Read-only", Toast.LENGTH_SHORT).show() + } + } + }, + ) + Spacer(Modifier.height(2.dp)) + // ── Hidden ── Wine's DOS hidden bit in the user.DOSATTRIB xattr. + val hiddenSupported = state?.hiddenSupported == true + AttributeToggleRow( + label = "Hidden", + description = if (state != null && !hiddenSupported) + "This storage can't store the hidden flag." + else "Marks the file hidden in Windows (Wine's hidden attribute).", + checked = state?.hidden == true, + enabled = state != null && hiddenSupported && !busy, + onToggle = { want -> + busy = true + scope.launch { + val ok = withContext(Dispatchers.IO) { setHidden(file, want) } + busy = false + if (ok) { + attrs = attrs?.copy(hidden = want) + onChanged() + } else { + // The write failed after all - disable the toggle rather than lie. + attrs = attrs?.copy(hiddenSupported = false) + Toast.makeText(context, "Couldn't change Hidden on this storage", Toast.LENGTH_SHORT).show() + } + } + }, + ) + } + }, + confirmButton = { TextButton(onClick = onDismiss) { Text("Done") } }, + ) +} + +// One "label: value" line in the Properties info block. +@Composable +private fun PropertyLine(label: String, value: String) { + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) { + Text( + text = label, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 12.sp, + modifier = Modifier.width(76.dp), + ) + Text( + text = value, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 12.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } +} + +// A labelled attribute switch with a one-line description, used by the Properties sheet. +@Composable +private fun AttributeToggleRow( + label: String, + description: String, + checked: Boolean, + enabled: Boolean, + onToggle: (Boolean) -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = label, + color = MaterialTheme.colorScheme.onBackground, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + ) + Text( + text = description, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 12.sp, + ) + } + Spacer(Modifier.width(8.dp)) + androidx.compose.material3.Switch( + checked = checked, + enabled = enabled, + onCheckedChange = onToggle, + ) + } +}